Skip to main content

stacksdapp_deployer/
devnet_recovery.rs

1//! Local Clarinet devnet recovery when Bitcoin burn advances but Stacks tip stalls
2//! (PoX / Nakamoto tenure desync around reward-cycle boundaries).
3
4use anyhow::{anyhow, Result};
5use std::time::Duration;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct LocalCoreInfo {
9    pub stacks_tip_height: u64,
10    pub burn_block_height: u64,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RecoveryLevel {
15    /// Restart stacks-signer + stacks-node only.
16    StacksOnly,
17    /// Restart bitcoin-node, then stacks-signer + stacks-node.
18    WithBitcoin,
19    /// Restart all devnet containers (bitcoin, postgres, stacks-node, signer).
20    Full,
21}
22
23impl RecoveryLevel {
24    pub fn label(self) -> &'static str {
25        match self {
26            Self::StacksOnly => "stacks-node + signer",
27            Self::WithBitcoin => "bitcoin-node + stacks-node + signer",
28            Self::Full => "full devnet stack",
29        }
30    }
31}
32
33/// True when Stacks tip is unchanged while burn height increased (classic devnet stall).
34pub fn is_devnet_chain_stalled(previous: &LocalCoreInfo, current: &LocalCoreInfo) -> bool {
35    current.stacks_tip_height == previous.stacks_tip_height
36        && current.burn_block_height > previous.burn_block_height
37}
38
39pub fn devnet_project_name() -> Option<String> {
40    let raw = std::fs::read_to_string("contracts/Clarinet.toml").ok()?;
41    for line in raw.lines() {
42        let trimmed = line.trim();
43        if trimmed.starts_with("name = ") {
44            return trimmed
45                .trim_start_matches("name = ")
46                .trim()
47                .trim_matches('"')
48                .to_string()
49                .into();
50        }
51    }
52    None
53}
54
55pub async fn fetch_local_core_info_optional() -> Option<LocalCoreInfo> {
56    let client = reqwest::Client::builder()
57        .timeout(Duration::from_secs(3))
58        .build()
59        .ok()?;
60    let response = client
61        .get("http://localhost:20443/v2/info")
62        .send()
63        .await
64        .ok()?;
65    if !response.status().is_success() {
66        return None;
67    }
68    let json: serde_json::Value = response.json().await.ok()?;
69    Some(LocalCoreInfo {
70        stacks_tip_height: json.get("stacks_tip_height")?.as_u64()?,
71        burn_block_height: json.get("burn_block_height")?.as_u64()?,
72    })
73}
74
75fn docker_restart_container(name: &str) -> bool {
76    let id = docker_container_id(name);
77    let Some(id) = id else {
78        return false;
79    };
80    std::process::Command::new("docker")
81        .args(["restart", &id])
82        .stdout(std::process::Stdio::null())
83        .stderr(std::process::Stdio::null())
84        .status()
85        .map(|s| s.success())
86        .unwrap_or(false)
87}
88
89fn docker_container_id(name: &str) -> Option<String> {
90    std::process::Command::new("docker")
91        .args(["ps", "-q", "--filter", &format!("name={name}")])
92        .output()
93        .ok()
94        .filter(|o| o.status.success())
95        .and_then(|o| String::from_utf8(o.stdout).ok())
96        .map(|s| s.trim().lines().next().unwrap_or("").to_string())
97        .filter(|s| !s.is_empty())
98}
99
100fn docker_stop_container(name: &str) -> bool {
101    let Some(id) = docker_container_id(name) else {
102        return false;
103    };
104    std::process::Command::new("docker")
105        .args(["stop", &id])
106        .stdout(std::process::Stdio::null())
107        .stderr(std::process::Stdio::null())
108        .status()
109        .map(|s| s.success())
110        .unwrap_or(false)
111}
112
113fn docker_start_container(name: &str) -> bool {
114    let id = std::process::Command::new("docker")
115        .args(["ps", "-aq", "--filter", &format!("name={name}")])
116        .output()
117        .ok()
118        .filter(|o| o.status.success())
119        .and_then(|o| String::from_utf8(o.stdout).ok())
120        .map(|s| s.trim().lines().next().unwrap_or("").to_string())
121        .filter(|s| !s.is_empty());
122    let Some(id) = id else {
123        return false;
124    };
125    std::process::Command::new("docker")
126        .args(["start", &id])
127        .stdout(std::process::Stdio::null())
128        .stderr(std::process::Stdio::null())
129        .status()
130        .map(|s| s.success())
131        .unwrap_or(false)
132}
133
134/// Stop all devnet containers for a project (used before Clarinet respawn).
135pub fn stop_devnet_containers(project: &str) -> bool {
136    let names = [
137        format!("stacks-signer-0.{project}.devnet"),
138        format!("stacks-node.{project}.devnet"),
139        format!("postgres.{project}.devnet"),
140        format!("bitcoin-node.{project}.devnet"),
141    ];
142    let mut any = false;
143    for name in &names {
144        if docker_stop_container(name) {
145            any = true;
146        }
147    }
148    any
149}
150/// Restart devnet Docker containers for `project`. Returns true if anything restarted.
151pub fn try_recover_devnet_chain(project: &str, level: RecoveryLevel) -> bool {
152    let stacks = [
153        format!("stacks-signer-0.{project}.devnet"),
154        format!("stacks-node.{project}.devnet"),
155    ];
156    let bitcoin = format!("bitcoin-node.{project}.devnet");
157    let postgres = format!("postgres.{project}.devnet");
158
159    let mut any = false;
160    match level {
161        RecoveryLevel::StacksOnly => {
162            for name in &stacks {
163                if docker_restart_container(name) {
164                    any = true;
165                }
166            }
167        }
168        RecoveryLevel::WithBitcoin => {
169            if docker_restart_container(&bitcoin) {
170                any = true;
171            }
172            std::thread::sleep(Duration::from_secs(5));
173            for name in &stacks {
174                if docker_restart_container(name) {
175                    any = true;
176                }
177            }
178        }
179        RecoveryLevel::Full => {
180            // Stop/start (not restart) resets container processes while Clarinet's
181            // bitcoin controller reconnects — more reliable than restart alone.
182            for name in [&stacks[0], &stacks[1], &postgres, &bitcoin] {
183                docker_stop_container(name);
184            }
185            std::thread::sleep(Duration::from_secs(3));
186            for name in [&bitcoin, &postgres, &stacks[1], &stacks[0]] {
187                if docker_start_container(name) {
188                    any = true;
189                }
190            }
191        }
192    }
193    any
194}
195
196async fn wait_for_stacks_tip_above(min_exclusive: u64, timeout: Duration) -> bool {
197    let deadline = tokio::time::Instant::now() + timeout;
198    while tokio::time::Instant::now() < deadline {
199        if let Some(info) = fetch_local_core_info_optional().await {
200            if info.stacks_tip_height > min_exclusive {
201                return true;
202            }
203        }
204        tokio::time::sleep(Duration::from_secs(3)).await;
205    }
206    false
207}
208
209/// Block until Stacks tip advances, attempting escalating Docker recovery on stall.
210/// Devnet-only — callers must gate on `network == "devnet"`.
211pub async fn ensure_devnet_chain_mining(context: &str) -> Result<()> {
212    let project = devnet_project_name().ok_or_else(|| {
213        anyhow!(
214            "Cannot recover devnet: missing contracts/Clarinet.toml project name.\n\
215             Run deploy from a scaffold project root with `stacksdapp dev` running."
216        )
217    })?;
218
219    let first = fetch_local_core_info_optional()
220        .await
221        .ok_or_else(|| anyhow!("Local stacks-node at http://localhost:20443 is not responding."))?;
222
223    // Fast path: tip advancing normally (typical deploy within ~2 min of dev ready).
224    tokio::time::sleep(Duration::from_secs(8)).await;
225    if let Some(second) = fetch_local_core_info_optional().await {
226        if second.stacks_tip_height > first.stacks_tip_height {
227            return Ok(());
228        }
229        if !is_devnet_chain_stalled(&first, &second)
230            && wait_for_stacks_tip_above(first.stacks_tip_height, Duration::from_secs(45)).await
231        {
232            return Ok(());
233        }
234        // Stalled sample — fall through to recovery using latest observation.
235        return recover_devnet_chain_mining(&project, context, &second).await;
236    }
237
238    recover_devnet_chain_mining(&project, context, &first).await
239}
240
241async fn recover_devnet_chain_mining(
242    project: &str,
243    context: &str,
244    stalled_at: &LocalCoreInfo,
245) -> Result<()> {
246    stacksdapp_shell::println_human_safe(format!(
247        "[deploy] Devnet chain stalled (Stacks #{}, burn {}) before {context}.",
248        stalled_at.stacks_tip_height, stalled_at.burn_block_height
249    ));
250
251    let levels = [
252        RecoveryLevel::StacksOnly,
253        RecoveryLevel::WithBitcoin,
254        RecoveryLevel::Full,
255    ];
256    for (idx, level) in levels.iter().enumerate() {
257        stacksdapp_shell::println_human_safe(format!(
258            "[deploy] Recovering stalled chain (attempt {}/3): {} for {project}...",
259            idx + 1,
260            level.label()
261        ));
262        try_recover_devnet_chain(project, *level);
263        tokio::time::sleep(Duration::from_secs(12)).await;
264        if wait_for_stacks_tip_above(stalled_at.stacks_tip_height, Duration::from_secs(120)).await {
265            stacksdapp_shell::println_human_safe(
266                "[deploy] Devnet chain recovered — Stacks tip is advancing again.",
267            );
268            return Ok(());
269        }
270    }
271
272    Err(anyhow!(
273        "Devnet chain stalled at Stacks #{} (burn {}) and recovery did not restore block production.\n\
274         If `stacksdapp dev` is running, wait for \"[devnet] Clarinet devnet restarted\" then retry.\n\
275         Otherwise run `stacksdapp clean --force`, restart with `stacksdapp dev --auto-deploy`, and deploy while the tip advances.\n\
276         If this repeats, try `bitcoin_controller_block_time = 60_000` in contracts/settings/Devnet.toml.",
277        stalled_at.stacks_tip_height,
278        stalled_at.burn_block_height
279    ))
280}
281
282/// Poll during contract confirmation; trigger recovery if burn advances without new Stacks blocks.
283pub async fn recover_devnet_if_stalled_during_wait(
284    previous: &LocalCoreInfo,
285    project: Option<&str>,
286) -> bool {
287    let Some(current) = fetch_local_core_info_optional().await else {
288        return false;
289    };
290    if !is_devnet_chain_stalled(previous, &current) {
291        return false;
292    }
293    let Some(name) = project.map(str::to_string).or_else(devnet_project_name) else {
294        return false;
295    };
296    stacksdapp_shell::println_human_safe(format!(
297        "[deploy] Chain stalled while waiting (Stacks #{}, burn {}) — restarting devnet containers...",
298        current.stacks_tip_height, current.burn_block_height
299    ));
300    try_recover_devnet_chain(&name, RecoveryLevel::WithBitcoin)
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn detects_stall_when_burn_advances_one_block() {
309        let prev = LocalCoreInfo {
310            stacks_tip_height: 71,
311            burn_block_height: 176,
312        };
313        let curr = LocalCoreInfo {
314            stacks_tip_height: 71,
315            burn_block_height: 177,
316        };
317        assert!(is_devnet_chain_stalled(&prev, &curr));
318    }
319
320    #[test]
321    fn not_stalled_when_stacks_advances() {
322        let prev = LocalCoreInfo {
323            stacks_tip_height: 71,
324            burn_block_height: 176,
325        };
326        let curr = LocalCoreInfo {
327            stacks_tip_height: 72,
328            burn_block_height: 177,
329        };
330        assert!(!is_devnet_chain_stalled(&prev, &curr));
331    }
332
333    #[test]
334    fn not_stalled_when_both_unchanged() {
335        let prev = LocalCoreInfo {
336            stacks_tip_height: 71,
337            burn_block_height: 176,
338        };
339        let curr = LocalCoreInfo {
340            stacks_tip_height: 71,
341            burn_block_height: 176,
342        };
343        assert!(!is_devnet_chain_stalled(&prev, &curr));
344    }
345}