cargo_tangle/command/dev/
up.rs1use std::collections::HashMap;
5use std::fs;
6use std::os::unix::process::CommandExt;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9use std::str::FromStr;
10use std::time::{Duration, Instant};
11
12use alloy_network::EthereumWallet;
13use alloy_primitives::Address;
14use alloy_provider::{Provider, ProviderBuilder};
15use alloy_rpc_types_eth::TransactionRequest;
16use alloy_signer_local::PrivateKeySigner;
17use alloy_sol_types::SolCall;
18use blueprint_client_tangle::contracts::ITangle::addPermittedCallerCall;
19use blueprint_testing_utils::anvil::tangle::insert_default_operator_key;
20use clap::Args;
21use color_eyre::eyre::{Context, Result, bail, eyre};
22use tokio::time::sleep;
23use url::Url;
24
25use crate::workspace::{Defaults, Network, TangleWorkspace, WORKSPACE_FILE};
26
27pub(crate) const MANAGED_MARKER: &str = "# managed-by = \"cargo-tangle-dev\"";
31
32const TANGLE_CONTRACT: &str = "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9";
37const STAKING_CONTRACT: &str = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512";
38const STATUS_REGISTRY_CONTRACT: &str = "0x8f86403A4DE0bb5791fa46B8e795C547942fE4Cf";
39const OPERATOR1_ADDRESS: &str = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
40const SERVICE_OWNER_PRIVATE_KEY: &str =
41 "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
42const CHAIN_ID: u64 = 31_337;
43const DEFAULT_SERVICE_ID: u64 = 0;
44const DEFAULT_BLUEPRINT_ID: u64 = 0;
45
46const DEV_DIR: &str = ".tangle/dev";
47
48#[derive(Args, Debug)]
49pub struct UpArgs {
50 #[arg(long, default_value_t = 8545)]
52 pub port: u16,
53 #[arg(long)]
56 pub no_grant_caller: bool,
57 #[arg(long)]
59 pub force: bool,
60}
61
62const SNAPSHOT_MIN_BLOCK: u64 = 200;
67
68pub async fn execute(args: UpArgs) -> Result<()> {
69 if Path::new(WORKSPACE_FILE).exists() {
70 let existing = fs::read_to_string(WORKSPACE_FILE).unwrap_or_default();
71 let is_managed = existing.contains(MANAGED_MARKER);
72 if !args.force {
73 bail!(
74 "{WORKSPACE_FILE} already exists. Use --force to overwrite (only overwrites files `dev up` created), or `cargo-tangle dev down` to tear down an existing devnet first."
75 );
76 }
77 if !is_managed {
78 bail!(
79 "{WORKSPACE_FILE} exists but was NOT created by `cargo-tangle dev up` (no managed-by marker). Refusing to overwrite user-authored config even with --force. Delete it manually if you really want to replace it."
80 );
81 }
82 }
83
84 ensure_anvil_on_path()?;
85
86 let dev_dir = PathBuf::from(DEV_DIR);
87 fs::create_dir_all(&dev_dir).with_context(|| format!("creating {}", dev_dir.display()))?;
88 let anvil_pid_file = dev_dir.join("anvil.pid");
89 let anvil_log_path = dev_dir.join("anvil.log");
90 let keystore_dir = dev_dir.join("keystore");
91
92 let _lock = acquire_dev_lock(&dev_dir.join(".lock"))?;
96
97 if let Some(pid) = read_pid(&anvil_pid_file) {
99 if pid_alive(pid) {
100 bail!(
101 "anvil already running (PID {pid}) from a previous `dev up`. Run `cargo-tangle dev down` first."
102 );
103 }
104 }
105
106 let snapshot = locate_snapshot()?;
107 println!("→ Anvil snapshot: {}", snapshot.display());
108
109 let anvil_log = fs::File::create(&anvil_log_path)
117 .with_context(|| format!("creating {}", anvil_log_path.display()))?;
118 let anvil_err = anvil_log
119 .try_clone()
120 .with_context(|| "duplicating anvil log file")?;
121
122 let mut cmd = Command::new("anvil");
123 cmd.arg("--load-state")
124 .arg(&snapshot)
125 .args(["--host", "127.0.0.1"])
126 .args(["--port", &args.port.to_string()])
127 .args(["--base-fee", "0"])
128 .args(["--gas-price", "0"])
129 .args(["--gas-limit", "100000000"])
130 .args(["--hardfork", "cancun"])
131 .stdin(Stdio::null())
132 .stdout(Stdio::from(anvil_log))
133 .stderr(Stdio::from(anvil_err));
134 unsafe {
137 cmd.pre_exec(|| {
138 if nix::unistd::setsid().is_err() {
139 return Err(std::io::Error::last_os_error());
140 }
141 Ok(())
142 });
143 }
144
145 let anvil = cmd.spawn().with_context(|| "launching anvil")?;
146 let anvil_pid = anvil.id();
147 fs::write(&anvil_pid_file, anvil_pid.to_string())
148 .with_context(|| format!("writing {}", anvil_pid_file.display()))?;
149 drop(anvil);
152
153 let http_rpc_url =
154 Url::parse(&format!("http://127.0.0.1:{}", args.port)).expect("literal URL is valid");
155 let ws_rpc_url =
156 Url::parse(&format!("ws://127.0.0.1:{}", args.port)).expect("literal URL is valid");
157
158 let seeded_block = wait_for_seeded_chain(&http_rpc_url).await.map_err(|e| {
160 let _ = kill_pid(anvil_pid);
162 let _ = fs::remove_file(&anvil_pid_file);
163 e
164 })?;
165 println!("✓ Anvil ready at {http_rpc_url} (block {seeded_block})");
166
167 fs::create_dir_all(&keystore_dir)
169 .with_context(|| format!("creating {}", keystore_dir.display()))?;
170 let keystore = blueprint_keystore::Keystore::new(
171 blueprint_keystore::KeystoreConfig::new().fs_root(&keystore_dir),
172 )
173 .map_err(|e| eyre!(e.to_string()))?;
174 insert_default_operator_key(&keystore).map_err(|e| eyre!(e.to_string()))?;
175 println!("✓ Operator keystore at {}", keystore_dir.display());
176
177 let tangle_contract = Address::from_str(TANGLE_CONTRACT).expect("literal address");
178 let staking_contract = Address::from_str(STAKING_CONTRACT).expect("literal address");
179 let status_registry_contract =
180 Address::from_str(STATUS_REGISTRY_CONTRACT).expect("literal address");
181 let operator1 = Address::from_str(OPERATOR1_ADDRESS).expect("literal address");
182
183 if !args.no_grant_caller {
185 grant_permitted_caller(
186 &http_rpc_url,
187 tangle_contract,
188 DEFAULT_SERVICE_ID,
189 operator1,
190 )
191 .await
192 .with_context(|| "granting addPermittedCaller")?;
193 println!("✓ Permitted caller granted for service {DEFAULT_SERVICE_ID} -> {operator1}");
194 } else {
195 println!(" Skipped addPermittedCaller (--no-grant-caller).");
196 }
197
198 let mut networks = HashMap::new();
200 networks.insert(
201 "local".to_string(),
202 Network {
203 http_rpc_url: http_rpc_url.clone(),
204 ws_rpc_url: ws_rpc_url.clone(),
205 tangle_contract,
206 staking_contract,
207 status_registry_contract: Some(status_registry_contract),
208 chain_id: Some(CHAIN_ID),
209 },
210 );
211 let ws = TangleWorkspace {
212 source: PathBuf::from(WORKSPACE_FILE),
213 active: "local".to_string(),
214 networks,
215 defaults: Defaults {
216 keystore_path: Some(keystore_dir.clone()),
217 blueprint_id: Some(DEFAULT_BLUEPRINT_ID),
218 service_id: Some(DEFAULT_SERVICE_ID),
219 },
220 };
221 ws.write_with_header(Some(MANAGED_MARKER))
222 .with_context(|| format!("writing {}", WORKSPACE_FILE))?;
223 println!("✓ Workspace written to {WORKSPACE_FILE}");
224
225 println!();
226 println!("Devnet is up.");
227 println!(
228 " anvil PID {anvil_pid} (log: {})",
229 anvil_log_path.display()
230 );
231 println!(" http_rpc_url {http_rpc_url}");
232 println!(" ws_rpc_url {ws_rpc_url}");
233 println!(" chain_id {CHAIN_ID}");
234 println!(" tangle_contract {tangle_contract}");
235 println!(" keystore {}", keystore_dir.display());
236 println!();
237 println!(
238 "Try: cargo-tangle blueprint jobs submit --job 0 --payload-hex 000000000000000000000000000000000000000000000000000000000000002a --watch"
239 );
240 println!("Stop with: cargo-tangle dev down");
241 Ok(())
242}
243
244fn ensure_anvil_on_path() -> Result<()> {
245 let mut child = Command::new("anvil")
247 .arg("--version")
248 .stdin(Stdio::null())
249 .stdout(Stdio::null())
250 .stderr(Stdio::null())
251 .spawn()
252 .map_err(|_| {
253 eyre!(
254 "`anvil` not found on PATH. Install Foundry: `curl -L https://foundry.paradigm.xyz | bash && foundryup`"
255 )
256 })?;
257
258 let deadline = Instant::now() + Duration::from_secs(3);
259 loop {
260 match child.try_wait() {
261 Ok(Some(status)) if status.success() => return Ok(()),
262 Ok(Some(_)) => {
263 bail!("`anvil --version` exited non-zero; check your Foundry install");
264 }
265 Ok(None) if Instant::now() >= deadline => {
266 let _ = child.kill();
267 bail!("`anvil --version` did not return within 3s — PATH shim or hung binary?");
268 }
269 Ok(None) => std::thread::sleep(Duration::from_millis(50)),
270 Err(e) => return Err(eyre!("waiting on `anvil --version`: {e}")),
271 }
272 }
273}
274
275fn locate_snapshot() -> Result<PathBuf> {
276 use blueprint_chain_setup::anvil::snapshot::default_snapshot_path;
278 let path = default_snapshot_path();
279 if !path.is_file() {
280 bail!(
281 "Anvil snapshot not found at {}. Rebuild the blueprint-sdk workspace or ensure the crate's snapshots/ directory is populated.",
282 path.display()
283 );
284 }
285 Ok(path)
286}
287
288async fn wait_for_seeded_chain(http_rpc_url: &Url) -> Result<u64> {
289 let deadline = Instant::now() + Duration::from_secs(15);
290 let mut last_err: Option<color_eyre::eyre::Report> = None;
291 while Instant::now() < deadline {
292 match try_block_number(http_rpc_url).await {
293 Ok(block) if block >= SNAPSHOT_MIN_BLOCK => return Ok(block),
294 Ok(block) => {
295 last_err = Some(eyre!(
296 "anvil responded but seeded state looks incomplete (block {block} < {SNAPSHOT_MIN_BLOCK})"
297 ));
298 }
299 Err(e) => last_err = Some(e),
300 }
301 sleep(Duration::from_millis(250)).await;
302 }
303 Err(last_err.unwrap_or_else(|| eyre!("anvil did not become ready in 15s")))
304}
305
306async fn try_block_number(http_rpc_url: &Url) -> Result<u64> {
307 let provider = ProviderBuilder::new()
308 .connect(http_rpc_url.as_str())
309 .await
310 .with_context(|| format!("connecting to {http_rpc_url}"))?;
311 let block = provider
312 .get_block_number()
313 .await
314 .with_context(|| "fetching block number")?;
315 Ok(block)
316}
317
318async fn grant_permitted_caller(
319 http_rpc_url: &Url,
320 tangle: Address,
321 service_id: u64,
322 caller: Address,
323) -> Result<()> {
324 let signer = PrivateKeySigner::from_str(SERVICE_OWNER_PRIVATE_KEY)
325 .with_context(|| "decoding service-owner key")?;
326 let wallet = EthereumWallet::from(signer);
327 let provider = ProviderBuilder::new()
328 .wallet(wallet)
329 .connect(http_rpc_url.as_str())
330 .await
331 .with_context(|| "connecting with service-owner wallet")?;
332
333 let call = addPermittedCallerCall {
334 serviceId: service_id,
335 caller,
336 };
337 let tx = TransactionRequest::default()
338 .to(tangle)
339 .input(call.abi_encode().into());
340
341 let pending = provider
342 .send_transaction(tx)
343 .await
344 .with_context(|| "sending addPermittedCaller tx")?;
345 let receipt = pending
346 .get_receipt()
347 .await
348 .with_context(|| "awaiting addPermittedCaller receipt")?;
349 if !receipt.status() {
350 bail!("addPermittedCaller reverted on-chain");
351 }
352 Ok(())
353}
354
355fn read_pid(path: &Path) -> Option<u32> {
356 fs::read_to_string(path)
357 .ok()
358 .and_then(|s| s.trim().parse().ok())
359}
360
361fn acquire_dev_lock(path: &Path) -> Result<nix::fcntl::Flock<fs::File>> {
366 use nix::fcntl::{Flock, FlockArg};
367
368 let file = fs::OpenOptions::new()
369 .create(true)
370 .truncate(false)
371 .write(true)
372 .open(path)
373 .with_context(|| format!("opening {}", path.display()))?;
374
375 Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_file, errno)| {
376 eyre!(
377 "another `cargo-tangle dev up` appears to be running in this directory (flock failed: {errno}). If that's wrong, remove {} and retry.",
378 path.display()
379 )
380 })
381}
382
383fn pid_alive(pid: u32) -> bool {
384 use nix::sys::signal::kill;
385 use nix::unistd::Pid;
386 kill(Pid::from_raw(pid as i32), None).is_ok()
388}
389
390fn kill_pid(pid: u32) -> Result<()> {
391 use nix::errno::Errno;
392 use nix::sys::signal::{Signal, kill};
393 use nix::unistd::Pid;
394 match kill(Pid::from_raw(pid as i32), Signal::SIGTERM) {
395 Ok(()) | Err(Errno::ESRCH) => Ok(()),
396 Err(e) => Err(eyre!("SIGTERM to {pid} failed: {e}")),
397 }
398}