Skip to main content

calimero_context_primitives/client/
sync.rs

1//! Context configuration synchronization and application installation.
2//!
3//! This module handles syncing context configuration from external sources,
4//! installing applications (both bundles and regular WASM), and managing
5//! context metadata updates.
6
7use calimero_node_primitives::client::NodeClient;
8use calimero_primitives::application::ApplicationId;
9use calimero_primitives::blobs::BlobId;
10use calimero_primitives::common::DIGEST_SIZE;
11use calimero_primitives::context::{Context, ContextConfigParams, ContextId};
12use calimero_primitives::hash::Hash;
13use calimero_store::{key, types};
14use std::collections::BTreeSet;
15use tokio::sync::oneshot;
16use tokio::time::{sleep, Duration};
17use tracing::{debug, info, warn};
18use url::Url;
19
20use super::external::ExternalClient;
21use super::ContextClient;
22use crate::messages::{ContextMessage, SyncRequest};
23
24impl ContextClient {
25    // Constants for application installation
26    const DEFAULT_PACKAGE: &str = "unknown";
27    const DEFAULT_VERSION: &str = "0.0.0";
28    const MAX_BLOB_RETRIES: u32 = 20;
29    const BLOB_RETRY_DELAY_MS: u64 = 1000;
30    const MEMBERS_PAGE_SIZE: usize = 100;
31
32    /// Try to install application from URL (for HTTP/HTTPS sources)
33    async fn try_install_from_url(
34        &self,
35        source: &Url,
36        metadata: &[u8],
37    ) -> eyre::Result<Option<ApplicationId>> {
38        match source.scheme() {
39            "http" | "https" => Ok(Some(
40                self.node_client
41                    .install_application_from_url(source.clone(), metadata.to_vec(), None)
42                    .await?,
43            )),
44            _ => Ok(None),
45        }
46    }
47
48    /// Install a regular (non-bundle) application
49    async fn install_regular_application(
50        &self,
51        blob_id: &BlobId,
52        size: u64,
53        source: &Url,
54        metadata: &[u8],
55    ) -> eyre::Result<ApplicationId> {
56        self.node_client.install_application(
57            blob_id,
58            size,
59            &source.clone().into(),
60            metadata.to_vec(),
61            Self::DEFAULT_PACKAGE,
62            Self::DEFAULT_VERSION,
63            None,  // signer_id: None for non-bundle installations
64            false, // is_bundle: false
65        )
66    }
67
68    /// Check if blob is a bundle and install accordingly
69    async fn check_bundle_and_install(
70        &self,
71        blob_id: &BlobId,
72        blob_bytes: &[u8],
73        source: &Url,
74        size: u64,
75        metadata: &[u8],
76    ) -> eyre::Result<ApplicationId> {
77        let blob_bytes_clone = blob_bytes.to_vec();
78        let is_bundle =
79            tokio::task::spawn_blocking(move || NodeClient::is_bundle_blob(&blob_bytes_clone))
80                .await?;
81
82        if is_bundle {
83            debug!(
84                blob_id = %blob_id,
85                "Blob is a bundle, installing from bundle blob"
86            );
87            self.node_client
88                .install_application_from_bundle_blob(blob_id, &source.clone().into())
89                .await
90        } else {
91            debug!(
92                blob_id = %blob_id,
93                "Blob is not a bundle, using regular installation"
94            );
95            self.install_regular_application(blob_id, size, source, metadata)
96                .await
97        }
98    }
99
100    /// Install application from existing blob (checks if bundle and installs accordingly)
101    async fn install_from_existing_blob(
102        &self,
103        blob_id: &BlobId,
104        source: &Url,
105        size: u64,
106        metadata: &[u8],
107    ) -> eyre::Result<ApplicationId> {
108        debug!(
109            blob_id = %blob_id,
110            "Blob exists locally, checking if it's a bundle"
111        );
112
113        // Check if blob is a bundle
114        let Some(blob_bytes) = self.node_client.get_blob_bytes(blob_id, None).await? else {
115            debug!(
116                blob_id = %blob_id,
117                "Failed to read blob, falling back to regular installation"
118            );
119            // Failed to read blob, fall back to regular installation
120            return self
121                .install_regular_application(blob_id, size, source, metadata)
122                .await;
123        };
124
125        // Check if bundle and install accordingly
126        self.check_bundle_and_install(blob_id, &blob_bytes, source, size, metadata)
127            .await
128    }
129
130    /// Wait for blob to arrive and install bundle (with retry logic)
131    async fn wait_for_blob_and_install(
132        &self,
133        blob_id: &BlobId,
134        source: &Url,
135        size: u64,
136        metadata: &[u8],
137        expected_app_id: ApplicationId,
138    ) -> eyre::Result<ApplicationId> {
139        debug!(
140            blob_id = %blob_id,
141            "Source indicates bundle (.mpk), waiting for blob to arrive via blob sharing"
142        );
143        // For bundles, we need the blob to extract package/version from manifest
144        // Wait a bit for blob sharing to deliver it, then retry
145
146        for _ in 0..Self::MAX_BLOB_RETRIES {
147            // Check if blob is available
148            if !self.node_client.has_blob(blob_id)? {
149                sleep(Duration::from_millis(Self::BLOB_RETRY_DELAY_MS)).await;
150                continue;
151            }
152
153            // Blob arrived, try to read and install
154            let Some(blob_bytes) = self.node_client.get_blob_bytes(blob_id, None).await? else {
155                sleep(Duration::from_millis(Self::BLOB_RETRY_DELAY_MS)).await;
156                continue;
157            };
158
159            debug!(
160                blob_id = %blob_id,
161                "Blob arrived, installing bundle"
162            );
163
164            // Check if bundle and install
165            return self
166                .check_bundle_and_install(blob_id, &blob_bytes, source, size, metadata)
167                .await;
168        }
169
170        // Retries exhausted
171        warn!(
172            blob_id = %blob_id,
173            "Blob didn't arrive within retry window - bundle installation will be retried when blob arrives"
174        );
175        // Blob didn't arrive in time - we can't install without package/version from manifest
176        // Return the ApplicationId from context config to pass the check
177        // The application will be installed when blob arrives via blob sharing
178        // This will cause initiate_sync_inner to fail with "application not found",
179        // but blob sharing will happen and installation will succeed on retry
180        Ok(expected_app_id)
181    }
182
183    /// Install application when blob doesn't exist locally yet
184    async fn install_when_blob_missing(
185        &self,
186        blob_id: &BlobId,
187        source: &Url,
188        size: u64,
189        metadata: &[u8],
190        expected_app_id: ApplicationId,
191    ) -> eyre::Result<ApplicationId> {
192        debug!(
193            blob_id = %blob_id,
194            "Blob doesn't exist locally, checking source for bundle detection"
195        );
196        // Blob doesn't exist yet - try to detect if it's a bundle from source URL
197        // If source ends with .mpk, it's likely a bundle
198        let is_bundle_from_source = source.path().ends_with(".mpk");
199
200        if is_bundle_from_source {
201            // Wait for blob to arrive and install bundle
202            self.wait_for_blob_and_install(blob_id, source, size, metadata, expected_app_id)
203                .await
204        } else {
205            debug!(
206                blob_id = %blob_id,
207                "Blob doesn't exist locally, using regular installation"
208            );
209            // Blob doesn't exist yet - create ApplicationMeta entry anyway
210            // The blob will be shared later in initiate_sync_inner
211            // When blob arrives, get_application_bytes will handle extraction on-demand
212            self.install_regular_application(blob_id, size, source, metadata)
213                .await
214        }
215    }
216
217    pub async fn sync_context_config(
218        &self,
219        context_id: ContextId,
220        config: Option<ContextConfigParams<'_>>,
221    ) -> eyre::Result<Context> {
222        let mut handle = self.datastore.handle();
223
224        let context = handle.get(&key::ContextMeta::new(context_id))?;
225
226        let (mut config, mut should_save_config) = config.map_or_else(
227            || {
228                let Some(config) = handle.get(&key::ContextConfig::new(context_id))? else {
229                    eyre::bail!("context config not found")
230                };
231
232                let config = ContextConfigParams {
233                    protocol: config.protocol.into_string().into(),
234                    network_id: config.network.into_string().into(),
235                    contract_id: config.contract.into_string().into(),
236                    proxy_contract: config.proxy_contract.into_string().into(),
237                    application_revision: config.application_revision,
238                    members_revision: config.members_revision,
239                };
240
241                Ok((config, false))
242            },
243            |config| Ok((config, true)),
244        )?;
245
246        // Fetch the LATEST revision from the blockchain
247        let remote_members_revision = {
248            let external_client = self.external_client(&context_id, &config)?;
249            external_client.config().members_revision().await?
250        };
251
252        // Check members revision and sync members, if needed
253        if context.is_none() || remote_members_revision != config.members_revision {
254            tracing::info!(
255                %context_id,
256                local_members_revision = config.members_revision,
257                remote_members_revision,
258                "Members revision changed, synchronizing member list...",
259            );
260
261            should_save_config = true;
262            config.members_revision = remote_members_revision;
263
264            // Perform the sync of the members.
265            let external_client = self.external_client(&context_id, &config)?;
266            self.sync_members(context_id, &external_client).await?;
267        } else {
268            debug!(
269                %context_id,
270                local_members_revision = config.members_revision,
271                remote_members_revision,
272                "Members revision was not changed, skipping sync",
273            );
274        }
275
276        let application_revision = {
277            let external_client = self.external_client(&context_id, &config)?;
278            let config_client = external_client.config();
279            config_client.application_revision().await?
280        };
281
282        let mut application_id = None;
283
284        if context.is_none() || application_revision != config.application_revision {
285            should_save_config = true;
286            config.application_revision = application_revision;
287
288            let external_client = self.external_client(&context_id, &config)?;
289            let config_client = external_client.config();
290            let application = config_client.application().await?;
291
292            application_id = Some(application.id);
293
294            if !self.node_client.has_application(&application.id)? {
295                let source: Url = application.source.into();
296                let metadata = application.metadata.clone();
297                let blob_id = application.blob.bytecode;
298
299                let derived_application_id = {
300                    // Try URL installation first (for HTTP/HTTPS sources)
301                    if let Some(app_id) = self.try_install_from_url(&source, &metadata).await? {
302                        app_id
303                    } else {
304                        // URL installation failed or not applicable
305                        // Check if blob exists locally (might have been received via blob sharing)
306                        if self.node_client.has_blob(&blob_id)? {
307                            self.install_from_existing_blob(
308                                &blob_id,
309                                &source,
310                                application.size,
311                                &metadata,
312                            )
313                            .await?
314                        } else {
315                            self.install_when_blob_missing(
316                                &blob_id,
317                                &source,
318                                application.size,
319                                &metadata,
320                                application.id,
321                            )
322                            .await?
323                        }
324                    }
325                };
326
327                if application.id != derived_application_id {
328                    eyre::bail!(
329                        "application mismatch: expected {}, got {}",
330                        application.id,
331                        derived_application_id
332                    )
333                }
334            }
335        }
336
337        if should_save_config {
338            // todo! we shouldn't be reallocating here
339            // todo! but store requires ContextConfig: 'static
340            let config = config.clone();
341
342            handle.put(
343                &key::ContextConfig::new(context_id),
344                &types::ContextConfig::new(
345                    config.protocol.into_owned().into_boxed_str(),
346                    config.network_id.into_owned().into_boxed_str(),
347                    config.contract_id.into_owned().into_boxed_str(),
348                    config.proxy_contract.into_owned().into_boxed_str(),
349                    config.application_revision,
350                    config.members_revision,
351                ),
352            )?;
353        }
354
355        let (should_save, application_id, root_hash, dag_heads) = context.map_or_else(
356            || {
357                (
358                    true,
359                    application_id.expect("must've been defined if context doesn't exist"),
360                    Hash::default(),
361                    vec![],
362                )
363            },
364            |meta| {
365                (
366                    application_id.is_some(),
367                    application_id.unwrap_or_else(|| meta.application.application_id()),
368                    meta.root_hash.into(),
369                    meta.dag_heads.clone(),
370                )
371            },
372        );
373
374        if should_save {
375            handle.put(
376                &key::ContextMeta::new(context_id),
377                &types::ContextMeta::new(
378                    key::ApplicationMeta::new(application_id),
379                    *root_hash,
380                    dag_heads.clone(),
381                ),
382            )?;
383
384            let (sender, receiver) = oneshot::channel();
385
386            self.context_manager
387                .send(ContextMessage::Sync {
388                    request: SyncRequest {
389                        context_id,
390                        application_id,
391                    },
392                    outcome: sender,
393                })
394                .await
395                .expect("Mailbox not to be dropped");
396
397            receiver.await.expect("Mailbox not to be dropped");
398        }
399
400        let context = Context::with_dag_heads(context_id, application_id, root_hash, dag_heads);
401
402        Ok(context)
403    }
404
405    /// Synchronizes the local member list with the authoritative state from the blockchain.
406    ///
407    /// These actions are performed:
408    /// 1. Fetch the complete list of members from the external contract.
409    /// 2. Adds any missing members to the local `datastore`.
410    /// 3. Prunes (deletes) any local members that are no longer present in the external contract.
411    async fn sync_members(
412        &self,
413        context_id: ContextId,
414        external_client: &ExternalClient<'_>,
415    ) -> eyre::Result<()> {
416        let mut handle = self.datastore.handle();
417        let config_client = external_client.config();
418
419        let mut external_members = BTreeSet::new();
420
421        // Fetch ALL remote members
422        for (offset, length) in (0..).map(|i| {
423            (
424                Self::MEMBERS_PAGE_SIZE.saturating_mul(i),
425                Self::MEMBERS_PAGE_SIZE,
426            )
427        }) {
428            let members = config_client.members(offset, length).await?;
429            if members.is_empty() {
430                break;
431            }
432
433            for member in members {
434                external_members.insert(member);
435
436                // Upsert: add to local DB if missing
437                let key = key::ContextIdentity::new(context_id, member);
438                if !handle.has(&key)? {
439                    handle.put(
440                        &key,
441                        &types::ContextIdentity {
442                            private_key: None,
443                            sender_key: None,
444                        },
445                    )?;
446                }
447            }
448        }
449
450        // PRUNING stage.
451        // Identify members that exist locally but NOT remotely.
452        let mut members_to_remove = Vec::new();
453
454        // Create a scope for the iterator to avoid borrowing issues
455        {
456            if let Ok(mut iter) = handle.iter::<key::ContextIdentity>() {
457                let start_key = key::ContextIdentity::new(context_id, [0u8; DIGEST_SIZE].into());
458
459                // Capture the first element from seek() and chain with the rest to avoid skipping the first member
460                let first = iter.seek(start_key).ok().flatten();
461
462                // Iterate over the first item + the rest of the keys
463                for k in first.into_iter().chain(iter.keys().flatten()) {
464                    // Stop if we drifted to another context
465                    if k.context_id() != context_id {
466                        break;
467                    }
468
469                    // If local member is missing from external set -> Mark for removal
470                    if !external_members.contains(&k.public_key()) {
471                        members_to_remove.push(*k.public_key());
472                    }
473                }
474            }
475        }
476
477        // Execute deletions of members that exist in the local DB, but don't exist remotely anymore
478        for member in members_to_remove {
479            let member_public_key = member.into();
480            debug!(%context_id, %member_public_key, "Trying to prune member from local store (it was removed from the contract)");
481
482            let key = key::ContextIdentity::new(context_id, member_public_key);
483            handle.delete(&key)?;
484
485            info!(%context_id, %member_public_key, "Pruned member from local store (it was removed from the contract)");
486        }
487
488        Ok(())
489    }
490}