penumbra_sdk_ibc/component/
client_recovery.rs

1use anyhow::{ensure, Context, Result};
2use async_trait::async_trait;
3use cnidarium::StateWrite;
4use ibc_types::core::client::{ClientId, Height};
5use penumbra_sdk_sct::component::clock::EpochRead;
6
7use crate::component::{ConsensusStateWriteExt, HostInterface};
8
9use super::client::{
10    ClientStatus, StateReadExt as ClientStateReadExt, StateWriteExt as ClientStateWriteExt,
11};
12
13/// Extension trait for IBC client recovery operations.
14///
15/// This trait provides privileged operations for recovering frozen/expired IBC clients
16/// by substituting them with active clients. This is typically used during chain upgrades
17/// or emergency recovery scenarios.
18#[async_trait]
19pub trait ClientRecoveryExt: StateWrite + ConsensusStateWriteExt {
20    /// Validate a client recovery operation
21    async fn validate_recover_client<HI: HostInterface>(
22        &self,
23        subject_client_id: &ClientId,
24        substitute_client_id: &ClientId,
25    ) -> Result<()> {
26        tracing::debug!(
27            %subject_client_id,
28            %substitute_client_id,
29            "validating ibc client recovery"
30        );
31
32        // 1. Check that the clients are well-formed (regex validation)
33        validate_client_id_format_ics07(subject_client_id)?;
34        validate_client_id_format_ics07(substitute_client_id)?;
35
36        // Needed for status checks
37        let local_chain_current_time = self
38            .get_current_block_timestamp()
39            .await
40            .context("failed to get current block timestamp")?;
41
42        // 2. Check that the clients are found
43        let subject_client_state = self
44            .get_client_state(subject_client_id)
45            .await
46            .context("subject client not found")?;
47
48        let substitute_client_state = self
49            .get_client_state(substitute_client_id)
50            .await
51            .context("substitute client not found")?;
52
53        // 3. Check that the subject client is NOT Active
54        let subject_status = self
55            .get_client_status(subject_client_id, local_chain_current_time)
56            .await;
57        ensure!(
58            subject_status != ClientStatus::Active,
59            "subject client must not be Active, found: {}",
60            subject_status
61        );
62
63        // 4. Check that the substitute client IS Active
64        let substitute_status = self
65            .get_client_status(substitute_client_id, local_chain_current_time)
66            .await;
67        ensure!(
68            substitute_status == ClientStatus::Active,
69            "substitute client must be Active, found: {}",
70            substitute_status
71        );
72
73        // 5. Check that all client parameters must match except
74        // for the frozen height, latest height, trust period, and proof specs
75        check_field_consistency(&subject_client_state, &substitute_client_state)?;
76
77        // 6. Check that the substitute client height is greater than subject's latest height
78        let subject_height = get_client_latest_height(&subject_client_state)?;
79        let substitute_height = get_client_latest_height(&substitute_client_state)?;
80        ensure!(
81            substitute_height > subject_height,
82            "substitute client height ({}) must be greater than subject client height ({})",
83            substitute_height,
84            subject_height
85        );
86
87        // 7. Perform the recovery: copy substitute client state to subject client
88        tracing::debug!("overwriting client state");
89
90        Ok(())
91    }
92    /// Recover a frozen or expired client by substituting it with an active client.
93    ///
94    /// This operation will:
95    /// 1. Validate both client IDs are well-formed
96    /// 2. Verify both clients exist
97    /// 3. Check that the subject client is NOT Active
98    /// 4. Check that the substitute client IS Active
99    /// 5. Verify client parameters match.
100    /// 6. Verify substitute client has greater height
101    /// 7. Copy the substitute client's state over the subject client
102    async fn recover_client<HI: HostInterface>(
103        &mut self,
104        subject_client_id: &ClientId,
105        substitute_client_id: &ClientId,
106    ) -> Result<()> {
107        tracing::debug!(
108            %subject_client_id,
109            %substitute_client_id,
110            "starting ibc client recovery"
111        );
112        self.validate_recover_client::<HI>(&subject_client_id, &substitute_client_id)
113            .await?;
114
115        let substitute_client_state = self
116            .get_client_state(substitute_client_id)
117            .await
118            .context("substitute client not found")?;
119
120        let substitute_consensus_state = self
121            .get_verified_consensus_state(
122                &substitute_client_state.latest_height(),
123                &substitute_client_id,
124            )
125            .await?;
126
127        // smooth brain: we write the substitute - into -> the subject.
128        self.put_verified_consensus_state::<HI>(
129            substitute_client_state.latest_height(),
130            subject_client_id.clone(),
131            substitute_consensus_state,
132        )
133        .await?;
134
135        self.put_client(&subject_client_id, substitute_client_state);
136
137        tracing::info!(
138            subject = %subject_client_id,
139            substitute = %substitute_client_id,
140            "client recovery completed successfully"
141        );
142
143        Ok(())
144    }
145}
146
147impl<T: StateWrite + ConsensusStateWriteExt> ClientRecoveryExt for T {}
148
149/// Validate that a client ID matches the expected format.
150/// Client IDs must be of the form: 07-tendermint-<NUM> where NUM is a non-empty sequence of digits
151/// TODO(erwan): iirc there's an ibc types routine that does this?
152pub fn validate_client_id_format_ics07(client_id: &ClientId) -> Result<()> {
153    use regex::Regex;
154
155    let client_id_str = client_id.as_str();
156
157    // Match exactly: 07-tendermint- followed by one or more digits
158    let re = Regex::new(r"^07-tendermint-\d+$").expect("valid regex");
159
160    ensure!(
161        re.is_match(client_id_str),
162        "invalid client ID format: '{}'. Expected format: 07-tendermint-<NUM> (e.g., 07-tendermint-0, 07-tendermint-123)",
163        client_id_str
164    );
165
166    let parts: Vec<&str> = client_id_str.split('-').collect();
167    if parts.len() == 3 {
168        let num_part = parts[2];
169        ensure!(
170            !(num_part.len() > 1 && num_part.starts_with('0')),
171            "invalid client ID: '{}'. Number part cannot have leading zeros",
172            client_id_str
173        );
174    }
175
176    Ok(())
177}
178
179/// Check that the field of two client states are coherent.
180///
181/// The goal is to verify that a subject/substitute couple are fundamentally the same client.
182/// Evne if at different points in their lifecyle. This is directly inspired by Cosmos ADR-26,
183/// which recognizes that client recovery is a form of controlled mutation: we are not replacing
184/// one client state with an arbitrary other, but ratehr fast-forwarding a stuck client to a
185/// healthy state.
186///
187/// The Tendermint ClientState contains:
188/// ```
189/// ClientState {
190///     // IMMUTABLE
191///     chain_id,          
192///     trust_level,       
193///     trusting_period,   
194///     unbonding_period,  
195///     max_clock_drift,   
196///     upgrade_path,      
197///     allow_update,      
198///     
199///     // MUTABLE
200///     latest_height,     // can advance
201///     frozen_height,     // can be unfrozen
202///     proof_specs,       // mechanical, not trust-related
203/// }
204/// ```
205pub fn check_field_consistency(
206    subject: &ibc_types::lightclients::tendermint::client_state::ClientState,
207    substitute: &ibc_types::lightclients::tendermint::client_state::ClientState,
208) -> Result<()> {
209    ensure!(
210        subject.chain_id == substitute.chain_id,
211        "chain IDs must match: subject has '{}', substitute has '{}'",
212        subject.chain_id,
213        substitute.chain_id
214    );
215
216    ensure!(
217        subject.trust_level == substitute.trust_level,
218        "trust levels must match: subject has '{:?}', substitute has '{:?}'",
219        subject.trust_level,
220        substitute.trust_level
221    );
222
223    // We leave out checking the trust period.
224    // This makes testing easier, gives some leeway in case of
225    // misconfiguration, and is safe because ICS02 validation requires:
226    // `trust_period < unbonding_period`
227
228    ensure!(
229        subject.unbonding_period == substitute.unbonding_period,
230        "unbonding periods must match: subject has '{:?}', substitute has '{:?}'",
231        subject.unbonding_period,
232        substitute.unbonding_period
233    );
234
235    ensure!(
236        subject.max_clock_drift == substitute.max_clock_drift,
237        "max clock drifts must match: subject has '{:?}', substitute has '{:?}'",
238        subject.max_clock_drift,
239        substitute.max_clock_drift
240    );
241
242    ensure!(
243        subject.upgrade_path == substitute.upgrade_path,
244        "upgrade paths must match: subject has '{:?}', substitute has '{:?}'",
245        subject.upgrade_path,
246        substitute.upgrade_path
247    );
248
249    ensure!(
250        subject.allow_update == substitute.allow_update,
251        "allow_update flags must match: subject has '{:?}', substitute has '{:?}'",
252        subject.allow_update,
253        substitute.allow_update
254    );
255
256    Ok(())
257}
258
259/// Extract the latest height from a client state.
260pub fn get_client_latest_height(
261    client_state: &ibc_types::lightclients::tendermint::client_state::ClientState,
262) -> Result<Height> {
263    Ok(client_state.latest_height)
264}