Skip to main content

cargo_tangle/command/dev/
up.rs

1//! `cargo-tangle dev up` — boot a local Anvil devnet, pre-register the seeded operator,
2//! and write a `.tangle.toml` so every other cargo-tangle command works without arguments.
3
4use 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
27/// Sentinel line written into the managed `.tangle.toml` so `dev down` can
28/// positively identify files it created. A user-authored workspace that
29/// happens to point at 127.0.0.1 is NEVER deleted.
30pub(crate) const MANAGED_MARKER: &str = "# managed-by = \"cargo-tangle-dev\"";
31
32// These values are fixed by the LocalTestnet broadcast/snapshot bundled with
33// blueprint-chain-setup-anvil. The same constants live (privately) in
34// crates/testing-utils/anvil/src/tangle.rs; if that crate ever exports them,
35// delete this block and import from there.
36const 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    /// Port for the Anvil HTTP/WS RPC.
51    #[arg(long, default_value_t = 8545)]
52    pub port: u16,
53    /// Skip granting `addPermittedCaller` for the seeded operator. You will need
54    /// to do this yourself before the operator can submit jobs on its own behalf.
55    #[arg(long)]
56    pub no_grant_caller: bool,
57    /// Overwrite an existing `.tangle.toml` in the current directory.
58    #[arg(long)]
59    pub force: bool,
60}
61
62/// Block height the seeded LocalTestnet snapshot reaches after replay.
63/// Kept loose (≥200) so minor regenerations don't break the readiness probe.
64/// If the snapshot ever drops meaningfully below this, update here in lockstep
65/// with `blueprint-chain-setup-anvil`.
66const 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    // Single-writer guard for the full `dev up` lifecycle. Blocks concurrent
93    // `dev up` invocations in the same directory from racing on the pid file,
94    // the anvil port, or the `.tangle.toml` write. Released on drop.
95    let _lock = acquire_dev_lock(&dev_dir.join(".lock"))?;
96
97    // Refuse to clobber a running devnet.
98    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    // 1. Launch anvil as a detached session leader.
110    //
111    //    `setsid` moves the child out of our controlling TTY's process group,
112    //    so closing this terminal (SIGHUP to the foreground pgrp) does NOT kill
113    //    anvil. We intentionally LET the Child drop — on Unix that does not
114    //    kill the child; it just closes our handle. That releases the pidfd /
115    //    zombie-reaper setup cleanly.
116    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    // SAFETY: `setsid` is async-signal-safe; we call nothing else from the
135    // forked child before exec.
136    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 the Child normally — on Unix this does not kill the child (it's
150    // just a handle). setsid above ensures it survives this process exiting.
151    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    // 2. Wait for the chain to respond with a block number past the seeded state.
159    let seeded_block = wait_for_seeded_chain(&http_rpc_url).await.map_err(|e| {
160        // If readiness fails, best-effort kill what we just spawned.
161        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    // 3. Seed the keystore with the well-known operator1 key.
168    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    // 4. Grant addPermittedCaller so the operator can submit its own jobs.
184    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    // 5. Write .tangle.toml in the current working directory.
199    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    // 3s cap: defends against shims / FUSE paths that block.
246    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    // Re-exported by the chain-setup crate so callers don't need to touch paths.
277    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
361/// Own the `dev up` critical section via a non-blocking exclusive `flock`.
362/// Scope is the CWD (lock lives at `.tangle/dev/.lock`). The lock is released
363/// when this guard drops — typically at the end of `execute`, by which point
364/// the anvil PID file is the authoritative "already running" signal.
365fn 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    // signal None is a liveness check (ESRCH iff the process is gone).
387    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}