penumbra_sdk_ibc/component/
client_recovery.rs1use 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#[async_trait]
19pub trait ClientRecoveryExt: StateWrite + ConsensusStateWriteExt {
20 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 validate_client_id_format_ics07(subject_client_id)?;
34 validate_client_id_format_ics07(substitute_client_id)?;
35
36 let local_chain_current_time = self
38 .get_current_block_timestamp()
39 .await
40 .context("failed to get current block timestamp")?;
41
42 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 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 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 check_field_consistency(&subject_client_state, &substitute_client_state)?;
76
77 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 tracing::debug!("overwriting client state");
89
90 Ok(())
91 }
92 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 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
149pub 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 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
179pub 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 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
259pub 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}