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,457 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ------------------------------------------------------------------------------
4
+ #
5
+ # Copyright 2024 Valory AG
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License");
8
+ # you may not use this file except in compliance with the License.
9
+ # You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+ #
19
+ # ------------------------------------------------------------------------------
20
+ """Relay provider."""
21
+
22
+
23
+ import enum
24
+ import json
25
+ import math
26
+ import time
27
+ import typing as t
28
+ from http import HTTPStatus
29
+ from urllib.parse import urlencode
30
+
31
+ import requests
32
+
33
+ from operate.bridge.providers.provider import (
34
+ DEFAULT_MAX_QUOTE_RETRIES,
35
+ MESSAGE_EXECUTION_FAILED,
36
+ MESSAGE_QUOTE_ZERO,
37
+ Provider,
38
+ ProviderRequest,
39
+ ProviderRequestStatus,
40
+ QuoteData,
41
+ )
42
+ from operate.ledger import update_tx_with_gas_estimate, update_tx_with_gas_pricing
43
+ from operate.operate_types import Chain
44
+
45
+
46
+ GAS_ESTIMATE_FALLBACK_ADDRESS = "0x000000000000000000000000000000000000dEaD"
47
+
48
+ # The following constants were determined empirically (+ margin) from the Relay API/Dapp.
49
+ RELAY_DEFAULT_GAS = {
50
+ Chain.ETHEREUM: {
51
+ "deposit": 50_000,
52
+ "approve": 200_000,
53
+ "authorize": 1,
54
+ "authorize1": 1,
55
+ "authorize2": 1,
56
+ "swap": 400_000,
57
+ "send": 1,
58
+ },
59
+ Chain.BASE: {
60
+ "deposit": 50_000,
61
+ "approve": 200_000,
62
+ "authorize": 1,
63
+ "authorize1": 1,
64
+ "authorize2": 1,
65
+ "swap": 400_000,
66
+ "send": 1,
67
+ },
68
+ Chain.CELO: {
69
+ "deposit": 50_000,
70
+ "approve": 200_000,
71
+ "authorize": 1,
72
+ "authorize1": 1,
73
+ "authorize2": 1,
74
+ "swap": 400_000,
75
+ "send": 1,
76
+ },
77
+ Chain.GNOSIS: {
78
+ "deposit": 350_000,
79
+ "approve": 200_000,
80
+ "authorize": 1,
81
+ "authorize1": 1,
82
+ "authorize2": 1,
83
+ "swap": 500_000,
84
+ "send": 1,
85
+ },
86
+ Chain.MODE: {
87
+ "deposit": 50_000,
88
+ "approve": 200_000,
89
+ "authorize": 1,
90
+ "authorize1": 1,
91
+ "authorize2": 1,
92
+ "swap": 1_500_000,
93
+ "send": 1,
94
+ },
95
+ Chain.OPTIMISM: {
96
+ "deposit": 50_000,
97
+ "approve": 200_000,
98
+ "authorize": 1,
99
+ "authorize1": 1,
100
+ "authorize2": 1,
101
+ "swap": 400_000,
102
+ "send": 1,
103
+ },
104
+ }
105
+
106
+
107
+ # https://docs.relay.link/guides/bridging#status-values
108
+ class RelayExecutionStatus(str, enum.Enum):
109
+ """Relay execution status."""
110
+
111
+ REFUND = "refund"
112
+ DELAYED = "delayed"
113
+ WAITING = "waiting"
114
+ FAILURE = "failure"
115
+ PENDING = "pending"
116
+ SUCCESS = "success"
117
+
118
+ def __str__(self) -> str:
119
+ """__str__"""
120
+ return self.value
121
+
122
+
123
+ class RelayProvider(Provider):
124
+ """Relay provider."""
125
+
126
+ def description(self) -> str:
127
+ """Get a human-readable description of the provider."""
128
+ return "Relay Protocol https://www.relay.link/"
129
+
130
+ def quote(self, provider_request: ProviderRequest) -> None:
131
+ """Update the request with the quote."""
132
+ self._validate(provider_request)
133
+
134
+ if provider_request.status not in (
135
+ ProviderRequestStatus.CREATED,
136
+ ProviderRequestStatus.QUOTE_DONE,
137
+ ProviderRequestStatus.QUOTE_FAILED,
138
+ ):
139
+ raise RuntimeError(
140
+ f"Cannot quote request {provider_request.id} with status {provider_request.status}."
141
+ )
142
+
143
+ if provider_request.execution_data:
144
+ raise RuntimeError(
145
+ f"Cannot quote request {provider_request.id}: execution already present."
146
+ )
147
+
148
+ from_chain = provider_request.params["from"]["chain"]
149
+ from_address = provider_request.params["from"]["address"]
150
+ from_token = provider_request.params["from"]["token"]
151
+ to_chain = provider_request.params["to"]["chain"]
152
+ to_address = provider_request.params["to"]["address"]
153
+ to_token = provider_request.params["to"]["token"]
154
+ to_amount = provider_request.params["to"]["amount"]
155
+
156
+ if to_amount == 0:
157
+ self.logger.info(f"[RELAY PROVIDER] {MESSAGE_QUOTE_ZERO}")
158
+ quote_data = QuoteData(
159
+ eta=0,
160
+ elapsed_time=0,
161
+ message=MESSAGE_QUOTE_ZERO,
162
+ provider_data=None,
163
+ timestamp=int(time.time()),
164
+ )
165
+ provider_request.quote_data = quote_data
166
+ provider_request.status = ProviderRequestStatus.QUOTE_DONE
167
+ return
168
+
169
+ url = "https://api.relay.link/quote"
170
+ headers = {"Content-Type": "application/json"}
171
+ payload = {
172
+ "originChainId": Chain(from_chain).id,
173
+ "user": from_address,
174
+ "originCurrency": from_token,
175
+ "destinationChainId": Chain(to_chain).id,
176
+ "recipient": to_address,
177
+ "destinationCurrency": to_token,
178
+ "amount": str(to_amount),
179
+ "tradeType": "EXACT_OUTPUT",
180
+ "enableTrueExactOutput": False,
181
+ }
182
+ for attempt in range(1, DEFAULT_MAX_QUOTE_RETRIES + 1):
183
+ start = time.time()
184
+ try:
185
+ self.logger.info(f"[RELAY PROVIDER] POST {url}")
186
+ self.logger.info(
187
+ f"[RELAY PROVIDER] BODY {json.dumps(payload, indent=2, sort_keys=True)}"
188
+ )
189
+ response = requests.post(
190
+ url=url, headers=headers, json=payload, timeout=30
191
+ )
192
+ response.raise_for_status()
193
+ response_json = response.json()
194
+
195
+ # Gas will be returned as 0 (unable to estimate) by the API endpoint when simulation fails.
196
+ # This happens when 'from_address'
197
+ # * does not have enough funds/ERC20,
198
+ # * requires to approve an ERC20 before another transaction.
199
+ # Call the API again using the default 'from_address' placeholder used by Relay DApp.
200
+ gas_missing = any(
201
+ "gas" not in item.get("data", {})
202
+ for step in response_json.get("steps", [])
203
+ for item in step.get("items", [])
204
+ )
205
+
206
+ if gas_missing:
207
+ placeholder_payload = payload.copy()
208
+ placeholder_payload["user"] = GAS_ESTIMATE_FALLBACK_ADDRESS
209
+ self.logger.info(f"[RELAY PROVIDER] POST {url}")
210
+ self.logger.info(
211
+ f"[RELAY PROVIDER] BODY {json.dumps(placeholder_payload, indent=2, sort_keys=True)}"
212
+ )
213
+ placeholder_response = requests.post(
214
+ url=url, headers=headers, json=placeholder_payload, timeout=30
215
+ )
216
+ response_json_placeholder = placeholder_response.json()
217
+
218
+ for i, step in enumerate(response_json.get("steps", [])):
219
+ for j, item in enumerate(step.get("items", [])):
220
+ if "gas" not in item.get("data", {}):
221
+ placeholder_gas = (
222
+ response_json_placeholder.get("steps", {i: {}})[i]
223
+ .get("items", {j: {}})[j]
224
+ .get("data", {})
225
+ .get("gas")
226
+ )
227
+ item["data"]["gas"] = (
228
+ placeholder_gas
229
+ or RELAY_DEFAULT_GAS[Chain(from_chain)][step["id"]]
230
+ )
231
+
232
+ quote_data = QuoteData(
233
+ eta=math.ceil(response_json["details"]["timeEstimate"]),
234
+ elapsed_time=time.time() - start,
235
+ message=None,
236
+ provider_data={
237
+ "attempts": attempt,
238
+ "response": response_json,
239
+ "response_status": response.status_code,
240
+ },
241
+ timestamp=int(time.time()),
242
+ )
243
+ provider_request.quote_data = quote_data
244
+ provider_request.status = ProviderRequestStatus.QUOTE_DONE
245
+ return
246
+ except requests.Timeout as e:
247
+ self.logger.warning(
248
+ f"[RELAY PROVIDER] Timeout request on attempt {attempt}/{DEFAULT_MAX_QUOTE_RETRIES}: {e}."
249
+ )
250
+ quote_data = QuoteData(
251
+ eta=None,
252
+ elapsed_time=time.time() - start,
253
+ message=str(e),
254
+ provider_data={
255
+ "attempts": attempt,
256
+ "response": None,
257
+ "response_status": HTTPStatus.GATEWAY_TIMEOUT,
258
+ },
259
+ timestamp=int(time.time()),
260
+ )
261
+ except requests.RequestException as e:
262
+ response_json = response.json()
263
+ self.logger.warning(
264
+ f"[RELAY PROVIDER] Request failed on attempt {attempt}/{DEFAULT_MAX_QUOTE_RETRIES}: {response_json}."
265
+ )
266
+ quote_data = QuoteData(
267
+ eta=None,
268
+ elapsed_time=time.time() - start,
269
+ message=response_json.get("message") or str(e),
270
+ provider_data={
271
+ "attempts": attempt,
272
+ "response": response_json,
273
+ "response_status": getattr(
274
+ response, "status_code", HTTPStatus.BAD_GATEWAY
275
+ ),
276
+ },
277
+ timestamp=int(time.time()),
278
+ )
279
+ except Exception as e: # pylint:disable=broad-except
280
+ self.logger.warning(
281
+ f"[RELAY PROVIDER] Request failed on attempt {attempt}/{DEFAULT_MAX_QUOTE_RETRIES}: {e}."
282
+ )
283
+ quote_data = QuoteData(
284
+ eta=None,
285
+ elapsed_time=time.time() - start,
286
+ message=str(e),
287
+ provider_data={
288
+ "attempts": attempt,
289
+ "response": None,
290
+ "response_status": HTTPStatus.INTERNAL_SERVER_ERROR,
291
+ },
292
+ timestamp=int(time.time()),
293
+ )
294
+ if attempt >= DEFAULT_MAX_QUOTE_RETRIES:
295
+ self.logger.error(
296
+ f"[RELAY PROVIDER] Request failed after {DEFAULT_MAX_QUOTE_RETRIES} attempts."
297
+ )
298
+ provider_request.quote_data = quote_data
299
+ provider_request.status = ProviderRequestStatus.QUOTE_FAILED
300
+ return
301
+
302
+ time.sleep(2)
303
+
304
+ def _get_txs(
305
+ self, provider_request: ProviderRequest, *args: t.Any, **kwargs: t.Any
306
+ ) -> t.List[t.Tuple[str, t.Dict]]:
307
+ """Get the sorted list of transactions to execute the quote."""
308
+
309
+ if provider_request.params["to"]["amount"] == 0:
310
+ return []
311
+
312
+ quote_data = provider_request.quote_data
313
+ if not quote_data:
314
+ raise RuntimeError(
315
+ f"Cannot get transaction builders {provider_request.id}: quote data not present."
316
+ )
317
+
318
+ provider_data = quote_data.provider_data
319
+ if not provider_data:
320
+ raise RuntimeError(
321
+ f"Cannot get transaction builders {provider_request.id}: provider data not present."
322
+ )
323
+
324
+ txs: t.List[t.Tuple[str, t.Dict]] = []
325
+
326
+ response = provider_data.get("response")
327
+ if not response:
328
+ return txs
329
+
330
+ steps = response.get("steps", [])
331
+ from_ledger_api = self._from_ledger_api(provider_request)
332
+
333
+ for step in steps:
334
+ for i, item in enumerate(step["items"]):
335
+ tx = item["data"].copy()
336
+ tx["to"] = from_ledger_api.api.to_checksum_address(tx["to"])
337
+ tx["value"] = int(tx.get("value", 0))
338
+ tx["gas"] = int(tx.get("gas", 1))
339
+ tx["maxFeePerGas"] = int(tx.get("maxFeePerGas", 0))
340
+ tx["maxPriorityFeePerGas"] = int(tx.get("maxPriorityFeePerGas", 0))
341
+ tx["nonce"] = from_ledger_api.api.eth.get_transaction_count(tx["from"])
342
+ update_tx_with_gas_pricing(tx, from_ledger_api)
343
+ update_tx_with_gas_estimate(tx, from_ledger_api)
344
+ txs.append((f"{step['id']}-{i}", tx))
345
+
346
+ return txs
347
+
348
+ def _update_execution_status(self, provider_request: ProviderRequest) -> None:
349
+ """Update the execution status."""
350
+
351
+ if provider_request.status not in (
352
+ ProviderRequestStatus.EXECUTION_PENDING,
353
+ ProviderRequestStatus.EXECUTION_UNKNOWN,
354
+ ):
355
+ return
356
+
357
+ execution_data = provider_request.execution_data
358
+ if not execution_data:
359
+ raise RuntimeError(
360
+ f"Cannot update request {provider_request.id}: execution data not present."
361
+ )
362
+
363
+ from_tx_hash = execution_data.from_tx_hash
364
+ if not from_tx_hash:
365
+ execution_data.message = (
366
+ f"{MESSAGE_EXECUTION_FAILED} missing transaction hash."
367
+ )
368
+ provider_request.status = ProviderRequestStatus.EXECUTION_FAILED
369
+ return
370
+
371
+ url = "https://api.relay.link/requests/v2"
372
+ headers = {"accept": "application/json"}
373
+ params = {
374
+ "hash": from_tx_hash,
375
+ "sortBy": "createdAt",
376
+ }
377
+
378
+ try:
379
+ self.logger.info(f"[RELAY PROVIDER] GET {url}?{urlencode(params)}")
380
+ response = requests.get(url=url, headers=headers, params=params, timeout=30)
381
+ response_json = response.json()
382
+ relay_requests = response_json.get("requests")
383
+ if relay_requests:
384
+ relay_status = relay_requests[0].get(
385
+ "status", str(RelayExecutionStatus.WAITING)
386
+ )
387
+ execution_data.message = str(relay_status)
388
+ else:
389
+ provider_request.status = ProviderRequestStatus.EXECUTION_UNKNOWN
390
+ return
391
+ response.raise_for_status()
392
+ except Exception as e:
393
+ self.logger.error(
394
+ f"[RELAY PROVIDER] Failed to update status for request {provider_request.id}: {e}"
395
+ )
396
+ provider_request.status = ProviderRequestStatus.EXECUTION_UNKNOWN
397
+ return
398
+
399
+ if relay_status == RelayExecutionStatus.SUCCESS:
400
+ self.logger.info(
401
+ f"[RELAY PROVIDER] Execution done for {provider_request.id}."
402
+ )
403
+ from_ledger_api = self._from_ledger_api(provider_request)
404
+ to_ledger_api = self._to_ledger_api(provider_request)
405
+
406
+ if (
407
+ response_json["requests"][0]["data"]["outTxs"][0]["chainId"]
408
+ == response_json["requests"][0]["data"]["inTxs"][0]["chainId"]
409
+ ):
410
+ to_tx_hash = from_tx_hash # Should match response_json["requests"][0]["data"]["inTxs"][0]["hash"]
411
+ else:
412
+ to_tx_hash = response_json["requests"][0]["data"]["outTxs"][0]["hash"]
413
+
414
+ execution_data.message = response_json.get("details", None)
415
+ execution_data.to_tx_hash = to_tx_hash
416
+ execution_data.elapsed_time = Provider._tx_timestamp(
417
+ to_tx_hash, to_ledger_api
418
+ ) - Provider._tx_timestamp(from_tx_hash, from_ledger_api)
419
+ provider_request.status = ProviderRequestStatus.EXECUTION_DONE
420
+ execution_data.provider_data = {
421
+ "response": response_json,
422
+ }
423
+ elif relay_status in (
424
+ RelayExecutionStatus.FAILURE,
425
+ RelayExecutionStatus.REFUND,
426
+ ):
427
+ provider_request.status = ProviderRequestStatus.EXECUTION_FAILED
428
+ elif relay_status in (
429
+ RelayExecutionStatus.PENDING,
430
+ RelayExecutionStatus.DELAYED,
431
+ RelayExecutionStatus.WAITING,
432
+ ):
433
+ provider_request.status = ProviderRequestStatus.EXECUTION_PENDING
434
+ else:
435
+ provider_request.status = ProviderRequestStatus.EXECUTION_UNKNOWN
436
+
437
+ def _get_explorer_link(self, provider_request: ProviderRequest) -> t.Optional[str]:
438
+ """Get the explorer link for a transaction."""
439
+ if not provider_request.execution_data:
440
+ return None
441
+
442
+ quote_data = provider_request.quote_data
443
+ if not quote_data:
444
+ raise RuntimeError(
445
+ f"Cannot get explorer link for request {provider_request.id}: quote data not present."
446
+ )
447
+
448
+ provider_data = quote_data.provider_data
449
+ if not provider_data:
450
+ return None
451
+
452
+ steps = provider_data.get("response", {}).get("steps", [])
453
+ if not steps:
454
+ return None
455
+
456
+ request_id = steps[-1].get("requestId")
457
+ return f"https://relay.link/transaction/{request_id}"