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,929 @@
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
+ """Funding manager"""
21
+
22
+
23
+ # pylint: disable=too-many-locals,too-many-statements
24
+
25
+ import asyncio
26
+ import threading
27
+ import traceback
28
+ import typing as t
29
+ from concurrent.futures import ThreadPoolExecutor
30
+ from logging import Logger
31
+ from time import time
32
+
33
+ from aea_ledger_ethereum import defaultdict
34
+ from autonomy.chain.base import registry_contracts
35
+ from autonomy.chain.config import CHAIN_PROFILES, ChainType
36
+ from web3 import Web3
37
+
38
+ from operate.constants import (
39
+ DEFAULT_FUNDING_REQUESTS_COOLDOWN_SECONDS,
40
+ MASTER_EOA_PLACEHOLDER,
41
+ MASTER_SAFE_PLACEHOLDER,
42
+ MIN_AGENT_BOND,
43
+ MIN_SECURITY_DEPOSIT,
44
+ NO_STAKING_PROGRAM_ID,
45
+ SERVICE_SAFE_PLACEHOLDER,
46
+ ZERO_ADDRESS,
47
+ )
48
+ from operate.keys import KeysManager
49
+ from operate.ledger import get_currency_denom, get_default_ledger_api
50
+ from operate.ledger.profiles import (
51
+ CONTRACTS,
52
+ DEFAULT_EOA_THRESHOLD,
53
+ DEFAULT_EOA_TOPUPS,
54
+ DEFAULT_EOA_TOPUPS_WITHOUT_SAFE,
55
+ OLAS,
56
+ USDC,
57
+ WRAPPED_NATIVE_ASSET,
58
+ get_asset_name,
59
+ )
60
+ from operate.operate_types import Chain, ChainAmounts, LedgerType, OnChainState
61
+ from operate.services.protocol import EthSafeTxBuilder, StakingManager, StakingState
62
+ from operate.services.service import NON_EXISTENT_TOKEN, Service
63
+ from operate.utils.gnosis import drain_eoa, get_asset_balance, get_owners
64
+ from operate.utils.gnosis import transfer as transfer_from_safe
65
+ from operate.utils.gnosis import transfer_erc20_from_safe
66
+ from operate.wallet.master import InsufficientFundsException, MasterWalletManager
67
+
68
+
69
+ if t.TYPE_CHECKING:
70
+ from operate.services.manage import ServiceManager # pylint: disable=unused-import
71
+
72
+
73
+ class FundingInProgressError(RuntimeError):
74
+ """Raised when an attempt is made to fund a service that is already being funded."""
75
+
76
+
77
+ class FundingManager:
78
+ """FundingManager"""
79
+
80
+ def __init__(
81
+ self,
82
+ keys_manager: KeysManager,
83
+ wallet_manager: MasterWalletManager,
84
+ logger: Logger,
85
+ funding_requests_cooldown_seconds: int = DEFAULT_FUNDING_REQUESTS_COOLDOWN_SECONDS,
86
+ ) -> None:
87
+ """Initialize funding manager."""
88
+ self.keys_manager = keys_manager
89
+ self.wallet_manager = wallet_manager
90
+ self.logger = logger
91
+ self.funding_requests_cooldown_seconds = funding_requests_cooldown_seconds
92
+ self._lock = threading.Lock()
93
+ self._funding_in_progress: t.Dict[str, bool] = {}
94
+ self._funding_requests_cooldown_until: t.Dict[str, float] = {}
95
+ self.is_for_quickstart = False
96
+
97
+ def drain_agents_eoas(
98
+ self, service: Service, withdrawal_address: str, chain: Chain
99
+ ) -> None:
100
+ """Drain the funds out of the service agents EOAs."""
101
+ service_config_id = service.service_config_id
102
+ ledger_api = get_default_ledger_api(chain)
103
+ self.logger.info(
104
+ f"Draining service agents {service.name} ({service_config_id=})"
105
+ )
106
+ for agent_address in service.agent_addresses:
107
+ ethereum_crypto = self.keys_manager.get_crypto_instance(agent_address)
108
+ balance = ledger_api.get_balance(agent_address)
109
+ self.logger.info(
110
+ f"Draining {balance} (approx) {get_currency_denom(chain)} from {agent_address} (agent) to {withdrawal_address}"
111
+ )
112
+ drain_eoa(
113
+ ledger_api=ledger_api,
114
+ crypto=ethereum_crypto,
115
+ withdrawal_address=withdrawal_address,
116
+ chain_id=chain.id,
117
+ )
118
+ self.logger.info(f"{service.name} signer drained")
119
+
120
+ def drain_service_safe( # pylint: disable=too-many-locals
121
+ self, service: Service, withdrawal_address: str, chain: Chain
122
+ ) -> None:
123
+ """Drain the funds out of the service safe."""
124
+ service_config_id = service.service_config_id
125
+ self.logger.info(f"Draining service safe {service.name} ({service_config_id=})")
126
+ chain_config = service.chain_configs[chain.value]
127
+ chain_data = chain_config.chain_data
128
+ ledger_api = get_default_ledger_api(chain)
129
+ withdrawal_address = Web3.to_checksum_address(withdrawal_address)
130
+ service_safe = chain_data.multisig
131
+ wallet = self.wallet_manager.load(chain.ledger_type)
132
+ master_safe = wallet.safes[chain]
133
+ ledger_config = chain_config.ledger_config
134
+ sftxb = EthSafeTxBuilder(
135
+ rpc=ledger_config.rpc,
136
+ wallet=wallet,
137
+ contracts=CONTRACTS[ledger_config.chain],
138
+ chain_type=ChainType(ledger_config.chain.value),
139
+ )
140
+
141
+ owners = get_owners(ledger_api=ledger_api, safe=service_safe)
142
+
143
+ # Drain ERC20 tokens from service Safe
144
+ tokens = {
145
+ WRAPPED_NATIVE_ASSET[chain],
146
+ OLAS[chain],
147
+ USDC[chain],
148
+ } | service.chain_configs[
149
+ chain.value
150
+ ].chain_data.user_params.fund_requirements.keys()
151
+ tokens.discard(ZERO_ADDRESS)
152
+
153
+ for token_address in tokens:
154
+ token_instance = registry_contracts.erc20.get_instance(
155
+ ledger_api=ledger_api,
156
+ contract_address=token_address,
157
+ )
158
+ balance = token_instance.functions.balanceOf(service_safe).call()
159
+ token_name = get_asset_name(chain, token_address)
160
+ if balance == 0:
161
+ self.logger.info(
162
+ f"No {token_name} to drain from service safe: {service_safe}"
163
+ )
164
+ continue
165
+
166
+ self.logger.info(
167
+ f"Draining {balance} {token_name} from {service_safe} (service safe) to {withdrawal_address}"
168
+ )
169
+
170
+ # Safe not swapped
171
+ if set(owners) == set(service.agent_addresses):
172
+ ethereum_crypto = self.keys_manager.get_crypto_instance(
173
+ service.agent_addresses[0]
174
+ )
175
+ transfer_erc20_from_safe(
176
+ ledger_api=ledger_api,
177
+ crypto=ethereum_crypto,
178
+ safe=chain_data.multisig,
179
+ token=token_address,
180
+ to=withdrawal_address,
181
+ amount=balance,
182
+ )
183
+ elif set(owners) == {master_safe}:
184
+ messages = sftxb.get_safe_b_erc20_transfer_messages(
185
+ safe_b_address=service_safe,
186
+ token=token_address,
187
+ to=withdrawal_address,
188
+ amount=balance,
189
+ )
190
+ tx = sftxb.new_tx()
191
+ for message in messages:
192
+ tx.add(message)
193
+ tx.settle()
194
+
195
+ else:
196
+ raise RuntimeError(
197
+ f"Cannot drain service safe: unrecognized owner set {owners=}"
198
+ )
199
+
200
+ # Drain native asset from service Safe
201
+ balance = ledger_api.get_balance(service_safe)
202
+ if balance == 0:
203
+ self.logger.info(
204
+ f"No {get_currency_denom(chain)} to drain from service safe: {service_safe}"
205
+ )
206
+ else:
207
+ self.logger.info(
208
+ f"Draining {balance} {get_currency_denom(chain)} from {service_safe} (service safe) to {withdrawal_address}"
209
+ )
210
+
211
+ if set(owners) == set(service.agent_addresses):
212
+ ethereum_crypto = self.keys_manager.get_crypto_instance(
213
+ service.agent_addresses[0]
214
+ )
215
+ transfer_from_safe(
216
+ ledger_api=ledger_api,
217
+ crypto=ethereum_crypto,
218
+ safe=chain_data.multisig,
219
+ to=withdrawal_address,
220
+ amount=balance,
221
+ )
222
+ elif set(owners) == {master_safe}:
223
+ messages = sftxb.get_safe_b_native_transfer_messages(
224
+ safe_b_address=service_safe,
225
+ to=withdrawal_address,
226
+ amount=balance,
227
+ )
228
+ tx = sftxb.new_tx()
229
+ for message in messages:
230
+ tx.add(message)
231
+ tx.settle()
232
+
233
+ else:
234
+ raise RuntimeError(
235
+ f"Cannot drain service safe: unrecognized owner set {owners=}"
236
+ )
237
+
238
+ self.logger.info(f"Service safe {service.name} drained ({service_config_id=})")
239
+
240
+ # -------------------------------------------------------------------------------------
241
+ # -------------------------------------------------------------------------------------
242
+
243
+ def _compute_protocol_asset_requirements(self, service: Service) -> ChainAmounts:
244
+ """Computes the protocol asset requirements to deploy on-chain and stake (if necessary)"""
245
+
246
+ self.logger.info(
247
+ f"[FUNDING MANAGER] Computing protocol asset requirements for service {service.service_config_id}"
248
+ )
249
+ protocol_asset_requirements = ChainAmounts()
250
+
251
+ for chain, chain_config in service.chain_configs.items():
252
+ user_params = chain_config.chain_data.user_params
253
+ number_of_agents = len(service.agent_addresses)
254
+
255
+ requirements: defaultdict = defaultdict(int)
256
+
257
+ if (
258
+ not user_params.use_staking
259
+ or not user_params.staking_program_id
260
+ or user_params.staking_program_id == NO_STAKING_PROGRAM_ID
261
+ ):
262
+ protocol_agent_bonds = (
263
+ max(MIN_AGENT_BOND, user_params.cost_of_bond) * number_of_agents
264
+ )
265
+ protocol_security_deposit = max(
266
+ MIN_SECURITY_DEPOSIT, user_params.cost_of_bond
267
+ )
268
+ staking_agent_bonds = 0
269
+ staking_security_deposit = 0
270
+ else:
271
+ protocol_agent_bonds = MIN_AGENT_BOND * number_of_agents
272
+ protocol_security_deposit = MIN_SECURITY_DEPOSIT
273
+
274
+ staking_manager = StakingManager(chain=Chain(chain))
275
+ staking_params = staking_manager.get_staking_params(
276
+ staking_contract=staking_manager.get_staking_contract(
277
+ staking_program_id=user_params.staking_program_id,
278
+ ),
279
+ )
280
+
281
+ staking_agent_bonds = (
282
+ staking_params["min_staking_deposit"] * number_of_agents
283
+ )
284
+ staking_security_deposit = staking_params["min_staking_deposit"]
285
+ staking_token = staking_params["staking_token"]
286
+ requirements[staking_token] += staking_agent_bonds
287
+ requirements[staking_token] += staking_security_deposit
288
+
289
+ for token, amount in staking_params[
290
+ "additional_staking_tokens"
291
+ ].items():
292
+ requirements[token] = amount
293
+
294
+ requirements[ZERO_ADDRESS] += protocol_agent_bonds
295
+ requirements[ZERO_ADDRESS] += protocol_security_deposit
296
+
297
+ master_safe = self._resolve_master_safe(Chain(chain))
298
+
299
+ protocol_asset_requirements[chain] = {master_safe: dict(requirements)}
300
+
301
+ return protocol_asset_requirements
302
+
303
+ # TODO address this comment
304
+ # This computation assumes the service will be/has been minted with these
305
+ # parameters. Otherwise, these values should be retrieved on-chain as follows:
306
+ # - agent_bonds: by combining the output of ServiceRegistry .getAgentParams .getService
307
+ # and ServiceRegistryTokenUtility .getAgentBond
308
+ # - security_deposit: as the maximum agent bond.
309
+
310
+ def _compute_protocol_bonded_assets( # pylint: disable=too-many-locals,too-many-statements
311
+ self, service: Service
312
+ ) -> ChainAmounts:
313
+ """Computes the bonded assets: current agent bonds and current security deposit"""
314
+
315
+ protocol_bonded_assets = ChainAmounts()
316
+
317
+ for chain, chain_config in service.chain_configs.items():
318
+ bonded_assets: defaultdict = defaultdict(int)
319
+ ledger_config = chain_config.ledger_config
320
+ user_params = chain_config.chain_data.user_params
321
+
322
+ if not self.wallet_manager.exists(ledger_config.chain.ledger_type):
323
+ protocol_bonded_assets[chain] = {
324
+ MASTER_SAFE_PLACEHOLDER: dict(bonded_assets)
325
+ }
326
+ continue
327
+
328
+ wallet = self.wallet_manager.load(ledger_config.chain.ledger_type)
329
+ ledger_api = get_default_ledger_api(Chain(chain))
330
+ staking_manager = StakingManager(Chain(chain))
331
+
332
+ if Chain(chain) not in wallet.safes:
333
+ protocol_bonded_assets[chain] = {
334
+ MASTER_SAFE_PLACEHOLDER: dict(bonded_assets)
335
+ }
336
+ continue
337
+
338
+ master_safe = wallet.safes[Chain(chain)]
339
+
340
+ service_id = chain_config.chain_data.token
341
+ if service_id == NON_EXISTENT_TOKEN:
342
+ protocol_bonded_assets[chain] = {master_safe: dict(bonded_assets)}
343
+ continue
344
+
345
+ # os.environ["CUSTOM_CHAIN_RPC"] = ledger_config.rpc # TODO do we need this?
346
+
347
+ # Determine bonded native amount
348
+ service_registry_address = CHAIN_PROFILES[chain]["service_registry"]
349
+ service_registry = registry_contracts.service_registry.get_instance(
350
+ ledger_api=ledger_api,
351
+ contract_address=service_registry_address,
352
+ )
353
+ service_info = service_registry.functions.getService(service_id).call()
354
+ security_deposit = service_info[0]
355
+ service_state = service_info[6]
356
+ agent_ids = service_info[7]
357
+
358
+ if (
359
+ OnChainState.ACTIVE_REGISTRATION
360
+ <= service_state
361
+ < OnChainState.TERMINATED_BONDED
362
+ ):
363
+ bonded_assets[ZERO_ADDRESS] += security_deposit
364
+
365
+ operator_balance = service_registry.functions.getOperatorBalance(
366
+ master_safe, service_id
367
+ ).call()
368
+ bonded_assets[ZERO_ADDRESS] += operator_balance
369
+
370
+ # Determine bonded token amount for staking programs
371
+ current_staking_program = staking_manager.get_current_staking_program(
372
+ service_id=service_id,
373
+ )
374
+ target_staking_program = user_params.staking_program_id
375
+ staking_contract = staking_manager.get_staking_contract(
376
+ staking_program_id=current_staking_program or target_staking_program,
377
+ )
378
+
379
+ if not staking_contract:
380
+ return dict(bonded_assets)
381
+
382
+ staking_manager = StakingManager(Chain(chain))
383
+ staking_params = staking_manager.get_staking_params(
384
+ staking_contract=staking_manager.get_staking_contract(
385
+ staking_program_id=user_params.staking_program_id,
386
+ ),
387
+ )
388
+
389
+ service_registry_token_utility_address = staking_params[
390
+ "service_registry_token_utility"
391
+ ]
392
+ service_registry_token_utility = (
393
+ registry_contracts.service_registry_token_utility.get_instance(
394
+ ledger_api=ledger_api,
395
+ contract_address=service_registry_token_utility_address,
396
+ )
397
+ )
398
+
399
+ agent_bonds = 0
400
+ for agent_id in agent_ids:
401
+ num_agent_instances = service_registry.functions.getInstancesForAgentId(
402
+ service_id, agent_id
403
+ ).call()[0]
404
+ agent_bond = service_registry_token_utility.functions.getAgentBond(
405
+ service_id, agent_id
406
+ ).call()
407
+ agent_bonds += num_agent_instances * agent_bond
408
+
409
+ if service_state == OnChainState.TERMINATED_BONDED:
410
+ num_agent_instances = service_info[5]
411
+ token_bond = (
412
+ service_registry_token_utility.functions.getOperatorBalance(
413
+ master_safe,
414
+ service_id,
415
+ ).call()
416
+ )
417
+ agent_bonds += num_agent_instances * token_bond
418
+
419
+ security_deposit = 0
420
+ if (
421
+ OnChainState.ACTIVE_REGISTRATION
422
+ <= service_state
423
+ < OnChainState.TERMINATED_BONDED
424
+ ):
425
+ security_deposit = (
426
+ service_registry_token_utility.functions.mapServiceIdTokenDeposit(
427
+ service_id
428
+ ).call()[1]
429
+ )
430
+
431
+ bonded_assets[staking_params["staking_token"]] += agent_bonds
432
+ bonded_assets[staking_params["staking_token"]] += security_deposit
433
+
434
+ staking_state = staking_manager.staking_state(
435
+ service_id=service_id,
436
+ staking_contract=staking_params["staking_contract"],
437
+ )
438
+
439
+ if staking_state in (StakingState.STAKED, StakingState.EVICTED):
440
+ for token, amount in staking_params[
441
+ "additional_staking_tokens"
442
+ ].items():
443
+ bonded_assets[token] += amount
444
+
445
+ protocol_bonded_assets[chain] = {master_safe: dict(bonded_assets)}
446
+
447
+ return protocol_bonded_assets
448
+
449
+ @staticmethod
450
+ def _compute_shortfalls(
451
+ balances: ChainAmounts,
452
+ thresholds: ChainAmounts,
453
+ topups: ChainAmounts,
454
+ ) -> ChainAmounts:
455
+ """Compute shortfall per chain/address/asset: if balance < threshold, shortfall = topup - balance, else 0"""
456
+ shortfalls = ChainAmounts()
457
+
458
+ for chain, addresses in thresholds.items():
459
+ shortfalls.setdefault(chain, {})
460
+ for address, assets in addresses.items():
461
+ shortfalls[chain].setdefault(address, {})
462
+ for asset, threshold in assets.items():
463
+ balance = balances.get(chain, {}).get(address, {}).get(asset, 0)
464
+ topup = topups.get(chain, {}).get(address, {}).get(asset, 0)
465
+ if balance < threshold:
466
+ shortfalls[chain][address][asset] = max(topup - balance, 0)
467
+ else:
468
+ shortfalls[chain][address][asset] = 0
469
+
470
+ return shortfalls
471
+
472
+ def _resolve_master_eoa(self, chain: Chain) -> str:
473
+ if self.wallet_manager.exists(chain.ledger_type):
474
+ wallet = self.wallet_manager.load(chain.ledger_type)
475
+ return wallet.address
476
+ return MASTER_EOA_PLACEHOLDER
477
+
478
+ def _resolve_master_safe(self, chain: Chain) -> str:
479
+ if self.wallet_manager.exists(chain.ledger_type):
480
+ wallet = self.wallet_manager.load(chain.ledger_type)
481
+ if chain in wallet.safes:
482
+ return wallet.safes[chain]
483
+ return MASTER_SAFE_PLACEHOLDER
484
+
485
+ def _aggregate_as_master_safe_amounts(self, *amounts: ChainAmounts) -> ChainAmounts:
486
+ output = ChainAmounts()
487
+ for amts in amounts:
488
+ for chain_str, addresses in amts.items():
489
+ chain = Chain(chain_str)
490
+ master_safe = self._resolve_master_safe(chain)
491
+ master_safe_dict = output.setdefault(chain_str, {}).setdefault(
492
+ master_safe, {}
493
+ )
494
+ for _, assets in addresses.items():
495
+ for asset, amount in assets.items():
496
+ master_safe_dict[asset] = (
497
+ master_safe_dict.get(asset, 0) + amount
498
+ )
499
+
500
+ return output
501
+
502
+ def _split_excess_assets_master_eoa_balances(
503
+ self, balances: ChainAmounts
504
+ ) -> t.Tuple[ChainAmounts, ChainAmounts]:
505
+ """Splits excess balances from master EOA only on chains without a Master Safe."""
506
+ excess_balance = ChainAmounts()
507
+ remaining_balance = ChainAmounts()
508
+
509
+ for chain_str, addresses in balances.items():
510
+ chain = Chain(chain_str)
511
+ master_safe = self._resolve_master_safe(chain)
512
+ for address, assets in addresses.items():
513
+ for asset, amount in assets.items():
514
+ if master_safe == MASTER_SAFE_PLACEHOLDER:
515
+ remaining = min(
516
+ amount, DEFAULT_EOA_TOPUPS[chain].get(asset, 0)
517
+ ) # When transferring, the Master Safe will be already created, that is why we are only retaining DEFAULT_EOA_TOPUPS
518
+ excess = amount - remaining
519
+ else:
520
+ remaining = amount
521
+ excess = 0
522
+
523
+ excess_balance.setdefault(chain_str, {}).setdefault(
524
+ master_safe, {}
525
+ )[asset] = excess
526
+ remaining_balance.setdefault(chain_str, {}).setdefault(address, {})[
527
+ asset
528
+ ] = remaining
529
+
530
+ return excess_balance, remaining_balance
531
+
532
+ @staticmethod
533
+ def _split_critical_eoa_shortfalls(
534
+ balances: ChainAmounts, shortfalls: ChainAmounts
535
+ ) -> t.Tuple[ChainAmounts, ChainAmounts]:
536
+ """Splits critical EOA shortfalls in two: the first split containins the native shortfalls whose balance is < threshold / 2. The second one, contains the remaining shortfalls. This is to ensure EOA operational balance."""
537
+ critical_shortfalls = ChainAmounts()
538
+ remaining_shortfalls = ChainAmounts()
539
+
540
+ for chain_str, addresses in shortfalls.items():
541
+ chain = Chain(chain_str)
542
+ for address, assets in addresses.items():
543
+ for asset, amount in assets.items():
544
+ if asset == ZERO_ADDRESS and balances[chain_str][address][
545
+ asset
546
+ ] < int(
547
+ DEFAULT_EOA_TOPUPS[chain][asset] / 4
548
+ ): # TODO Ensure that this is enough to pay a transfer tx at least.
549
+ critical_shortfalls.setdefault(chain_str, {}).setdefault(
550
+ address, {}
551
+ )[asset] = amount
552
+ remaining_shortfalls.setdefault(chain_str, {}).setdefault(
553
+ address, {}
554
+ )[asset] = 0
555
+ else:
556
+ critical_shortfalls.setdefault(chain_str, {}).setdefault(
557
+ address, {}
558
+ )[asset] = 0
559
+ remaining_shortfalls.setdefault(chain_str, {}).setdefault(
560
+ address, {}
561
+ )[asset] = amount
562
+
563
+ return critical_shortfalls, remaining_shortfalls
564
+
565
+ def _get_master_safe_balances(self, thresholds: ChainAmounts) -> ChainAmounts:
566
+ output = ChainAmounts()
567
+ for chain_str, addresses in thresholds.items():
568
+ chain = Chain(chain_str)
569
+ master_safe = self._resolve_master_safe(chain)
570
+ master_safe_dict = output.setdefault(chain_str, {}).setdefault(
571
+ master_safe, {}
572
+ )
573
+ for _, assets in addresses.items():
574
+ for asset, _ in assets.items():
575
+ master_safe_dict[asset] = get_asset_balance(
576
+ ledger_api=get_default_ledger_api(chain),
577
+ asset_address=asset,
578
+ address=master_safe,
579
+ raise_on_invalid_address=False,
580
+ )
581
+
582
+ return output
583
+
584
+ def _get_master_eoa_balances(self, thresholds: ChainAmounts) -> ChainAmounts:
585
+ output = ChainAmounts()
586
+ for chain_str, addresses in thresholds.items():
587
+ chain = Chain(chain_str)
588
+ master_eoa = self._resolve_master_eoa(chain)
589
+ master_eoa_dict = output.setdefault(chain_str, {}).setdefault(
590
+ master_eoa, {}
591
+ )
592
+ for _, assets in addresses.items():
593
+ for asset, _ in assets.items():
594
+ master_eoa_dict[asset] = get_asset_balance(
595
+ ledger_api=get_default_ledger_api(chain),
596
+ asset_address=asset,
597
+ address=master_eoa,
598
+ raise_on_invalid_address=False,
599
+ )
600
+
601
+ return output
602
+
603
+ def fund_master_eoa(self) -> None:
604
+ """Fund Master EOA"""
605
+ if not self.wallet_manager.exists(LedgerType.ETHEREUM):
606
+ self.logger.warning(
607
+ "[FUNDING MANAGER] Cannot fund Master EOA: No Ethereum wallet available."
608
+ )
609
+ return
610
+
611
+ master_wallet = self.wallet_manager.load(
612
+ ledger_type=LedgerType.ETHEREUM
613
+ ) # Only for ethereum for now
614
+ self.logger.info(
615
+ f"[FUNDING MANAGER] Funding Master EOA {master_wallet.address}"
616
+ )
617
+ master_eoa_topups = ChainAmounts(
618
+ {
619
+ chain.value: {
620
+ self._resolve_master_eoa(chain): dict(DEFAULT_EOA_TOPUPS[chain])
621
+ }
622
+ for chain in master_wallet.safes
623
+ }
624
+ )
625
+ master_eoa_balances = self._get_master_eoa_balances(master_eoa_topups)
626
+ master_eoa_shortfalls = self._compute_shortfalls(
627
+ balances=master_eoa_balances,
628
+ thresholds=master_eoa_topups * DEFAULT_EOA_THRESHOLD,
629
+ topups=master_eoa_topups,
630
+ )
631
+ self.fund_chain_amounts(master_eoa_shortfalls)
632
+
633
+ def funding_requirements(self, service: Service) -> t.Dict:
634
+ """Funding requirements"""
635
+ balances: ChainAmounts
636
+ protocol_bonded_assets: ChainAmounts
637
+ protocol_asset_requirements: ChainAmounts
638
+ refill_requirements: ChainAmounts
639
+ total_requirements: ChainAmounts
640
+ chains = [Chain(chain_str) for chain_str in service.chain_configs.keys()]
641
+
642
+ # Protocol shortfall
643
+ protocol_thresholds = self._compute_protocol_asset_requirements(service)
644
+ protocol_balances = self._compute_protocol_bonded_assets(service)
645
+ protocol_topups = protocol_thresholds
646
+ protocol_shortfalls = self._compute_shortfalls(
647
+ balances=protocol_balances,
648
+ thresholds=protocol_thresholds,
649
+ topups=protocol_topups,
650
+ )
651
+
652
+ # Initial service shortfall
653
+ # We assume that if the service safe is created in any chain,
654
+ # we have requested the funding already.
655
+ service_initial_topup = service.get_initial_funding_amounts()
656
+ if self.is_for_quickstart:
657
+ service_initial_shortfalls = self.compute_service_initial_shortfalls(
658
+ service
659
+ )
660
+ elif not all(
661
+ SERVICE_SAFE_PLACEHOLDER in addresses
662
+ for addresses in service_initial_topup.values()
663
+ ):
664
+ service_initial_shortfalls = ChainAmounts()
665
+ else:
666
+ service_initial_shortfalls = service_initial_topup
667
+
668
+ # Service funding requests
669
+ service_config_id = service.service_config_id
670
+ funding_in_progress = self._funding_in_progress.get(service_config_id, False)
671
+ now = time()
672
+ if funding_in_progress:
673
+ funding_requests = ChainAmounts()
674
+ funding_requests_cooldown = False
675
+ elif now < self._funding_requests_cooldown_until.get(service_config_id, 0):
676
+ funding_requests = ChainAmounts()
677
+ funding_requests_cooldown = True
678
+ elif self.is_for_quickstart:
679
+ funding_requests = ChainAmounts()
680
+ funding_requests_cooldown = False
681
+ else:
682
+ funding_requests = service.get_funding_requests()
683
+ funding_requests_cooldown = False
684
+
685
+ # Master EOA shortfall
686
+ master_eoa_topups = ChainAmounts()
687
+ for chain in chains:
688
+ chain_str = chain.value
689
+ master_eoa = self._resolve_master_eoa(chain)
690
+ master_safe = self._resolve_master_safe(chain)
691
+
692
+ if master_safe != MASTER_SAFE_PLACEHOLDER:
693
+ master_eoa_topups[chain_str] = {
694
+ master_eoa: dict(DEFAULT_EOA_TOPUPS[chain])
695
+ }
696
+ else:
697
+ master_eoa_topups[chain_str] = {
698
+ master_eoa: dict(DEFAULT_EOA_TOPUPS_WITHOUT_SAFE[chain])
699
+ }
700
+
701
+ # Set the topup for MasterEOA for remaining tokens to 0 if they don't exist
702
+ # This ensures that the balances of MasterEOA are collected for relevant tokens
703
+ all_assets = {ZERO_ADDRESS} | {
704
+ asset
705
+ for addresses in (
706
+ protocol_topups[chain_str],
707
+ service_initial_topup[chain_str],
708
+ )
709
+ for assets in addresses.values()
710
+ for asset in assets
711
+ }
712
+ for asset in all_assets:
713
+ master_eoa_topups[chain_str][master_eoa].setdefault(asset, 0)
714
+
715
+ master_eoa_thresholds = master_eoa_topups // 2
716
+ master_eoa_balances = self._get_master_eoa_balances(master_eoa_thresholds)
717
+
718
+ # BEGIN Bridging patch: remove excess balances for chains without a Safe:
719
+ (
720
+ excess_master_eoa_balances,
721
+ master_eoa_balances,
722
+ ) = self._split_excess_assets_master_eoa_balances(master_eoa_balances)
723
+ # END Bridging patch
724
+
725
+ master_eoa_shortfalls = self._compute_shortfalls(
726
+ balances=master_eoa_balances,
727
+ thresholds=master_eoa_thresholds,
728
+ topups=master_eoa_topups,
729
+ )
730
+
731
+ (
732
+ master_eoa_critical_shortfalls,
733
+ master_eoa_shortfalls,
734
+ ) = self._split_critical_eoa_shortfalls(
735
+ balances=master_eoa_balances, shortfalls=master_eoa_shortfalls
736
+ )
737
+
738
+ # Master Safe shortfall
739
+ master_safe_thresholds = self._aggregate_as_master_safe_amounts(
740
+ master_eoa_shortfalls,
741
+ protocol_shortfalls,
742
+ service_initial_shortfalls,
743
+ )
744
+ master_safe_topup = master_safe_thresholds
745
+ master_safe_balances = ChainAmounts.add(
746
+ self._get_master_safe_balances(master_safe_thresholds),
747
+ self._aggregate_as_master_safe_amounts(excess_master_eoa_balances),
748
+ )
749
+ master_safe_shortfalls = self._compute_shortfalls(
750
+ balances=master_safe_balances,
751
+ thresholds=master_safe_thresholds,
752
+ topups=master_safe_topup,
753
+ )
754
+
755
+ # Prepare output values
756
+ protocol_bonded_assets = protocol_balances
757
+ protocol_asset_requirements = protocol_thresholds
758
+ refill_requirements = ChainAmounts.add(
759
+ master_eoa_critical_shortfalls,
760
+ master_safe_shortfalls,
761
+ )
762
+ total_requirements = ChainAmounts.add( # TODO Review if this is correct
763
+ master_eoa_critical_shortfalls,
764
+ master_safe_thresholds,
765
+ )
766
+ balances = ChainAmounts.add(
767
+ master_eoa_balances,
768
+ master_safe_balances,
769
+ )
770
+
771
+ # Compute boolean flags
772
+ is_refill_required = any(
773
+ amount > 0
774
+ for address in refill_requirements.values()
775
+ for assets in address.values()
776
+ for amount in assets.values()
777
+ )
778
+
779
+ allow_start_agent = True
780
+ if any(
781
+ MASTER_SAFE_PLACEHOLDER in addresses
782
+ for addresses in refill_requirements.values()
783
+ ) or any(
784
+ amount > 0
785
+ for address in master_eoa_critical_shortfalls.values()
786
+ for assets in address.values()
787
+ for amount in assets.values()
788
+ ):
789
+ allow_start_agent = False
790
+
791
+ return {
792
+ "balances": balances,
793
+ "bonded_assets": protocol_bonded_assets,
794
+ "total_requirements": total_requirements,
795
+ "refill_requirements": refill_requirements,
796
+ "protocol_asset_requirements": protocol_asset_requirements,
797
+ "is_refill_required": is_refill_required,
798
+ "allow_start_agent": allow_start_agent,
799
+ "agent_funding_requests": funding_requests,
800
+ "agent_funding_requests_cooldown": funding_requests_cooldown,
801
+ "agent_funding_in_progress": funding_in_progress,
802
+ }
803
+
804
+ def fund_service_initial(self, service: Service) -> None:
805
+ """Fund service initially"""
806
+ self.fund_chain_amounts(service.get_initial_funding_amounts())
807
+
808
+ def compute_service_initial_shortfalls(self, service: Service) -> ChainAmounts:
809
+ """Compute service initial shortfalls"""
810
+ initial_funding_amounts = service.get_initial_funding_amounts()
811
+ service_balances = service.get_balances()
812
+ return self._compute_shortfalls(
813
+ balances=service_balances,
814
+ thresholds=initial_funding_amounts,
815
+ topups=initial_funding_amounts,
816
+ )
817
+
818
+ def topup_service_initial(self, service: Service) -> None:
819
+ """Fund service enough to reach initial funding amounts"""
820
+ service_initial_shortfalls = self.compute_service_initial_shortfalls(service)
821
+ self.fund_chain_amounts(service_initial_shortfalls)
822
+
823
+ def fund_chain_amounts(self, amounts: ChainAmounts) -> None:
824
+ """Fund chain amounts"""
825
+ required = self._aggregate_as_master_safe_amounts(amounts)
826
+ balances = self._get_master_safe_balances(required)
827
+
828
+ if balances < required:
829
+ raise InsufficientFundsException(
830
+ f"Insufficient funds in Master Safe to perform funding. Required: {amounts}, Available: {balances}"
831
+ )
832
+
833
+ for chain_str, addresses in amounts.items():
834
+ chain = Chain(chain_str)
835
+ wallet = self.wallet_manager.load(chain.ledger_type)
836
+ for address, assets in addresses.items():
837
+ for asset, amount in assets.items():
838
+ if amount <= 0:
839
+ continue
840
+
841
+ self.logger.info(
842
+ f"[FUNDING MANAGER] Funding {amount} of {asset} to {address} on chain {chain.value} from Master Safe {wallet.safes.get(chain, 'N/A')}"
843
+ )
844
+ wallet.transfer(
845
+ chain=chain,
846
+ to=address,
847
+ asset=asset,
848
+ amount=amount,
849
+ from_safe=True,
850
+ )
851
+
852
+ def fund_service(self, service: Service, amounts: ChainAmounts) -> None:
853
+ """Fund service-related wallets."""
854
+ service_config_id = service.service_config_id
855
+
856
+ # Atomic, thread-safe get-and-set of the _funding_in_progress boolean.
857
+ # This ensures only one funding operation per service at a time, and
858
+ # any call to fund_service while a funding operation is in progress will
859
+ # raise a FundingInProgressError (instead of blocking and piling up calls).
860
+ with self._lock:
861
+ if self._funding_in_progress.get(service_config_id, False):
862
+ raise FundingInProgressError(
863
+ f"Funding already in progress for service {service_config_id}."
864
+ )
865
+ self._funding_in_progress[service_config_id] = True
866
+
867
+ try:
868
+ for chain_str, addresses in amounts.items():
869
+ for address in addresses:
870
+ if (
871
+ address not in service.agent_addresses
872
+ and address
873
+ != service.chain_configs[chain_str].chain_data.multisig
874
+ ):
875
+ raise ValueError(
876
+ f"Failed to fund from Master Safe: Address {address} is not an agent EOA or service Safe for service {service.service_config_id}."
877
+ )
878
+
879
+ self.fund_chain_amounts(amounts)
880
+ self._funding_requests_cooldown_until[service_config_id] = (
881
+ time() + self.funding_requests_cooldown_seconds
882
+ )
883
+ finally:
884
+ with self._lock:
885
+ self._funding_in_progress[service_config_id] = False
886
+
887
+ async def funding_job(
888
+ self,
889
+ service_manager: "ServiceManager",
890
+ loop: t.Optional[asyncio.AbstractEventLoop] = None,
891
+ ) -> None:
892
+ """Start a background funding job."""
893
+ loop = loop or asyncio.get_event_loop()
894
+ with ThreadPoolExecutor() as executor:
895
+ last_claim = 0.0
896
+ last_master_eoa_funding = 0.0
897
+ while True:
898
+ # try claiming rewards every hour
899
+ if last_claim + 3600 < time():
900
+ try:
901
+ await loop.run_in_executor(
902
+ executor,
903
+ service_manager.claim_all_on_chain_from_safe,
904
+ )
905
+ except Exception: # pylint: disable=broad-except
906
+ self.logger.info(
907
+ f"Error occured while claiming rewards\n{traceback.format_exc()}"
908
+ )
909
+ last_claim = time()
910
+
911
+ # fund Master EOA every hour
912
+ if last_master_eoa_funding + 3600 < time():
913
+ try:
914
+ await loop.run_in_executor(
915
+ executor,
916
+ self.fund_master_eoa,
917
+ )
918
+ except Exception: # pylint: disable=broad-except
919
+ self.logger.info(
920
+ f"Error occured while funding Master EOA\n{traceback.format_exc()}"
921
+ )
922
+ last_master_eoa_funding = time()
923
+
924
+ await asyncio.sleep(60)
925
+
926
+ # TODO Below this line - pending finish funding Job for Master EOA
927
+ # TODO cache _resolve methods to avoid loading multiple times file.
928
+ # TODO refactor: move protocol_ methods to protocol class, refactor to accomodate arbitrary owner and operators,
929
+ # refactor to manage multiple chains with different master safes, etc.