Skip to content

Commit 664d8cb

Browse files
committed
add crosschain test orchestration (ported from protocol-internal PR #13)
Adds CrossChainTestManager used by deploy.py for crosschaintest:hub and crosschaintest:spoke steps. Was missing from the branch despite deploy.py already importing it.
1 parent 9beffab commit 664d8cb

File tree

1 file changed

+265
-0
lines changed

1 file changed

+265
-0
lines changed

script/deploy/lib/crosschain.py

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Centrifuge Protocol Deployment Tool - Cross-Chain Test Manager
4+
5+
Handles orchestration of cross-chain test deployments:
6+
- Hub test: runs TestCrossChainHub.s.sol and logs parameters
7+
- Spoke tests: runs TestCrossChainSpoke.s.sol on connected networks
8+
- Explorer links: generates adapter and contract explorer URLs
9+
"""
10+
11+
import json
12+
import time
13+
import re
14+
import pathlib
15+
from typing import Dict, List, Optional, Any
16+
from .formatter import *
17+
from .load_config import EnvironmentLoader
18+
from .runner import DeploymentRunner
19+
20+
21+
class CrossChainTestManager:
22+
def __init__(self, env_loader: EnvironmentLoader, args, root_dir: pathlib.Path):
23+
self.env_loader = env_loader
24+
self.args = args
25+
self.root_dir = root_dir
26+
self.logs_dir = root_dir / "script" / "deploy" / "logs"
27+
self.logs_dir.mkdir(exist_ok=True)
28+
29+
def run_hub_test(self) -> Dict[str, Any]:
30+
"""Run hub cross-chain test and log parameters"""
31+
print_section("Running Cross-Chain Hub Test")
32+
33+
# Ensure we have connectsTo networks
34+
connects_to = self.env_loader.config.get("network", {}).get("connectsTo", [])
35+
if not connects_to:
36+
print_error("No connected networks found in config. Add 'connectsTo' array to network config.")
37+
raise SystemExit(1)
38+
39+
# Validate required contracts exist
40+
required_contracts = ["gateway", "hub", "balanceSheet", "batchRequestManager"]
41+
contracts = self.env_loader.config.get("contracts", {})
42+
missing_contracts = [c for c in required_contracts if c not in contracts]
43+
if missing_contracts:
44+
print_error(f"Missing required contracts in config: {', '.join(missing_contracts)}")
45+
print_info("Run deploy:protocol first to deploy these contracts")
46+
raise SystemExit(1)
47+
48+
print_info(f"Hub network: {self.env_loader.network_name}")
49+
print_info(f"Connected networks: {', '.join(connects_to)}")
50+
print_info(f"Ops admin: {format_account(self.env_loader.ops_admin_address)}")
51+
52+
# Strip --resume if present for fresh run
53+
original_forge_args = list(self.args.forge_args)
54+
self.args.forge_args = [a for a in self.args.forge_args if a != "--resume"]
55+
56+
# Run the hub test
57+
runner = DeploymentRunner(self.env_loader, self.args)
58+
if not runner.run_deploy("TestCrossChainHub"):
59+
print_error("Hub test deployment failed")
60+
raise SystemExit(1)
61+
62+
# Restore forge args
63+
self.args.forge_args = original_forge_args
64+
65+
# Extract parameters from script output (fallback to defaults)
66+
pool_index_offset = self._extract_pool_offset()
67+
test_run_id = self._extract_test_run_id()
68+
69+
# Create log entry
70+
log_data = {
71+
"hubNetwork": self.env_loader.network_name,
72+
"hubCentrifugeId": self.env_loader.config["network"]["centrifugeId"],
73+
"poolIndexOffset": pool_index_offset,
74+
"testRunId": test_run_id,
75+
"spokes": []
76+
}
77+
78+
# Add spoke network info and validate
79+
for spoke_network in connects_to:
80+
spoke_config_file = self.root_dir / "env" / f"{spoke_network}.json"
81+
if not spoke_config_file.exists():
82+
print_error(f"Spoke network config not found: {spoke_config_file}")
83+
raise SystemExit(1)
84+
85+
with open(spoke_config_file, 'r') as f:
86+
spoke_config = json.load(f)
87+
88+
# Validate spoke has required contracts
89+
spoke_contracts = spoke_config.get("contracts", {})
90+
spoke_required = ["gateway", "spoke", "vaultRegistry"]
91+
missing_spoke_contracts = [c for c in spoke_required if c not in spoke_contracts]
92+
if missing_spoke_contracts:
93+
print_error(f"Spoke {spoke_network} missing required contracts: {', '.join(missing_spoke_contracts)}")
94+
print_info(f"Run deploy:protocol on {spoke_network} first")
95+
raise SystemExit(1)
96+
97+
log_data["spokes"].append({
98+
"network": spoke_network,
99+
"centrifugeId": spoke_config["network"]["centrifugeId"]
100+
})
101+
102+
# Save log file
103+
timestamp = int(time.time())
104+
log_file = self.logs_dir / f"crosschain-{self.env_loader.network_name}-{timestamp}.json"
105+
with open(log_file, 'w') as f:
106+
json.dump(log_data, f, indent=2)
107+
108+
print_success("Hub test completed successfully")
109+
print_info(f"Parameters logged to: {log_file}")
110+
111+
# Generate and print explorer links
112+
self._print_hub_explorer_links(log_data)
113+
114+
return {
115+
"log_file": str(log_file),
116+
"log_data": log_data
117+
}
118+
119+
def run_spoke_tests(self, log_path: Optional[str] = None) -> Dict[str, Any]:
120+
"""Run spoke tests for all connected networks"""
121+
print_section("Running Cross-Chain Spoke Tests")
122+
123+
# Load hub log
124+
if log_path:
125+
log_file = pathlib.Path(log_path)
126+
else:
127+
# Find most recent log file
128+
log_files = list(self.logs_dir.glob("crosschain-*.json"))
129+
if not log_files:
130+
print_error("No hub test log found. Run crosschaintest:hub first.")
131+
raise SystemExit(1)
132+
log_file = max(log_files, key=lambda f: f.stat().st_mtime)
133+
134+
print_info(f"Loading hub test log: {log_file}")
135+
with open(log_file, 'r') as f:
136+
log_data = json.load(f)
137+
138+
print_warning(f"About to run spoke tests for {len(log_data['spokes'])} networks:")
139+
for spoke in log_data["spokes"]:
140+
print_info(f" - {spoke['network']} (centrifugeId: {spoke['centrifugeId']})")
141+
print_warning("Press Ctrl+C to abort in the next 5 seconds...")
142+
143+
try:
144+
time.sleep(5)
145+
except KeyboardInterrupt:
146+
print_info("Aborted by user")
147+
raise SystemExit(1)
148+
149+
# Run spoke tests
150+
results = []
151+
for spoke in log_data["spokes"]:
152+
spoke_network = spoke["network"]
153+
print_section(f"Running spoke test for {spoke_network}")
154+
155+
try:
156+
# Create environment loader for spoke network
157+
spoke_env_loader = EnvironmentLoader(spoke_network, self.root_dir, self.args)
158+
spoke_runner = DeploymentRunner(spoke_env_loader, self.args)
159+
160+
# Set environment variables for the spoke script
161+
spoke_env = spoke_runner.env.copy()
162+
spoke_env["HUB_CENTRIFUGE_ID"] = str(log_data["hubCentrifugeId"])
163+
# Allow environment variable to override log file value
164+
import os
165+
pool_offset = os.environ.get("POOL_INDEX_OFFSET", str(log_data["poolIndexOffset"]))
166+
spoke_env["POOL_INDEX_OFFSET"] = pool_offset
167+
spoke_env["TEST_RUN_ID"] = log_data["testRunId"]
168+
169+
# Update runner environment
170+
spoke_runner.env = spoke_env
171+
172+
# Strip --resume if present
173+
original_forge_args = list(self.args.forge_args)
174+
self.args.forge_args = [a for a in self.args.forge_args if a != "--resume"]
175+
176+
success = spoke_runner.run_deploy("TestCrossChainSpoke")
177+
178+
# Restore forge args
179+
self.args.forge_args = original_forge_args
180+
181+
if success:
182+
print_success(f"Spoke test completed for {spoke_network}")
183+
results.append({"network": spoke_network, "success": True})
184+
else:
185+
print_error(f"Spoke test failed for {spoke_network}")
186+
results.append({"network": spoke_network, "success": False})
187+
188+
except Exception as e:
189+
print_error(f"Error running spoke test for {spoke_network}: {e}")
190+
results.append({"network": spoke_network, "success": False})
191+
192+
# Print hub explorer links
193+
self._print_spoke_explorer_links(log_data)
194+
195+
return {
196+
"log_data": log_data,
197+
"results": results
198+
}
199+
200+
def _extract_pool_offset(self) -> int:
201+
"""Extract pool index offset from environment or generate from timestamp"""
202+
import os
203+
# Check if POOL_INDEX_OFFSET is set in environment
204+
if "POOL_INDEX_OFFSET" in os.environ:
205+
return int(os.environ["POOL_INDEX_OFFSET"])
206+
# The TestCrossChainHub script uses timestamp-based default if not set
207+
# We'll use the same logic for consistency
208+
return int(time.time()) % 1000
209+
210+
def _extract_test_run_id(self) -> str:
211+
"""Extract test run ID from script output"""
212+
# The TestCrossChainHub script uses timestamp-based default if not set
213+
# We'll use the same logic for consistency
214+
return str(int(time.time()))
215+
216+
def _print_hub_explorer_links(self, log_data: Dict[str, Any]) -> None:
217+
"""Print explorer links for hub test"""
218+
print_section("Cross-Chain Test Explorer Links")
219+
220+
ops_admin = self.env_loader.ops_admin_address
221+
222+
print_subsection("Adapter Explorers (watch for outgoing messages)")
223+
print_info("Axelar: https://testnet.axelarscan.io/address/{0}?transfersType=gmp".format(ops_admin))
224+
print_info("Wormhole: https://wormholescan.io/#/txs?address={0}&network=Testnet".format(ops_admin))
225+
print_info("LayerZero: https://testnet.layerzeroscan.com/address/{0}".format(ops_admin))
226+
227+
print_subsection("Destination Contract Explorers (watch for incoming messages)")
228+
for spoke in log_data["spokes"]:
229+
spoke_network = spoke["network"]
230+
spoke_config_file = self.root_dir / "env" / f"{spoke_network}.json"
231+
if spoke_config_file.exists():
232+
with open(spoke_config_file, 'r') as f:
233+
spoke_config = json.load(f)
234+
etherscan_url = spoke_config.get("network", {}).get("etherscanUrl", "")
235+
if etherscan_url:
236+
contracts = spoke_config.get("contracts", {})
237+
print_info(f"\n{spoke_network} contracts:")
238+
if "gateway" in contracts:
239+
print_info(f" Gateway: {etherscan_url}/address/{contracts['gateway']}")
240+
if "spoke" in contracts:
241+
print_info(f" Spoke: {etherscan_url}/address/{contracts['spoke']}")
242+
if "vaultRegistry" in contracts:
243+
print_info(f" VaultRegistry: {etherscan_url}/address/{contracts['vaultRegistry']}")
244+
245+
def _print_spoke_explorer_links(self, log_data: Dict[str, Any]) -> None:
246+
"""Print hub explorer links for spoke tests"""
247+
print_section("Hub Contract Explorers (watch for cross-chain results)")
248+
249+
hub_network = log_data["hubNetwork"]
250+
hub_config_file = self.root_dir / "env" / f"{hub_network}.json"
251+
if hub_config_file.exists():
252+
with open(hub_config_file, 'r') as f:
253+
hub_config = json.load(f)
254+
etherscan_url = hub_config.get("network", {}).get("etherscanUrl", "")
255+
if etherscan_url:
256+
contracts = hub_config.get("contracts", {})
257+
print_info(f"\n{hub_network} hub contracts:")
258+
if "gateway" in contracts:
259+
print_info(f" Gateway: {etherscan_url}/address/{contracts['gateway']}")
260+
if "hub" in contracts:
261+
print_info(f" Hub: {etherscan_url}/address/{contracts['hub']}")
262+
if "balanceSheet" in contracts:
263+
print_info(f" BalanceSheet: {etherscan_url}/address/{contracts['balanceSheet']}")
264+
if "batchRequestManager" in contracts:
265+
print_info(f" BatchRequestManager: {etherscan_url}/address/{contracts['batchRequestManager']}")

0 commit comments

Comments
 (0)