iwa 0.0.0__py3-none-any.whl → 0.0.1a2__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 (191) hide show
  1. conftest.py +22 -0
  2. iwa/__init__.py +1 -0
  3. iwa/__main__.py +6 -0
  4. iwa/core/__init__.py +1 -0
  5. iwa/core/chain/__init__.py +68 -0
  6. iwa/core/chain/errors.py +47 -0
  7. iwa/core/chain/interface.py +514 -0
  8. iwa/core/chain/manager.py +38 -0
  9. iwa/core/chain/models.py +128 -0
  10. iwa/core/chain/rate_limiter.py +193 -0
  11. iwa/core/cli.py +210 -0
  12. iwa/core/constants.py +28 -0
  13. iwa/core/contracts/__init__.py +1 -0
  14. iwa/core/contracts/contract.py +297 -0
  15. iwa/core/contracts/erc20.py +79 -0
  16. iwa/core/contracts/multisend.py +71 -0
  17. iwa/core/db.py +317 -0
  18. iwa/core/keys.py +361 -0
  19. iwa/core/mnemonic.py +385 -0
  20. iwa/core/models.py +344 -0
  21. iwa/core/monitor.py +209 -0
  22. iwa/core/plugins.py +45 -0
  23. iwa/core/pricing.py +91 -0
  24. iwa/core/services/__init__.py +17 -0
  25. iwa/core/services/account.py +57 -0
  26. iwa/core/services/balance.py +113 -0
  27. iwa/core/services/plugin.py +88 -0
  28. iwa/core/services/safe.py +392 -0
  29. iwa/core/services/transaction.py +172 -0
  30. iwa/core/services/transfer/__init__.py +166 -0
  31. iwa/core/services/transfer/base.py +260 -0
  32. iwa/core/services/transfer/erc20.py +247 -0
  33. iwa/core/services/transfer/multisend.py +386 -0
  34. iwa/core/services/transfer/native.py +262 -0
  35. iwa/core/services/transfer/swap.py +326 -0
  36. iwa/core/settings.py +95 -0
  37. iwa/core/tables.py +60 -0
  38. iwa/core/test.py +27 -0
  39. iwa/core/tests/test_wallet.py +255 -0
  40. iwa/core/types.py +59 -0
  41. iwa/core/ui.py +99 -0
  42. iwa/core/utils.py +59 -0
  43. iwa/core/wallet.py +380 -0
  44. iwa/plugins/__init__.py +1 -0
  45. iwa/plugins/gnosis/__init__.py +5 -0
  46. iwa/plugins/gnosis/cow/__init__.py +6 -0
  47. iwa/plugins/gnosis/cow/quotes.py +148 -0
  48. iwa/plugins/gnosis/cow/swap.py +403 -0
  49. iwa/plugins/gnosis/cow/types.py +20 -0
  50. iwa/plugins/gnosis/cow_utils.py +44 -0
  51. iwa/plugins/gnosis/plugin.py +68 -0
  52. iwa/plugins/gnosis/safe.py +157 -0
  53. iwa/plugins/gnosis/tests/test_cow.py +227 -0
  54. iwa/plugins/gnosis/tests/test_safe.py +100 -0
  55. iwa/plugins/olas/__init__.py +5 -0
  56. iwa/plugins/olas/constants.py +106 -0
  57. iwa/plugins/olas/contracts/activity_checker.py +93 -0
  58. iwa/plugins/olas/contracts/base.py +10 -0
  59. iwa/plugins/olas/contracts/mech.py +49 -0
  60. iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
  61. iwa/plugins/olas/contracts/service.py +215 -0
  62. iwa/plugins/olas/contracts/staking.py +403 -0
  63. iwa/plugins/olas/importer.py +736 -0
  64. iwa/plugins/olas/mech_reference.py +135 -0
  65. iwa/plugins/olas/models.py +110 -0
  66. iwa/plugins/olas/plugin.py +243 -0
  67. iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
  68. iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
  69. iwa/plugins/olas/service_manager/__init__.py +60 -0
  70. iwa/plugins/olas/service_manager/base.py +113 -0
  71. iwa/plugins/olas/service_manager/drain.py +336 -0
  72. iwa/plugins/olas/service_manager/lifecycle.py +839 -0
  73. iwa/plugins/olas/service_manager/mech.py +322 -0
  74. iwa/plugins/olas/service_manager/staking.py +530 -0
  75. iwa/plugins/olas/tests/conftest.py +30 -0
  76. iwa/plugins/olas/tests/test_importer.py +128 -0
  77. iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
  78. iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
  79. iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
  80. iwa/plugins/olas/tests/test_olas_integration.py +561 -0
  81. iwa/plugins/olas/tests/test_olas_models.py +144 -0
  82. iwa/plugins/olas/tests/test_olas_view.py +258 -0
  83. iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
  84. iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
  85. iwa/plugins/olas/tests/test_plugin.py +70 -0
  86. iwa/plugins/olas/tests/test_plugin_full.py +212 -0
  87. iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
  88. iwa/plugins/olas/tests/test_service_manager.py +1065 -0
  89. iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
  90. iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
  91. iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
  92. iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
  93. iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
  94. iwa/plugins/olas/tests/test_service_staking.py +342 -0
  95. iwa/plugins/olas/tests/test_staking_integration.py +269 -0
  96. iwa/plugins/olas/tests/test_staking_validation.py +109 -0
  97. iwa/plugins/olas/tui/__init__.py +1 -0
  98. iwa/plugins/olas/tui/olas_view.py +952 -0
  99. iwa/tools/check_profile.py +67 -0
  100. iwa/tools/release.py +111 -0
  101. iwa/tools/reset_env.py +111 -0
  102. iwa/tools/reset_tenderly.py +362 -0
  103. iwa/tools/restore_backup.py +82 -0
  104. iwa/tui/__init__.py +1 -0
  105. iwa/tui/app.py +174 -0
  106. iwa/tui/modals/__init__.py +5 -0
  107. iwa/tui/modals/base.py +406 -0
  108. iwa/tui/rpc.py +63 -0
  109. iwa/tui/screens/__init__.py +1 -0
  110. iwa/tui/screens/wallets.py +749 -0
  111. iwa/tui/tests/test_app.py +125 -0
  112. iwa/tui/tests/test_rpc.py +139 -0
  113. iwa/tui/tests/test_wallets_refactor.py +30 -0
  114. iwa/tui/tests/test_widgets.py +123 -0
  115. iwa/tui/widgets/__init__.py +5 -0
  116. iwa/tui/widgets/base.py +100 -0
  117. iwa/tui/workers.py +42 -0
  118. iwa/web/dependencies.py +76 -0
  119. iwa/web/models.py +76 -0
  120. iwa/web/routers/accounts.py +115 -0
  121. iwa/web/routers/olas/__init__.py +24 -0
  122. iwa/web/routers/olas/admin.py +169 -0
  123. iwa/web/routers/olas/funding.py +135 -0
  124. iwa/web/routers/olas/general.py +29 -0
  125. iwa/web/routers/olas/services.py +378 -0
  126. iwa/web/routers/olas/staking.py +341 -0
  127. iwa/web/routers/state.py +65 -0
  128. iwa/web/routers/swap.py +617 -0
  129. iwa/web/routers/transactions.py +153 -0
  130. iwa/web/server.py +155 -0
  131. iwa/web/tests/test_web_endpoints.py +713 -0
  132. iwa/web/tests/test_web_olas.py +430 -0
  133. iwa/web/tests/test_web_swap.py +103 -0
  134. iwa-0.0.1a2.dist-info/METADATA +234 -0
  135. iwa-0.0.1a2.dist-info/RECORD +186 -0
  136. iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
  137. iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
  138. iwa-0.0.1a2.dist-info/top_level.txt +4 -0
  139. tests/legacy_cow.py +248 -0
  140. tests/legacy_safe.py +93 -0
  141. tests/legacy_transaction_retry_logic.py +51 -0
  142. tests/legacy_tui.py +440 -0
  143. tests/legacy_wallets_screen.py +554 -0
  144. tests/legacy_web.py +243 -0
  145. tests/test_account_service.py +120 -0
  146. tests/test_balance_service.py +186 -0
  147. tests/test_chain.py +490 -0
  148. tests/test_chain_interface.py +210 -0
  149. tests/test_cli.py +139 -0
  150. tests/test_contract.py +195 -0
  151. tests/test_db.py +180 -0
  152. tests/test_drain_coverage.py +174 -0
  153. tests/test_erc20.py +95 -0
  154. tests/test_gnosis_plugin.py +111 -0
  155. tests/test_keys.py +449 -0
  156. tests/test_legacy_wallet.py +1285 -0
  157. tests/test_main.py +13 -0
  158. tests/test_mnemonic.py +217 -0
  159. tests/test_modals.py +109 -0
  160. tests/test_models.py +213 -0
  161. tests/test_monitor.py +202 -0
  162. tests/test_multisend.py +84 -0
  163. tests/test_plugin_service.py +119 -0
  164. tests/test_pricing.py +143 -0
  165. tests/test_rate_limiter.py +199 -0
  166. tests/test_reset_tenderly.py +202 -0
  167. tests/test_rpc_view.py +73 -0
  168. tests/test_safe_coverage.py +139 -0
  169. tests/test_safe_service.py +168 -0
  170. tests/test_service_manager_integration.py +61 -0
  171. tests/test_service_manager_structure.py +31 -0
  172. tests/test_service_transaction.py +176 -0
  173. tests/test_staking_router.py +71 -0
  174. tests/test_staking_simple.py +31 -0
  175. tests/test_tables.py +76 -0
  176. tests/test_transaction_service.py +161 -0
  177. tests/test_transfer_multisend.py +179 -0
  178. tests/test_transfer_native.py +220 -0
  179. tests/test_transfer_security.py +93 -0
  180. tests/test_transfer_structure.py +37 -0
  181. tests/test_transfer_swap_unit.py +155 -0
  182. tests/test_ui_coverage.py +66 -0
  183. tests/test_utils.py +53 -0
  184. tests/test_workers.py +91 -0
  185. tools/verify_drain.py +183 -0
  186. __init__.py +0 -2
  187. hello.py +0 -6
  188. iwa-0.0.0.dist-info/METADATA +0 -10
  189. iwa-0.0.0.dist-info/RECORD +0 -6
  190. iwa-0.0.0.dist-info/top_level.txt +0 -2
  191. {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env python3
2
+ """Tool to check the active Tenderly profile."""
3
+
4
+ import requests
5
+
6
+ from iwa.core.settings import settings
7
+
8
+
9
+ def check_rpc_status(rpc_url):
10
+ """Check the status of an RPC endpoint."""
11
+ if not rpc_url:
12
+ print(" [!] No RPC URL found in secrets.env for this profile.")
13
+ return
14
+
15
+ print(f" Checking RPC: {rpc_url}")
16
+
17
+ headers = {"Content-Type": "application/json"}
18
+ # Include access key if present
19
+ if settings.tenderly_access_key:
20
+ headers["X-Access-Key"] = settings.tenderly_access_key.get_secret_value()
21
+
22
+ payload = {"jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 1}
23
+
24
+ try:
25
+ response = requests.post(rpc_url, json=payload, headers=headers, timeout=5)
26
+
27
+ # Check for HTTP errors (like 403)
28
+ if response.status_code != 200:
29
+ print(f" [X] HTTP ERROR: {response.status_code}")
30
+ try:
31
+ err = response.json()
32
+ if "error" in err:
33
+ print(f" Details: {err['error']}")
34
+ except Exception:
35
+ print(f" Response text: {response.text}")
36
+
37
+ if response.status_code == 403:
38
+ print(" => LIKELY QUOTA EXCEEDED (Rate Limit).")
39
+ return
40
+
41
+ # Check for JSON-RPC errors (like -32004)
42
+ data = response.json()
43
+ if "error" in data:
44
+ code = data["error"].get("code")
45
+ msg = data["error"].get("message")
46
+ print(f" [X] JSON-RPC ERROR: {code} - {msg}")
47
+ if code == -32004:
48
+ print(" => QUOTA LIMIT REACHED.")
49
+ else:
50
+ block = int(data["result"], 16)
51
+ print(f" [OK] API Operational. Block: {block}")
52
+
53
+ except Exception as e:
54
+ print(f" [!] Exception checking RPC: {e}")
55
+
56
+
57
+ def main():
58
+ """Check and display the active Tenderly profile status."""
59
+ print(f"Active Tenderly Profile: {settings.tenderly_profile}")
60
+
61
+ # Check Gnosis RPC as primary indicator
62
+ rpc = settings.gnosis_rpc.get_secret_value() if settings.gnosis_rpc else None
63
+ check_rpc_status(rpc)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
iwa/tools/release.py ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Release helper script.
3
+
4
+ Usage: python release.py <version>
5
+ """
6
+ import argparse
7
+ import subprocess # nosec: B404
8
+ import sys
9
+ from typing import NoReturn
10
+
11
+
12
+ def run(cmd: str, check: bool = True, capture: bool = False) -> str:
13
+ """Run a shell command."""
14
+ try:
15
+ result = subprocess.run(
16
+ cmd,
17
+ shell=True, # nosec: B602
18
+ check=check,
19
+ text=True,
20
+ stdout=subprocess.PIPE if capture else None,
21
+ stderr=subprocess.PIPE if capture else None
22
+ )
23
+ return result.stdout.strip() if capture else ""
24
+ except subprocess.CalledProcessError as e:
25
+ if capture:
26
+ print(f"Error output: {e.stderr}")
27
+ sys.exit(e.returncode)
28
+
29
+ def error(msg: str) -> NoReturn:
30
+ """Print error and exit."""
31
+ print(f"❌ Error: {msg}")
32
+ sys.exit(1)
33
+
34
+ def info(msg: str) -> None:
35
+ """Print info message."""
36
+ print(f"🚀 {msg}")
37
+
38
+ def confirm(question: str) -> bool:
39
+ """Ask for user confirmation."""
40
+ while True:
41
+ try:
42
+ choice = input(f"{question} [y/N] ").lower()
43
+ if not choice or choice == 'n':
44
+ return False
45
+ if choice == 'y':
46
+ return True
47
+ except EOFError:
48
+ return False
49
+
50
+ def main() -> None:
51
+ """Execute the release process."""
52
+ parser = argparse.ArgumentParser(description="Create a new release")
53
+ parser.add_argument("version", help="Version tag (e.g., 0.1.0)")
54
+ args = parser.parse_args()
55
+
56
+ version = args.version
57
+ if version.startswith("v"):
58
+ version = version[1:]
59
+ tag = f"v{version}"
60
+
61
+ # 1. Check git status
62
+ print("Checking git status...")
63
+ status = run("git status --porcelain", capture=True)
64
+ if status:
65
+ error("Working directory is not clean. Commit changes first.")
66
+
67
+ try:
68
+ # User requested SSH agent usage. If origin is HTTPS, we try to use SSH for the check explicitly.
69
+ # This bypasses the HTTPS auth prompt if the user has SSH keys configured.
70
+ origin_url = run("git remote get-url origin", capture=True)
71
+ check_url = origin_url
72
+ if origin_url.startswith("https://github.com/"):
73
+ # Convert https://github.com/user/repo.git -> git@github.com:user/repo.git
74
+ ssh_url = origin_url.replace("https://github.com/", "git@github.com:")
75
+ check_url = ssh_url
76
+ print(f"checking remote tags via SSH ({ssh_url})...")
77
+
78
+ # Use ls-remote on the specific URL (SSH) to check tags without prompting for HTTPS creds
79
+ exists_remotely = run(f"git ls-remote --tags {check_url} {tag}", check=False, capture=True)
80
+
81
+ except Exception:
82
+ # fetch failed (likely auth), assume we can proceed to push (which might prompt or work if user is right)
83
+ print("⚠️ Could not fetch remote info (auth needed?). Assuming tag is new remotely.")
84
+ exists_remotely = ""
85
+
86
+ # 3. Check if tag exists
87
+ exists_locally = run(f"git rev-parse {tag}", check=False, capture=True)
88
+
89
+ if exists_locally or exists_remotely:
90
+ print(f"⚠️ Tag {tag} already exists!")
91
+ if not confirm("Do you want to FORCE update it (delete and overwrite)?"):
92
+ error("Aborted by user.")
93
+
94
+ info(f"Force updating {tag}...")
95
+ run(f"git tag -f {tag}")
96
+ # Push to the explicit URL (likely SSH) to avoid HTTPS prompts
97
+ run(f"git push -f {check_url} {tag}")
98
+ print(f"✅ Tag {tag} force updated. GitHub Actions triggered.")
99
+
100
+ else:
101
+ info(f"Preparing to release {tag}...")
102
+ if not confirm("Are you sure? This will trigger a deployment to PyPI and DockerHub"):
103
+ error("Aborted by user.")
104
+
105
+ run(f"git tag {tag}")
106
+ # Push to the explicit URL (likely SSH) to avoid HTTPS prompts
107
+ run(f"git push {check_url} {tag}")
108
+ print(f"✅ Release {tag} triggered! Check GitHub Actions.")
109
+
110
+ if __name__ == "__main__":
111
+ main()
iwa/tools/reset_env.py ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Tool to reset the full environment.
3
+
4
+ 1. Resets Tenderly networks (based on active profile).
5
+ 2. Clears Olas services from config.yaml.
6
+ 3. Clears all accounts from wallet.json except 'master'.
7
+ """
8
+
9
+ import json
10
+ import subprocess # nosec B404
11
+ import sys
12
+
13
+ import yaml
14
+
15
+ from iwa.core.constants import CONFIG_PATH, WALLET_PATH
16
+ from iwa.core.settings import settings
17
+
18
+
19
+ def _reset_tenderly(profile: int) -> None:
20
+ """Reset Tenderly networks using just command."""
21
+ cmd = ["just", "reset-tenderly", str(profile)]
22
+ print(f"Running: {' '.join(cmd)}")
23
+ try:
24
+ subprocess.check_call(cmd) # nosec B603
25
+ except subprocess.CalledProcessError as e:
26
+ print(f"Error running reset-tenderly: {e}")
27
+ sys.exit(1)
28
+
29
+
30
+ def _clean_olas_services() -> None:
31
+ """Remove all Olas services from config.yaml."""
32
+ if not CONFIG_PATH.exists():
33
+ return
34
+
35
+ try:
36
+ with open(CONFIG_PATH, "r") as f:
37
+ config = yaml.safe_load(f) or {}
38
+
39
+ if "plugins" not in config or "olas" not in config["plugins"]:
40
+ return
41
+
42
+ if "services" not in config["plugins"]["olas"]:
43
+ return
44
+
45
+ services = config["plugins"]["olas"]["services"]
46
+ if not services:
47
+ print("No Olas services found in config.yaml.")
48
+ return
49
+
50
+ print(f"Removing {len(services)} Olas services from config.yaml...")
51
+ config["plugins"]["olas"]["services"] = {}
52
+ with open(CONFIG_PATH, "w") as f:
53
+ yaml.dump(config, f)
54
+ except Exception as e:
55
+ print(f"Error cleaning config.yaml: {e}")
56
+
57
+
58
+ def _clean_wallet_accounts() -> None:
59
+ """Remove all accounts from wallet.json except master."""
60
+ if not WALLET_PATH.exists():
61
+ return
62
+
63
+ try:
64
+ with open(WALLET_PATH, "r") as f:
65
+ data = json.load(f)
66
+
67
+ accounts = data.get("accounts", {})
68
+
69
+ # Find master account
70
+ master_addr = None
71
+ master_acct = None
72
+ for addr, acct in accounts.items():
73
+ if acct.get("tag") == "master":
74
+ master_addr = addr
75
+ master_acct = acct
76
+ break
77
+
78
+ if not master_addr:
79
+ print(
80
+ "Warning: Master account not found in wallet.json! Skipping cleanup to avoid data loss."
81
+ )
82
+ return
83
+
84
+ if len(accounts) <= 1:
85
+ print("Only master account exists in wallet.json.")
86
+ return
87
+
88
+ print(
89
+ f"Preserving master account ({master_addr}), removing {len(accounts) - 1} other accounts..."
90
+ )
91
+ data["accounts"] = {master_addr: master_acct}
92
+ with open(WALLET_PATH, "w") as f:
93
+ json.dump(data, f, indent=4)
94
+ except Exception as e:
95
+ print(f"Error cleaning wallet.json: {e}")
96
+
97
+
98
+ def main():
99
+ """Reset the environment by clearing networks, services, and accounts."""
100
+ profile = settings.tenderly_profile
101
+ print(f"Detected Tenderly profile: {profile}")
102
+
103
+ _reset_tenderly(profile)
104
+ _clean_olas_services()
105
+ _clean_wallet_accounts()
106
+
107
+ print("Environment reset complete.")
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()
@@ -0,0 +1,362 @@
1
+ """Recreates Tenderly networks and funds wallets as per configuration."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import random
7
+ import re
8
+ import string
9
+ import sys
10
+ from typing import List, Optional, Tuple
11
+
12
+ import requests
13
+ from web3 import Web3
14
+
15
+ from iwa.core.constants import SECRETS_PATH, get_tenderly_config_path
16
+ from iwa.core.keys import KeyStorage
17
+ from iwa.core.models import TenderlyConfig
18
+ from iwa.core.settings import settings
19
+
20
+
21
+ def get_tenderly_credentials() -> Tuple[Optional[str], Optional[str], Optional[str]]:
22
+ """Get Tenderly credentials from settings (based on current profile)."""
23
+ return (
24
+ settings.tenderly_account_slug.get_secret_value()
25
+ if settings.tenderly_account_slug
26
+ else None,
27
+ settings.tenderly_project_slug.get_secret_value()
28
+ if settings.tenderly_project_slug
29
+ else None,
30
+ settings.tenderly_access_key.get_secret_value() if settings.tenderly_access_key else None,
31
+ )
32
+
33
+
34
+ def _generate_default_config() -> TenderlyConfig:
35
+ """Generate a default TenderlyConfig from SupportedChains."""
36
+ from iwa.core.chain import SupportedChains
37
+ from iwa.core.models import FundRequirements, TokenAmount, VirtualNet
38
+
39
+ vnets = {}
40
+ chains = SupportedChains()
41
+
42
+ for chain_name, chain in [
43
+ ("gnosis", chains.gnosis),
44
+ ("ethereum", chains.ethereum),
45
+ ("base", chains.base),
46
+ ]:
47
+ # Get OLAS token address for this chain
48
+ olas_address = chain.tokens.get("OLAS")
49
+
50
+ tokens = []
51
+ if olas_address:
52
+ tokens.append(
53
+ TokenAmount(
54
+ address=str(olas_address),
55
+ symbol="OLAS",
56
+ amount_eth=settings.tenderly_olas_funds,
57
+ )
58
+ )
59
+
60
+ vnets[chain_name] = VirtualNet(
61
+ chain_id=chain.chain_id,
62
+ funds_requirements={
63
+ "all": FundRequirements(
64
+ native_eth=settings.tenderly_native_funds,
65
+ tokens=tokens,
66
+ )
67
+ },
68
+ )
69
+
70
+ return TenderlyConfig(vnets=vnets)
71
+
72
+
73
+ def _delete_vnet(
74
+ tenderly_access_key: str, account_slug: str, project_slug: str, vnet_id: str
75
+ ) -> None:
76
+ url = f"https://api.tenderly.co/api/v1/account/{account_slug}/project/{project_slug}/vnets/{vnet_id}"
77
+ requests.delete(
78
+ url=url,
79
+ timeout=300,
80
+ headers={"Accept": "application/json", "X-Access-Key": tenderly_access_key},
81
+ )
82
+ print(f"Deleted vnet {vnet_id}")
83
+
84
+
85
+ def _create_vnet(
86
+ tenderly_access_key: str,
87
+ account_slug: str,
88
+ project_slug: str,
89
+ network_id: int,
90
+ chain_id: int,
91
+ vnet_slug: str,
92
+ vnet_display_name: str,
93
+ block_number: Optional[str] = "latest",
94
+ ) -> Tuple[str | None, str | None, str | None]:
95
+ # Define the payload for the fork creation
96
+ payload = {
97
+ "slug": vnet_slug,
98
+ "display_name": vnet_display_name,
99
+ "fork_config": {"network_id": network_id, "block_number": str(block_number)},
100
+ "virtual_network_config": {"chain_config": {"chain_id": chain_id}},
101
+ "sync_state_config": {"enabled": False},
102
+ "explorer_page_config": {
103
+ "enabled": False,
104
+ "verification_visibility": "bytecode",
105
+ },
106
+ }
107
+
108
+ url = f"https://api.tenderly.co/api/v1/account/{account_slug}/project/{project_slug}/vnets"
109
+ response = requests.post(
110
+ url=url,
111
+ timeout=300,
112
+ headers={
113
+ "Accept": "application/json",
114
+ "Content-Type": "application/json",
115
+ "X-Access-Key": tenderly_access_key,
116
+ },
117
+ data=json.dumps(payload),
118
+ )
119
+
120
+ json_response = response.json()
121
+ vnet_id = json_response.get("id")
122
+ admin_rpc = next(
123
+ (rpc["url"] for rpc in json_response.get("rpcs", []) if rpc["name"] == "Admin RPC"),
124
+ None,
125
+ )
126
+ public_rpc = next(
127
+ (rpc["url"] for rpc in json_response.get("rpcs", []) if rpc["name"] == "Public RPC"),
128
+ None,
129
+ )
130
+ print(f"Created vnet of chain_id={network_id} at block number {block_number}")
131
+ return vnet_id, admin_rpc, public_rpc
132
+
133
+
134
+ def _generate_vnet_slug(preffix: str = "vnet", length: int = 4):
135
+ characters = string.ascii_lowercase
136
+ return (
137
+ preffix + "-" + "".join(random.choice(characters) for _ in range(length)) # nosec
138
+ )
139
+
140
+
141
+ def update_rpc_variables(tenderly_config: TenderlyConfig) -> None:
142
+ """Updates several files"""
143
+ with open(SECRETS_PATH, "r", encoding="utf-8") as file:
144
+ content = file.read()
145
+
146
+ for chain_name, vnet in tenderly_config.vnets.items():
147
+ pattern = rf"{chain_name.lower()}_test_rpc=(\S+)"
148
+
149
+ if re.search(pattern, content, re.MULTILINE):
150
+ content = re.sub(
151
+ pattern,
152
+ f"{chain_name.lower()}_test_rpc={vnet.public_rpc}",
153
+ content,
154
+ flags=re.MULTILINE,
155
+ )
156
+ else:
157
+ if content and not content.endswith("\n"):
158
+ content += "\n"
159
+ content += f"{chain_name.lower()}_test_rpc={vnet.public_rpc}\n"
160
+
161
+ with open(SECRETS_PATH, "w", encoding="utf-8") as file:
162
+ file.write(content)
163
+
164
+ print("Updated RPCs in secrets.env")
165
+
166
+
167
+ def _fund_wallet( # nosec
168
+ admin_rpc: str,
169
+ wallet_addresses: List[str],
170
+ amount_eth: float,
171
+ native_or_token_address: str = "native",
172
+ ) -> None:
173
+ if native_or_token_address == "native": # nosec
174
+ json_data = {
175
+ "jsonrpc": "2.0",
176
+ "method": "tenderly_setBalance",
177
+ "params": [
178
+ wallet_addresses,
179
+ hex(Web3.to_wei(amount_eth, "ether")), # to wei
180
+ ],
181
+ "id": "1234",
182
+ }
183
+ else:
184
+ json_data = {
185
+ "jsonrpc": "2.0",
186
+ "method": "tenderly_setErc20Balance",
187
+ "params": [
188
+ native_or_token_address,
189
+ wallet_addresses,
190
+ hex(Web3.to_wei(amount_eth, "ether")), # to wei
191
+ ],
192
+ "id": "1234",
193
+ }
194
+
195
+ response = requests.post(
196
+ url=admin_rpc,
197
+ timeout=300,
198
+ headers={"Content-Type": "application/json"},
199
+ json=json_data,
200
+ )
201
+ if response.status_code != 200:
202
+ print(response.status_code)
203
+ try:
204
+ print(response.json())
205
+ except requests.exceptions.JSONDecodeError: # type: ignore
206
+ pass
207
+
208
+
209
+ def _process_vnet(
210
+ vnet_name: str,
211
+ vnet,
212
+ tenderly_access_key: str,
213
+ account_slug: str,
214
+ project_slug: str,
215
+ tenderly_config,
216
+ ) -> bool:
217
+ """Process a single vnet: delete old, create new, capture block."""
218
+ # Delete existing vnet
219
+ if vnet.vnet_id:
220
+ _delete_vnet(
221
+ tenderly_access_key=tenderly_access_key,
222
+ account_slug=account_slug,
223
+ project_slug=project_slug,
224
+ vnet_id=vnet.vnet_id,
225
+ )
226
+
227
+ # Create new network
228
+ vnet_slug = _generate_vnet_slug(preffix=vnet_name.lower())
229
+ vnet_id, admin_rpc, public_rpc = _create_vnet(
230
+ tenderly_access_key=tenderly_access_key,
231
+ account_slug=account_slug,
232
+ project_slug=project_slug,
233
+ network_id=vnet.chain_id,
234
+ chain_id=vnet.chain_id,
235
+ vnet_slug=vnet_slug,
236
+ vnet_display_name=vnet_slug,
237
+ )
238
+
239
+ if not vnet_id or not admin_rpc or not public_rpc:
240
+ print(f"Failed to create valid vnet for {vnet_name}")
241
+ return False
242
+
243
+ vnet.vnet_id = vnet_id
244
+ vnet.admin_rpc = admin_rpc
245
+ vnet.public_rpc = public_rpc
246
+ vnet.vnet_slug = vnet_slug
247
+
248
+ # Capture initial block
249
+ try:
250
+ w3 = Web3(Web3.HTTPProvider(public_rpc))
251
+ start_block = w3.eth.block_number
252
+ vnet.initial_block = start_block
253
+ print(f"Captured initial block for {vnet_name}: {start_block}")
254
+ except Exception as e:
255
+ print(f"Failed to capture initial block: {e}")
256
+ vnet.initial_block = 0
257
+
258
+ tenderly_config.save()
259
+ update_rpc_variables(tenderly_config)
260
+ return True
261
+
262
+
263
+ def _fund_vnet_accounts(vnet, keys) -> None:
264
+ """Fund all accounts for a vnet based on requirements."""
265
+ for account_tags, requirement in vnet.funds_requirements.items():
266
+ tags = account_tags.split(",")
267
+ if account_tags != "all":
268
+ addresses = []
269
+ for tag in tags:
270
+ if acc := keys.get_account(tag):
271
+ addresses.append(acc.address)
272
+ else:
273
+ addresses = list(keys.accounts.keys())
274
+
275
+ if not addresses:
276
+ continue
277
+
278
+ if requirement.native_eth > 0:
279
+ _fund_wallet(
280
+ admin_rpc=vnet.admin_rpc,
281
+ wallet_addresses=addresses,
282
+ amount_eth=requirement.native_eth,
283
+ native_or_token_address="native",
284
+ )
285
+ print(f"Funded {tags} with {requirement.native_eth} native")
286
+
287
+ for token in requirement.tokens:
288
+ _fund_wallet(
289
+ admin_rpc=vnet.admin_rpc,
290
+ wallet_addresses=addresses,
291
+ amount_eth=token.amount_eth,
292
+ native_or_token_address=str(token.address),
293
+ )
294
+ print(f"Funded {tags} with {token.amount_eth} {token.symbol}")
295
+
296
+
297
+ def main() -> None:
298
+ """Main - uses tenderly_profile from settings."""
299
+ profile = settings.tenderly_profile
300
+ print(f"Recreating Tenderly Networks (Profile {profile})")
301
+
302
+ account_slug, project_slug, tenderly_access_key = get_tenderly_credentials()
303
+
304
+ if not account_slug or not project_slug or not tenderly_access_key:
305
+ print(f"Missing Tenderly environment variables for profile {profile}")
306
+ return
307
+
308
+ config_path = get_tenderly_config_path(profile)
309
+
310
+ if not config_path.exists():
311
+ print(f"Generating new config file: {config_path}")
312
+ tenderly_config = _generate_default_config()
313
+ tenderly_config.save(config_path)
314
+
315
+ tenderly_config = TenderlyConfig.load(config_path)
316
+
317
+ for vnet_name, vnet in tenderly_config.vnets.items():
318
+ if not _process_vnet(
319
+ vnet_name, vnet, tenderly_access_key, account_slug, project_slug, tenderly_config
320
+ ):
321
+ continue
322
+
323
+ # Fund wallets
324
+ keys = KeyStorage()
325
+ from iwa.core.services import AccountService, SafeService
326
+
327
+ account_service = AccountService(keys)
328
+ safe_service = SafeService(keys, account_service)
329
+
330
+ _fund_vnet_accounts(vnet, keys)
331
+
332
+ # Redeploy safes for Gnosis
333
+ if vnet_name == "Gnosis":
334
+ safe_service.redeploy_safes()
335
+
336
+
337
+ if __name__ == "__main__": # pragma: no cover
338
+ parser = argparse.ArgumentParser(description="Reset Tenderly networks")
339
+ parser.add_argument(
340
+ "--profile",
341
+ "-p",
342
+ type=int,
343
+ default=1,
344
+ choices=[1, 2, 3],
345
+ help="Tenderly profile to use (1 or 2)",
346
+ )
347
+ args = parser.parse_args()
348
+
349
+ # Set profile env var
350
+ os.environ["TENDERLY_PROFILE"] = str(args.profile)
351
+
352
+ # Reset the singleton to reload with new env
353
+ from iwa.core.settings import Settings
354
+
355
+ Settings._instance = None # type: ignore
356
+
357
+ # Reimport settings to get fresh instance
358
+ if "iwa.core.settings" in sys.modules:
359
+ del sys.modules["iwa.core.settings"]
360
+ from iwa.core.settings import settings # noqa: F401
361
+
362
+ main()