calimero_context_primitives/client.rs
1#![allow(clippy::multiple_inherent_impl, reason = "better readability")]
2
3use async_stream::try_stream;
4use borsh::BorshDeserialize;
5use calimero_context_config::client::{AnyTransport, Client as ExternalClient};
6use calimero_context_config::types::{
7 BlockHeight, InvitationFromMember, RevealPayloadData, SignedOpenInvitation, SignedRevealPayload,
8};
9use calimero_node_primitives::client::NodeClient;
10use calimero_primitives::alias::Alias;
11use calimero_primitives::application::ApplicationId;
12use calimero_primitives::common::DIGEST_SIZE;
13use calimero_primitives::context::{
14 Context, ContextConfigParams, ContextId, ContextInvitationPayload,
15};
16use calimero_primitives::hash::Hash;
17use calimero_primitives::identity::{PrivateKey, PublicKey};
18use calimero_store::{key, Store};
19use calimero_utils_actix::LazyRecipient;
20use eyre::{bail, ContextCompat, WrapErr};
21use futures_util::Stream;
22use rand::Rng;
23use sha2::{Digest, Sha256};
24use tokio::sync::oneshot;
25
26use crate::messages::{
27 ContextMessage, CreateContextRequest, CreateContextResponse, DeleteContextRequest,
28 DeleteContextResponse, ExecuteError, ExecuteRequest, ExecuteResponse, JoinContextRequest,
29 JoinContextResponse, MigrationParams, UpdateApplicationRequest,
30};
31use crate::ContextAtomic;
32
33pub mod crypto;
34pub mod external;
35mod sync;
36
37/// A client for interacting with the context management system.
38///
39/// This struct serves as the primary public API, providing methods to create,
40/// join, query, and manage contexts and their members. It orchestrates
41/// interactions between the datastore, background actors, and external networks.
42#[derive(Clone, Debug)]
43pub struct ContextClient {
44 /// A handle to the persistent key-value store for all context-related data.
45 datastore: Store,
46 /// A client for communicating with the underlying Calimero node.
47 node_client: NodeClient,
48 /// A client for interacting with external services, such as on-chain smart contracts.
49 external_client: ExternalClient<AnyTransport>,
50 /// A lazy-initialized sender handle to the `ContextManager` actor. This is used
51 /// to send asynchronous messages for processing.
52 context_manager: LazyRecipient<ContextMessage>,
53}
54
55impl ContextClient {
56 #[must_use]
57 pub const fn new(
58 datastore: Store,
59 node_client: NodeClient,
60 external_client: ExternalClient<AnyTransport>,
61 context_manager: LazyRecipient<ContextMessage>,
62 ) -> Self {
63 Self {
64 datastore,
65 node_client,
66 external_client,
67 context_manager,
68 }
69 }
70
71 /// Returns a handle to the datastore for direct access.
72 /// Used by node components that need to read stored data.
73 pub fn datastore_handle(&self) -> calimero_store::Handle<Store> {
74 self.datastore.handle()
75 }
76
77 /// Sends a request to create a new context.
78 ///
79 /// This operation is asynchronous and is handled by the `ContextManager` actor.
80 ///
81 /// # Arguments
82 ///
83 /// * `protocol` - The name of the protocol that will be used for the new context.
84 /// * `application_id` - The ID of the application that will run in the context.
85 /// * `identity_secret` - An optional private key to use for the initial identity. If not
86 /// provided, a new identity will be generated.
87 /// * `init_params` - Raw byte parameters for initializing the application state.
88 /// * `seed` - An optional 32-byte seed for deterministic context ID and identity creation.
89 ///
90 /// # Returns
91 ///
92 /// A `Result` containing the `CreateContextResponse` from the actor upon completion.
93 pub async fn create_context(
94 &self,
95 protocol: String,
96 application_id: &ApplicationId,
97 identity_secret: Option<PrivateKey>,
98 init_params: Vec<u8>,
99 seed: Option<[u8; DIGEST_SIZE]>,
100 ) -> eyre::Result<CreateContextResponse> {
101 let (sender, receiver) = oneshot::channel();
102
103 self.context_manager
104 .send(ContextMessage::CreateContext {
105 request: CreateContextRequest {
106 protocol,
107 seed,
108 application_id: *application_id,
109 identity_secret,
110 init_params,
111 },
112 outcome: sender,
113 })
114 .await
115 .expect("Mailbox not to be dropped");
116
117 receiver.await.expect("Mailbox not to be dropped")
118 }
119
120 /// Invites a new member to an existing context.
121 ///
122 /// This involves an external call to the on-chain contract to register the new member.
123 ///
124 /// # Arguments
125 ///
126 /// * `context_id` - The context to invite the member to.
127 /// * `inviter_id` - The public key of an existing member who is performing the invitation.
128 /// * `invitee_id` - The public key of the identity being invited.
129 ///
130 /// # Returns
131 ///
132 /// * A `Result` containing an `Option` with the shareable `ContextInvitationPayload`.
133 /// * Returns `Ok(None)` if the context configuration cannot be found locally.
134 pub async fn invite_member(
135 &self,
136 context_id: &ContextId,
137 inviter_id: &PublicKey,
138 invitee_id: &PublicKey,
139 ) -> eyre::Result<Option<ContextInvitationPayload>> {
140 let Some(external_config) = self.context_config(context_id)? else {
141 return Ok(None);
142 };
143
144 let external_client = self.external_client(context_id, &external_config)?;
145
146 external_client
147 .config()
148 .add_members(inviter_id, &[*invitee_id])
149 .await?;
150
151 let invitation_payload = ContextInvitationPayload::new(
152 *context_id,
153 *invitee_id,
154 external_config.protocol,
155 external_config.network_id,
156 external_config.contract_id,
157 )?;
158
159 Ok(Some(invitation_payload))
160 }
161
162 /// Creates and signs a one-time, expiring open invitation for a new member.
163 ///
164 /// This method allows an existing member of a context (the inviter) to generate a
165 /// shareable invitation. The method fetches the inviter's private key managed
166 /// by the local node, signs the invitation details, and returns the resulting
167 /// payload and signature.
168 ///
169 /// # Arguments
170 /// * `context_id` - The context to invite the new member to.
171 /// * `inviter_id` - The public key of the existing member creating the invitation.
172 /// This node must have the corresponding private key for this identity.
173 /// * `valid_for_blocks` - A number of blocks from the current block height for which the
174 /// invitation is considered to be valid.
175 /// * `secret_salt` - A 32-byte random value to ensure the invitation is unique.
176 ///
177 /// # Returns
178 /// * A `Result` containing the `SignedOpenInvitation` if successful, or an error if
179 /// the inviter's private key is not found or signing fails.
180 /// * Returns `Ok(None)` if the context configuration cannot be found locally.
181 pub async fn invite_member_by_open_invitation(
182 &self,
183 context_id: &ContextId,
184 inviter_id: &PublicKey,
185 valid_for_blocks: BlockHeight,
186 _secret_salt: [u8; DIGEST_SIZE],
187 ) -> eyre::Result<Option<SignedOpenInvitation>> {
188 // TODO(identity): figure out the best place to generate salt.
189 // We temporarily ignore the passed `secret_salt` as we can't generate it in admin
190 // `invite_to_context_open_invitation::handler` as `Rng` is not thread-safe.
191 let mut rng = rand::thread_rng();
192 let salt: [u8; DIGEST_SIZE] = rng.gen::<[_; DIGEST_SIZE]>();
193 let secret_salt = salt;
194
195 let Some(external_config) = self.context_config(context_id)? else {
196 return Ok(None);
197 };
198
199 if external_config.protocol != "near" {
200 bail!("Failed to create an open invitaiton: only NEAR Protocol currently supports this feature");
201 }
202
203 //let external_client = self.external_client(context_id, &external_config)?;
204 // TODO: query the current block height from the NEAR client and use it to calculate the
205 // real expiration block height.
206 let current_block_height: BlockHeight = 999_999_999;
207 let expiration_block_height = current_block_height + valid_for_blocks;
208
209 // 1. Fetch the inviter's identity to get their private key for signing.
210 let inviter_identity = self
211 .get_identity(context_id, inviter_id)?
212 .with_context(|| format!("Inviter identity {inviter_id} not found"))?;
213 let inviter_private_key = inviter_identity.private_key()?;
214
215 let inviter_identity: [u8; DIGEST_SIZE] = **inviter_id;
216 let inviter_identity_context_type = inviter_identity.into();
217 let context_id = **context_id;
218
219 // 2. Construct the invitation payload.
220 let invitation = InvitationFromMember {
221 inviter_identity: inviter_identity_context_type,
222 context_id: context_id.into(),
223 expiration_height: expiration_block_height,
224 secret_salt,
225 protocol: external_config.protocol.to_string(),
226 network: external_config.network_id.to_string(),
227 contract_id: external_config.contract_id.to_string(),
228 };
229
230 // 3. Sign the invitation payload.
231 // The process is: borsh-serialize -> sha256-hash -> sign the hash.
232 let invitation_bytes =
233 borsh::to_vec(&invitation).context("Failed to serialize invitation")?;
234 let hash = Sha256::digest(&invitation_bytes);
235 let signature = inviter_private_key.sign(&hash).context("Signing failed")?;
236
237 // 4. Hex-encode the signature and return the complete package.
238 Ok(Some(SignedOpenInvitation {
239 invitation,
240 inviter_signature: hex::encode(signature.to_bytes()),
241 }))
242 }
243
244 /// Sends a request to join a context using an invitation payload.
245 ///
246 /// This is an asynchronous operation handled by the `ContextManager` actor. The actor
247 /// will parse the payload, validate the information, and configure the local node
248 /// to participate in the specified context.
249 ///
250 /// # Arguments
251 ///
252 /// * `invitation_payload` - The opaque `ContextInvitationPayload` received from an inviter.
253 ///
254 /// # Returns
255 ///
256 /// A `Result` containing the `JoinContextResponse` from the actor upon completion.
257 pub async fn join_context(
258 &self,
259 invitation_payload: ContextInvitationPayload,
260 ) -> eyre::Result<JoinContextResponse> {
261 let (sender, receiver) = oneshot::channel();
262
263 self.context_manager
264 .send(ContextMessage::JoinContext {
265 request: JoinContextRequest { invitation_payload },
266 outcome: sender,
267 })
268 .await
269 .expect("Mailbox not to be dropped");
270
271 receiver.await.expect("Mailbox not to be dropped")
272 }
273
274 // TODO(invitation): implement
275 /// Sends a request to join a context using the new commit-reveal open invitation flow.
276 ///
277 /// This is an asynchronous operation handled by the `ContextManager` actor. The actor
278 /// will parse the payload, validate the information, and configure the local node
279 /// to participate in the specified context.
280 ///
281 /// # Arguments
282 ///
283 /// * `invitation_payload` - The opaque `ContextInvitationPayload` received from an inviter.
284 ///
285 /// # Returns
286 ///
287 /// * A `Result` containing the `JoinContextResponse` from the actor upon completion.
288 /// * Returns `Ok(None)` if the context configuration cannot be found locally.
289 pub async fn join_context_by_open_invitation(
290 &self,
291 signed_invitation: SignedOpenInvitation,
292 new_member_public_key: &PublicKey,
293 ) -> eyre::Result<Option<JoinContextResponse>> {
294 let invitation = signed_invitation.invitation.clone();
295 // Convert `config::types::ContextId` to `crypto::ContextId`
296 let context_id = invitation.context_id.to_bytes().into();
297 println!("Try to join by open invitation the Context ID: {context_id}");
298
299 // At this step the identity should be at the zeroth context:
300 // it should exist on the node, but available to be assigned for a new context.
301 let new_member_identity = self
302 .get_identity(&ContextId::zero(), new_member_public_key)?
303 .with_context(|| format!("New member's identity {new_member_public_key} not found"))?;
304 let new_member_private_key = new_member_identity.private_key()?;
305
306 // Convert `crypto::ContextIdentity` to `calimero_contex_config::types::ContextId`
307 let new_member_identity_bytes: [u8; DIGEST_SIZE] = *new_member_identity.public_key;
308 let new_member_identity_context_type = new_member_identity_bytes.into();
309
310 let reveal_payload_data = RevealPayloadData {
311 signed_open_invitation: signed_invitation,
312 new_member_identity: new_member_identity_context_type,
313 };
314
315 let reveal_payload_data_bytes =
316 borsh::to_vec(&reveal_payload_data).context("Failed to serialize invitation")?;
317 let commitment_hash = hex::encode(Sha256::digest(&reveal_payload_data_bytes));
318 println!("New member commitment hash: {commitment_hash:?}");
319
320 // Create a config for the external client.
321 // We don't have a config for that context ID yet, as we are about to join it.
322 let mut external_config_params = None;
323 if !self.has_context(&context_id)? {
324 let mut external_config = ContextConfigParams {
325 protocol: invitation.protocol.into(),
326 network_id: invitation.network.into(),
327 contract_id: invitation.contract_id.into(),
328 proxy_contract: "".into(),
329 application_revision: 0,
330 members_revision: 0,
331 };
332
333 let external_client = self.external_client(&context_id, &external_config)?;
334 let config_client = external_client.config();
335 let proxy_contract = config_client.get_proxy_contract().await?;
336 external_config.proxy_contract = proxy_contract.into();
337
338 external_config_params = Some(external_config);
339 }
340
341 let external_config_params =
342 external_config_params.context("External config is None while it should be set")?;
343 let external_client = self.external_client(&context_id, &external_config_params)?;
344
345 external_client
346 .config()
347 .join_context_commit_invitation(
348 &new_member_identity.public_key,
349 commitment_hash,
350 reveal_payload_data
351 .signed_open_invitation
352 .invitation
353 .expiration_height,
354 )
355 .await?;
356 println!("Successfully committed the invitation payload");
357
358 // The new member that is going to join the context by open invitation, signs the payload
359 // that will be committed and revealed later
360 let new_member_signature = {
361 let hash = Sha256::digest(&reveal_payload_data_bytes);
362 let signature = new_member_private_key
363 .sign(&hash)
364 .context("Signing reveal payload data failed")?;
365 hex::encode(signature.to_bytes())
366 };
367 println!("New member signature: {new_member_signature:?}");
368
369 let signed_payload = SignedRevealPayload {
370 data: reveal_payload_data,
371 invitee_signature: new_member_signature,
372 };
373
374 external_client
375 .config()
376 .join_context_reveal_invitation(&new_member_identity.public_key, signed_payload)
377 .await?;
378 println!("Successfully submitted the revealed invitation payload");
379
380 // Create the ContextInvitationPayload
381 let invitation_payload = ContextInvitationPayload::new(
382 context_id,
383 new_member_identity.public_key,
384 external_config_params.protocol,
385 external_config_params.network_id,
386 external_config_params.contract_id,
387 )?;
388
389 // Join the context in the node
390 let (sender, receiver) = oneshot::channel();
391
392 self.context_manager
393 .send(ContextMessage::JoinContext {
394 request: JoinContextRequest { invitation_payload },
395 outcome: sender,
396 })
397 .await
398 .expect("Mailbox not to be dropped");
399
400 let response = receiver.await.expect("Mailbox not to be dropped")?;
401 Ok(Some(response))
402 }
403
404 /// Checks if a context's metadata exists in the local datastore.
405 ///
406 /// # Arguments
407 ///
408 /// * `context_id` - The ID of the context to check for.
409 ///
410 /// # Returns
411 ///
412 /// A `Result` containing `true` if the context exists locally, `false` otherwise.
413 pub fn has_context(&self, context_id: &ContextId) -> eyre::Result<bool> {
414 let handle = self.datastore.handle();
415
416 let key = key::ContextMeta::new(*context_id);
417
418 Ok(handle.has(&key)?)
419 }
420
421 /// Retrieves a context metadata from the local datastore.
422 ///
423 /// # Arguments
424 ///
425 /// * `context_id` - The ID of the context to retrieve.
426 ///
427 /// # Returns
428 ///
429 /// A `Result` containing `Some(Context)` if the context is found, or `None` if it is not.
430 pub fn get_context(&self, context_id: &ContextId) -> eyre::Result<Option<Context>> {
431 let handle = self.datastore.handle();
432
433 let key = key::ContextMeta::new(*context_id);
434
435 let Some(meta) = handle.get(&key)? else {
436 return Ok(None);
437 };
438
439 let context = Context::with_dag_heads(
440 *context_id,
441 meta.application.application_id(),
442 meta.root_hash.into(),
443 meta.dag_heads.clone(),
444 );
445
446 tracing::debug!(
447 %context_id,
448 dag_heads_count = meta.dag_heads.len(),
449 "Loaded context from database"
450 );
451
452 Ok(Some(context))
453 }
454
455 /// Updates the DAG heads for a context after applying a delta.
456 ///
457 /// # Arguments
458 ///
459 /// * `context_id` - The ID of the context to update.
460 /// * `dag_heads` - The new DAG heads (typically the delta ID that was just applied).
461 ///
462 /// # Returns
463 ///
464 /// A `Result` indicating success or failure.
465 pub fn update_dag_heads(
466 &self,
467 context_id: &ContextId,
468 dag_heads: Vec<[u8; 32]>,
469 ) -> eyre::Result<()> {
470 let handle = self.datastore.handle();
471
472 let key = key::ContextMeta::new(*context_id);
473
474 let Some(mut meta) = handle.get(&key)? else {
475 eyre::bail!("Context not found: {}", context_id);
476 };
477
478 // Update dag_heads
479 meta.dag_heads = dag_heads.clone();
480
481 // Write back to database
482 self.datastore.clone().handle().put(&key, &meta)?;
483
484 tracing::debug!(
485 %context_id,
486 dag_heads_count = dag_heads.len(),
487 "Updated dag_heads in database"
488 );
489
490 Ok(())
491 }
492
493 /// Updates the ApplicationId for a context.
494 ///
495 /// # Arguments
496 ///
497 /// * `context_id` - The ID of the context to update.
498 /// * `application_id` - The new ApplicationId.
499 ///
500 /// # Returns
501 ///
502 /// A `Result` indicating success or failure.
503 pub fn update_context_application_id(
504 &self,
505 context_id: &ContextId,
506 application_id: ApplicationId,
507 ) -> eyre::Result<()> {
508 let handle = self.datastore.handle();
509
510 let key = key::ContextMeta::new(*context_id);
511
512 let Some(mut meta) = handle.get(&key)? else {
513 eyre::bail!("Context not found: {}", context_id);
514 };
515
516 // Update application_id
517 meta.application = key::ApplicationMeta::new(application_id);
518
519 // Write back to database
520 self.datastore.clone().handle().put(&key, &meta)?;
521
522 tracing::debug!(
523 %context_id,
524 %application_id,
525 "Updated application_id in database"
526 );
527
528 Ok(())
529 }
530
531 /// Computes the actual root hash from storage by reading the root Index entry.
532 ///
533 /// This reads the EntityIndex for Id::root() from RocksDB and extracts the
534 /// Merkle full_hash. This is the authoritative hash computed from the actual
535 /// state, not a claimed value.
536 ///
537 /// # Arguments
538 ///
539 /// * `context_id` - The ID of the context to compute the root hash for.
540 ///
541 /// # Returns
542 ///
543 /// The computed root hash, or `[0; 32]` if no root index exists (empty state).
544 pub fn compute_root_hash(&self, context_id: &ContextId) -> eyre::Result<[u8; 32]> {
545 // Compute the state_key for Key::Index(Id::root())
546 // Id::root() = Id::new(context_id) in WASM context
547 // Key::Index(id).to_bytes() = SHA256([0] || id.as_bytes())
548 let root_id: [u8; 32] = **context_id;
549 let mut key_bytes = [0u8; 33];
550 key_bytes[0] = 0; // Index discriminant
551 key_bytes[1..33].copy_from_slice(&root_id);
552 let state_key: [u8; 32] = Sha256::digest(key_bytes).into();
553
554 let handle = self.datastore.handle();
555 let db_key = key::ContextState::new(*context_id, state_key);
556
557 // Get data and convert to owned bytes to avoid lifetime issues
558 let data_opt = handle.get(&db_key)?;
559
560 match data_opt {
561 Some(data) => {
562 // Convert to owned Vec<u8> to avoid lifetime issues
563 let bytes: Vec<u8> = data.as_ref().to_vec();
564 drop(data); // Explicitly drop the borrowed data
565
566 self.parse_entity_index_root_hash(context_id, &bytes)
567 }
568 None => {
569 // No root index exists - empty state
570 tracing::debug!(
571 %context_id,
572 "No root index found, returning zero hash"
573 );
574 Ok([0; 32])
575 }
576 }
577 }
578
579 /// Parse EntityIndex bytes to extract the root hash.
580 fn parse_entity_index_root_hash(
581 &self,
582 context_id: &ContextId,
583 bytes: &[u8],
584 ) -> eyre::Result<[u8; 32]> {
585 // Deserialize EntityIndex and extract full_hash
586 // EntityIndex is borsh-serialized with full_hash at a known offset
587 // Structure: id(32) + parent_id(Option<32>) + children(Option<Vec>) + full_hash(32) + ...
588
589 if bytes.len() < 68 {
590 // Minimum size: id(32) + parent_id_tag(1) + children_tag(1) + full_hash(32) + own_hash(32) = 98
591 // But we check for 68 to be safe (id + tags + full_hash)
592 eyre::bail!(
593 "EntityIndex too small: {} bytes, expected at least 68",
594 bytes.len()
595 );
596 }
597
598 // Parse the EntityIndex structure manually for efficiency
599 // id: [u8; 32]
600 // parent_id: Option<Id> - 1 byte tag + optional 32 bytes
601 // children: Option<Vec<ChildInfo>> - 1 byte tag + optional length + data
602 // full_hash: [u8; 32]
603
604 let mut offset = 32; // Skip id
605
606 // Skip parent_id (Option<Id>)
607 let parent_tag = bytes[offset];
608 offset += 1;
609 if parent_tag == 1 {
610 offset += 32; // Skip the Id bytes
611 }
612
613 // Skip children (Option<Vec<ChildInfo>>)
614 let children_tag = bytes[offset];
615 offset += 1;
616 if children_tag == 1 {
617 // Children present - use full borsh deserialization for correctness
618 return self.compute_root_hash_via_borsh(context_id, bytes);
619 }
620
621 // Now at full_hash position
622 if offset + 32 > bytes.len() {
623 eyre::bail!(
624 "EntityIndex full_hash truncated at offset {}, len {}",
625 offset,
626 bytes.len()
627 );
628 }
629
630 let mut full_hash = [0u8; 32];
631 full_hash.copy_from_slice(&bytes[offset..offset + 32]);
632
633 tracing::debug!(
634 %context_id,
635 computed_root = ?Hash::from(full_hash),
636 "Computed root hash from storage"
637 );
638
639 Ok(full_hash)
640 }
641
642 /// Helper to compute root hash using full borsh deserialization.
643 fn compute_root_hash_via_borsh(
644 &self,
645 context_id: &ContextId,
646 bytes: &[u8],
647 ) -> eyre::Result<[u8; 32]> {
648 // Minimal EntityIndex structure for deserialization
649 #[derive(BorshDeserialize)]
650 struct EntityIndexMinimal {
651 _id: [u8; 32],
652 _parent_id: Option<[u8; 32]>,
653 _children: Option<Vec<ChildInfoMinimal>>,
654 full_hash: [u8; 32],
655 // Don't need rest
656 }
657
658 #[derive(BorshDeserialize)]
659 struct ChildInfoMinimal {
660 _id: [u8; 32],
661 _merkle_hash: [u8; 32],
662 _metadata: MetadataMinimal,
663 }
664
665 #[derive(BorshDeserialize)]
666 struct MetadataMinimal {
667 _created_at: u64,
668 _updated_at: UpdatedAtMinimal,
669 _storage_type: u8,
670 }
671
672 #[derive(BorshDeserialize)]
673 struct UpdatedAtMinimal(u64);
674
675 let index: EntityIndexMinimal = EntityIndexMinimal::try_from_slice(bytes)
676 .map_err(|e| eyre::eyre!("Failed to deserialize EntityIndex: {}", e))?;
677
678 tracing::debug!(
679 %context_id,
680 computed_root = ?Hash::from(index.full_hash),
681 "Computed root hash from storage (via borsh)"
682 );
683
684 Ok(index.full_hash)
685 }
686
687 /// Forces the root hash for a context to a specific value.
688 ///
689 /// **WARNING**: This bypasses verification and should only be used when
690 /// the hash has already been verified or during controlled operations.
691 /// Prefer `compute_root_hash` + `set_root_hash` for safety.
692 ///
693 /// # Arguments
694 ///
695 /// * `context_id` - The ID of the context to update.
696 /// * `root_hash` - The root hash to set.
697 ///
698 /// # Returns
699 ///
700 /// A `Result` indicating success or failure.
701 pub fn force_root_hash(&self, context_id: &ContextId, root_hash: Hash) -> eyre::Result<()> {
702 let handle = self.datastore.handle();
703
704 let key = key::ContextMeta::new(*context_id);
705
706 let Some(mut meta) = handle.get(&key)? else {
707 eyre::bail!("Context not found: {}", context_id);
708 };
709
710 tracing::debug!(
711 %context_id,
712 old_root = ?Hash::from(meta.root_hash),
713 new_root = ?root_hash,
714 "Setting root hash"
715 );
716
717 meta.root_hash = *root_hash;
718
719 self.datastore.clone().handle().put(&key, &meta)?;
720
721 Ok(())
722 }
723
724 /// Verifies that the stored root hash matches the actual state.
725 ///
726 /// Computes the root hash from storage and compares with the claimed hash.
727 /// Returns Ok(()) if they match, or an error describing the mismatch.
728 ///
729 /// # Arguments
730 ///
731 /// * `context_id` - The ID of the context to verify.
732 /// * `claimed_hash` - The hash to verify against.
733 ///
734 /// # Returns
735 ///
736 /// A `Result` indicating success if hashes match, or an error if they don't.
737 pub fn verify_root_hash(
738 &self,
739 context_id: &ContextId,
740 claimed_hash: [u8; 32],
741 ) -> eyre::Result<()> {
742 let computed = self.compute_root_hash(context_id)?;
743
744 if computed != claimed_hash {
745 eyre::bail!(
746 "Root hash verification failed for context {}: computed {} != claimed {}",
747 context_id,
748 hex::encode(computed),
749 hex::encode(claimed_hash)
750 );
751 }
752
753 tracing::debug!(
754 %context_id,
755 hash = ?Hash::from(computed),
756 "Root hash verified successfully"
757 );
758
759 Ok(())
760 }
761
762 /// Returns a stream of all context IDs stored locally.
763 ///
764 /// # Arguments
765 ///
766 /// * `start` - An optional `ContextId` from which to begin the stream. If `None`,
767 /// the stream starts from the beginning.
768 ///
769 /// # Returns
770 ///
771 /// An implementation of `Stream` that yields `Result<ContextId>`.
772 pub fn get_context_ids(
773 &self,
774 start: Option<ContextId>,
775 ) -> impl Stream<Item = eyre::Result<ContextId>> {
776 let handle = self.datastore.handle();
777
778 try_stream! {
779 let mut iter = handle.iter::<key::ContextMeta>()?;
780
781 let start = start.and_then(|s| iter.seek(key::ContextMeta::new(s)).transpose());
782
783 for key in start.into_iter().chain(iter.keys()) {
784 yield key?.context_id();
785 }
786 }
787 }
788
789 /// Checks if a given public key is a member of a context in the local datastore.
790 ///
791 /// # Arguments
792 ///
793 /// * `context_id` - The context to check within.
794 /// * `public_key` - The public key of the potential member.
795 ///
796 /// # Returns
797 ///
798 /// A `Result` containing `true` if the identity is a known member, `false` otherwise.
799 pub fn has_member(
800 &self,
801 context_id: &ContextId,
802 public_key: &PublicKey,
803 // is_owned: Option<bool>,
804 ) -> eyre::Result<bool> {
805 let handle = self.datastore.handle();
806
807 let key = key::ContextIdentity::new(*context_id, *public_key);
808
809 Ok(handle.has(&key)?)
810 }
811
812 /// Retrieves and returns a stream of all members of a given context.
813 ///
814 /// # Arguments
815 ///
816 /// * `context_id` - The context to query for members.
817 /// * `owned` - If `Some(true)`, the stream returns only members for which this node holds
818 /// the private key. If `Some(false)` or `None`, it returns all members.
819 ///
820 /// # Returns
821 ///
822 /// A stream of tuples `(PublicKey, bool)`, where the boolean indicates if the identity is owned.
823 pub fn get_context_members(
824 &self,
825 context_id: &ContextId,
826 owned: Option<bool>,
827 ) -> impl Stream<Item = eyre::Result<(PublicKey, bool)>> {
828 let handle = self.datastore.handle();
829 let context_id = *context_id;
830 let only_owned = owned.unwrap_or(false);
831
832 try_stream! {
833 let mut iter = handle.iter::<key::ContextIdentity>()?;
834
835 let first = iter
836 .seek(key::ContextIdentity::new(context_id, [0; DIGEST_SIZE].into()))
837 .transpose()
838 .map(|k| (k, iter.read()));
839
840 for (k, v) in first.into_iter().chain(iter.entries()) {
841 let (k, v) = (k?, v?);
842
843 if k.context_id() != context_id {
844 break;
845 }
846
847 let is_owned = v.private_key.is_some();
848 if !only_owned || is_owned {
849 yield (k.public_key(), is_owned);
850 }
851 }
852 }
853 }
854
855 /// Sends a request to execute a method within a context.
856 ///
857 /// This is the primary way to interact with the application running inside a context.
858 /// The request is handled asynchronously by the `ContextManager` actor.
859 ///
860 /// # Arguments
861 ///
862 /// * `context_id` - The ID of the context where the execution should occur.
863 /// * `executor` - The public key of the identity performing the execution. The executor
864 /// must be a member of the context.
865 /// * `method` - The string name of the application method to call.
866 /// * `payload` - The input data (e.g., serialized JSON) for the method.
867 /// * `aliases` - A list of public key aliases to use for this specific execution.
868 /// * `atomic` - An optional handle for batching multiple executions into an atomic transaction.
869 ///
870 /// # Returns
871 ///
872 /// A `Result` containing the `ExecuteResponse` on success, or an `ExecuteError` on failure.
873 pub async fn execute(
874 &self,
875 context_id: &ContextId,
876 executor: &PublicKey,
877 method: String,
878 payload: Vec<u8>,
879 aliases: Vec<Alias<PublicKey>>,
880 atomic: Option<ContextAtomic>,
881 ) -> Result<ExecuteResponse, ExecuteError> {
882 let (sender, receiver) = oneshot::channel();
883
884 self.context_manager
885 .send(ContextMessage::Execute {
886 request: ExecuteRequest {
887 context: *context_id,
888 executor: *executor,
889 method,
890 payload,
891 aliases,
892 atomic,
893 },
894 outcome: sender,
895 })
896 .await
897 .expect("Mailbox not to be dropped");
898
899 receiver.await.expect("Mailbox not to be dropped")
900 }
901
902 /// Sends a request to update the application for a given context.
903 /// This is an asynchronous operation handled by the `ContextManager` actor.
904 ///
905 /// # Arguments
906 /// * `context_id` - The ID of the context where to update the application.
907 /// * `application_id` - The ID of the new application to switch to.
908 /// * `identity` - The public key of the member authorizing the update.
909 /// * `migrate_method` - Optional name of the migration function to execute.
910 ///
911 /// # Returns
912 ///
913 /// An empty `Result` indicating the outcome of the application update request.
914 pub async fn update_application(
915 &self,
916 context_id: &ContextId,
917 application_id: &ApplicationId,
918 identity: &PublicKey,
919 migrate_method: Option<String>,
920 ) -> eyre::Result<()> {
921 let (sender, receiver) = oneshot::channel();
922
923 let migration = migrate_method.map(|method| MigrationParams { method });
924
925 self.context_manager
926 .send(ContextMessage::UpdateApplication {
927 request: UpdateApplicationRequest {
928 context_id: *context_id,
929 application_id: *application_id,
930 public_key: *identity,
931 migration,
932 },
933 outcome: sender,
934 })
935 .await
936 .expect("Mailbox not to be dropped");
937
938 receiver.await.expect("Mailbox not to be dropped")
939 }
940
941 /// Sends a request to delete a context from the local node.
942 /// This is an asynchronous operation handled by the `ContextManager` actor. It will remove
943 /// all associated data for the context from the local datastore.
944 ///
945 /// # Arguments
946 /// * `context_id` - The ID of the context to delete.
947 ///
948 /// # Returns
949 ///
950 ///A `Result` containing the `DeleteContextResponse` from the actor.
951 pub async fn delete_context(
952 &self,
953 context_id: &ContextId,
954 ) -> eyre::Result<DeleteContextResponse> {
955 let (sender, receiver) = oneshot::channel();
956
957 self.context_manager
958 .send(ContextMessage::DeleteContext {
959 request: DeleteContextRequest {
960 context_id: *context_id,
961 },
962 outcome: sender,
963 })
964 .await
965 .expect("Mailbox not to be dropped");
966
967 receiver.await.expect("Mailbox not to be dropped")
968 }
969}