Skip to main content

nexus_sdk/nexus/
crawler.rs

1//! Module defining a Sui object crawler - this struct is able to fetch object
2//! and dynamic field data from Sui GRPC and deserialize them into Rust structs.
3
4use {
5    crate::{
6        move_bindings::sui_framework::{object::ID, table_vec::TableVec},
7        nexus::error::NexusError,
8        sui::{self, traits::FieldMaskUtil},
9    },
10    anyhow::{anyhow, bail, ensure, Context as _},
11    serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize},
12    std::{
13        collections::{HashMap, HashSet},
14        hash::Hash,
15        sync::Arc,
16    },
17};
18
19lazy_static::lazy_static! {
20    static ref DYNAMIC_FIELD_LIST_REQUESTS: prometheus::CounterVec =
21        prometheus::register_counter_vec!(
22            "nexus_dynamic_field_list_requests_total",
23            "ListDynamicFields requests issued by each typed crawler operation",
24            &["operation"],
25        )
26        .unwrap();
27}
28
29fn observe_dynamic_field_list(operation: &'static str) {
30    DYNAMIC_FIELD_LIST_REQUESTS
31        .with_label_values(&[operation])
32        .inc();
33}
34
35#[derive(Debug, Deserialize)]
36struct DynamicFieldNameBcs<K> {
37    #[allow(unused)]
38    id: sui::types::Address,
39    name: K,
40}
41
42#[derive(Debug, Deserialize, Serialize)]
43struct DynamicFieldValue<K, V> {
44    #[allow(unused)]
45    id: sui::types::Address,
46    #[allow(unused)]
47    name: K,
48    value: V,
49}
50
51/// BCS representation of `sui::dynamic_object_field::Wrapper<K>`.
52///
53/// [`DynamicFieldValue`] cannot represent this name because ordinary dynamic
54/// fields store `K` directly, while dynamic object fields store this wrapper.
55#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
56struct DynamicObjectFieldName<K> {
57    name: K,
58}
59
60fn parse_dynamic_field_name<K>(bytes: &[u8]) -> Result<K, bcs::Error>
61where
62    K: DeserializeOwned,
63{
64    bcs::from_bytes::<K>(bytes)
65        .or_else(|_| bcs::from_bytes::<DynamicFieldNameBcs<K>>(bytes).map(|field| field.name))
66}
67
68pub(crate) fn derive_dynamic_field_id<K>(
69    parent_id: sui::types::Address,
70    key: &K,
71    key_type: &sui::types::TypeTag,
72) -> anyhow::Result<sui::types::Address>
73where
74    K: Serialize,
75{
76    let key_bytes = bcs::to_bytes(key).context("Could not encode dynamic field key")?;
77    Ok(parent_id.derive_dynamic_child_id(key_type, &key_bytes))
78}
79
80fn validate_dynamic_field<K, V>(
81    field_id: sui::types::Address,
82    key: &K,
83    field: &DynamicFieldValue<K, V>,
84) -> anyhow::Result<()>
85where
86    K: Eq,
87{
88    if field.id != field_id {
89        bail!("Dynamic field '{field_id}' decoded with an unexpected embedded ID");
90    }
91    if &field.name != key {
92        bail!("Dynamic field '{field_id}' decoded with an unexpected key");
93    }
94    Ok(())
95}
96
97enum FinalizedObjectChange {
98    Unchanged,
99    Written(Box<sui::grpc::Object>),
100    Removed,
101}
102
103fn validate_executed_transaction(
104    executed: &sui::grpc::ExecutedTransaction,
105    transaction: sui::types::Digest,
106) -> anyhow::Result<()> {
107    let observed = executed
108        .digest_opt()
109        .ok_or_else(|| anyhow!("Transaction '{transaction}' response has no digest"))?
110        .parse::<sui::types::Digest>()
111        .map_err(|error| {
112            anyhow!("Transaction '{transaction}' response has an invalid digest: {error}")
113        })?;
114    if observed != transaction {
115        bail!("Requested transaction '{transaction}', received transaction '{observed}'");
116    }
117    Ok(())
118}
119
120fn finalized_transaction_object_change(
121    objects: &[sui::grpc::Object],
122    transaction: sui::types::Digest,
123    object_id: sui::types::Address,
124) -> anyhow::Result<FinalizedObjectChange> {
125    let mut mentioned = false;
126    let mut output = None;
127    for object in objects {
128        if Crawler::parse_object_id(object)? != object_id {
129            continue;
130        }
131        mentioned = true;
132        let previous_transaction = object
133            .previous_transaction_opt()
134            .ok_or_else(|| {
135                anyhow!(
136                    "Finalized transaction '{transaction}' returned object '{object_id}' without \
137                     its previous transaction"
138                )
139            })?
140            .parse::<sui::types::Digest>()
141            .map_err(|error| {
142                anyhow!(
143                    "Finalized transaction '{transaction}' returned object '{object_id}' with an \
144                     invalid previous transaction: {error}"
145                )
146            })?;
147        if previous_transaction == transaction && output.replace(object.clone()).is_some() {
148            bail!(
149                "Finalized transaction '{transaction}' returned output object '{object_id}' \
150                 more than once"
151            );
152        }
153    }
154
155    match (mentioned, output) {
156        (_, Some(output)) => Ok(FinalizedObjectChange::Written(Box::new(output))),
157        (true, None) => Ok(FinalizedObjectChange::Removed),
158        (false, None) => Ok(FinalizedObjectChange::Unchanged),
159    }
160}
161
162fn reject_removed_state_object(
163    transaction: sui::types::Digest,
164    object_id: sui::types::Address,
165    role: &str,
166) -> ObjectStateSnapshotError {
167    ObjectStateSnapshotError::Invalid(format!(
168        "Finalized transaction '{transaction}' removed the {role} object '{object_id}'"
169    ))
170}
171
172fn dynamic_object_field_wrapper_type(key_type: &sui::types::TypeTag) -> sui::types::TypeTag {
173    sui::types::TypeTag::Struct(Box::new(sui::types::StructTag::new(
174        sui::types::Address::from_static("0x2"),
175        sui::types::Identifier::from_static("dynamic_object_field"),
176        sui::types::Identifier::from_static("Wrapper"),
177        vec![key_type.clone()],
178    )))
179}
180
181/// The main crawler struct.
182#[derive(Clone)]
183pub struct Crawler {
184    client: Arc<sui::grpc::Client>,
185    state_catalog: Arc<sui::grpc::Client>,
186}
187
188#[derive(Debug)]
189pub struct DynamicObjectFieldReference<K> {
190    pub name: K,
191    pub field_id: sui::types::Address,
192    pub child_id: sui::types::Address,
193}
194
195#[derive(Clone, Debug)]
196pub struct DynamicFieldReference<K> {
197    pub name: K,
198    pub field_id: sui::types::Address,
199}
200
201/// Immutable type metadata for one ordinary dynamic field.
202///
203/// The key and value types come from the complete
204/// `0x2::dynamic_field::Field<K, V>` object type. The value bytes are never
205/// decoded while this metadata is collected.
206#[derive(Clone, Debug, Eq, PartialEq)]
207pub struct DynamicFieldMetadata {
208    /// Object ID of the dynamic field wrapper.
209    pub field_id: sui::types::Address,
210    /// Exact Move type of the field key.
211    pub key_type: sui::types::TypeTag,
212    /// Exact Move type of the field value.
213    pub value_type: sui::types::TypeTag,
214}
215
216/// Identity, owner, and Move type of one live object.
217///
218/// Mutable version and digest data are deliberately absent because neither is
219/// package authority or stable object identity.
220#[derive(Clone, Debug, Eq, PartialEq)]
221pub struct ObjectMetadata {
222    /// Stable Sui object ID.
223    pub object_id: sui::types::Address,
224    /// Current object owner.
225    pub owner: sui::types::Owner,
226    /// Exact Move type of the object anchor.
227    pub object_type: sui::types::StructTag,
228}
229
230/// One live anchor and its state fields returned by one Sui batch read.
231///
232/// The snapshot retains object bytes only until the caller validates the
233/// complete anchor, witness, and inner type identity. This lets typed state
234/// reads decode the value without a second RPC.
235#[derive(Clone, Debug)]
236pub(crate) struct ObjectStateSnapshot {
237    pub(crate) object: ObjectMetadata,
238    pub(crate) witness: DynamicFieldMetadata,
239    pub(crate) inner: DynamicFieldMetadata,
240    anchor_object: sui::grpc::Object,
241    inner_object: sui::grpc::Object,
242}
243
244impl ObjectStateSnapshot {
245    pub(crate) fn inner_object_reference(&self) -> anyhow::Result<sui::types::ObjectReference> {
246        let object_id = Crawler::parse_object_id(&self.inner_object)?;
247        ensure!(
248            object_id == self.inner.field_id,
249            "State inner object '{}' does not match expected field '{}'",
250            object_id,
251            self.inner.field_id,
252        );
253        let version = self
254            .inner_object
255            .version_opt()
256            .ok_or_else(|| anyhow!("Version missing for state inner object '{object_id}'"))?;
257        let digest = self
258            .inner_object
259            .digest_opt()
260            .ok_or_else(|| anyhow!("Digest missing for state inner object '{object_id}'"))?
261            .parse()
262            .map_err(|_| anyhow!("Invalid digest for state inner object '{object_id}'"))?;
263
264        Ok(sui::types::ObjectReference::new(object_id, version, digest))
265    }
266}
267
268/// Failure while reading a typed state snapshot.
269#[derive(Debug)]
270pub(crate) enum ObjectStateSnapshotError {
271    /// Sui transport or object availability failed.
272    Rpc(anyhow::Error),
273    /// Returned object identity, ownership, or type metadata was invalid.
274    Invalid(String),
275}
276
277#[derive(Debug)]
278struct ObjectStateObjects {
279    anchor: sui::grpc::Object,
280    witness: sui::grpc::Object,
281    inner: sui::grpc::Object,
282}
283
284fn parse_dynamic_field_metadata(
285    parent_id: sui::types::Address,
286    field: &sui::grpc::DynamicField,
287) -> anyhow::Result<Option<DynamicFieldMetadata>> {
288    match field.kind {
289        Some(kind) if kind == sui::grpc::dynamic_field::DynamicFieldKind::Object as i32 => {
290            return Ok(None);
291        }
292        Some(kind) if kind != sui::grpc::dynamic_field::DynamicFieldKind::Field as i32 => {
293            bail!("Dynamic field for parent '{parent_id}' has unknown kind '{kind}'");
294        }
295        _ => {}
296    }
297
298    let field_id = field
299        .field_id_opt()
300        .ok_or_else(|| anyhow!("Dynamic field ID missing for parent '{parent_id}'"))?
301        .parse()
302        .map_err(|error| anyhow!("Could not parse dynamic field ID: {error}"))?;
303    let object_type = field
304        .field_object_opt()
305        .and_then(|object| object.object_type_opt())
306        .ok_or_else(|| anyhow!("Dynamic field '{field_id}' has no field object type"))?;
307    let (key_type, value_type) = parse_dynamic_field_type(field_id, object_type)?;
308    if let Some(reported_value_type) = field.value_type_opt() {
309        let reported_value_type =
310            reported_value_type
311                .parse::<sui::types::TypeTag>()
312                .map_err(|error| {
313                    anyhow!("Dynamic field '{field_id}' has invalid reported value type: {error}")
314                })?;
315        if reported_value_type != value_type {
316            bail!(
317                "Dynamic field '{field_id}' reports value type '{reported_value_type}', but its \
318                 field object contains '{value_type}'"
319            );
320        }
321    }
322
323    Ok(Some(DynamicFieldMetadata {
324        field_id,
325        key_type,
326        value_type,
327    }))
328}
329
330fn parse_dynamic_field_type(
331    field_id: sui::types::Address,
332    object_type: &str,
333) -> anyhow::Result<(sui::types::TypeTag, sui::types::TypeTag)> {
334    let field_type = object_type
335        .parse::<sui::types::StructTag>()
336        .map_err(|error| anyhow!("Dynamic field '{field_id}' has invalid type: {error}"))?;
337    if *field_type.address() != sui::types::Address::from_static("0x2")
338        || field_type.module().as_str() != "dynamic_field"
339        || field_type.name().as_str() != "Field"
340        || field_type.type_params().len() != 2
341    {
342        bail!(
343            "Dynamic field '{field_id}' has object type '{field_type}', expected \
344             '0x2::dynamic_field::Field<K, V>'"
345        );
346    }
347
348    let key_type = field_type.type_params()[0].clone();
349    let value_type = field_type.type_params()[1].clone();
350    Ok((key_type, value_type))
351}
352
353/// One RPC page of typed dynamic field values.
354#[derive(Clone, Debug, PartialEq, Eq)]
355pub struct DynamicFieldPage<K, V> {
356    data: Vec<(K, V)>,
357    next_cursor: Option<Vec<u8>>,
358}
359
360impl<K, V> DynamicFieldPage<K, V> {
361    /// Returns the decoded values in RPC order.
362    pub fn data(&self) -> &[(K, V)] {
363        &self.data
364    }
365
366    /// Returns the opaque cursor for the next RPC page.
367    pub fn next_cursor(&self) -> Option<&[u8]> {
368        self.next_cursor.as_deref()
369    }
370
371    /// Separates the decoded values from the opaque next page cursor.
372    pub fn into_parts(self) -> (Vec<(K, V)>, Option<Vec<u8>>) {
373        (self.data, self.next_cursor)
374    }
375}
376
377/// One RPC page of typed objects owned by one address or object.
378#[derive(Clone, Debug)]
379pub struct OwnedObjectPage<T> {
380    data: Vec<Response<T>>,
381    next_cursor: Option<Vec<u8>>,
382}
383
384fn is_owned_by_address(owner: &sui::types::Owner, address: sui::types::Address) -> bool {
385    match owner {
386        sui::types::Owner::Address(owner) | sui::types::Owner::ConsensusAddress { owner, .. } => {
387            *owner == address
388        }
389        _ => false,
390    }
391}
392
393fn matches_expected_owner(observed: &sui::types::Owner, expected: &sui::types::Owner) -> bool {
394    match expected {
395        sui::types::Owner::Address(address) => is_owned_by_address(observed, *address),
396        _ => observed == expected,
397    }
398}
399
400impl<T> OwnedObjectPage<T> {
401    /// Returns the decoded objects in RPC order.
402    pub fn data(&self) -> &[Response<T>] {
403        &self.data
404    }
405
406    /// Returns the opaque cursor for the next RPC page.
407    pub fn next_cursor(&self) -> Option<&[u8]> {
408        self.next_cursor.as_deref()
409    }
410
411    /// Separates the decoded objects from the opaque next page cursor.
412    pub fn into_parts(self) -> (Vec<Response<T>>, Option<Vec<u8>>) {
413        (self.data, self.next_cursor)
414    }
415}
416
417/// The on-chain reference that identifies the transaction which produced one
418/// version of an object.
419#[derive(Clone, Debug, PartialEq, Eq)]
420pub struct ObjectUpdateReference {
421    pub owner: sui::types::Owner,
422    pub object_type: sui::types::StructTag,
423    pub version: sui::types::Version,
424    pub digest: sui::types::Digest,
425    pub previous_transaction: sui::types::Digest,
426}
427
428/// Effects and events fetched for one transaction that updated an object.
429#[derive(Clone, Debug)]
430pub struct TransactionUpdate {
431    pub digest: sui::types::Digest,
432    pub checkpoint: u64,
433    pub effects: sui::types::TransactionEffectsV2,
434    pub events: Vec<sui::types::Event>,
435}
436
437impl Crawler {
438    pub fn new(client: Arc<sui::grpc::Client>) -> Self {
439        Self {
440            state_catalog: Arc::clone(&client),
441            client,
442        }
443    }
444
445    /// Uses `state_catalog` for indexed dynamic field discovery.
446    ///
447    /// Exact objects, transactions, and packages continue to come from
448    /// `client`. Collection identities can therefore be discovered without
449    /// making an indexed service the authority for current object state.
450    pub fn with_state_catalog(
451        client: Arc<sui::grpc::Client>,
452        state_catalog: Arc<sui::grpc::Client>,
453    ) -> Self {
454        Self {
455            client,
456            state_catalog,
457        }
458    }
459
460    pub(crate) fn grpc_client(&self) -> Arc<sui::grpc::Client> {
461        Arc::clone(&self.client)
462    }
463
464    pub(crate) fn clone_grpc_client(&self) -> sui::grpc::Client {
465        self.client.as_ref().clone()
466    }
467
468    fn clone_state_catalog_client(&self) -> sui::grpc::Client {
469        self.state_catalog.as_ref().clone()
470    }
471
472    /// Fetches stable identity, owner, and exact Move type for `object_id`.
473    ///
474    /// Object contents are not requested or decoded.
475    ///
476    /// # Errors
477    ///
478    /// Returns an error when the object cannot be fetched or its identity,
479    /// owner, or Move type is absent or invalid.
480    pub async fn observe_object_metadata(
481        &self,
482        object_id: sui::types::Address,
483    ) -> anyhow::Result<ObjectMetadata> {
484        let object = self
485            .fetch_object(
486                object_id,
487                sui::grpc::FieldMask::from_paths(["object_id", "owner", "object_type"]),
488            )
489            .await?;
490        Self::parse_observed_object_metadata(object_id, &object)
491    }
492
493    fn parse_observed_object_metadata(
494        object_id: sui::types::Address,
495        object: &sui::grpc::Object,
496    ) -> anyhow::Result<ObjectMetadata> {
497        let returned_id = Self::parse_object_id(object)?;
498        if returned_id != object_id {
499            bail!("Requested object '{object_id}', received object '{returned_id}'");
500        }
501        let owner = object
502            .owner_opt()
503            .ok_or_else(|| anyhow!("Owner missing for object '{object_id}'"))?
504            .try_into()
505            .map_err(|_| anyhow!("Could not parse owner for object '{object_id}'"))?;
506        let object_type = object
507            .object_type_opt()
508            .ok_or_else(|| anyhow!("Object type missing for object '{object_id}'"))?
509            .parse()
510            .map_err(|error| anyhow!("Could not parse type for object '{object_id}': {error}"))?;
511
512        Ok(ObjectMetadata {
513            object_id,
514            owner,
515            object_type,
516        })
517    }
518
519    /// Observes one anchor and two known dynamic field wrappers in one batch.
520    ///
521    /// The field IDs must already have been derived from their exact typed
522    /// keys. A missing wrapper returns [`None`] so the caller can discover a
523    /// different key lineage. All present identities, owners, and complete
524    /// `0x2::dynamic_field::Field<K, V>` types are validated.
525    pub(crate) async fn observe_object_state_fields(
526        &self,
527        object_id: sui::types::Address,
528        witness_field_id: sui::types::Address,
529        inner_field_id: sui::types::Address,
530    ) -> anyhow::Result<Option<(ObjectMetadata, Vec<DynamicFieldMetadata>)>> {
531        let field_mask = sui::grpc::FieldMask::from_paths(["object_id", "owner", "object_type"]);
532        let Some(objects) = self
533            .fetch_object_state_objects(object_id, witness_field_id, inner_field_id, field_mask)
534            .await?
535        else {
536            return Ok(None);
537        };
538
539        let object = Self::parse_observed_object_metadata(object_id, &objects.anchor)?;
540        let witness =
541            Self::parse_state_field_metadata(object_id, witness_field_id, &objects.witness)?;
542        let inner = Self::parse_state_field_metadata(object_id, inner_field_id, &objects.inner)?;
543
544        Ok(Some((object, vec![witness, inner])))
545    }
546
547    /// Reads one anchor and both known state fields in one batch.
548    ///
549    /// The returned bytes remain opaque until the caller validates the exact
550    /// state pair. Missing derived fields return [`None`] so an unknown key
551    /// lineage can be discovered through the metadata path.
552    pub(crate) async fn object_state_snapshot(
553        &self,
554        object_id: sui::types::Address,
555        witness_field_id: sui::types::Address,
556        inner_field_id: sui::types::Address,
557    ) -> Result<Option<ObjectStateSnapshot>, ObjectStateSnapshotError> {
558        let field_mask = sui::grpc::FieldMask::from_paths([
559            "object_id",
560            "owner",
561            "object_type",
562            "version",
563            "digest",
564            "balance",
565            "contents",
566        ]);
567        let Some(objects) = self
568            .fetch_object_state_objects(object_id, witness_field_id, inner_field_id, field_mask)
569            .await
570            .map_err(ObjectStateSnapshotError::Rpc)?
571        else {
572            return Ok(None);
573        };
574        let invalid = |error: anyhow::Error| ObjectStateSnapshotError::Invalid(error.to_string());
575        let object =
576            Self::parse_observed_object_metadata(object_id, &objects.anchor).map_err(invalid)?;
577        let witness =
578            Self::parse_state_field_metadata(object_id, witness_field_id, &objects.witness)
579                .map_err(invalid)?;
580        let inner = Self::parse_state_field_metadata(object_id, inner_field_id, &objects.inner)
581            .map_err(invalid)?;
582
583        Ok(Some(ObjectStateSnapshot {
584            object,
585            witness,
586            inner,
587            anchor_object: objects.anchor,
588            inner_object: objects.inner,
589        }))
590    }
591
592    /// Build one state snapshot from a verified causal transaction view.
593    ///
594    /// The view may contain objects inherited from finalized ancestors, so an
595    /// object's `previous_transaction` need not equal `transaction`. Exact
596    /// object identity, ownership, field derivation, and Move types are still
597    /// validated before any bytes can become application authority.
598    pub(crate) fn object_state_snapshot_from_transaction_view(
599        &self,
600        object_id: sui::types::Address,
601        witness_field_id: sui::types::Address,
602        inner_field_id: sui::types::Address,
603        executed: &sui::grpc::ExecutedTransaction,
604        transaction: sui::types::Digest,
605    ) -> Result<Option<ObjectStateSnapshot>, ObjectStateSnapshotError> {
606        let invalid = |error: anyhow::Error| ObjectStateSnapshotError::Invalid(error.to_string());
607        validate_executed_transaction(executed, transaction).map_err(invalid)?;
608
609        let find =
610            |id| -> anyhow::Result<Option<sui::grpc::Object>> {
611                let mut matches = executed.objects().objects.iter().filter_map(|object| {
612                    match Self::parse_object_id(object) {
613                        Ok(returned) if returned == id => Some(Ok(object.clone())),
614                        Ok(_) => None,
615                        Err(error) => Some(Err(error)),
616                    }
617                });
618                let Some(object) = matches.next().transpose()? else {
619                    return Ok(None);
620                };
621                ensure!(
622                    matches.next().transpose()?.is_none(),
623                    "Causal transaction view contains object '{id}' more than once"
624                );
625                Ok(Some(object))
626            };
627
628        let Some(anchor) = find(object_id).map_err(invalid)? else {
629            return Ok(None);
630        };
631        let Some(witness_object) = find(witness_field_id).map_err(invalid)? else {
632            return Ok(None);
633        };
634        let Some(inner_object) = find(inner_field_id).map_err(invalid)? else {
635            return Ok(None);
636        };
637
638        let object = Self::parse_observed_object_metadata(object_id, &anchor).map_err(invalid)?;
639        let witness =
640            Self::parse_state_field_metadata(object_id, witness_field_id, &witness_object)
641                .map_err(invalid)?;
642        let inner = Self::parse_state_field_metadata(object_id, inner_field_id, &inner_object)
643            .map_err(invalid)?;
644
645        Ok(Some(ObjectStateSnapshot {
646            object,
647            witness,
648            inner,
649            anchor_object: anchor,
650            inner_object,
651        }))
652    }
653
654    async fn fetch_object_state_objects(
655        &self,
656        object_id: sui::types::Address,
657        witness_field_id: sui::types::Address,
658        inner_field_id: sui::types::Address,
659        field_mask: sui::grpc::FieldMask,
660    ) -> anyhow::Result<Option<ObjectStateObjects>> {
661        let object_ids = [object_id, witness_field_id, inner_field_id];
662        let results = self.fetch_object_results(&object_ids, field_mask).await?;
663        if results.len() != object_ids.len() {
664            bail!(
665                "Batch object response contained {} results for {} state objects",
666                results.len(),
667                object_ids.len()
668            );
669        }
670
671        let mut results = results.into_iter();
672        let anchor = results
673            .next()
674            .expect("response length was validated")
675            .to_result()
676            .map_err(|status| {
677                if status.code == i32::from(tonic::Code::NotFound) {
678                    anyhow::Error::new(NexusError::ObjectNotFound { object: object_id })
679                } else {
680                    anyhow!("Could not fetch object '{object_id}': {}", status.message)
681                }
682            })?;
683        let field = |field_id, result: sui::grpc::GetObjectResult| match result.to_result() {
684            Ok(field) => Ok(Some(field)),
685            Err(status) if status.code == i32::from(tonic::Code::NotFound) => Ok(None),
686            Err(status) => Err(anyhow!(
687                "Could not fetch dynamic field '{field_id}': {}",
688                status.message
689            )),
690        };
691        let Some(witness) = field(
692            witness_field_id,
693            results.next().expect("response length was validated"),
694        )?
695        else {
696            return Ok(None);
697        };
698        let Some(inner) = field(
699            inner_field_id,
700            results.next().expect("response length was validated"),
701        )?
702        else {
703            return Ok(None);
704        };
705
706        Ok(Some(ObjectStateObjects {
707            anchor,
708            witness,
709            inner,
710        }))
711    }
712
713    fn parse_state_field_metadata(
714        parent_id: sui::types::Address,
715        field_id: sui::types::Address,
716        field: &sui::grpc::Object,
717    ) -> anyhow::Result<DynamicFieldMetadata> {
718        let returned_id = Self::parse_object_id(field)?;
719        if returned_id != field_id {
720            bail!("Requested dynamic field '{field_id}', received '{returned_id}'");
721        }
722        let owner: sui::types::Owner = field
723            .owner_opt()
724            .ok_or_else(|| anyhow!("Owner missing for dynamic field '{field_id}'"))?
725            .try_into()
726            .map_err(|_| anyhow!("Could not parse owner for dynamic field '{field_id}'"))?;
727        if owner != sui::types::Owner::Object(parent_id) {
728            bail!(
729                "Dynamic field '{field_id}' has owner '{owner:?}', expected object '{parent_id}'"
730            );
731        }
732        let object_type = field
733            .object_type_opt()
734            .ok_or_else(|| anyhow!("Object type missing for dynamic field '{field_id}'"))?;
735        let (key_type, value_type) = parse_dynamic_field_type(field_id, object_type)?;
736        Ok(DynamicFieldMetadata {
737            field_id,
738            key_type,
739            value_type,
740        })
741    }
742
743    /// Decodes a validated object state snapshot without another RPC.
744    pub(crate) fn decode_object_state_snapshot<A, K, V>(
745        &self,
746        snapshot: &ObjectStateSnapshot,
747        key: &K,
748    ) -> anyhow::Result<(Response<A>, V)>
749    where
750        A: DeserializeOwned,
751        K: DeserializeOwned + Eq,
752        V: DeserializeOwned,
753    {
754        let object_id = snapshot.object.object_id;
755        let (owner, digest, version, balance) =
756            self.parse_object_metadata(object_id, &snapshot.anchor_object)?;
757        let data = self.parse_object_contents_bcs::<A>(&snapshot.anchor_object)?;
758        let field =
759            self.parse_object_contents_bcs::<DynamicFieldValue<K, V>>(&snapshot.inner_object)?;
760        validate_dynamic_field(snapshot.inner.field_id, key, &field)?;
761        Ok((
762            Response {
763                object_id,
764                owner,
765                version,
766                data,
767                digest,
768                balance,
769            },
770            field.value,
771        ))
772    }
773
774    /// Lists exact key and value types for every dynamic field below `parent_id`.
775    ///
776    /// This method reads only field identities and type metadata. In particular,
777    /// it does not decode field names or values, so an unknown value layout can
778    /// still be reported to compatibility logic.
779    ///
780    /// # Errors
781    ///
782    /// Returns an error when the RPC request fails or a returned field does not
783    /// have a valid `0x2::dynamic_field::Field<K, V>` object type.
784    pub async fn get_dynamic_field_metadata(
785        &self,
786        parent_id: sui::types::Address,
787    ) -> anyhow::Result<Vec<DynamicFieldMetadata>> {
788        let field_mask = sui::grpc::FieldMask::from_paths([
789            "kind",
790            "field_id",
791            "field_object.object_type",
792            "value_type",
793        ]);
794        let mut metadata = Vec::new();
795        let mut page_token = None;
796        let client = self.clone_state_catalog_client();
797
798        loop {
799            observe_dynamic_field_list("state_metadata");
800            let mut request = sui::grpc::ListDynamicFieldsRequest::default()
801                .with_parent(parent_id)
802                .with_page_size(1000)
803                .with_read_mask(field_mask.clone());
804            if let Some(token) = page_token {
805                request = request.with_page_token(token);
806            }
807
808            let response = sui::grpc::retry_read(|| {
809                let mut client = client.clone();
810                let request = request.clone();
811                async move { client.state_client().list_dynamic_fields(request).await }
812            })
813            .await
814            .map(|response| response.into_inner())
815            .map_err(anyhow::Error::new)
816            .with_context(|| format!("Could not fetch dynamic fields for parent '{parent_id}'"))?;
817            page_token = response.next_page_token;
818            metadata.extend(
819                response
820                    .dynamic_fields
821                    .iter()
822                    .map(|field| parse_dynamic_field_metadata(parent_id, field))
823                    .collect::<anyhow::Result<Vec<_>>>()?
824                    .into_iter()
825                    .flatten(),
826            );
827
828            if page_token.is_none() {
829                break;
830            }
831        }
832
833        Ok(metadata)
834    }
835
836    /// Fetch a published Move package descriptor for ABI inspection.
837    pub async fn get_package(
838        &self,
839        package_id: sui::types::Address,
840    ) -> anyhow::Result<sui::grpc::Package> {
841        let request = sui::grpc::GetPackageRequest::default().with_package_id(package_id);
842        let mut client = self.clone_grpc_client();
843        client
844            .package_client()
845            .get_package(request)
846            .await
847            .map_err(|e| anyhow!("Could not fetch package '{package_id}': {e}"))?
848            .into_inner()
849            .package
850            .ok_or_else(|| anyhow!("Package '{package_id}' was not returned"))
851    }
852
853    async fn get_object_parsed<T>(
854        &self,
855        object_id: sui::types::Address,
856        field_mask: sui::grpc::FieldMask,
857        parse_data: fn(&Crawler, &sui::grpc::Object) -> anyhow::Result<T>,
858    ) -> anyhow::Result<Response<T>>
859    where
860        T: DeserializeOwned,
861    {
862        let object = self.fetch_object(object_id, field_mask).await?;
863
864        let (owner, digest, version, balance) = self.parse_object_metadata(object_id, &object)?;
865        let data = parse_data(self, &object)?;
866
867        Ok(Response {
868            object_id,
869            owner,
870            version,
871            data,
872            digest,
873            balance,
874        })
875    }
876
877    async fn get_objects_parsed<T>(
878        &self,
879        object_ids: &[sui::types::Address],
880        field_mask: sui::grpc::FieldMask,
881        parse_data: fn(&Crawler, &sui::grpc::Object) -> anyhow::Result<T>,
882    ) -> anyhow::Result<Vec<Response<T>>>
883    where
884        T: DeserializeOwned,
885    {
886        let objects = self.fetch_objects(object_ids, field_mask).await?;
887
888        objects
889            .into_iter()
890            .map(|object| {
891                let object_id = Self::parse_object_id(&object)?;
892                let (owner, digest, version, balance) =
893                    self.parse_object_metadata(object_id, &object)?;
894                let data = parse_data(self, &object)?;
895
896                Ok(Response {
897                    object_id,
898                    owner,
899                    version,
900                    data,
901                    digest,
902                    balance,
903                })
904            })
905            .collect()
906    }
907
908    async fn get_optional_objects_parsed<T>(
909        &self,
910        object_ids: &[sui::types::Address],
911        field_mask: sui::grpc::FieldMask,
912        parse_data: fn(&Crawler, &sui::grpc::Object) -> anyhow::Result<T>,
913    ) -> anyhow::Result<Vec<Option<Response<T>>>>
914    where
915        T: DeserializeOwned,
916    {
917        let results = self.fetch_object_results(object_ids, field_mask).await?;
918        if results.len() != object_ids.len() {
919            bail!(
920                "Batch object response contained {} results for {} requests",
921                results.len(),
922                object_ids.len()
923            );
924        }
925
926        object_ids
927            .iter()
928            .copied()
929            .zip(results)
930            .map(|(requested_id, result)| {
931                let object = match result.to_result() {
932                    Ok(object) => object,
933                    Err(status) if status.code == i32::from(tonic::Code::NotFound) => {
934                        return Ok(None);
935                    }
936                    Err(status) => {
937                        bail!(
938                            "Could not fetch object '{requested_id}': {}",
939                            status.message
940                        );
941                    }
942                };
943                let object_id = Self::parse_object_id(&object)?;
944                if object_id != requested_id {
945                    bail!("Requested object '{requested_id}', received object '{object_id}'");
946                }
947                let (owner, digest, version, balance) =
948                    self.parse_object_metadata(object_id, &object)?;
949                let data = parse_data(self, &object)?;
950                Ok(Some(Response {
951                    object_id,
952                    owner,
953                    version,
954                    data,
955                    digest,
956                    balance,
957                }))
958            })
959            .collect()
960    }
961
962    fn parse_object_id(object: &sui::grpc::Object) -> anyhow::Result<sui::types::Address> {
963        object
964            .object_id_opt()
965            .ok_or_else(|| anyhow!("Object ID missing"))?
966            .parse()
967            .map_err(|_| anyhow!("Could not parse object ID"))
968    }
969
970    /// Fetch an object by its ID and deserialize its Move struct contents from BCS.
971    pub async fn get_object<T>(&self, object_id: sui::types::Address) -> anyhow::Result<Response<T>>
972    where
973        T: DeserializeOwned,
974    {
975        let field_mask = sui::grpc::FieldMask::from_paths([
976            "object_id",
977            "owner",
978            "version",
979            "digest",
980            "balance",
981            "contents",
982        ]);
983
984        self.get_object_parsed(object_id, field_mask, Self::parse_object_contents_bcs::<T>)
985            .await
986    }
987
988    /// Fetches one object and one of its ordinary dynamic fields in one batch.
989    ///
990    /// The object and field identities, current owner, anchor type, field value
991    /// type, embedded field ID, and field key are validated before values are
992    /// returned.
993    pub(crate) async fn get_object_with_dynamic_field<A, K, V>(
994        &self,
995        object_id: sui::types::Address,
996        expected_object_type: &sui::types::StructTag,
997        field_id: sui::types::Address,
998        expected_value_type: &sui::types::TypeTag,
999        key: &K,
1000    ) -> anyhow::Result<(Response<A>, V)>
1001    where
1002        A: DeserializeOwned,
1003        K: DeserializeOwned + Eq,
1004        V: DeserializeOwned,
1005    {
1006        let field_mask = sui::grpc::FieldMask::from_paths([
1007            "object_id",
1008            "owner",
1009            "object_type",
1010            "version",
1011            "digest",
1012            "balance",
1013            "contents",
1014        ]);
1015        let requested = [object_id, field_id];
1016        let results = self.fetch_object_results(&requested, field_mask).await?;
1017        if results.len() != requested.len() {
1018            bail!(
1019                "Batch object response contained {} results for {} requests",
1020                results.len(),
1021                requested.len()
1022            );
1023        }
1024        let mut results = results.into_iter();
1025        let object = results
1026            .next()
1027            .expect("response length was validated")
1028            .to_result()
1029            .map_err(|status| {
1030                anyhow!("Could not fetch object '{object_id}': {}", status.message)
1031            })?;
1032        let field = results
1033            .next()
1034            .expect("response length was validated")
1035            .to_result()
1036            .map_err(|status| {
1037                anyhow!(
1038                    "Could not fetch dynamic field '{field_id}': {}",
1039                    status.message
1040                )
1041            })?;
1042
1043        let returned_object_id = Self::parse_object_id(&object)?;
1044        if returned_object_id != object_id {
1045            bail!("Requested object '{object_id}', received object '{returned_object_id}'");
1046        }
1047        let object_type = object
1048            .object_type_opt()
1049            .ok_or_else(|| anyhow!("Object type missing for object '{object_id}'"))?
1050            .parse::<sui::types::StructTag>()
1051            .map_err(|error| anyhow!("Could not parse type for object '{object_id}': {error}"))?;
1052        if &object_type != expected_object_type {
1053            bail!(
1054                "Object '{object_id}' has type '{object_type}', expected '{expected_object_type}'"
1055            );
1056        }
1057
1058        let returned_field_id = Self::parse_object_id(&field)?;
1059        if returned_field_id != field_id {
1060            bail!("Requested dynamic field '{field_id}', received '{returned_field_id}'");
1061        }
1062        let field_owner: sui::types::Owner = field
1063            .owner_opt()
1064            .ok_or_else(|| anyhow!("Owner missing for dynamic field '{field_id}'"))?
1065            .try_into()
1066            .map_err(|_| anyhow!("Could not parse owner for dynamic field '{field_id}'"))?;
1067        if field_owner != sui::types::Owner::Object(object_id) {
1068            bail!(
1069                "Dynamic field '{field_id}' has owner '{field_owner:?}', expected object \
1070                 '{object_id}'"
1071            );
1072        }
1073        let field_type = field
1074            .object_type_opt()
1075            .ok_or_else(|| anyhow!("Object type missing for dynamic field '{field_id}'"))?;
1076        let (_, value_type) = parse_dynamic_field_type(field_id, field_type)?;
1077        if &value_type != expected_value_type {
1078            bail!(
1079                "Dynamic field '{field_id}' has value type '{value_type}', expected \
1080                 '{expected_value_type}'"
1081            );
1082        }
1083
1084        let (owner, digest, version, balance) = self.parse_object_metadata(object_id, &object)?;
1085        let data = self.parse_object_contents_bcs::<A>(&object)?;
1086        let field = self.parse_object_contents_bcs::<DynamicFieldValue<K, V>>(&field)?;
1087        validate_dynamic_field(field_id, key, &field)?;
1088        Ok((
1089            Response::<A> {
1090                object_id,
1091                owner,
1092                version,
1093                data,
1094                digest,
1095                balance,
1096            },
1097            field.value,
1098        ))
1099    }
1100
1101    /// Fetch an object when present.
1102    ///
1103    /// # Errors
1104    ///
1105    /// Returns an error for transport and decoding failures. A missing object
1106    /// returns `Ok(None)`.
1107    pub async fn get_optional_object<T>(
1108        &self,
1109        object_id: sui::types::Address,
1110    ) -> anyhow::Result<Option<Response<T>>>
1111    where
1112        T: DeserializeOwned,
1113    {
1114        let field_mask = sui::grpc::FieldMask::from_paths([
1115            "object_id",
1116            "owner",
1117            "version",
1118            "digest",
1119            "balance",
1120            "contents",
1121        ]);
1122        let Some(object) = self.fetch_optional_object(object_id, field_mask).await? else {
1123            return Ok(None);
1124        };
1125        let returned_id = Self::parse_object_id(&object)?;
1126        if returned_id != object_id {
1127            bail!("Requested object '{object_id}', received object '{returned_id}'");
1128        }
1129        let (owner, digest, version, balance) = self.parse_object_metadata(object_id, &object)?;
1130        let data = self.parse_object_contents_bcs::<T>(&object)?;
1131        Ok(Some(Response {
1132            object_id,
1133            owner,
1134            version,
1135            data,
1136            digest,
1137            balance,
1138        }))
1139    }
1140
1141    /// Fetch many objects by their IDs in batch and deserialize Move struct contents from BCS.
1142    pub async fn get_objects<T>(
1143        &self,
1144        object_ids: &[sui::types::Address],
1145    ) -> anyhow::Result<Vec<Response<T>>>
1146    where
1147        T: DeserializeOwned,
1148    {
1149        let field_mask = sui::grpc::FieldMask::from_paths([
1150            "object_id",
1151            "owner",
1152            "version",
1153            "digest",
1154            "balance",
1155            "contents",
1156        ]);
1157
1158        self.get_objects_parsed(object_ids, field_mask, Self::parse_object_contents_bcs::<T>)
1159            .await
1160    }
1161
1162    /// Fetches many objects that may no longer exist.
1163    ///
1164    /// Each result corresponds to the identifier at the same position in
1165    /// `object_ids`. A missing object produces [`None`].
1166    ///
1167    /// # Errors
1168    ///
1169    /// Returns an error when the RPC response is malformed, a present object
1170    /// has the wrong identity, or transport or decoding fails.
1171    pub async fn get_optional_objects<T>(
1172        &self,
1173        object_ids: &[sui::types::Address],
1174    ) -> anyhow::Result<Vec<Option<Response<T>>>>
1175    where
1176        T: DeserializeOwned,
1177    {
1178        if object_ids.is_empty() {
1179            return Ok(Vec::new());
1180        }
1181
1182        let field_mask = sui::grpc::FieldMask::from_paths([
1183            "object_id",
1184            "owner",
1185            "version",
1186            "digest",
1187            "balance",
1188            "contents",
1189        ]);
1190
1191        self.get_optional_objects_parsed(
1192            object_ids,
1193            field_mask,
1194            Self::parse_object_contents_bcs::<T>,
1195        )
1196        .await
1197    }
1198
1199    /// Fetches the short chain identifier used by Move environments.
1200    ///
1201    /// This is the first four bytes of [`Self::get_chain_digest`] encoded as
1202    /// hexadecimal, matching `sui client chain-identifier`. It is an
1203    /// environment selector, not the complete chain identity used for replay
1204    /// protection.
1205    pub async fn get_chain_id(&self) -> anyhow::Result<String> {
1206        let digest = self.get_chain_digest().await?;
1207        Ok(hex::encode(&digest.as_bytes()[..4]))
1208    }
1209
1210    /// Fetches the complete genesis checkpoint digest that identifies the chain.
1211    ///
1212    /// Use this value for durable environment identity and transaction replay
1213    /// protection. Use [`Self::get_chain_id`] only when selecting a Move build
1214    /// environment.
1215    pub async fn get_chain_digest(&self) -> anyhow::Result<sui::types::Digest> {
1216        let mut client = self.clone_grpc_client();
1217        let response = client
1218            .ledger_client()
1219            .get_service_info(sui::grpc::GetServiceInfoRequest::default())
1220            .await
1221            .map_err(|e| anyhow!("failed to fetch service info from the connected RPC: {e}"))?;
1222        let base58 = response
1223            .into_inner()
1224            .chain_id
1225            .ok_or_else(|| anyhow!("connected RPC did not return a chain id in service info"))?;
1226        sui::types::Digest::from_base58(&base58)
1227            .map_err(|e| anyhow!("connected RPC returned an unparsable chain id '{base58}': {e}"))
1228    }
1229
1230    /// Fetch an object's metadata only, omitting its content.
1231    pub async fn get_object_metadata(
1232        &self,
1233        object_id: sui::types::Address,
1234    ) -> anyhow::Result<Response<()>> {
1235        let field_mask = sui::grpc::FieldMask::from_paths([
1236            "object_id",
1237            "owner",
1238            "version",
1239            "digest",
1240            "balance",
1241        ]);
1242
1243        let object = self.fetch_object(object_id, field_mask).await?;
1244        let (owner, digest, version, balance) = self.parse_object_metadata(object_id, &object)?;
1245
1246        Ok(Response {
1247            object_id,
1248            owner,
1249            version,
1250            data: (),
1251            digest,
1252            balance,
1253        })
1254    }
1255
1256    /// Fetch the update reference for the latest or a historical object
1257    /// version. Unlike [`Self::get_object_metadata`], this includes the
1258    /// transaction digest that produced the requested version.
1259    pub async fn get_object_update_reference(
1260        &self,
1261        object_id: sui::types::Address,
1262        version: Option<sui::types::Version>,
1263    ) -> anyhow::Result<ObjectUpdateReference> {
1264        let mut request = sui::grpc::GetObjectRequest::default()
1265            .with_object_id(object_id)
1266            .with_read_mask(sui::grpc::FieldMask::from_paths([
1267                "object_id",
1268                "owner",
1269                "object_type",
1270                "version",
1271                "digest",
1272                "previous_transaction",
1273            ]));
1274        if let Some(version) = version {
1275            request = request.with_version(version);
1276        }
1277
1278        let client = self.clone_grpc_client();
1279        let object = sui::grpc::retry_read(|| {
1280            let mut client = client.clone();
1281            let request = request.clone();
1282            async move { client.ledger_client().get_object(request).await }
1283        })
1284        .await
1285        .map(|response| response.into_inner().object)
1286        .with_context(|| {
1287            let version = version
1288                .map(|version| format!(" at version {version}"))
1289                .unwrap_or_default();
1290            format!("Could not fetch object '{object_id}'{version}")
1291        })?
1292        .ok_or_else(|| {
1293            let version = version
1294                .map(|version| format!(" at version {version}"))
1295                .unwrap_or_default();
1296            anyhow!("Object '{object_id}'{version} not found")
1297        })?;
1298
1299        let (owner, digest, observed_version, _) =
1300            self.parse_object_metadata(object_id, &object)?;
1301        let object_type = object
1302            .object_type_opt()
1303            .ok_or_else(|| anyhow!("Object type missing for object '{object_id}'"))?
1304            .parse()
1305            .map_err(|e| anyhow!("Could not parse object type for object '{object_id}': {e}"))?;
1306        if version.is_some_and(|requested| requested != observed_version) {
1307            bail!(
1308                "Requested object '{object_id}' at version {}, received version {observed_version}",
1309                version.expect("checked as some")
1310            );
1311        }
1312        let previous_transaction = object
1313            .previous_transaction_opt()
1314            .ok_or_else(|| {
1315                anyhow!(
1316                    "Object '{object_id}' at version {observed_version} has no previous_transaction"
1317                )
1318            })?
1319            .parse()
1320            .map_err(|e| {
1321                anyhow!(
1322                    "Object '{object_id}' at version {observed_version} has an invalid previous_transaction: {e}"
1323                )
1324            })?;
1325
1326        Ok(ObjectUpdateReference {
1327            owner,
1328            object_type,
1329            version: observed_version,
1330            digest,
1331            previous_transaction,
1332        })
1333    }
1334
1335    /// Fetch and decode the effects and events for one transaction digest
1336    pub async fn get_transaction_update(
1337        &self,
1338        digest: sui::types::Digest,
1339    ) -> anyhow::Result<TransactionUpdate> {
1340        let request = sui::grpc::GetTransactionRequest::default()
1341            .with_digest(digest.to_string())
1342            .with_read_mask(sui::grpc::FieldMask::from_paths([
1343                "digest",
1344                "checkpoint",
1345                "effects.bcs",
1346                "events.events",
1347            ]));
1348        let client = self.clone_grpc_client();
1349        let transaction = sui::grpc::retry_read(|| {
1350            let mut client = client.clone();
1351            let request = request.clone();
1352            async move { client.ledger_client().get_transaction(request).await }
1353        })
1354        .await
1355        .map(|response| response.into_inner().transaction)
1356        .with_context(|| format!("Could not fetch transaction '{digest}'"))?
1357        .ok_or_else(|| anyhow!("Transaction '{digest}' not found"))?;
1358
1359        let observed_digest = transaction
1360            .digest_opt()
1361            .ok_or_else(|| anyhow!("Failed to get Executed Transaction for digest '{digest}'"))?
1362            .parse()
1363            .map_err(|e| anyhow!("Transaction '{digest}' response has an invalid digest: {e}"))?;
1364        if observed_digest != digest {
1365            bail!("Requested transaction '{digest}', received transaction '{observed_digest}'");
1366        }
1367        let checkpoint = transaction
1368            .checkpoint_opt()
1369            .ok_or_else(|| anyhow!("Transaction '{digest}' response has no checkpoint"))?;
1370
1371        let effects = match sui::types::TransactionEffects::try_from(transaction.effects())
1372            .map_err(|e| anyhow!("Could not decode effects for transaction '{digest}': {e}"))?
1373        {
1374            sui::types::TransactionEffects::V2(effects) => *effects,
1375            sui::types::TransactionEffects::V1(_) => {
1376                bail!("Transaction '{digest}' returned unsupported V1 effects")
1377            }
1378        };
1379        if effects.transaction_digest != observed_digest {
1380            bail!(
1381                "Transaction '{observed_digest}' response contains effects for transaction '{}'; expected the effects transaction digest to match the requested transaction '{digest}'",
1382                effects.transaction_digest
1383            );
1384        }
1385        let events = sui::types::TransactionEvents::try_from(transaction.events())
1386            .map_err(|e| anyhow!("Could not decode events for transaction '{digest}': {e}"))?
1387            .0;
1388
1389        Ok(TransactionUpdate {
1390            digest: observed_digest,
1391            checkpoint,
1392            effects,
1393            events,
1394        })
1395    }
1396
1397    /// Fetches the object set returned for one exact transaction.
1398    ///
1399    /// The response can contain both input and output versions. Consumers must
1400    /// select outputs by checking `previous_transaction` against `transaction`.
1401    ///
1402    /// # Errors
1403    ///
1404    /// Returns an error when the transaction is absent or the response names a
1405    /// different transaction.
1406    pub async fn get_transaction_objects(
1407        &self,
1408        transaction: sui::types::Digest,
1409    ) -> anyhow::Result<sui::grpc::ExecutedTransaction> {
1410        let request = sui::grpc::GetTransactionRequest::default()
1411            .with_digest(transaction.to_string())
1412            .with_read_mask(sui::grpc::FieldMask::from_paths([
1413                "digest",
1414                "objects.objects.object_id",
1415                "objects.objects.owner",
1416                "objects.objects.version",
1417                "objects.objects.digest",
1418                "objects.objects.balance",
1419                "objects.objects.object_type",
1420                "objects.objects.contents",
1421                "objects.objects.previous_transaction",
1422            ]));
1423        let client = self.clone_grpc_client();
1424        let executed = sui::grpc::retry_read(|| {
1425            let mut client = client.clone();
1426            let request = request.clone();
1427            async move { client.ledger_client().get_transaction(request).await }
1428        })
1429        .await
1430        .map(|response| response.into_inner().transaction)
1431        .with_context(|| format!("Could not fetch transaction '{transaction}'"))?
1432        .ok_or_else(|| anyhow!("Transaction '{transaction}' not found"))?;
1433        validate_executed_transaction(&executed, transaction)?;
1434        Ok(executed)
1435    }
1436
1437    /// Decodes one object version written by an exact transaction response.
1438    ///
1439    /// Sui can return both input and output versions. This method accepts only
1440    /// the version whose `previous_transaction` is `transaction`.
1441    ///
1442    /// # Errors
1443    ///
1444    /// Returns an error when the response identity is inconsistent, the output
1445    /// object is absent or repeated, or its value cannot be decoded.
1446    pub fn transaction_output_object<T>(
1447        &self,
1448        executed: &sui::grpc::ExecutedTransaction,
1449        transaction: sui::types::Digest,
1450        object_id: sui::types::Address,
1451    ) -> anyhow::Result<Response<T>>
1452    where
1453        T: DeserializeOwned,
1454    {
1455        validate_executed_transaction(executed, transaction)?;
1456
1457        let mut output = executed.objects().objects.iter().filter(|object| {
1458            Self::parse_object_id(object).ok() == Some(object_id)
1459                && object
1460                    .previous_transaction_opt()
1461                    .and_then(|digest| digest.parse::<sui::types::Digest>().ok())
1462                    == Some(transaction)
1463        });
1464        let object = output.next().ok_or_else(|| {
1465            anyhow!("Transaction '{transaction}' did not write object '{object_id}'")
1466        })?;
1467        if output.next().is_some() {
1468            bail!("Transaction '{transaction}' returned object '{object_id}' more than once");
1469        }
1470        let (owner, digest, version, balance) = self.parse_object_metadata(object_id, object)?;
1471        let data = self.parse_object_contents_bcs::<T>(object)?;
1472
1473        Ok(Response {
1474            object_id,
1475            owner,
1476            version,
1477            data,
1478            digest,
1479            balance,
1480        })
1481    }
1482
1483    /// Returns the initial version of one shared object named by an exact
1484    /// [`sui::grpc::ExecutedTransaction`].
1485    ///
1486    /// A transaction object set can contain both the input and output version
1487    /// of the same object. Every matching version must retain the expected
1488    /// type and the same shared start version. This permits a consumer to
1489    /// construct a causal shared-object input without consulting a lagging
1490    /// latest-object surface.
1491    ///
1492    /// # Errors
1493    ///
1494    /// Returns an error when the response identity is inconsistent, the object
1495    /// is absent, its type differs from `expected_type`, it is not shared, or
1496    /// matching versions disagree about the shared start version.
1497    pub fn transaction_shared_initial_version(
1498        &self,
1499        executed: &sui::grpc::ExecutedTransaction,
1500        transaction: sui::types::Digest,
1501        object_id: sui::types::Address,
1502        expected_type: &sui::types::StructTag,
1503    ) -> anyhow::Result<sui::types::Version> {
1504        validate_executed_transaction(executed, transaction)?;
1505
1506        let mut initial_version = None;
1507        for object in executed
1508            .objects()
1509            .objects
1510            .iter()
1511            .filter(|object| Self::parse_object_id(object).ok() == Some(object_id))
1512        {
1513            let object_type = object
1514                .object_type_opt()
1515                .ok_or_else(|| anyhow!("Object type missing for transaction object '{object_id}'"))?
1516                .parse::<sui::types::StructTag>()
1517                .map_err(|error| {
1518                    anyhow!("Transaction object '{object_id}' has an invalid type: {error}")
1519                })?;
1520            ensure!(
1521                &object_type == expected_type,
1522                "Transaction object '{object_id}' has type '{object_type}', expected '{expected_type}'"
1523            );
1524            let (owner, _, _, _) = self.parse_object_metadata(object_id, object)?;
1525            let observed = match owner {
1526                sui::types::Owner::Shared(version) => version,
1527                sui::types::Owner::ConsensusAddress { start_version, .. } => start_version,
1528                owner => bail!(
1529                    "Transaction object '{object_id}' is not shared; observed owner {owner:?}"
1530                ),
1531            };
1532            match initial_version {
1533                Some(expected) => ensure!(
1534                    observed == expected,
1535                    "Transaction object '{object_id}' has inconsistent shared start versions {expected} and {observed}"
1536                ),
1537                None => initial_version = Some(observed),
1538            }
1539        }
1540
1541        initial_version.ok_or_else(|| {
1542            anyhow!("Transaction '{transaction}' does not name shared object '{object_id}'")
1543        })
1544    }
1545
1546    /// Fetches and decodes one object version written by an exact transaction.
1547    ///
1548    /// # Errors
1549    ///
1550    /// Returns the errors from [`Self::get_transaction_objects`] or
1551    /// [`Self::transaction_output_object`].
1552    pub async fn get_transaction_output_object<T>(
1553        &self,
1554        transaction: sui::types::Digest,
1555        object_id: sui::types::Address,
1556    ) -> anyhow::Result<Response<T>>
1557    where
1558        T: DeserializeOwned,
1559    {
1560        let executed = self.get_transaction_objects(transaction).await?;
1561        self.transaction_output_object(&executed, transaction, object_id)
1562    }
1563
1564    pub(crate) fn transaction_dynamic_field_outputs<K, V>(
1565        &self,
1566        executed: &sui::grpc::ExecutedTransaction,
1567        transaction: sui::types::Digest,
1568        key: &K,
1569        expected_key_type: &sui::types::TypeTag,
1570        expected_value_type: &sui::types::TypeTag,
1571    ) -> anyhow::Result<Vec<TransactionStateOutput<V>>>
1572    where
1573        K: DeserializeOwned + Eq + Serialize,
1574        V: DeserializeOwned,
1575    {
1576        validate_executed_transaction(executed, transaction)?;
1577        let mut outputs = Vec::new();
1578        for object in &executed.objects().objects {
1579            let object_id = Self::parse_object_id(object)?;
1580            let previous_transaction = object
1581                .previous_transaction_opt()
1582                .ok_or_else(|| {
1583                    anyhow!(
1584                        "Transaction '{transaction}' returned object '{object_id}' without its \
1585                         previous transaction"
1586                    )
1587                })?
1588                .parse::<sui::types::Digest>()
1589                .map_err(|error| {
1590                    anyhow!(
1591                        "Transaction '{transaction}' returned object '{object_id}' with an \
1592                         invalid previous transaction: {error}"
1593                    )
1594                })?;
1595            if previous_transaction != transaction {
1596                continue;
1597            }
1598            if let Some(output) = self.decode_transaction_dynamic_field(
1599                object,
1600                object_id,
1601                key,
1602                expected_key_type,
1603                expected_value_type,
1604            )? {
1605                outputs.push(output);
1606            }
1607        }
1608        Ok(outputs)
1609    }
1610
1611    /// Decodes matching state fields from one retained causal view.
1612    ///
1613    /// Unlike [`Self::transaction_dynamic_field_outputs`], values written by
1614    /// ancestors are included. A direct execution response can also contain
1615    /// the input and output versions of an object written by `transaction`.
1616    /// In that representation the root output is the visible value. Any other
1617    /// repeated object history is ambiguous and rejected.
1618    pub(crate) fn causal_dynamic_field_values<K, V>(
1619        &self,
1620        executed: &sui::grpc::ExecutedTransaction,
1621        transaction: sui::types::Digest,
1622        key: &K,
1623        expected_key_type: &sui::types::TypeTag,
1624        expected_value_type: &sui::types::TypeTag,
1625    ) -> anyhow::Result<Vec<TransactionStateOutput<V>>>
1626    where
1627        K: DeserializeOwned + Eq + Serialize,
1628        V: DeserializeOwned,
1629    {
1630        validate_executed_transaction(executed, transaction)?;
1631        let mut positions = HashMap::new();
1632        let mut paired = HashSet::new();
1633        let mut visible = Vec::new();
1634        for object in &executed.objects().objects {
1635            let object_id = Self::parse_object_id(object)?;
1636            let previous_transaction = object
1637                .previous_transaction_opt()
1638                .ok_or_else(|| {
1639                    anyhow!(
1640                        "Causal transaction view returned object '{object_id}' without its \
1641                         previous transaction"
1642                    )
1643                })?
1644                .parse::<sui::types::Digest>()
1645                .map_err(|error| {
1646                    anyhow!(
1647                        "Causal transaction view returned object '{object_id}' with an invalid \
1648                         previous transaction: {error}"
1649                    )
1650                })?;
1651            let root_output = previous_transaction == transaction;
1652
1653            let Some(index) = positions.get(&object_id).copied() else {
1654                positions.insert(object_id, visible.len());
1655                visible.push((object, root_output));
1656                continue;
1657            };
1658
1659            ensure!(
1660                paired.insert(object_id),
1661                "Causal transaction view contains more than an input and output version of \
1662                 object '{object_id}'"
1663            );
1664            let (selected, selected_is_root_output) = visible[index];
1665            ensure!(
1666                selected_is_root_output != root_output,
1667                "Causal transaction view contains ambiguous versions of object '{object_id}'"
1668            );
1669            let (input, output) = if root_output {
1670                (selected, object)
1671            } else {
1672                (object, selected)
1673            };
1674            let input_version = input
1675                .version_opt()
1676                .ok_or_else(|| anyhow!("Version missing for causal input object '{object_id}'"))?;
1677            let output_version = output
1678                .version_opt()
1679                .ok_or_else(|| anyhow!("Version missing for causal output object '{object_id}'"))?;
1680            ensure!(
1681                output_version > input_version,
1682                "Causal output object '{object_id}' has version {output_version}, not newer than \
1683                 input version {input_version}"
1684            );
1685            if root_output {
1686                visible[index] = (object, true);
1687            }
1688        }
1689
1690        let mut values = Vec::new();
1691        for (object, _) in visible {
1692            let object_id = Self::parse_object_id(object)?;
1693            if let Some(value) = self.decode_transaction_dynamic_field(
1694                object,
1695                object_id,
1696                key,
1697                expected_key_type,
1698                expected_value_type,
1699            )? {
1700                values.push(value);
1701            }
1702        }
1703        Ok(values)
1704    }
1705
1706    fn decode_transaction_dynamic_field<K, V>(
1707        &self,
1708        object: &sui::grpc::Object,
1709        object_id: sui::types::Address,
1710        key: &K,
1711        expected_key_type: &sui::types::TypeTag,
1712        expected_value_type: &sui::types::TypeTag,
1713    ) -> anyhow::Result<Option<TransactionStateOutput<V>>>
1714    where
1715        K: DeserializeOwned + Eq + Serialize,
1716        V: DeserializeOwned,
1717    {
1718        let object_type = object
1719            .object_type_opt()
1720            .ok_or_else(|| anyhow!("Object type missing for transaction object '{object_id}'"))?
1721            .parse::<sui::types::StructTag>()
1722            .map_err(|error| {
1723                anyhow!("Transaction object '{object_id}' has an invalid type: {error}")
1724            })?;
1725        if *object_type.address() != sui::types::Address::from_static("0x2")
1726            || object_type.module().as_str() != "dynamic_field"
1727            || object_type.name().as_str() != "Field"
1728        {
1729            return Ok(None);
1730        }
1731        if object_type.type_params().len() != 2 {
1732            bail!("Dynamic field '{object_id}' does not have two type arguments");
1733        }
1734        if &object_type.type_params()[0] != expected_key_type
1735            || &object_type.type_params()[1] != expected_value_type
1736        {
1737            return Ok(None);
1738        }
1739
1740        let (owner, digest, version, _) = self.parse_object_metadata(object_id, object)?;
1741        let sui::types::Owner::Object(parent_id) = owner else {
1742            bail!("Dynamic field '{object_id}' is not owned by an object");
1743        };
1744        let expected_field_id = derive_dynamic_field_id(parent_id, key, expected_key_type)?;
1745        if object_id != expected_field_id {
1746            bail!("Dynamic field '{object_id}' does not derive from parent '{parent_id}'");
1747        }
1748        let field = self.parse_object_contents_bcs::<DynamicFieldValue<K, V>>(object)?;
1749        validate_dynamic_field(object_id, key, &field)?;
1750        Ok(Some(TransactionStateOutput {
1751            object_id: parent_id,
1752            field_ref: sui::types::ObjectReference::new(object_id, version, digest),
1753            data: field.value,
1754        }))
1755    }
1756
1757    /// Advances one validated object state snapshot from a finalized response.
1758    ///
1759    /// Sui returns only objects named by transaction effects: input and output
1760    /// versions of changed objects. This remains necessary for immediate
1761    /// execution responses even when a causal stream can return complete
1762    /// retained state. The existing snapshot supplies unchanged anchor and
1763    /// witness data, while the response must supply the inner value written by
1764    /// `transaction`. A mentioned object with no output was removed and cannot
1765    /// be reused. No Sui request is performed.
1766    pub(crate) fn advance_object_state_snapshot(
1767        &self,
1768        basis: &ObjectStateSnapshot,
1769        executed: &sui::grpc::ExecutedTransaction,
1770        transaction: sui::types::Digest,
1771    ) -> Result<Option<ObjectStateSnapshot>, ObjectStateSnapshotError> {
1772        let invalid = |error: anyhow::Error| ObjectStateSnapshotError::Invalid(error.to_string());
1773        let observed_transaction = executed
1774            .digest_opt()
1775            .ok_or_else(|| anyhow!("Finalized transaction response has no digest"))
1776            .and_then(|digest| {
1777                digest.parse::<sui::types::Digest>().map_err(|error| {
1778                    anyhow!("Finalized transaction response has an invalid digest: {error}")
1779                })
1780            })
1781            .map_err(invalid)?;
1782        if observed_transaction != transaction {
1783            return Err(ObjectStateSnapshotError::Invalid(format!(
1784                "Finalized transaction response names '{observed_transaction}', expected \
1785                 '{transaction}'"
1786            )));
1787        }
1788        let objects = &executed.objects().objects;
1789        let object_id = basis.object.object_id;
1790        let witness_field_id = basis.witness.field_id;
1791        let inner_field_id = basis.inner.field_id;
1792        let change =
1793            |id| finalized_transaction_object_change(objects, transaction, id).map_err(invalid);
1794
1795        let inner_object = match change(inner_field_id)? {
1796            FinalizedObjectChange::Written(object) => *object,
1797            FinalizedObjectChange::Unchanged => return Ok(None),
1798            FinalizedObjectChange::Removed => {
1799                return Err(reject_removed_state_object(
1800                    transaction,
1801                    inner_field_id,
1802                    "inner field",
1803                ));
1804            }
1805        };
1806        let inner = Self::parse_state_field_metadata(object_id, inner_field_id, &inner_object)
1807            .map_err(invalid)?;
1808
1809        let (object, anchor_object) = match change(object_id)? {
1810            FinalizedObjectChange::Written(anchor) => {
1811                let metadata =
1812                    Self::parse_observed_object_metadata(object_id, &anchor).map_err(invalid)?;
1813                (metadata, *anchor)
1814            }
1815            FinalizedObjectChange::Unchanged => (basis.object.clone(), basis.anchor_object.clone()),
1816            FinalizedObjectChange::Removed => {
1817                return Err(reject_removed_state_object(
1818                    transaction,
1819                    object_id,
1820                    "anchor",
1821                ));
1822            }
1823        };
1824        let witness = match change(witness_field_id)? {
1825            FinalizedObjectChange::Written(witness) => {
1826                Self::parse_state_field_metadata(object_id, witness_field_id, &witness)
1827                    .map_err(invalid)?
1828            }
1829            FinalizedObjectChange::Unchanged => basis.witness.clone(),
1830            FinalizedObjectChange::Removed => {
1831                return Err(reject_removed_state_object(
1832                    transaction,
1833                    witness_field_id,
1834                    "witness field",
1835                ));
1836            }
1837        };
1838
1839        Ok(Some(ObjectStateSnapshot {
1840            object,
1841            witness,
1842            inner,
1843            anchor_object,
1844            inner_object,
1845        }))
1846    }
1847
1848    /// Fetch many objects' metadata only in batch, omitting their content.
1849    pub async fn get_objects_metadata(
1850        &self,
1851        object_ids: &[sui::types::Address],
1852    ) -> anyhow::Result<Vec<Response<()>>> {
1853        let field_mask = sui::grpc::FieldMask::from_paths([
1854            "object_id",
1855            "owner",
1856            "version",
1857            "digest",
1858            "balance",
1859        ]);
1860
1861        let objects = self.fetch_objects(object_ids, field_mask).await?;
1862
1863        objects
1864            .into_iter()
1865            .map(|object| {
1866                let object_id = Self::parse_object_id(&object)?;
1867
1868                let (owner, digest, version, balance) =
1869                    self.parse_object_metadata(object_id, &object)?;
1870
1871                Ok(Response {
1872                    object_id,
1873                    owner,
1874                    version,
1875                    data: (),
1876                    digest,
1877                    balance,
1878                })
1879            })
1880            .collect()
1881    }
1882
1883    /// Fetch every coin owned by `owner` with the exact requested Move struct tag.
1884    ///
1885    /// The state service applies owner and type filters. Returned objects are validated again so
1886    /// callers never receive a reference whose address owner or type differs from the request.
1887    pub async fn fetch_coins_for_address_by_type(
1888        &self,
1889        owner: sui::types::Address,
1890        object_type: sui::types::StructTag,
1891    ) -> anyhow::Result<Vec<(sui::types::ObjectReference, u64)>> {
1892        let field_mask = sui::grpc::FieldMask::from_paths([
1893            "object_id",
1894            "owner",
1895            "version",
1896            "digest",
1897            "balance",
1898            "object_type",
1899        ]);
1900        let mut results = Vec::new();
1901        let mut page_token = None;
1902        let mut client = self.clone_grpc_client();
1903
1904        loop {
1905            let mut request = sui::grpc::ListOwnedObjectsRequest::default()
1906                .with_owner(owner)
1907                .with_page_size(1000)
1908                .with_object_type(object_type.clone())
1909                .with_read_mask(field_mask.clone());
1910
1911            if let Some(token) = page_token.clone() {
1912                request = request.with_page_token(token);
1913            }
1914
1915            let response = client
1916                .state_client()
1917                .list_owned_objects(request)
1918                .await
1919                .map(|response| response.into_inner())
1920                .map_err(|e| {
1921                    anyhow!("Could not fetch coins of type '{object_type}' owned by '{owner}': {e}")
1922                })?;
1923
1924            page_token = response.next_page_token;
1925            results.extend(response.objects.iter().filter_map(|object| {
1926                Self::parse_owned_coin_with_type(object, owner, &object_type)
1927            }));
1928
1929            if page_token.is_none() {
1930                break;
1931            }
1932        }
1933
1934        Ok(results)
1935    }
1936
1937    /// Fetch owned objects of a specific type for an owner and deserialize BCS contents.
1938    pub async fn get_owned_objects<T>(
1939        &self,
1940        owner: sui::types::Address,
1941        object_type: sui::types::StructTag,
1942    ) -> anyhow::Result<Vec<Response<T>>>
1943    where
1944        T: DeserializeOwned,
1945    {
1946        let mut results = Vec::new();
1947        let mut cursor = None;
1948
1949        loop {
1950            let page = self
1951                .get_owned_object_page(owner, object_type.clone(), cursor, 1000)
1952                .await?;
1953            let (data, next_cursor) = page.into_parts();
1954            results.extend(data);
1955            cursor = next_cursor;
1956            if cursor.is_none() {
1957                break;
1958            }
1959        }
1960
1961        Ok(results)
1962    }
1963
1964    /// Fetch exact-type objects owned by another object and deserialize their BCS contents.
1965    pub async fn get_object_owned_objects<T>(
1966        &self,
1967        parent_id: sui::types::Address,
1968        object_type: sui::types::StructTag,
1969    ) -> anyhow::Result<Vec<Response<T>>>
1970    where
1971        T: DeserializeOwned,
1972    {
1973        let mut results = Vec::new();
1974        let mut cursor = None;
1975
1976        loop {
1977            let page = self
1978                .get_object_owned_object_page(parent_id, object_type.clone(), cursor, 1000)
1979                .await?;
1980            let (data, next_cursor) = page.into_parts();
1981            results.extend(data);
1982            cursor = next_cursor;
1983            if cursor.is_none() {
1984                break;
1985            }
1986        }
1987
1988        Ok(results)
1989    }
1990
1991    /// Fetch one RPC page of objects owned by an address with an exact type.
1992    ///
1993    /// The address owner and type of every returned object are validated after
1994    /// the state service applies the same filters.
1995    ///
1996    /// # Errors
1997    ///
1998    /// Returns an error when `limit` is zero, the RPC request fails, the
1999    /// response violates its owner or type filter, or an object cannot be
2000    /// decoded.
2001    pub async fn get_owned_object_page<T>(
2002        &self,
2003        owner: sui::types::Address,
2004        object_type: sui::types::StructTag,
2005        cursor: Option<Vec<u8>>,
2006        limit: usize,
2007    ) -> anyhow::Result<OwnedObjectPage<T>>
2008    where
2009        T: DeserializeOwned,
2010    {
2011        self.get_typed_owned_object_page(
2012            sui::types::Owner::Address(owner),
2013            object_type,
2014            cursor,
2015            limit,
2016        )
2017        .await
2018    }
2019
2020    /// Fetch one RPC page of exact-type objects owned by another object.
2021    ///
2022    /// Unlike [`Crawler::get_owned_object_page`], this requires every returned object to have the
2023    /// exact `Owner::Object(parent_id)` owner. Address and consensus-address ownership are rejected.
2024    pub async fn get_object_owned_object_page<T>(
2025        &self,
2026        parent_id: sui::types::Address,
2027        object_type: sui::types::StructTag,
2028        cursor: Option<Vec<u8>>,
2029        limit: usize,
2030    ) -> anyhow::Result<OwnedObjectPage<T>>
2031    where
2032        T: DeserializeOwned,
2033    {
2034        self.get_typed_owned_object_page(
2035            sui::types::Owner::Object(parent_id),
2036            object_type,
2037            cursor,
2038            limit,
2039        )
2040        .await
2041    }
2042
2043    async fn get_typed_owned_object_page<T>(
2044        &self,
2045        expected_owner: sui::types::Owner,
2046        object_type: sui::types::StructTag,
2047        cursor: Option<Vec<u8>>,
2048        limit: usize,
2049    ) -> anyhow::Result<OwnedObjectPage<T>>
2050    where
2051        T: DeserializeOwned,
2052    {
2053        let request_owner = match &expected_owner {
2054            sui::types::Owner::Address(owner) | sui::types::Owner::Object(owner) => *owner,
2055            _ => bail!("typed owned-object reads require an address or object owner"),
2056        };
2057        let page_size =
2058            u32::try_from(limit).context("owned object page limit does not fit in u32")?;
2059        if page_size == 0 {
2060            bail!("owned object page limit must be greater than zero");
2061        }
2062
2063        let field_mask = sui::grpc::FieldMask::from_paths([
2064            "object_id",
2065            "owner",
2066            "object_type",
2067            "version",
2068            "digest",
2069            "balance",
2070            "contents",
2071        ]);
2072        let mut request = sui::grpc::ListOwnedObjectsRequest::default()
2073            .with_owner(request_owner)
2074            .with_page_size(page_size)
2075            .with_object_type(object_type.clone())
2076            .with_read_mask(field_mask);
2077        if let Some(cursor) = cursor {
2078            request = request.with_page_token(cursor);
2079        }
2080
2081        let mut client = self.clone_grpc_client();
2082        let response = client
2083            .state_client()
2084            .list_owned_objects(request)
2085            .await
2086            .map(|response| response.into_inner())
2087            .map_err(|error| {
2088                anyhow!(
2089                    "Could not fetch objects of type '{object_type}' owned by \
2090                     '{expected_owner:?}': {error}"
2091                )
2092            })?;
2093
2094        let mut data = Vec::with_capacity(response.objects.len());
2095        for object in response.objects {
2096            let object_id = Self::parse_object_id(&object)?;
2097            let (observed_owner, digest, version, balance) =
2098                self.parse_object_metadata(object_id, &object)?;
2099            if !matches_expected_owner(&observed_owner, &expected_owner) {
2100                bail!(
2101                    "Object '{object_id}' has owner '{observed_owner:?}', expected \
2102                     '{expected_owner:?}'"
2103                );
2104            }
2105            let observed_type = object
2106                .object_type_opt()
2107                .ok_or_else(|| anyhow!("Object type missing for object '{object_id}'"))?
2108                .parse::<sui::types::StructTag>()
2109                .map_err(|error| {
2110                    anyhow!("Could not parse object type for object '{object_id}': {error}")
2111                })?;
2112            if observed_type != object_type {
2113                bail!("Object '{object_id}' has type '{observed_type}', expected '{object_type}'");
2114            }
2115            let decoded = Self::parse_object_contents_bcs::<T>(self, &object)?;
2116            data.push(Response {
2117                object_id,
2118                owner: observed_owner,
2119                version,
2120                data: decoded,
2121                digest,
2122                balance,
2123            });
2124        }
2125
2126        Ok(OwnedObjectPage {
2127            data,
2128            next_cursor: response.next_page_token.map(|cursor| cursor.to_vec()),
2129        })
2130    }
2131
2132    /// Fetch all dynamic fields for a given parent table object and parse them into a
2133    /// `HashMap<K, V>`.
2134    pub async fn get_dynamic_fields<K, V>(
2135        &self,
2136        parent_id: sui::types::Address,
2137        expected_size: usize,
2138    ) -> anyhow::Result<HashMap<K, V>>
2139    where
2140        K: Eq + Hash + DeserializeOwned,
2141        V: DeserializeOwned,
2142    {
2143        let names_and_ids = self
2144            .fetch_dynamic_fields::<K>(parent_id, expected_size)
2145            .await?;
2146
2147        let mut name_by_field_id = HashMap::with_capacity(names_and_ids.len());
2148        let mut field_ids = Vec::with_capacity(names_and_ids.len());
2149
2150        for (name, _child_id, field_id) in names_and_ids {
2151            let Some(field_id) = field_id else {
2152                bail!("Dynamic field ID missing for dynamic map");
2153            };
2154
2155            if name_by_field_id.insert(field_id, name).is_some() {
2156                bail!("Duplicate dynamic field ID '{field_id}' for dynamic map");
2157            }
2158
2159            field_ids.push(field_id);
2160        }
2161
2162        let field_objects = self
2163            .get_objects::<DynamicFieldValue<K, V>>(&field_ids)
2164            .await?;
2165
2166        let mut out = HashMap::with_capacity(field_objects.len());
2167        for obj in field_objects {
2168            let name = name_by_field_id.remove(&obj.object_id).ok_or_else(|| {
2169                anyhow!(
2170                    "Unexpected dynamic field ID '{}' for dynamic map",
2171                    obj.object_id
2172                )
2173            })?;
2174
2175            out.insert(name, obj.data.value);
2176        }
2177
2178        Ok(out)
2179    }
2180
2181    /// Fetch dynamic field references whose BCS names decode as `K`.
2182    ///
2183    /// This is the right shape for heterogeneous dynamic field parents: inspect
2184    /// names first, then fetch the selected field object with the expected value type.
2185    pub async fn get_dynamic_field_refs_matching_key<K>(
2186        &self,
2187        parent_id: sui::types::Address,
2188    ) -> anyhow::Result<Vec<DynamicFieldReference<K>>>
2189    where
2190        K: Eq + Hash + DeserializeOwned,
2191    {
2192        Ok(self
2193            .fetch_dynamic_fields::<K>(parent_id, 0)
2194            .await?
2195            .into_iter()
2196            .filter_map(|(name, _child_id, field_id)| {
2197                Some(DynamicFieldReference {
2198                    name,
2199                    field_id: field_id?,
2200                })
2201            })
2202            .collect())
2203    }
2204
2205    /// Fetch a dynamic field object by field ID and decode only its value.
2206    pub async fn get_dynamic_field_value_by_id<K, V>(
2207        &self,
2208        field_id: sui::types::Address,
2209    ) -> anyhow::Result<V>
2210    where
2211        K: DeserializeOwned,
2212        V: DeserializeOwned,
2213    {
2214        Ok(self
2215            .get_object::<DynamicFieldValue<K, V>>(field_id)
2216            .await?
2217            .data
2218            .value)
2219    }
2220
2221    /// Fetch the dynamic field selected by one typed key.
2222    ///
2223    /// The field object identifier is derived from `parent_id`, `key`, and
2224    /// `key_type`, so sibling fields are not enumerated. `key_type` must be the
2225    /// canonical Move [`sui::types::TypeTag`] for `K`. An absent field returns
2226    /// [`None`].
2227    ///
2228    /// # Errors
2229    ///
2230    /// Returns an error when the key cannot be encoded, the object request
2231    /// fails, or the field identity, key, or value is invalid.
2232    pub async fn get_dynamic_field_by_key<K, V>(
2233        &self,
2234        parent_id: sui::types::Address,
2235        key: K,
2236        key_type: &sui::types::TypeTag,
2237    ) -> anyhow::Result<Option<V>>
2238    where
2239        K: Eq + Serialize + DeserializeOwned,
2240        V: DeserializeOwned,
2241    {
2242        let field_id = derive_dynamic_field_id(parent_id, &key, key_type)?;
2243        let Some(field) = self
2244            .get_optional_object::<DynamicFieldValue<K, V>>(field_id)
2245            .await?
2246        else {
2247            return Ok(None);
2248        };
2249        validate_dynamic_field(field_id, &key, &field.data)?;
2250        Ok(Some(field.data.value))
2251    }
2252
2253    /// Fetch values for the distinct dynamic field keys requested from one parent.
2254    ///
2255    /// Field identities are derived from `parent_id`, `key_type`, and each BCS
2256    /// encoded key. Unlike [`Crawler::get_dynamic_fields`], the amount of work
2257    /// is independent of unrelated fields stored under the parent.
2258    ///
2259    /// Missing fields are omitted from the returned [`HashMap`]. Duplicate keys
2260    /// produce one object request.
2261    ///
2262    /// # Errors
2263    ///
2264    /// Returns an error when a key cannot be encoded, an RPC response has an
2265    /// unexpected identity, or a present field cannot be decoded and validated.
2266    pub async fn get_dynamic_fields_by_keys<K, V, I>(
2267        &self,
2268        parent_id: sui::types::Address,
2269        keys: I,
2270        key_type: &sui::types::TypeTag,
2271    ) -> anyhow::Result<HashMap<K, V>>
2272    where
2273        K: Eq + Hash + Serialize + DeserializeOwned,
2274        V: DeserializeOwned,
2275        I: IntoIterator<Item = K>,
2276    {
2277        let mut index_by_field_id = HashMap::<sui::types::Address, usize>::new();
2278        let mut requests = Vec::<(sui::types::Address, K)>::new();
2279
2280        for key in keys {
2281            let field_id = derive_dynamic_field_id(parent_id, &key, key_type)?;
2282            if let Some(index) = index_by_field_id.get(&field_id).copied() {
2283                if requests[index].1 != key {
2284                    bail!("Distinct dynamic field keys derived the same object ID '{field_id}'");
2285                }
2286                continue;
2287            }
2288            index_by_field_id.insert(field_id, requests.len());
2289            requests.push((field_id, key));
2290        }
2291
2292        let field_ids = requests
2293            .iter()
2294            .map(|(field_id, _)| *field_id)
2295            .collect::<Vec<_>>();
2296        let responses = self
2297            .get_optional_objects::<DynamicFieldValue<K, V>>(&field_ids)
2298            .await?;
2299        let mut values = HashMap::with_capacity(responses.len());
2300
2301        for ((field_id, key), response) in requests.into_iter().zip(responses) {
2302            let Some(response) = response else {
2303                continue;
2304            };
2305            validate_dynamic_field(field_id, &key, &response.data)?;
2306            values.insert(key, response.data.value);
2307        }
2308
2309        Ok(values)
2310    }
2311
2312    /// Fetch one RPC page of dynamic fields matching a key and value type.
2313    ///
2314    /// Fields from other namespaces are skipped. The returned cursor is the
2315    /// unmodified cursor supplied by Sui for the requested page.
2316    ///
2317    /// # Errors
2318    ///
2319    /// Returns an error when `limit` is zero, the RPC request fails, or a
2320    /// matching field object cannot be decoded.
2321    pub async fn get_dynamic_field_page_matching_types<K, V>(
2322        &self,
2323        parent_id: sui::types::Address,
2324        cursor: Option<Vec<u8>>,
2325        limit: usize,
2326        value_type_suffix: &str,
2327    ) -> anyhow::Result<DynamicFieldPage<K, V>>
2328    where
2329        K: DeserializeOwned,
2330        V: DeserializeOwned,
2331    {
2332        let page_size =
2333            u32::try_from(limit).context("dynamic field page limit does not fit in u32")?;
2334        if page_size == 0 {
2335            bail!("dynamic field page limit must be greater than zero");
2336        }
2337
2338        let field_mask = sui::grpc::FieldMask::from_paths(["name", "field_id", "value_type"]);
2339        let mut request = sui::grpc::ListDynamicFieldsRequest::default()
2340            .with_parent(parent_id)
2341            .with_page_size(page_size)
2342            .with_read_mask(field_mask);
2343        if let Some(cursor) = cursor {
2344            request = request.with_page_token(cursor);
2345        }
2346
2347        observe_dynamic_field_list("typed_page");
2348        let client = self.clone_state_catalog_client();
2349        let response = sui::grpc::retry_read(|| {
2350            let mut client = client.clone();
2351            let request = request.clone();
2352            async move { client.state_client().list_dynamic_fields(request).await }
2353        })
2354        .await
2355        .map(|response| response.into_inner())
2356        .map_err(|error| {
2357            anyhow!("Could not fetch dynamic fields for parent '{parent_id}': {error}")
2358        })?;
2359
2360        let next_cursor = response.next_page_token.map(|cursor| cursor.to_vec());
2361        let mut field_ids = Vec::new();
2362        for field in response.dynamic_fields {
2363            if !field
2364                .value_type
2365                .as_deref()
2366                .is_some_and(|value_type| value_type.ends_with(value_type_suffix))
2367            {
2368                continue;
2369            }
2370            let Some(name) = field.name_opt() else {
2371                continue;
2372            };
2373            if parse_dynamic_field_name::<K>(name.value()).is_err() {
2374                continue;
2375            }
2376            let field_id = field
2377                .field_id_opt()
2378                .ok_or_else(|| anyhow!("Dynamic field ID missing for parent '{parent_id}'"))?
2379                .parse()
2380                .map_err(|_| anyhow!("Could not parse field ID for dynamic field"))?;
2381            field_ids.push(field_id);
2382        }
2383
2384        let data = if field_ids.is_empty() {
2385            Vec::new()
2386        } else {
2387            self.get_objects::<DynamicFieldValue<K, V>>(&field_ids)
2388                .await?
2389                .into_iter()
2390                .map(|field| (field.data.name, field.data.value))
2391                .collect()
2392        };
2393
2394        Ok(DynamicFieldPage { data, next_cursor })
2395    }
2396
2397    /// Fetch dynamic field values from their field objects without decoding key
2398    /// names. This is useful for singleton dynamic fields whose on-chain name
2399    /// encoding is package-version dependent.
2400    pub async fn get_dynamic_field_object_values<K, V>(
2401        &self,
2402        parent_id: sui::types::Address,
2403    ) -> anyhow::Result<Vec<V>>
2404    where
2405        K: DeserializeOwned,
2406        V: DeserializeOwned,
2407    {
2408        let names_and_ids = self.fetch_dynamic_fields_untyped(parent_id).await?;
2409        let mut field_ids = Vec::with_capacity(names_and_ids.len());
2410        for field_id in names_and_ids {
2411            field_ids.push(field_id);
2412        }
2413
2414        Ok(self
2415            .get_objects::<DynamicFieldValue<K, V>>(&field_ids)
2416            .await?
2417            .into_iter()
2418            .map(|response| response.data.value)
2419            .collect())
2420    }
2421
2422    /// Fetch all dynamic-field values for a parent object without attempting to decode keys.
2423    pub async fn get_dynamic_field_values<V>(
2424        &self,
2425        parent_id: sui::types::Address,
2426    ) -> anyhow::Result<Vec<(Option<String>, V)>>
2427    where
2428        V: DeserializeOwned,
2429    {
2430        let mut results = Vec::new();
2431        let mut page_token = None;
2432        let field_mask =
2433            sui::grpc::FieldMask::from_paths(["field_id", "kind", "value", "value_type"]);
2434        let client = self.clone_state_catalog_client();
2435
2436        loop {
2437            observe_dynamic_field_list("values");
2438            let mut request = sui::grpc::ListDynamicFieldsRequest::default()
2439                .with_parent(parent_id)
2440                .with_page_size(1000)
2441                .with_read_mask(field_mask.clone());
2442
2443            if let Some(token) = page_token.clone() {
2444                request = request.with_page_token(token);
2445            }
2446
2447            let response = sui::grpc::retry_read(|| {
2448                let mut client = client.clone();
2449                let request = request.clone();
2450                async move { client.state_client().list_dynamic_fields(request).await }
2451            })
2452            .await
2453            .map(|r| r.into_inner())
2454            .map_err(|e| anyhow!("Could not fetch dynamic fields for parent '{parent_id}': {e}"))?;
2455
2456            page_token = response.next_page_token;
2457
2458            for field in response.dynamic_fields {
2459                let value = field.value_opt().ok_or_else(|| {
2460                    anyhow!("Dynamic field value missing for parent '{parent_id}'")
2461                })?;
2462                let Some(bytes) = value.value_opt() else {
2463                    bail!("Dynamic field value BCS missing for parent '{parent_id}'");
2464                };
2465                let decoded = bcs::from_bytes::<V>(bytes).map_err(|e| {
2466                    anyhow!("Could not parse dynamic field value for parent '{parent_id}': {e}")
2467                })?;
2468                results.push((field.value_type, decoded));
2469            }
2470
2471            if page_token.is_none() {
2472                break;
2473            }
2474        }
2475
2476        Ok(results)
2477    }
2478
2479    /// Fetch all dynamic object fields for a parent object id without requiring
2480    /// a local dynamic-object-map wrapper.
2481    pub async fn get_dynamic_object_fields<K, V>(
2482        &self,
2483        parent_id: sui::types::Address,
2484    ) -> anyhow::Result<HashMap<K, Response<V>>>
2485    where
2486        K: Eq + Hash + DeserializeOwned,
2487        V: DeserializeOwned,
2488    {
2489        let names_and_ids = self.fetch_dynamic_fields::<K>(parent_id, 0).await?;
2490        let mut names = Vec::with_capacity(names_and_ids.len());
2491        let mut child_ids = Vec::with_capacity(names_and_ids.len());
2492
2493        for (name, child_id, _) in names_and_ids {
2494            let Some(child_id) = child_id else {
2495                bail!("Dynamic object field child ID missing for parent '{parent_id}'");
2496            };
2497            names.push(name);
2498            child_ids.push(child_id);
2499        }
2500
2501        let child_objects = self.get_objects::<V>(&child_ids).await?;
2502        Ok(names.into_iter().zip(child_objects).collect())
2503    }
2504
2505    /// Fetch the dynamic object field selected by one typed key.
2506    ///
2507    /// The method derives and reads the Sui wrapper field, then fetches only
2508    /// the child object named by that wrapper. `key_type` must be the canonical
2509    /// Move [`sui::types::TypeTag`] for `K`. Unlike
2510    /// [`Crawler::get_dynamic_object_fields`], it does not enumerate sibling
2511    /// fields. A missing wrapper returns `Ok(None)`.
2512    ///
2513    /// # Errors
2514    ///
2515    /// Returns an error when the wrapper key cannot be encoded, either object
2516    /// request fails, the wrapper or child identity is invalid, the child is
2517    /// absent, or a returned value cannot be decoded and validated.
2518    pub async fn get_dynamic_object_field_by_key<K, V>(
2519        &self,
2520        parent_id: sui::types::Address,
2521        key: K,
2522        key_type: &sui::types::TypeTag,
2523    ) -> anyhow::Result<Option<Response<V>>>
2524    where
2525        K: Eq + Serialize + DeserializeOwned,
2526        V: DeserializeOwned,
2527    {
2528        let wrapper_key = DynamicObjectFieldName { name: key };
2529        let wrapper_type = dynamic_object_field_wrapper_type(key_type);
2530        let Some(child_id) = self
2531            .get_dynamic_field_by_key::<DynamicObjectFieldName<K>, ID>(
2532                parent_id,
2533                wrapper_key,
2534                &wrapper_type,
2535            )
2536            .await?
2537        else {
2538            return Ok(None);
2539        };
2540
2541        let child_id = child_id.bytes;
2542        self.get_optional_object(child_id)
2543            .await?
2544            .map(Some)
2545            .ok_or_else(|| anyhow!("Dynamic object field child '{child_id}' not found"))
2546    }
2547
2548    /// Fetch dynamic object fields for a parent and keep only entries whose
2549    /// key BCS decodes as `K`. Parents may legitimately contain several child
2550    /// namespaces with unrelated key types.
2551    pub async fn get_dynamic_object_field_refs_matching_key<K>(
2552        &self,
2553        parent_id: sui::types::Address,
2554    ) -> anyhow::Result<Vec<DynamicObjectFieldReference<K>>>
2555    where
2556        K: Eq + Hash + DeserializeOwned,
2557    {
2558        Ok(self
2559            .fetch_dynamic_fields::<K>(parent_id, 0)
2560            .await?
2561            .into_iter()
2562            .filter_map(|(name, child_id, field_id)| {
2563                Some(DynamicObjectFieldReference {
2564                    name,
2565                    field_id: field_id?,
2566                    child_id: child_id?,
2567                })
2568            })
2569            .collect())
2570    }
2571
2572    /// Fetch all dynamic object field child IDs for a parent object.
2573    pub async fn get_dynamic_object_field_child_ids(
2574        &self,
2575        parent_id: sui::types::Address,
2576    ) -> anyhow::Result<Vec<sui::types::Address>> {
2577        let mut child_ids = Vec::new();
2578        let mut page_token = None;
2579        let field_mask = sui::grpc::FieldMask::from_paths(["child_id"]);
2580        let client = self.clone_state_catalog_client();
2581
2582        loop {
2583            observe_dynamic_field_list("object_children");
2584            let mut request = sui::grpc::ListDynamicFieldsRequest::default()
2585                .with_parent(parent_id)
2586                .with_page_size(1000)
2587                .with_read_mask(field_mask.clone());
2588
2589            if let Some(token) = page_token.clone() {
2590                request = request.with_page_token(token);
2591            }
2592
2593            let response = sui::grpc::retry_read(|| {
2594                let mut client = client.clone();
2595                let request = request.clone();
2596                async move { client.state_client().list_dynamic_fields(request).await }
2597            })
2598            .await
2599            .map(|r| r.into_inner())
2600            .map_err(|e| anyhow!("Could not fetch dynamic fields for parent '{parent_id}': {e}"))?;
2601
2602            page_token = response.next_page_token;
2603
2604            for field in response.dynamic_fields {
2605                let child_id = field
2606                    .child_id_opt()
2607                    .map(|id| id.parse())
2608                    .transpose()
2609                    .map_err(|_| anyhow!("Could not parse child ID for dynamic field"))?;
2610
2611                if let Some(child_id) = child_id {
2612                    child_ids.push(child_id);
2613                }
2614            }
2615
2616            if page_token.is_none() {
2617                break;
2618            }
2619        }
2620
2621        Ok(child_ids)
2622    }
2623
2624    /// Fetch every item in a [`TableVec`] by its contiguous index key.
2625    ///
2626    /// The request derives the fields for `0..parent.size_u64()` directly and
2627    /// returns values in index order.
2628    ///
2629    /// # Errors
2630    ///
2631    /// Returns an error when an index field cannot be fetched, validated, or
2632    /// decoded, or when any expected index is absent.
2633    pub async fn get_table_vec<T>(&self, parent: &TableVec<T>) -> anyhow::Result<Vec<T>>
2634    where
2635        T: DeserializeOwned,
2636    {
2637        let indexes = 0..parent.size_u64();
2638        let mut values = self
2639            .get_dynamic_fields_by_keys(parent.id(), indexes.clone(), &sui::types::TypeTag::U64)
2640            .await?;
2641
2642        indexes
2643            .map(|index| {
2644                values
2645                    .remove(&index)
2646                    .ok_or_else(|| anyhow!("Missing TableVec element {index}"))
2647            })
2648            .collect()
2649    }
2650
2651    /// Helper function to fetch an object based on its ID and field mask.
2652    async fn fetch_object(
2653        &self,
2654        object_id: sui::types::Address,
2655        field_mask: sui::grpc::FieldMask,
2656    ) -> anyhow::Result<sui::grpc::Object> {
2657        self.fetch_optional_object(object_id, field_mask)
2658            .await?
2659            .ok_or_else(|| anyhow::Error::new(NexusError::ObjectNotFound { object: object_id }))
2660    }
2661
2662    async fn fetch_optional_object(
2663        &self,
2664        object_id: sui::types::Address,
2665        field_mask: sui::grpc::FieldMask,
2666    ) -> anyhow::Result<Option<sui::grpc::Object>> {
2667        let client = self.clone_grpc_client();
2668
2669        let request = sui::grpc::GetObjectRequest::default()
2670            .with_object_id(object_id)
2671            .with_read_mask(field_mask);
2672
2673        match sui::grpc::retry_read(|| {
2674            let mut client = client.clone();
2675            let request = request.clone();
2676            async move { client.ledger_client().get_object(request).await }
2677        })
2678        .await
2679        {
2680            Ok(response) => Ok(response.into_inner().object),
2681            Err(status) if status.code() == tonic::Code::NotFound => Ok(None),
2682            Err(status) => Err(anyhow::Error::new(status))
2683                .with_context(|| format!("Could not fetch object '{object_id}'")),
2684        }
2685    }
2686
2687    /// Helper function to fetch many objects based on their IDs and field mask.
2688    async fn fetch_objects(
2689        &self,
2690        object_ids: &[sui::types::Address],
2691        field_mask: sui::grpc::FieldMask,
2692    ) -> anyhow::Result<Vec<sui::grpc::Object>> {
2693        let results = self.fetch_object_results(object_ids, field_mask).await?;
2694
2695        object_ids
2696            .iter()
2697            .copied()
2698            .zip(results)
2699            .map(|(object_id, result)| {
2700                result.to_result().map_err(|status| {
2701                    anyhow!("Could not fetch object '{object_id}': {}", status.message)
2702                })
2703            })
2704            .collect()
2705    }
2706
2707    async fn fetch_object_results(
2708        &self,
2709        object_ids: &[sui::types::Address],
2710        field_mask: sui::grpc::FieldMask,
2711    ) -> anyhow::Result<Vec<sui::grpc::GetObjectResult>> {
2712        let request = {
2713            let mut req = sui::grpc::BatchGetObjectsRequest::default();
2714
2715            req.set_requests(
2716                object_ids
2717                    .iter()
2718                    .map(|&id| {
2719                        sui::grpc::GetObjectRequest::default()
2720                            .with_object_id(id)
2721                            .with_read_mask(field_mask.clone())
2722                    })
2723                    .collect(),
2724            );
2725
2726            req.set_read_mask(field_mask);
2727
2728            req
2729        };
2730
2731        let client = self.clone_grpc_client();
2732
2733        let response = sui::grpc::retry_read(|| {
2734            let mut client = client.clone();
2735            let request = request.clone();
2736            async move { client.ledger_client().batch_get_objects(request).await }
2737        })
2738        .await
2739        .map(|r| r.into_inner())
2740        .map_err(|e| anyhow!("Could not fetch objects: {e}"))?;
2741
2742        Ok(response.objects)
2743    }
2744
2745    /// Helper function to fetch all dynamic fields for a given parent object.
2746    /// Optionally stopping at `stop_at` if we're only interested in a singular
2747    /// item.
2748    async fn fetch_dynamic_fields<K>(
2749        &self,
2750        parent_id: sui::types::Address,
2751        expected_size: usize,
2752    ) -> anyhow::Result<Vec<(K, Option<sui::types::Address>, Option<sui::types::Address>)>>
2753    where
2754        K: Eq + Hash + DeserializeOwned,
2755    {
2756        let mut results = Vec::with_capacity(expected_size);
2757        let mut page_token = None;
2758        let field_mask = sui::grpc::FieldMask::from_paths(["name", "child_id", "field_id"]);
2759        let client = self.clone_state_catalog_client();
2760
2761        loop {
2762            observe_dynamic_field_list("typed_values");
2763            let mut request = sui::grpc::ListDynamicFieldsRequest::default()
2764                .with_parent(parent_id)
2765                .with_page_size(1000)
2766                .with_read_mask(field_mask.clone());
2767
2768            if let Some(token) = page_token.clone() {
2769                request = request.with_page_token(token);
2770            }
2771
2772            let response = sui::grpc::retry_read(|| {
2773                let mut client = client.clone();
2774                let request = request.clone();
2775                async move { client.state_client().list_dynamic_fields(request).await }
2776            })
2777            .await
2778            .map(|r| r.into_inner())
2779            .map_err(|e| anyhow!("Could not fetch dynamic fields for parent '{parent_id}': {e}"))?;
2780
2781            page_token = response.next_page_token;
2782
2783            for field in response.dynamic_fields {
2784                // Parse the dynamic field name as K.
2785                let name = parse_dynamic_field_name::<K>(
2786                    field
2787                        .name_opt()
2788                        .ok_or_else(|| {
2789                            anyhow!("Dynamic field name missing for parent '{parent_id}'")
2790                        })?
2791                        .value(),
2792                )
2793                .map_err(|e| {
2794                    anyhow!("Could not parse dynamic field name for parent '{parent_id}': {e}")
2795                })?;
2796
2797                let field_id = field
2798                    .field_id_opt()
2799                    .map(|id| id.parse())
2800                    .transpose()
2801                    .map_err(|_| anyhow!("Could not parse field ID for dynamic field"))?;
2802
2803                let child_id = field
2804                    .child_id_opt()
2805                    .map(|id| id.parse())
2806                    .transpose()
2807                    .map_err(|_| anyhow!("Could not parse child ID for dynamic field"))?;
2808
2809                results.push((name, child_id, field_id));
2810            }
2811
2812            if page_token.is_none() {
2813                break;
2814            }
2815        }
2816
2817        Ok(results)
2818    }
2819
2820    async fn fetch_dynamic_fields_untyped(
2821        &self,
2822        parent_id: sui::types::Address,
2823    ) -> anyhow::Result<Vec<sui::types::Address>> {
2824        let mut results = Vec::new();
2825        let mut page_token = None;
2826        let field_mask = sui::grpc::FieldMask::from_paths(["field_id"]);
2827        let client = self.clone_state_catalog_client();
2828
2829        loop {
2830            observe_dynamic_field_list("field_ids");
2831            let mut request = sui::grpc::ListDynamicFieldsRequest::default()
2832                .with_parent(parent_id)
2833                .with_page_size(1000)
2834                .with_read_mask(field_mask.clone());
2835
2836            if let Some(token) = page_token.clone() {
2837                request = request.with_page_token(token);
2838            }
2839
2840            let response = sui::grpc::retry_read(|| {
2841                let mut client = client.clone();
2842                let request = request.clone();
2843                async move { client.state_client().list_dynamic_fields(request).await }
2844            })
2845            .await
2846            .map(|r| r.into_inner())
2847            .map_err(|e| anyhow!("Could not fetch dynamic fields for parent '{parent_id}': {e}"))?;
2848
2849            page_token = response.next_page_token;
2850
2851            for field in response.dynamic_fields {
2852                let field_id = field
2853                    .field_id_opt()
2854                    .ok_or_else(|| anyhow!("Dynamic field ID missing for parent '{parent_id}'"))?
2855                    .parse()
2856                    .map_err(|_| anyhow!("Could not parse field ID for dynamic field"))?;
2857                results.push(field_id);
2858            }
2859
2860            if page_token.is_none() {
2861                break;
2862            }
2863        }
2864
2865        Ok(results)
2866    }
2867
2868    /// Helper function to parse metadata from an object.
2869    fn parse_object_metadata(
2870        &self,
2871        object_id: sui::types::Address,
2872        object: &sui::grpc::Object,
2873    ) -> anyhow::Result<(
2874        sui::types::Owner,
2875        sui::types::Digest,
2876        sui::types::Version,
2877        Option<u64>,
2878    )> {
2879        let owner = object
2880            .owner_opt()
2881            .ok_or_else(|| anyhow!("Owner missing for object '{object_id}'"))?
2882            .try_into()
2883            .map_err(|_| anyhow!("Could not parse owner for object '{object_id}'"))?;
2884
2885        let digest = object
2886            .digest_opt()
2887            .ok_or_else(|| anyhow!("Digest missing for object '{object_id}'"))?
2888            .parse()
2889            .map_err(|_| anyhow!("Could not parse digest for object '{object_id}'"))?;
2890
2891        let version = object
2892            .version_opt()
2893            .ok_or_else(|| anyhow!("Version missing for object '{object_id}'"))?;
2894
2895        let balance = object.balance_opt();
2896
2897        Ok((owner, digest, version, balance))
2898    }
2899
2900    fn parse_owned_coin_with_type(
2901        object: &sui::grpc::Object,
2902        expected_owner: sui::types::Address,
2903        expected_type: &sui::types::StructTag,
2904    ) -> Option<(sui::types::ObjectReference, u64)> {
2905        let owner: sui::types::Owner = object.owner_opt()?.try_into().ok()?;
2906        if owner != sui::types::Owner::Address(expected_owner) {
2907            return None;
2908        }
2909
2910        let object_type = object
2911            .object_type_opt()?
2912            .parse::<sui::types::StructTag>()
2913            .ok()?;
2914        if object_type != *expected_type {
2915            return None;
2916        }
2917
2918        Some((
2919            sui::types::ObjectReference::new(
2920                object.object_id_opt()?.parse().ok()?,
2921                object.version_opt()?,
2922                object.digest_opt()?.parse().ok()?,
2923            ),
2924            object.balance_opt().unwrap_or(0),
2925        ))
2926    }
2927
2928    fn parse_object_contents_bcs<T>(&self, object: &sui::grpc::Object) -> anyhow::Result<T>
2929    where
2930        T: DeserializeOwned,
2931    {
2932        let Some(contents) = object.contents_opt() else {
2933            bail!("Object contents missing");
2934        };
2935
2936        let Some(bytes) = contents.value_opt() else {
2937            bail!("Object BCS contents missing");
2938        };
2939
2940        bcs::from_bytes::<T>(bytes).map_err(|e| {
2941            anyhow!(
2942                "Could not parse object contents BCS as `{ty}` (object id `{id}`, {len} bytes): {e}",
2943                ty = std::any::type_name::<T>(),
2944                id = object
2945                    .object_id_opt()
2946                    .map(ToString::to_string)
2947                    .unwrap_or_else(|| "<unknown>".into()),
2948                len = bytes.len(),
2949            )
2950        })
2951    }
2952}
2953
2954/// A generic response wrapper for fetched objects. Contains metadata such as
2955/// the object ID version and owner.
2956#[derive(Clone, Debug)]
2957pub struct Response<T> {
2958    pub object_id: sui::types::Address,
2959    pub owner: sui::types::Owner,
2960    pub version: sui::types::Version,
2961    pub data: T,
2962    pub digest: sui::types::Digest,
2963    /// If the object is `0x2::coin::Coin`, contains the balance.
2964    pub balance: Option<u64>,
2965}
2966
2967/// One typed object state value written by an exact transaction.
2968///
2969/// `object_id` is the stable state object which owns the written `Inner`
2970/// field. `field_ref` identifies the field version written by the transaction.
2971#[derive(Clone, Debug)]
2972pub struct TransactionStateOutput<T> {
2973    pub object_id: sui::types::Address,
2974    pub field_ref: sui::types::ObjectReference,
2975    pub data: T,
2976}
2977
2978impl<T> Response<T> {
2979    /// Check if the object is shared.
2980    pub fn is_shared(&self) -> bool {
2981        matches!(
2982            self.owner,
2983            sui::types::Owner::Shared(_) | sui::types::Owner::ConsensusAddress { .. }
2984        )
2985    }
2986
2987    /// Check if the object is immutable.
2988    pub fn is_immutable(&self) -> bool {
2989        matches!(self.owner, sui::types::Owner::Immutable)
2990    }
2991
2992    /// Get initial version of the object if it's shared or current version
2993    /// otherwise.
2994    pub fn get_initial_version(&self) -> sui::types::Version {
2995        match self.owner {
2996            sui::types::Owner::Shared(version) => version,
2997            sui::types::Owner::ConsensusAddress { start_version, .. } => start_version,
2998            _ => self.version,
2999        }
3000    }
3001
3002    // Get a Sui object ref.
3003    pub fn object_ref(&self) -> sui::types::ObjectReference {
3004        sui::types::ObjectReference::new(self.object_id, self.get_initial_version(), self.digest)
3005    }
3006}
3007
3008// == Wrappers ==
3009
3010/// Wrapper around any vec-like structure within parsed Sui object data. These
3011/// are always wrapped in a struct with a single `contents` field.
3012#[derive(Clone, Debug, Deserialize)]
3013pub struct Set<T>
3014where
3015    T: Eq + Hash,
3016{
3017    contents: HashSet<T>,
3018}
3019
3020impl<T> Set<T>
3021where
3022    T: Eq + Hash,
3023{
3024    pub fn new() -> Self {
3025        Self {
3026            contents: HashSet::new(),
3027        }
3028    }
3029
3030    pub fn into_inner(self) -> HashSet<T> {
3031        self.contents
3032    }
3033
3034    pub fn inner(&self) -> &HashSet<T> {
3035        &self.contents
3036    }
3037
3038    pub fn inner_mut(&mut self) -> &mut HashSet<T> {
3039        &mut self.contents
3040    }
3041
3042    pub fn is_empty(&self) -> bool {
3043        self.contents.is_empty()
3044    }
3045}
3046
3047impl<T> Default for Set<T>
3048where
3049    T: Eq + Hash,
3050{
3051    fn default() -> Self {
3052        Self::new()
3053    }
3054}
3055
3056impl<T> From<HashSet<T>> for Set<T>
3057where
3058    T: Eq + Hash,
3059{
3060    fn from(contents: HashSet<T>) -> Self {
3061        Self { contents }
3062    }
3063}
3064
3065impl<T> FromIterator<T> for Set<T>
3066where
3067    T: Eq + Hash,
3068{
3069    fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
3070        Self {
3071            contents: HashSet::from_iter(iter),
3072        }
3073    }
3074}
3075
3076impl<T> Serialize for Set<T>
3077where
3078    T: Eq + Hash + Serialize,
3079{
3080    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
3081    where
3082        S: serde::Serializer,
3083    {
3084        #[derive(Serialize)]
3085        struct Wrapper<'a, T> {
3086            contents: Vec<&'a T>,
3087        }
3088
3089        Wrapper {
3090            contents: self.contents.iter().collect(),
3091        }
3092        .serialize(serializer)
3093    }
3094}
3095
3096/// Wrapper around any map-like structure within parsed Sui object data. These
3097/// are always wrapped in a struct with a single `contents` field. The contents
3098/// is a vec of key-value pairs.
3099#[derive(Clone, Debug, PartialEq, Eq)]
3100pub struct Map<K, V>
3101where
3102    K: Eq + Hash,
3103{
3104    contents: HashMap<K, V>,
3105}
3106
3107impl<K, V> Map<K, V>
3108where
3109    K: Eq + Hash,
3110{
3111    pub fn new() -> Self {
3112        Self {
3113            contents: HashMap::new(),
3114        }
3115    }
3116
3117    pub fn from_map(contents: HashMap<K, V>) -> Self {
3118        Self { contents }
3119    }
3120
3121    pub fn into_inner(self) -> HashMap<K, V> {
3122        self.contents
3123    }
3124
3125    pub fn into_map(self) -> HashMap<K, V> {
3126        self.into_inner()
3127    }
3128
3129    pub fn inner(&self) -> &HashMap<K, V> {
3130        &self.contents
3131    }
3132
3133    pub fn inner_mut(&mut self) -> &mut HashMap<K, V> {
3134        &mut self.contents
3135    }
3136}
3137
3138impl<K, V> Default for Map<K, V>
3139where
3140    K: Eq + Hash,
3141{
3142    fn default() -> Self {
3143        Self::new()
3144    }
3145}
3146
3147impl<K, V> From<HashMap<K, V>> for Map<K, V>
3148where
3149    K: Eq + Hash,
3150{
3151    fn from(contents: HashMap<K, V>) -> Self {
3152        Self { contents }
3153    }
3154}
3155
3156impl<K, V> FromIterator<(K, V)> for Map<K, V>
3157where
3158    K: Eq + Hash,
3159{
3160    fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
3161        Self {
3162            contents: HashMap::from_iter(iter),
3163        }
3164    }
3165}
3166
3167impl<K, V> Serialize for Map<K, V>
3168where
3169    K: Eq + Hash + Serialize,
3170    V: Serialize,
3171{
3172    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
3173    where
3174        S: serde::Serializer,
3175    {
3176        #[derive(Serialize)]
3177        struct PairRef<'a, K, V> {
3178            key: &'a K,
3179            value: &'a V,
3180        }
3181
3182        #[derive(Serialize)]
3183        struct Wrapper<'a, K, V> {
3184            contents: Vec<PairRef<'a, K, V>>,
3185        }
3186
3187        let contents = self
3188            .contents
3189            .iter()
3190            .map(|(key, value)| PairRef { key, value })
3191            .collect();
3192
3193        Wrapper { contents }.serialize(serializer)
3194    }
3195}
3196
3197impl<'de, K, V> Deserialize<'de> for Map<K, V>
3198where
3199    K: Eq + Hash + DeserializeOwned,
3200    V: DeserializeOwned,
3201{
3202    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
3203    where
3204        D: Deserializer<'de>,
3205    {
3206        #[derive(Deserialize)]
3207        #[serde(untagged)]
3208        enum Wrapper<K, V>
3209        where
3210            K: Eq + Hash,
3211        {
3212            Pairs { contents: Vec<Pair<K, V>> },
3213            Map { contents: HashMap<K, V> },
3214        }
3215
3216        match Wrapper::<K, V>::deserialize(deserializer)? {
3217            Wrapper::Pairs { contents } => Ok(Self {
3218                contents: contents
3219                    .into_iter()
3220                    .map(|Pair { key, value }| (key, value))
3221                    .collect(),
3222            }),
3223            Wrapper::Map { contents } => Ok(Self { contents }),
3224        }
3225    }
3226}
3227
3228/// Internal wrapper around a key-value pair within a map-like structure.
3229#[derive(Clone, Debug, Deserialize)]
3230struct Pair<K, V> {
3231    #[serde(alias = "key", alias = "name")]
3232    key: K,
3233    value: V,
3234}
3235
3236#[cfg(test)]
3237mod tests {
3238    use {
3239        super::*,
3240        crate::test_utils::sui_mocks,
3241        mockall::predicate::always,
3242        serde::{Deserialize, Serialize},
3243        std::sync::{Arc, Barrier},
3244    };
3245
3246    #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
3247    struct TestValue {
3248        value: u64,
3249    }
3250
3251    #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
3252    struct TestKey {
3253        name: String,
3254    }
3255
3256    #[derive(Serialize)]
3257    struct TestDynamicObjectFieldName<K> {
3258        name: K,
3259    }
3260
3261    fn test_value_tag() -> sui::types::StructTag {
3262        sui::types::StructTag::new(
3263            sui::types::Address::from_static("0x1"),
3264            sui::types::Identifier::from_static("test"),
3265            sui::types::Identifier::from_static("TestValue"),
3266            vec![],
3267        )
3268    }
3269
3270    fn object_with_bcs<T>(
3271        object_ref: sui::types::ObjectReference,
3272        owner: sui::types::Owner,
3273        value: &T,
3274    ) -> sui::grpc::Object
3275    where
3276        T: Serialize,
3277    {
3278        let mut object = sui::grpc::Object::default();
3279        object.set_object_id(object_ref.object_id().to_string());
3280        object.set_owner(sui::grpc::Owner::from(owner));
3281        object.set_digest(*object_ref.digest());
3282        object.set_version(object_ref.version());
3283        let mut contents = sui::grpc::Bcs::default();
3284        contents.set_value(bcs::to_bytes(value).expect("object value serializes"));
3285        object.contents = Some(contents);
3286        object
3287    }
3288
3289    fn typed_object_with_bcs<T>(
3290        object_ref: sui::types::ObjectReference,
3291        owner: sui::types::Owner,
3292        object_type: &sui::types::StructTag,
3293        value: &T,
3294    ) -> sui::grpc::Object
3295    where
3296        T: Serialize,
3297    {
3298        let mut object = object_with_bcs(object_ref, owner, value);
3299        object.set_object_type(object_type.to_string());
3300        object
3301    }
3302
3303    #[test]
3304    fn dynamic_field_metadata_reports_unknown_value_type_without_bcs_decode() {
3305        let parent_id = sui::types::Address::from_static("0x91");
3306        let field_id = sui::types::Address::from_static("0x92");
3307        let key_type = "0xa1::object_state::Inner"
3308            .parse::<sui::types::TypeTag>()
3309            .unwrap();
3310        let value_type = "0xf0::future::UnknownInner"
3311            .parse::<sui::types::TypeTag>()
3312            .unwrap();
3313        let field_type = sui::types::StructTag::new(
3314            sui::types::Address::from_static("0x2"),
3315            sui::types::Identifier::from_static("dynamic_field"),
3316            sui::types::Identifier::from_static("Field"),
3317            vec![key_type.clone(), value_type.clone()],
3318        );
3319        let mut field_object = sui::grpc::Object::default();
3320        field_object.set_object_type(field_type.to_string());
3321        let mut invalid_value = sui::grpc::Bcs::default();
3322        invalid_value.set_value(vec![0xff]);
3323        let mut field = sui::grpc::DynamicField::default();
3324        field.set_field_id(field_id.to_string());
3325        field.set_field_object(field_object);
3326        field.set_value_type(value_type.to_string());
3327        field.set_value(invalid_value);
3328
3329        let metadata = parse_dynamic_field_metadata(parent_id, &field)
3330            .unwrap()
3331            .unwrap();
3332
3333        assert_eq!(metadata.field_id, field_id);
3334        assert_eq!(metadata.key_type, key_type);
3335        assert_eq!(metadata.value_type, value_type);
3336    }
3337
3338    #[test]
3339    fn dynamic_field_metadata_rejects_an_incomplete_field_shape() {
3340        let parent_id = sui::types::Address::from_static("0x91");
3341        let field_id = sui::types::Address::from_static("0x92");
3342        let mut field_object = sui::grpc::Object::default();
3343        field_object.set_object_type("0x2::dynamic_field::Field<u64>".to_owned());
3344        let mut field = sui::grpc::DynamicField::default();
3345        field.set_field_id(field_id.to_string());
3346        field.set_field_object(field_object);
3347
3348        let error = parse_dynamic_field_metadata(parent_id, &field)
3349            .unwrap_err()
3350            .to_string();
3351
3352        assert!(error.contains("Field<K, V>"));
3353    }
3354
3355    #[test]
3356    fn dynamic_object_field_metadata_is_excluded_from_ordinary_state_metadata() {
3357        let parent_id = sui::types::Address::from_static("0x91");
3358        let field_id = sui::types::Address::from_static("0x92");
3359        let key_type = "0xa1::task::AuthorizationKey"
3360            .parse::<sui::types::TypeTag>()
3361            .unwrap();
3362        let wrapper_type = dynamic_object_field_wrapper_type(&key_type);
3363        let child_type = "0xb1::authorization::AgentSkillAuthorization"
3364            .parse::<sui::types::TypeTag>()
3365            .unwrap();
3366        let field_type = sui::types::StructTag::new(
3367            sui::types::Address::from_static("0x2"),
3368            sui::types::Identifier::from_static("dynamic_field"),
3369            sui::types::Identifier::from_static("Field"),
3370            vec![
3371                wrapper_type,
3372                sui::types::TypeTag::Struct(Box::new(sui::types::StructTag::new(
3373                    sui::types::Address::from_static("0x2"),
3374                    sui::types::Identifier::from_static("object"),
3375                    sui::types::Identifier::from_static("ID"),
3376                    vec![],
3377                ))),
3378            ],
3379        );
3380        let mut field_object = sui::grpc::Object::default();
3381        field_object.set_object_type(field_type.to_string());
3382        let mut field = sui::grpc::DynamicField::default();
3383        field.set_kind(sui::grpc::dynamic_field::DynamicFieldKind::Object);
3384        field.set_field_id(field_id.to_string());
3385        field.set_field_object(field_object);
3386        field.set_value_type(child_type.to_string());
3387
3388        let metadata = parse_dynamic_field_metadata(parent_id, &field).unwrap();
3389
3390        assert!(metadata.is_none());
3391    }
3392
3393    fn coin_object(
3394        object_ref: sui::types::ObjectReference,
3395        owner: sui::types::Owner,
3396        balance: u64,
3397        object_type: &sui::types::StructTag,
3398    ) -> sui::grpc::Object {
3399        let mut object = sui::grpc::Object::default();
3400        object.set_object_id(*object_ref.object_id());
3401        object.set_owner(sui::grpc::Owner::from(owner));
3402        object.set_digest(*object_ref.digest());
3403        object.set_version(object_ref.version());
3404        object.set_balance(balance);
3405        object.set_object_type(object_type.to_string());
3406        object
3407    }
3408
3409    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
3410    async fn inner_client_clones_allow_independent_requests_to_progress_concurrently() {
3411        let owner = sui::types::Address::from_static("0xa");
3412        let object_type = sui::types::StructTag::gas_coin();
3413        let metadata_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x10"));
3414        let coin_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x11"));
3415        let request_barrier = Arc::new(Barrier::new(2));
3416        let mut ledger_service_mock = sui_mocks::grpc::MockLedgerService::new();
3417        ledger_service_mock
3418            .expect_get_object()
3419            .times(1)
3420            .return_once({
3421                let request_barrier = Arc::clone(&request_barrier);
3422                let metadata_ref = metadata_ref.clone();
3423                move |_request| {
3424                    request_barrier.wait();
3425                    let object = object_with_bcs(
3426                        metadata_ref,
3427                        sui::types::Owner::Immutable,
3428                        &TestValue { value: 1 },
3429                    );
3430                    let mut response = sui::grpc::GetObjectResponse::default();
3431                    response.set_object(object);
3432                    Ok(tonic::Response::new(response))
3433                }
3434            });
3435        let mut state_service_mock = sui_mocks::grpc::MockStateService::new();
3436        state_service_mock
3437            .expect_list_owned_objects()
3438            .times(1)
3439            .return_once({
3440                let request_barrier = Arc::clone(&request_barrier);
3441                let coin_ref = coin_ref.clone();
3442                let object_type = object_type.clone();
3443                move |_request| {
3444                    request_barrier.wait();
3445                    let mut response = sui::grpc::ListOwnedObjectsResponse::default();
3446                    response.set_objects(vec![coin_object(
3447                        coin_ref,
3448                        sui::types::Owner::Address(owner),
3449                        50,
3450                        &object_type,
3451                    )]);
3452                    Ok(tonic::Response::new(response))
3453                }
3454            });
3455        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
3456            ledger_service_mock: Some(ledger_service_mock),
3457            state_service_mock: Some(state_service_mock),
3458            ..Default::default()
3459        });
3460        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
3461
3462        let (metadata, coins) = tokio::time::timeout(std::time::Duration::from_secs(2), async {
3463            tokio::join!(
3464                crawler.get_object_metadata(*metadata_ref.object_id()),
3465                crawler.fetch_coins_for_address_by_type(owner, object_type),
3466            )
3467        })
3468        .await
3469        .expect("independent RPCs should not wait on a shared client mutex");
3470
3471        assert_eq!(
3472            metadata
3473                .expect("metadata request should succeed")
3474                .object_ref(),
3475            metadata_ref
3476        );
3477        assert_eq!(
3478            coins.expect("coin request should succeed"),
3479            vec![(coin_ref, 50)]
3480        );
3481    }
3482
3483    #[tokio::test]
3484    async fn preparation_retries_only_the_failed_object_read() {
3485        let good_id = sui::types::Address::from_static("0x91");
3486        let flaky_id = sui::types::Address::from_static("0x92");
3487        let mut ledger = sui_mocks::grpc::MockLedgerService::new();
3488        ledger
3489            .expect_get_object()
3490            .withf(move |request| request.get_ref().object_id() == good_id.to_string())
3491            .times(1)
3492            .returning(move |_| {
3493                let object = object_with_bcs(
3494                    sui_mocks::object_ref_for_id(good_id),
3495                    sui::types::Owner::Immutable,
3496                    &TestValue { value: 1 },
3497                );
3498                Ok(tonic::Response::new(
3499                    sui::grpc::GetObjectResponse::default().with_object(object),
3500                ))
3501            });
3502        let mut attempts = 0;
3503        ledger
3504            .expect_get_object()
3505            .withf(move |request| request.get_ref().object_id() == flaky_id.to_string())
3506            .times(3)
3507            .returning(move |_| {
3508                attempts += 1;
3509                if attempts < 3 {
3510                    return Err(tonic::Status::unavailable("offline"));
3511                }
3512                let object = object_with_bcs(
3513                    sui_mocks::object_ref_for_id(flaky_id),
3514                    sui::types::Owner::Immutable,
3515                    &TestValue { value: 2 },
3516                );
3517                Ok(tonic::Response::new(
3518                    sui::grpc::GetObjectResponse::default().with_object(object),
3519                ))
3520            });
3521        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
3522            ledger_service_mock: Some(ledger),
3523            ..Default::default()
3524        });
3525        sui::grpc::set_retry_request_budget(
3526            &rpc_url,
3527            std::num::NonZeroU32::new(20).unwrap(),
3528            std::num::NonZeroU32::new(1).unwrap(),
3529        )
3530        .unwrap();
3531        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).unwrap()));
3532        let (good, flaky) = sui::grpc::with_read_retry_until(
3533            tokio::time::Instant::now() + std::time::Duration::from_secs(5),
3534            async {
3535                tokio::try_join!(
3536                    crawler.get_object::<TestValue>(good_id),
3537                    crawler.get_object::<TestValue>(flaky_id)
3538                )
3539            },
3540        )
3541        .await
3542        .unwrap();
3543        assert_eq!(good.data.value, 1);
3544        assert_eq!(flaky.data.value, 2);
3545    }
3546
3547    #[tokio::test]
3548    async fn optional_object_batch_preserves_missing_positions() {
3549        let first_id = sui::types::Address::from_static("0x71");
3550        let missing_id = sui::types::Address::from_static("0x72");
3551        let last_id = sui::types::Address::from_static("0x73");
3552        let requested_ids = [first_id, missing_id, last_id];
3553        let first_ref = sui_mocks::object_ref_for_id(first_id);
3554        let last_ref = sui_mocks::object_ref_for_id(last_id);
3555        let expected_ids = requested_ids.map(|id| id.to_string());
3556        let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
3557        ledger_service
3558            .expect_batch_get_objects()
3559            .times(1)
3560            .return_once(move |request| {
3561                let actual_ids = request
3562                    .get_ref()
3563                    .requests
3564                    .iter()
3565                    .map(|request| request.object_id.clone().expect("object ID"))
3566                    .collect::<Vec<_>>();
3567                assert_eq!(actual_ids, expected_ids);
3568
3569                let missing = sui_rpc::proto::google::rpc::Status {
3570                    code: tonic::Code::NotFound.into(),
3571                    message: "object not found".to_owned(),
3572                    ..Default::default()
3573                };
3574                let response = sui::grpc::BatchGetObjectsResponse::new(vec![
3575                    sui::grpc::GetObjectResult::new_object(object_with_bcs(
3576                        first_ref,
3577                        sui::types::Owner::Shared(1),
3578                        &TestValue { value: 3 },
3579                    )),
3580                    sui::grpc::GetObjectResult::new_error(missing),
3581                    sui::grpc::GetObjectResult::new_object(object_with_bcs(
3582                        last_ref,
3583                        sui::types::Owner::Shared(1),
3584                        &TestValue { value: 5 },
3585                    )),
3586                ]);
3587                Ok(tonic::Response::new(response))
3588            });
3589
3590        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
3591            ledger_service_mock: Some(ledger_service),
3592            ..Default::default()
3593        });
3594        let client = sui::grpc::client(rpc_url).expect("mock client");
3595        let crawler = Crawler::new(Arc::new(client));
3596
3597        let objects = crawler
3598            .get_optional_objects::<TestValue>(&requested_ids)
3599            .await
3600            .expect("optional objects load");
3601
3602        assert_eq!(
3603            objects[0].as_ref().map(|object| &object.data),
3604            Some(&TestValue { value: 3 })
3605        );
3606        assert!(objects[1].is_none());
3607        assert_eq!(
3608            objects[2].as_ref().map(|object| &object.data),
3609            Some(&TestValue { value: 5 })
3610        );
3611    }
3612
3613    #[tokio::test]
3614    async fn fetch_coins_for_address_by_type_reads_every_page() {
3615        let owner = sui::types::Address::from_static("0xa");
3616        let object_type = sui::types::StructTag::gas_coin();
3617        let first_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x10"));
3618        let second_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x11"));
3619        let responses = vec![
3620            (
3621                vec![coin_object(
3622                    first_ref.clone(),
3623                    sui::types::Owner::Address(owner),
3624                    70,
3625                    &object_type,
3626                )],
3627                Some(Vec::from(&b"page-2"[..])),
3628            ),
3629            (
3630                vec![coin_object(
3631                    second_ref.clone(),
3632                    sui::types::Owner::Address(owner),
3633                    30,
3634                    &object_type,
3635                )],
3636                None,
3637            ),
3638        ];
3639        let mut responses = responses.into_iter();
3640        let mut state_service_mock = sui_mocks::grpc::MockStateService::new();
3641        state_service_mock
3642            .expect_list_owned_objects()
3643            .times(2)
3644            .with(always())
3645            .returning(move |_request| {
3646                let (objects, next_page_token) = responses.next().expect("typed coin page");
3647                let mut response = sui::grpc::ListOwnedObjectsResponse::default();
3648                response.set_objects(objects);
3649                response.next_page_token = next_page_token.map(Into::into);
3650                Ok(tonic::Response::new(response))
3651            });
3652
3653        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
3654            state_service_mock: Some(state_service_mock),
3655            ..Default::default()
3656        });
3657        let client = sui::grpc::Client::new(rpc_url).expect("mock client");
3658        let crawler = Crawler::new(Arc::new(client));
3659
3660        let coins = crawler
3661            .fetch_coins_for_address_by_type(owner, object_type)
3662            .await
3663            .expect("typed coins load");
3664
3665        assert_eq!(coins, vec![(first_ref, 70), (second_ref, 30)]);
3666    }
3667
3668    #[tokio::test]
3669    async fn fetch_coins_for_address_by_type_excludes_wrong_owner_and_type() {
3670        let owner = sui::types::Address::from_static("0xa");
3671        let other_owner = sui::types::Address::from_static("0xb");
3672        let object_type = sui::types::StructTag::gas_coin();
3673        let valid_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x20"));
3674        let wrong_owner_ref =
3675            sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x21"));
3676        let wrong_type_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x22"));
3677        let mut state_service_mock = sui_mocks::grpc::MockStateService::new();
3678        state_service_mock
3679            .expect_list_owned_objects()
3680            .times(1)
3681            .with(always())
3682            .return_once({
3683                let valid_ref = valid_ref.clone();
3684                let object_type = object_type.clone();
3685                move |_request| {
3686                    let mut response = sui::grpc::ListOwnedObjectsResponse::default();
3687                    response.set_objects(vec![
3688                        coin_object(
3689                            valid_ref,
3690                            sui::types::Owner::Address(owner),
3691                            50,
3692                            &object_type,
3693                        ),
3694                        coin_object(
3695                            wrong_owner_ref,
3696                            sui::types::Owner::Address(other_owner),
3697                            60,
3698                            &object_type,
3699                        ),
3700                        coin_object(
3701                            wrong_type_ref,
3702                            sui::types::Owner::Address(owner),
3703                            70,
3704                            &test_value_tag(),
3705                        ),
3706                    ]);
3707                    Ok(tonic::Response::new(response))
3708                }
3709            });
3710
3711        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
3712            state_service_mock: Some(state_service_mock),
3713            ..Default::default()
3714        });
3715        let client = sui::grpc::Client::new(rpc_url).expect("mock client");
3716        let crawler = Crawler::new(Arc::new(client));
3717
3718        let coins = crawler
3719            .fetch_coins_for_address_by_type(owner, object_type)
3720            .await
3721            .expect("typed coins load");
3722
3723        assert_eq!(coins, vec![(valid_ref, 50)]);
3724    }
3725
3726    #[tokio::test]
3727    async fn get_owned_objects_pages_and_deserializes_bcs() {
3728        let owner = sui::types::Address::from_static("0xa");
3729        let first_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x10"));
3730        let second_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x11"));
3731        let mut state_service_mock = sui_mocks::grpc::MockStateService::new();
3732        let first_object = typed_object_with_bcs(
3733            first_ref.clone(),
3734            sui::types::Owner::Address(owner),
3735            &test_value_tag(),
3736            &TestValue { value: 7 },
3737        );
3738        let second_object = typed_object_with_bcs(
3739            second_ref.clone(),
3740            sui::types::Owner::Address(owner),
3741            &test_value_tag(),
3742            &TestValue { value: 9 },
3743        );
3744        let responses: Vec<(Vec<sui::grpc::Object>, Option<Vec<u8>>)> = vec![
3745            (vec![first_object], Some(Vec::from(&b"page-2"[..]))),
3746            (vec![second_object], None),
3747        ];
3748        let mut responses = responses.into_iter();
3749        state_service_mock
3750            .expect_list_owned_objects()
3751            .times(2)
3752            .with(always())
3753            .returning(move |_request| {
3754                let (objects, next_page_token) = responses.next().expect("owned object page");
3755                let mut response = sui::grpc::ListOwnedObjectsResponse::default();
3756                response.set_objects(objects);
3757                response.next_page_token = next_page_token.map(Into::into);
3758                Ok(tonic::Response::new(response))
3759            });
3760
3761        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
3762            state_service_mock: Some(state_service_mock),
3763            ..Default::default()
3764        });
3765        let client = sui::grpc::client(rpc_url).expect("mock client");
3766        let crawler = Crawler::new(Arc::new(client));
3767
3768        let objects = crawler
3769            .get_owned_objects::<TestValue>(owner, test_value_tag())
3770            .await
3771            .expect("owned objects load");
3772
3773        assert_eq!(objects.len(), 2);
3774        assert_eq!(objects[0].object_id, *first_ref.object_id());
3775        assert_eq!(objects[0].data, TestValue { value: 7 });
3776        assert_eq!(objects[1].object_id, *second_ref.object_id());
3777        assert_eq!(objects[1].data, TestValue { value: 9 });
3778    }
3779
3780    #[tokio::test]
3781    async fn owned_object_page_preserves_filters_and_both_cursors() {
3782        let owner = sui::types::Address::from_static("0xa");
3783        let object_type = test_value_tag();
3784        let object_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x10"));
3785        let request_cursor = Vec::from(&b"request-cursor"[..]);
3786        let response_cursor = Vec::from(&b"response-cursor"[..]);
3787        let expected_owner = owner.to_string();
3788        let expected_type = object_type.to_string();
3789        let mut state_service_mock = sui_mocks::grpc::MockStateService::new();
3790        state_service_mock
3791            .expect_list_owned_objects()
3792            .times(1)
3793            .return_once({
3794                let object_type = object_type.clone();
3795                let object_ref = object_ref.clone();
3796                let request_cursor = request_cursor.clone();
3797                let response_cursor = response_cursor.clone();
3798                move |request| {
3799                    let request = request.get_ref();
3800                    assert_eq!(request.owner.as_deref(), Some(expected_owner.as_str()));
3801                    assert_eq!(request.object_type.as_deref(), Some(expected_type.as_str()));
3802                    assert_eq!(request.page_size, Some(2));
3803                    assert_eq!(
3804                        request.page_token.as_deref(),
3805                        Some(request_cursor.as_slice())
3806                    );
3807
3808                    let mut response = sui::grpc::ListOwnedObjectsResponse::default();
3809                    response.set_objects(vec![typed_object_with_bcs(
3810                        object_ref,
3811                        sui::types::Owner::ConsensusAddress {
3812                            start_version: 5,
3813                            owner,
3814                        },
3815                        &object_type,
3816                        &TestValue { value: 42 },
3817                    )]);
3818                    response.next_page_token = Some(response_cursor.into());
3819                    Ok(tonic::Response::new(response))
3820                }
3821            });
3822
3823        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
3824            state_service_mock: Some(state_service_mock),
3825            ..Default::default()
3826        });
3827        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
3828
3829        let page = crawler
3830            .get_owned_object_page::<TestValue>(owner, object_type, Some(request_cursor), 2)
3831            .await
3832            .expect("owned object page loads");
3833
3834        assert_eq!(page.data().len(), 1);
3835        assert_eq!(page.data()[0].object_id, *object_ref.object_id());
3836        assert_eq!(page.data()[0].data, TestValue { value: 42 });
3837        assert_eq!(
3838            page.data()[0].owner,
3839            sui::types::Owner::ConsensusAddress {
3840                start_version: 5,
3841                owner,
3842            }
3843        );
3844        assert_eq!(page.next_cursor(), Some(response_cursor.as_slice()));
3845    }
3846
3847    #[tokio::test]
3848    async fn object_owned_objects_page_and_validate_the_exact_parent() {
3849        let parent_id = sui::types::Address::from_static("0xa");
3850        let object_type = test_value_tag();
3851        let first_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x20"));
3852        let second_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x21"));
3853        let expected_owner = parent_id.to_string();
3854        let expected_type = object_type.to_string();
3855        let responses = vec![
3856            (
3857                typed_object_with_bcs(
3858                    first_ref.clone(),
3859                    sui::types::Owner::Object(parent_id),
3860                    &object_type,
3861                    &TestValue { value: 7 },
3862                ),
3863                Some(Vec::from(&b"page-2"[..])),
3864            ),
3865            (
3866                typed_object_with_bcs(
3867                    second_ref.clone(),
3868                    sui::types::Owner::Object(parent_id),
3869                    &object_type,
3870                    &TestValue { value: 9 },
3871                ),
3872                None,
3873            ),
3874        ];
3875        let mut responses = responses.into_iter();
3876        let mut state_service_mock = sui_mocks::grpc::MockStateService::new();
3877        state_service_mock
3878            .expect_list_owned_objects()
3879            .times(2)
3880            .returning(move |request| {
3881                let request = request.get_ref();
3882                assert_eq!(request.owner.as_deref(), Some(expected_owner.as_str()));
3883                assert_eq!(request.object_type.as_deref(), Some(expected_type.as_str()));
3884                let (object, next_page_token) = responses.next().expect("object-owned page");
3885                let mut response = sui::grpc::ListOwnedObjectsResponse::default();
3886                response.set_objects(vec![object]);
3887                response.next_page_token = next_page_token.map(Into::into);
3888                Ok(tonic::Response::new(response))
3889            });
3890
3891        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
3892            state_service_mock: Some(state_service_mock),
3893            ..Default::default()
3894        });
3895        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
3896
3897        let objects = crawler
3898            .get_object_owned_objects::<TestValue>(parent_id, object_type)
3899            .await
3900            .expect("object-owned objects load");
3901
3902        assert_eq!(objects.len(), 2);
3903        assert_eq!(objects[0].object_id, *first_ref.object_id());
3904        assert_eq!(objects[1].object_id, *second_ref.object_id());
3905        assert!(objects
3906            .iter()
3907            .all(|object| object.owner == sui::types::Owner::Object(parent_id)));
3908    }
3909
3910    #[tokio::test]
3911    async fn object_owned_object_page_rejects_address_ownership() {
3912        let parent_id = sui::types::Address::from_static("0xa");
3913        let object_type = test_value_tag();
3914        let object_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x20"));
3915        let mut state_service_mock = sui_mocks::grpc::MockStateService::new();
3916        state_service_mock
3917            .expect_list_owned_objects()
3918            .times(1)
3919            .return_once({
3920                let object_type = object_type.clone();
3921                move |_| {
3922                    let mut response = sui::grpc::ListOwnedObjectsResponse::default();
3923                    response.set_objects(vec![typed_object_with_bcs(
3924                        object_ref,
3925                        sui::types::Owner::Address(parent_id),
3926                        &object_type,
3927                        &TestValue { value: 7 },
3928                    )]);
3929                    Ok(tonic::Response::new(response))
3930                }
3931            });
3932
3933        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
3934            state_service_mock: Some(state_service_mock),
3935            ..Default::default()
3936        });
3937        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
3938
3939        let error = crawler
3940            .get_object_owned_object_page::<TestValue>(parent_id, object_type, None, 1)
3941            .await
3942            .expect_err("address ownership must be rejected");
3943        assert!(error.to_string().contains("expected 'Object"));
3944    }
3945
3946    #[tokio::test]
3947    async fn object_owned_object_page_rejects_the_wrong_type() {
3948        let parent_id = sui::types::Address::from_static("0xa");
3949        let expected_type = test_value_tag();
3950        let wrong_type = sui::types::StructTag::new(
3951            sui::types::Address::from_static("0xa1"),
3952            sui::types::Identifier::from_static("test"),
3953            sui::types::Identifier::from_static("WrongValue"),
3954            vec![],
3955        );
3956        let object_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x20"));
3957        let mut state_service_mock = sui_mocks::grpc::MockStateService::new();
3958        state_service_mock
3959            .expect_list_owned_objects()
3960            .times(1)
3961            .return_once(move |_| {
3962                let mut response = sui::grpc::ListOwnedObjectsResponse::default();
3963                response.set_objects(vec![typed_object_with_bcs(
3964                    object_ref,
3965                    sui::types::Owner::Object(parent_id),
3966                    &wrong_type,
3967                    &TestValue { value: 7 },
3968                )]);
3969                Ok(tonic::Response::new(response))
3970            });
3971
3972        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
3973            state_service_mock: Some(state_service_mock),
3974            ..Default::default()
3975        });
3976        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
3977
3978        let error = crawler
3979            .get_object_owned_object_page::<TestValue>(parent_id, expected_type.clone(), None, 1)
3980            .await
3981            .expect_err("wrong type must be rejected");
3982        assert!(error.to_string().contains(&expected_type.to_string()));
3983    }
3984
3985    #[tokio::test]
3986    async fn dynamic_object_field_by_key_fetches_only_the_wrapper_and_child() {
3987        let parent_id = sui::types::Address::from_static("0x40");
3988        let child_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x42"));
3989        let key = TestKey {
3990            name: "primary".to_string(),
3991        };
3992        let key_type = sui::types::TypeTag::Struct(Box::new(sui::types::StructTag::new(
3993            sui::types::Address::from_static("0xa1"),
3994            sui::types::Identifier::from_static("test"),
3995            sui::types::Identifier::from_static("TestKey"),
3996            vec![],
3997        )));
3998        let wrapper_type = sui::types::TypeTag::Struct(Box::new(sui::types::StructTag::new(
3999            sui::types::Address::from_static("0x2"),
4000            sui::types::Identifier::from_static("dynamic_object_field"),
4001            sui::types::Identifier::from_static("Wrapper"),
4002            vec![key_type.clone()],
4003        )));
4004        let wrapper_key = TestDynamicObjectFieldName { name: key.clone() };
4005        let field_id = parent_id.derive_dynamic_child_id(
4006            &wrapper_type,
4007            &bcs::to_bytes(&wrapper_key).expect("wrapper key serializes"),
4008        );
4009        let mut ledger_service_mock = sui_mocks::grpc::MockLedgerService::new();
4010        sui_mocks::grpc::mock_get_object_bcs(
4011            &mut ledger_service_mock,
4012            sui_mocks::object_ref_for_id(field_id),
4013            sui::types::Owner::Object(parent_id),
4014            bcs::to_bytes(&DynamicFieldValue {
4015                id: field_id,
4016                name: wrapper_key,
4017                value: crate::move_bindings::sui_framework::object::ID::new(*child_ref.object_id()),
4018            })
4019            .expect("wrapper field serializes"),
4020        );
4021        sui_mocks::grpc::mock_get_object_bcs(
4022            &mut ledger_service_mock,
4023            child_ref.clone(),
4024            sui::types::Owner::Shared(child_ref.version()),
4025            bcs::to_bytes(&TestValue { value: 11 }).expect("test value serializes"),
4026        );
4027
4028        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4029            ledger_service_mock: Some(ledger_service_mock),
4030            ..Default::default()
4031        });
4032        let client = sui::grpc::client(rpc_url).expect("mock client");
4033        let crawler = Crawler::new(Arc::new(client));
4034
4035        let object = crawler
4036            .get_dynamic_object_field_by_key::<TestKey, TestValue>(parent_id, key, &key_type)
4037            .await
4038            .expect("dynamic object field request succeeds")
4039            .expect("dynamic object field exists");
4040
4041        assert_eq!(object.object_id, *child_ref.object_id());
4042        assert_eq!(object.data, TestValue { value: 11 });
4043    }
4044
4045    #[tokio::test]
4046    async fn dynamic_object_field_by_key_rejects_a_child_identity_mismatch() {
4047        let parent_id = sui::types::Address::from_static("0x40");
4048        let child_id = sui::types::Address::from_static("0x42");
4049        let returned_child_ref =
4050            sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x43"));
4051        let key = TestKey {
4052            name: "primary".to_string(),
4053        };
4054        let key_type = sui::types::TypeTag::Struct(Box::new(sui::types::StructTag::new(
4055            sui::types::Address::from_static("0xa1"),
4056            sui::types::Identifier::from_static("test"),
4057            sui::types::Identifier::from_static("TestKey"),
4058            vec![],
4059        )));
4060        let wrapper_type = dynamic_object_field_wrapper_type(&key_type);
4061        let wrapper_key = TestDynamicObjectFieldName { name: key.clone() };
4062        let field_id = parent_id.derive_dynamic_child_id(
4063            &wrapper_type,
4064            &bcs::to_bytes(&wrapper_key).expect("wrapper key serializes"),
4065        );
4066        let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
4067        sui_mocks::grpc::mock_get_object_bcs(
4068            &mut ledger_service,
4069            sui_mocks::object_ref_for_id(field_id),
4070            sui::types::Owner::Object(parent_id),
4071            bcs::to_bytes(&DynamicFieldValue {
4072                id: field_id,
4073                name: wrapper_key,
4074                value: crate::move_bindings::sui_framework::object::ID::new(child_id),
4075            })
4076            .expect("wrapper field serializes"),
4077        );
4078        ledger_service
4079            .expect_get_object()
4080            .withf(move |request| {
4081                request.get_ref().object_id.as_deref() == Some(child_id.to_string().as_str())
4082            })
4083            .times(1)
4084            .return_once(move |_| {
4085                let mut response = sui::grpc::GetObjectResponse::default();
4086                response.set_object(object_with_bcs(
4087                    returned_child_ref,
4088                    sui::types::Owner::Shared(1),
4089                    &TestValue { value: 11 },
4090                ));
4091                Ok(tonic::Response::new(response))
4092            });
4093
4094        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4095            ledger_service_mock: Some(ledger_service),
4096            ..Default::default()
4097        });
4098        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
4099
4100        let error = crawler
4101            .get_dynamic_object_field_by_key::<TestKey, TestValue>(parent_id, key, &key_type)
4102            .await
4103            .expect_err("mismatched child identity must fail");
4104
4105        assert!(
4106            error.to_string().contains("received object"),
4107            "unexpected error: {error}"
4108        );
4109    }
4110
4111    #[tokio::test]
4112    async fn get_dynamic_fields_decodes_field_object_bcs() {
4113        let parent_id = sui::types::Address::from_static("0x70");
4114        let key = TestKey {
4115            name: "wanted".to_string(),
4116        };
4117        let field_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x71"));
4118        let field = DynamicFieldValue {
4119            id: *field_ref.object_id(),
4120            name: key.clone(),
4121            value: TestValue { value: 42 },
4122        };
4123        let field_type = sui::types::StructTag::new(
4124            sui::types::Address::from_static("0x2"),
4125            sui::types::Identifier::from_static("dynamic_field"),
4126            sui::types::Identifier::from_static("Field"),
4127            vec![],
4128        );
4129
4130        let mut state_service_mock = sui_mocks::grpc::MockStateService::new();
4131        sui_mocks::grpc::mock_list_dynamic_fields(
4132            &mut state_service_mock,
4133            vec![(key.clone(), *field_ref.object_id())],
4134        );
4135        let mut ledger_service_mock = sui_mocks::grpc::MockLedgerService::new();
4136        sui_mocks::grpc::mock_get_objects_bcs(
4137            &mut ledger_service_mock,
4138            vec![(
4139                field_ref.clone(),
4140                sui::types::Owner::Shared(1),
4141                bcs::to_bytes(&field).expect("dynamic field bcs"),
4142                field_type,
4143            )],
4144        );
4145
4146        let catalog_rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4147            state_service_mock: Some(state_service_mock),
4148            ..Default::default()
4149        });
4150        let live_rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4151            ledger_service_mock: Some(ledger_service_mock),
4152            ..Default::default()
4153        });
4154        let live = Arc::new(sui::grpc::client(live_rpc_url).expect("live mock client"));
4155        let catalog = Arc::new(sui::grpc::client(catalog_rpc_url).expect("catalog mock client"));
4156        let crawler = Crawler::with_state_catalog(live, catalog);
4157
4158        let fields = crawler
4159            .get_dynamic_fields::<TestKey, TestValue>(parent_id, 1)
4160            .await
4161            .expect("dynamic field value decodes");
4162
4163        assert_eq!(fields.get(&key), Some(&TestValue { value: 42 }));
4164    }
4165
4166    #[tokio::test]
4167    async fn get_dynamic_field_by_key_derives_the_field_identity() {
4168        let parent_id = sui::types::Address::from_static("0x70");
4169        let key = 7_u64;
4170        let key_type = sui::types::TypeTag::U64;
4171        let field_id = parent_id
4172            .derive_dynamic_child_id(&key_type, &bcs::to_bytes(&key).expect("key serializes"));
4173        let field_ref = sui_mocks::object_ref_for_id(field_id);
4174        let field = DynamicFieldValue {
4175            id: field_id,
4176            name: key,
4177            value: TestValue { value: 42 },
4178        };
4179        let mut ledger_service_mock = sui_mocks::grpc::MockLedgerService::new();
4180        sui_mocks::grpc::mock_get_object_bcs(
4181            &mut ledger_service_mock,
4182            field_ref,
4183            sui::types::Owner::Shared(1),
4184            bcs::to_bytes(&field).expect("dynamic field serializes"),
4185        );
4186
4187        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4188            ledger_service_mock: Some(ledger_service_mock),
4189            ..Default::default()
4190        });
4191        let client = sui::grpc::client(rpc_url).expect("mock client");
4192        let crawler = Crawler::new(Arc::new(client));
4193
4194        let value = crawler
4195            .get_dynamic_field_by_key::<u64, TestValue>(parent_id, key, &key_type)
4196            .await
4197            .expect("dynamic field loads");
4198
4199        assert_eq!(value, Some(TestValue { value: 42 }));
4200    }
4201
4202    #[tokio::test]
4203    async fn dynamic_field_by_key_rejects_an_embedded_id_mismatch() {
4204        let parent_id = sui::types::Address::from_static("0x70");
4205        let key = 7_u64;
4206        let key_type = sui::types::TypeTag::U64;
4207        let field_id = parent_id
4208            .derive_dynamic_child_id(&key_type, &bcs::to_bytes(&key).expect("key serializes"));
4209        let field_ref = sui_mocks::object_ref_for_id(field_id);
4210        let field = DynamicFieldValue {
4211            id: sui::types::Address::from_static("0x71"),
4212            name: key,
4213            value: TestValue { value: 42 },
4214        };
4215        let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
4216        sui_mocks::grpc::mock_get_object_bcs(
4217            &mut ledger_service,
4218            field_ref,
4219            sui::types::Owner::Object(parent_id),
4220            bcs::to_bytes(&field).expect("dynamic field serializes"),
4221        );
4222
4223        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4224            ledger_service_mock: Some(ledger_service),
4225            ..Default::default()
4226        });
4227        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
4228
4229        let error = crawler
4230            .get_dynamic_field_by_key::<u64, TestValue>(parent_id, key, &key_type)
4231            .await
4232            .expect_err("mismatched embedded ID must fail");
4233
4234        assert!(
4235            error.to_string().contains("unexpected embedded ID"),
4236            "unexpected error: {error}"
4237        );
4238    }
4239
4240    #[tokio::test]
4241    async fn dynamic_fields_by_keys_fetch_only_distinct_derived_ids() {
4242        let parent_id = sui::types::Address::from_static("0x70");
4243        let keys = [7_u64, 7_u64, 9_u64];
4244        let key_type = sui::types::TypeTag::U64;
4245        let requested_keys = [7_u64, 9_u64];
4246        let requested_ids = requested_keys.map(|key| {
4247            parent_id
4248                .derive_dynamic_child_id(&key_type, &bcs::to_bytes(&key).expect("key serializes"))
4249        });
4250
4251        let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
4252        ledger_service
4253            .expect_batch_get_objects()
4254            .times(1)
4255            .return_once(move |request| {
4256                let actual_ids = request
4257                    .get_ref()
4258                    .requests
4259                    .iter()
4260                    .map(|request| {
4261                        request
4262                            .object_id
4263                            .as_deref()
4264                            .expect("object ID")
4265                            .parse()
4266                            .expect("valid object ID")
4267                    })
4268                    .collect::<Vec<sui::types::Address>>();
4269                assert_eq!(actual_ids, requested_ids);
4270
4271                let objects = requested_keys
4272                    .into_iter()
4273                    .zip(requested_ids)
4274                    .map(|(name, object_id)| {
4275                        sui::grpc::GetObjectResult::new_object(object_with_bcs(
4276                            sui_mocks::object_ref_for_id(object_id),
4277                            sui::types::Owner::Object(parent_id),
4278                            &DynamicFieldValue {
4279                                id: object_id,
4280                                name,
4281                                value: TestValue { value: name },
4282                            },
4283                        ))
4284                    })
4285                    .collect();
4286                Ok(tonic::Response::new(
4287                    sui::grpc::BatchGetObjectsResponse::new(objects),
4288                ))
4289            });
4290
4291        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4292            ledger_service_mock: Some(ledger_service),
4293            ..Default::default()
4294        });
4295        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
4296
4297        let fields = crawler
4298            .get_dynamic_fields_by_keys::<u64, TestValue, _>(parent_id, keys, &key_type)
4299            .await
4300            .expect("exact fields load");
4301
4302        assert_eq!(fields.len(), 2);
4303        assert_eq!(fields[&7].value, 7);
4304        assert_eq!(fields[&9].value, 9);
4305    }
4306
4307    #[tokio::test]
4308    async fn dynamic_fields_by_keys_omit_missing_fields() {
4309        let parent_id = sui::types::Address::from_static("0x70");
4310        let key_type = sui::types::TypeTag::U64;
4311        let keys = [7_u64, 9_u64];
4312        let field_ids = keys.map(|key| {
4313            parent_id
4314                .derive_dynamic_child_id(&key_type, &bcs::to_bytes(&key).expect("key serializes"))
4315        });
4316        let present_id = field_ids[0];
4317
4318        let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
4319        ledger_service
4320            .expect_batch_get_objects()
4321            .times(1)
4322            .return_once(move |_| {
4323                let missing = sui_rpc::proto::google::rpc::Status {
4324                    code: tonic::Code::NotFound.into(),
4325                    message: "object not found".to_owned(),
4326                    ..Default::default()
4327                };
4328                Ok(tonic::Response::new(
4329                    sui::grpc::BatchGetObjectsResponse::new(vec![
4330                        sui::grpc::GetObjectResult::new_object(object_with_bcs(
4331                            sui_mocks::object_ref_for_id(present_id),
4332                            sui::types::Owner::Object(parent_id),
4333                            &DynamicFieldValue {
4334                                id: present_id,
4335                                name: 7_u64,
4336                                value: TestValue { value: 7 },
4337                            },
4338                        )),
4339                        sui::grpc::GetObjectResult::new_error(missing),
4340                    ]),
4341                ))
4342            });
4343
4344        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4345            ledger_service_mock: Some(ledger_service),
4346            ..Default::default()
4347        });
4348        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
4349
4350        let fields = crawler
4351            .get_dynamic_fields_by_keys::<u64, TestValue, _>(parent_id, keys, &key_type)
4352            .await
4353            .expect("exact fields load");
4354
4355        assert_eq!(fields, HashMap::from([(7, TestValue { value: 7 })]));
4356    }
4357
4358    #[tokio::test]
4359    async fn dynamic_fields_by_keys_skip_rpc_for_empty_input() {
4360        let rpc_url = sui_mocks::grpc::mock_server(Default::default());
4361        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
4362
4363        let fields = crawler
4364            .get_dynamic_fields_by_keys::<u64, TestValue, _>(
4365                sui::types::Address::from_static("0x70"),
4366                [],
4367                &sui::types::TypeTag::U64,
4368            )
4369            .await
4370            .expect("empty request succeeds");
4371
4372        assert!(fields.is_empty());
4373    }
4374
4375    #[tokio::test]
4376    async fn dynamic_fields_by_keys_reject_a_decoded_key_mismatch() {
4377        let parent_id = sui::types::Address::from_static("0x70");
4378        let key_type = sui::types::TypeTag::U64;
4379        let requested_key = 7_u64;
4380        let field_id = parent_id.derive_dynamic_child_id(
4381            &key_type,
4382            &bcs::to_bytes(&requested_key).expect("key serializes"),
4383        );
4384
4385        let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
4386        ledger_service
4387            .expect_batch_get_objects()
4388            .times(1)
4389            .return_once(move |_| {
4390                Ok(tonic::Response::new(
4391                    sui::grpc::BatchGetObjectsResponse::new(vec![
4392                        sui::grpc::GetObjectResult::new_object(object_with_bcs(
4393                            sui_mocks::object_ref_for_id(field_id),
4394                            sui::types::Owner::Object(parent_id),
4395                            &DynamicFieldValue {
4396                                id: field_id,
4397                                name: 9_u64,
4398                                value: TestValue { value: 9 },
4399                            },
4400                        )),
4401                    ]),
4402                ))
4403            });
4404
4405        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4406            ledger_service_mock: Some(ledger_service),
4407            ..Default::default()
4408        });
4409        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
4410
4411        let error = crawler
4412            .get_dynamic_fields_by_keys::<u64, TestValue, _>(parent_id, [requested_key], &key_type)
4413            .await
4414            .expect_err("mismatched key must fail");
4415
4416        assert!(
4417            error.to_string().contains("unexpected key"),
4418            "unexpected error: {error}"
4419        );
4420    }
4421
4422    #[tokio::test]
4423    async fn dynamic_fields_by_keys_reject_an_embedded_id_mismatch() {
4424        let parent_id = sui::types::Address::from_static("0x70");
4425        let key_type = sui::types::TypeTag::U64;
4426        let requested_key = 7_u64;
4427        let field_id = parent_id.derive_dynamic_child_id(
4428            &key_type,
4429            &bcs::to_bytes(&requested_key).expect("key serializes"),
4430        );
4431
4432        let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
4433        ledger_service
4434            .expect_batch_get_objects()
4435            .times(1)
4436            .return_once(move |_| {
4437                Ok(tonic::Response::new(
4438                    sui::grpc::BatchGetObjectsResponse::new(vec![
4439                        sui::grpc::GetObjectResult::new_object(object_with_bcs(
4440                            sui_mocks::object_ref_for_id(field_id),
4441                            sui::types::Owner::Object(parent_id),
4442                            &DynamicFieldValue {
4443                                id: sui::types::Address::from_static("0x71"),
4444                                name: requested_key,
4445                                value: TestValue { value: 7 },
4446                            },
4447                        )),
4448                    ]),
4449                ))
4450            });
4451
4452        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4453            ledger_service_mock: Some(ledger_service),
4454            ..Default::default()
4455        });
4456        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
4457
4458        let error = crawler
4459            .get_dynamic_fields_by_keys::<u64, TestValue, _>(parent_id, [requested_key], &key_type)
4460            .await
4461            .expect_err("mismatched embedded ID must fail");
4462
4463        assert!(
4464            error.to_string().contains("unexpected embedded ID"),
4465            "unexpected error: {error}"
4466        );
4467    }
4468
4469    #[tokio::test]
4470    async fn table_vec_fetches_exact_index_range_without_listing_fields() {
4471        let parent_id = sui::types::Address::from_static("0x70");
4472        let table = TableVec::<TestValue>::new(parent_id, 2);
4473        let indexes = [0_u64, 1_u64];
4474        let field_ids = indexes.map(|index| {
4475            parent_id.derive_dynamic_child_id(
4476                &sui::types::TypeTag::U64,
4477                &bcs::to_bytes(&index).expect("index serializes"),
4478            )
4479        });
4480
4481        let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
4482        ledger_service
4483            .expect_batch_get_objects()
4484            .times(1)
4485            .return_once(move |request| {
4486                let actual_ids = request
4487                    .get_ref()
4488                    .requests
4489                    .iter()
4490                    .map(|request| {
4491                        request
4492                            .object_id
4493                            .as_deref()
4494                            .expect("object ID")
4495                            .parse()
4496                            .expect("valid object ID")
4497                    })
4498                    .collect::<Vec<sui::types::Address>>();
4499                assert_eq!(actual_ids, field_ids);
4500
4501                let objects = indexes
4502                    .into_iter()
4503                    .zip(field_ids)
4504                    .map(|(index, field_id)| {
4505                        sui::grpc::GetObjectResult::new_object(object_with_bcs(
4506                            sui_mocks::object_ref_for_id(field_id),
4507                            sui::types::Owner::Object(parent_id),
4508                            &DynamicFieldValue {
4509                                id: field_id,
4510                                name: index,
4511                                value: TestValue { value: index + 3 },
4512                            },
4513                        ))
4514                    })
4515                    .collect();
4516                Ok(tonic::Response::new(
4517                    sui::grpc::BatchGetObjectsResponse::new(objects),
4518                ))
4519            });
4520
4521        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4522            ledger_service_mock: Some(ledger_service),
4523            ..Default::default()
4524        });
4525        let crawler = Crawler::new(Arc::new(sui::grpc::client(rpc_url).expect("mock client")));
4526
4527        let values = crawler
4528            .get_table_vec(&table)
4529            .await
4530            .expect("table vector loads");
4531
4532        assert_eq!(values, [TestValue { value: 3 }, TestValue { value: 4 }]);
4533    }
4534
4535    #[tokio::test]
4536    async fn dynamic_field_page_forwards_both_cursors_unchanged() {
4537        let parent_id = sui::types::Address::from_static("0x70");
4538        let key = TestKey {
4539            name: "occurrence".to_owned(),
4540        };
4541        let field_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0x71"));
4542        let request_cursor = Vec::from(&b"request-cursor"[..]);
4543        let response_cursor = Vec::from(&b"response-cursor"[..]);
4544        let expected_parent = parent_id.to_string();
4545        let listed_field_ref = field_ref.clone();
4546        let mut state_service_mock = sui_mocks::grpc::MockStateService::new();
4547        state_service_mock
4548            .expect_list_dynamic_fields()
4549            .times(1)
4550            .return_once({
4551                let key = key.clone();
4552                let expected_parent = expected_parent.clone();
4553                let request_cursor = request_cursor.clone();
4554                let response_cursor = response_cursor.clone();
4555                move |request| {
4556                    let request = request.get_ref();
4557                    assert_eq!(request.parent.as_deref(), Some(expected_parent.as_str()));
4558                    assert_eq!(request.page_size, Some(2));
4559                    assert_eq!(
4560                        request.page_token.as_deref(),
4561                        Some(request_cursor.as_slice())
4562                    );
4563
4564                    let mut field = sui::grpc::DynamicField::default();
4565                    field.set_field_id(*listed_field_ref.object_id());
4566                    let mut name = sui::grpc::Bcs::default();
4567                    name.set_value(bcs::to_bytes(&key).expect("key serializes"));
4568                    field.set_name(name);
4569                    field.set_value_type("0xa5::task::OccurrenceRecord");
4570                    let mut response = sui::grpc::ListDynamicFieldsResponse::default();
4571                    response.set_dynamic_fields(vec![field]);
4572                    response.next_page_token = Some(response_cursor.into());
4573                    Ok(tonic::Response::new(response))
4574                }
4575            });
4576        let mut ledger_service_mock = sui_mocks::grpc::MockLedgerService::new();
4577        sui_mocks::grpc::mock_get_dynamic_table_values_bcs(
4578            &mut ledger_service_mock,
4579            vec![(
4580                field_ref,
4581                sui::types::Owner::Shared(1),
4582                key.clone(),
4583                TestValue { value: 42 },
4584            )],
4585        );
4586
4587        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4588            ledger_service_mock: Some(ledger_service_mock),
4589            state_service_mock: Some(state_service_mock),
4590            ..Default::default()
4591        });
4592        let client = sui::grpc::client(rpc_url).expect("mock client");
4593        let crawler = Crawler::new(Arc::new(client));
4594
4595        let page = crawler
4596            .get_dynamic_field_page_matching_types::<TestKey, TestValue>(
4597                parent_id,
4598                Some(request_cursor),
4599                2,
4600                "::task::OccurrenceRecord",
4601            )
4602            .await
4603            .expect("dynamic field page loads");
4604
4605        assert_eq!(page.data(), &[(key, TestValue { value: 42 })]);
4606        assert_eq!(page.next_cursor(), Some(response_cursor.as_slice()));
4607    }
4608
4609    #[tokio::test]
4610    async fn get_dynamic_field_values_pages_and_decodes_values() {
4611        let parent_id = sui::types::Address::from_static("0x70");
4612        let mut state_service_mock = sui_mocks::grpc::MockStateService::new();
4613        type DynamicFieldTestResponse = (Vec<(&'static str, TestValue)>, Option<Vec<u8>>);
4614        let responses: Vec<DynamicFieldTestResponse> = vec![
4615            (
4616                vec![("test::First", TestValue { value: 3 })],
4617                Some(Vec::from(&b"page-2"[..])),
4618            ),
4619            (vec![("test::Second", TestValue { value: 5 })], None),
4620        ];
4621        let mut responses = responses.into_iter();
4622        state_service_mock
4623            .expect_list_dynamic_fields()
4624            .times(2)
4625            .with(always())
4626            .returning(move |_request| {
4627                let (fields, next_page_token) = responses.next().expect("dynamic field page");
4628                let mut response = sui::grpc::ListDynamicFieldsResponse::default();
4629                response.set_dynamic_fields(
4630                    fields
4631                        .into_iter()
4632                        .map(|(value_type, value)| {
4633                            let mut field = sui::grpc::DynamicField::default();
4634                            let mut bcs_value = sui::grpc::Bcs::default();
4635                            bcs_value.set_value(bcs::to_bytes(&value).expect("value bcs"));
4636                            field.set_value(bcs_value);
4637                            field.set_value_type(value_type);
4638                            field
4639                        })
4640                        .collect(),
4641                );
4642                response.next_page_token = next_page_token.map(Into::into);
4643                Ok(tonic::Response::new(response))
4644            });
4645
4646        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4647            state_service_mock: Some(state_service_mock),
4648            ..Default::default()
4649        });
4650        let client = sui::grpc::client(rpc_url).expect("mock client");
4651        let crawler = Crawler::new(Arc::new(client));
4652
4653        let values = crawler
4654            .get_dynamic_field_values::<TestValue>(parent_id)
4655            .await
4656            .expect("dynamic field values load");
4657
4658        assert_eq!(
4659            values,
4660            vec![
4661                (Some("test::First".to_string()), TestValue { value: 3 }),
4662                (Some("test::Second".to_string()), TestValue { value: 5 }),
4663            ]
4664        );
4665    }
4666
4667    #[tokio::test]
4668    async fn get_transaction_update_rejects_mismatched_effects_digest() {
4669        let mut rng = rand::thread_rng();
4670        let requested_digest = sui::types::Digest::generate(&mut rng);
4671        let effects_digest = sui::types::Digest::generate(&mut rng);
4672        let effects =
4673            sui::types::TransactionEffects::V2(Box::new(sui::types::TransactionEffectsV2 {
4674                status: sui::types::ExecutionStatus::Success,
4675                epoch: 1,
4676                gas_used: sui::types::GasCostSummary {
4677                    computation_cost: 0,
4678                    storage_cost: 0,
4679                    storage_rebate: 0,
4680                    non_refundable_storage_fee: 0,
4681                },
4682                transaction_digest: effects_digest,
4683                gas_object_index: None,
4684                events_digest: None,
4685                dependencies: vec![],
4686                lamport_version: 1,
4687                changed_objects: vec![],
4688                unchanged_consensus_objects: vec![],
4689                auxiliary_data_digest: None,
4690            }));
4691        let mut ledger_service_mock = sui_mocks::grpc::MockLedgerService::new();
4692        ledger_service_mock
4693            .expect_get_transaction()
4694            .times(1)
4695            .returning(move |request| {
4696                assert_eq!(
4697                    request.get_ref().digest_opt(),
4698                    Some(requested_digest.to_string().as_str())
4699                );
4700                let mut grpc_effects = sui::grpc::TransactionEffects::default();
4701                grpc_effects.set_bcs(bcs::to_bytes(&effects).expect("effects serialize"));
4702                let mut transaction = sui::grpc::ExecutedTransaction::default();
4703                transaction.set_digest(requested_digest);
4704                transaction.set_checkpoint(1);
4705                transaction.set_effects(grpc_effects);
4706                let mut response = sui::grpc::GetTransactionResponse::default();
4707                response.set_transaction(transaction);
4708                Ok(tonic::Response::new(response))
4709            });
4710
4711        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4712            ledger_service_mock: Some(ledger_service_mock),
4713            ..Default::default()
4714        });
4715        let client = sui::grpc::client(rpc_url).expect("mock client");
4716        let crawler = Crawler::new(Arc::new(client));
4717
4718        let error = crawler
4719            .get_transaction_update(requested_digest)
4720            .await
4721            .expect_err("mismatched effects digest must fail permanently");
4722        let message = error.to_string();
4723        assert!(message.contains(&requested_digest.to_string()));
4724        assert!(message.contains(&effects_digest.to_string()));
4725        assert!(message.contains("effects transaction digest"));
4726    }
4727
4728    #[tokio::test]
4729    async fn get_transaction_update_retains_checkpoint() {
4730        let mut rng = rand::thread_rng();
4731        let digest = sui::types::Digest::generate(&mut rng);
4732        let effects =
4733            sui::types::TransactionEffects::V2(Box::new(sui::types::TransactionEffectsV2 {
4734                status: sui::types::ExecutionStatus::Success,
4735                epoch: 1,
4736                gas_used: sui::types::GasCostSummary {
4737                    computation_cost: 0,
4738                    storage_cost: 0,
4739                    storage_rebate: 0,
4740                    non_refundable_storage_fee: 0,
4741                },
4742                transaction_digest: digest,
4743                gas_object_index: None,
4744                events_digest: None,
4745                dependencies: vec![],
4746                lamport_version: 1,
4747                changed_objects: vec![],
4748                unchanged_consensus_objects: vec![],
4749                auxiliary_data_digest: None,
4750            }));
4751        let mut ledger_service_mock = sui_mocks::grpc::MockLedgerService::new();
4752        ledger_service_mock
4753            .expect_get_transaction()
4754            .times(1)
4755            .returning(move |_| {
4756                let mut grpc_effects = sui::grpc::TransactionEffects::default();
4757                grpc_effects.set_bcs(bcs::to_bytes(&effects).expect("effects serialize"));
4758                let mut grpc_events = sui::grpc::TransactionEvents::default();
4759                grpc_events.set_events(vec![]);
4760                let mut transaction = sui::grpc::ExecutedTransaction::default();
4761                transaction.set_digest(digest);
4762                transaction.set_checkpoint(42);
4763                transaction.set_effects(grpc_effects);
4764                transaction.set_events(grpc_events);
4765                let mut response = sui::grpc::GetTransactionResponse::default();
4766                response.set_transaction(transaction);
4767                Ok(tonic::Response::new(response))
4768            });
4769
4770        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4771            ledger_service_mock: Some(ledger_service_mock),
4772            ..Default::default()
4773        });
4774        let client = sui::grpc::client(rpc_url).expect("mock client");
4775        let crawler = Crawler::new(Arc::new(client));
4776
4777        let update = crawler
4778            .get_transaction_update(digest)
4779            .await
4780            .expect("transaction update loads");
4781
4782        assert_eq!(update.checkpoint, 42);
4783    }
4784
4785    #[tokio::test]
4786    async fn transaction_output_object_selects_the_written_version() {
4787        let mut rng = rand::thread_rng();
4788        let transaction = sui::types::Digest::generate(&mut rng);
4789        let earlier_transaction = sui::types::Digest::generate(&mut rng);
4790        let object_id = sui::types::Address::generate(&mut rng);
4791        let owner = sui::types::Owner::Object(sui::types::Address::generate(&mut rng));
4792        let input_ref =
4793            sui::types::ObjectReference::new(object_id, 1, sui::types::Digest::generate(&mut rng));
4794        let output_ref =
4795            sui::types::ObjectReference::new(object_id, 2, sui::types::Digest::generate(&mut rng));
4796        let mut input = object_with_bcs(input_ref, owner, &TestValue { value: 3 });
4797        input.set_previous_transaction(earlier_transaction);
4798        let mut output = object_with_bcs(output_ref.clone(), owner, &TestValue { value: 5 });
4799        output.set_previous_transaction(transaction);
4800
4801        let mut ledger_service_mock = sui_mocks::grpc::MockLedgerService::new();
4802        ledger_service_mock
4803            .expect_get_transaction()
4804            .withf(move |request| {
4805                request.get_ref().digest_opt() == Some(transaction.to_string().as_str())
4806                    && request.get_ref().read_mask.as_ref().is_some_and(|mask| {
4807                        mask.paths
4808                            .iter()
4809                            .any(|path| path == "objects.objects.previous_transaction")
4810                    })
4811            })
4812            .times(1)
4813            .returning(move |_| {
4814                let mut objects = sui::grpc::ObjectSet::default();
4815                objects.set_objects(vec![input.clone(), output.clone()]);
4816                let mut executed = sui::grpc::ExecutedTransaction::default();
4817                executed.set_digest(transaction);
4818                executed.set_objects(objects);
4819                let mut response = sui::grpc::GetTransactionResponse::default();
4820                response.set_transaction(executed);
4821                Ok(tonic::Response::new(response))
4822            });
4823        let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
4824            ledger_service_mock: Some(ledger_service_mock),
4825            ..Default::default()
4826        });
4827        let client = sui::grpc::client(rpc_url).expect("mock client");
4828        let crawler = Crawler::new(Arc::new(client));
4829
4830        let observed = crawler
4831            .get_transaction_output_object::<TestValue>(transaction, object_id)
4832            .await
4833            .expect("transaction output object loads");
4834
4835        assert_eq!(observed.object_ref(), output_ref);
4836        assert_eq!(observed.owner, owner);
4837        assert_eq!(observed.data, TestValue { value: 5 });
4838    }
4839
4840    #[tokio::test]
4841    async fn transaction_shared_initial_version_requires_exact_type_and_owner() {
4842        let mut rng = rand::thread_rng();
4843        let transaction = sui::types::Digest::generate(&mut rng);
4844        let earlier_transaction = sui::types::Digest::generate(&mut rng);
4845        let object_id = sui::types::Address::generate(&mut rng);
4846        let object_type = test_value_tag();
4847        let input_ref =
4848            sui::types::ObjectReference::new(object_id, 9, sui::types::Digest::generate(&mut rng));
4849        let output_ref =
4850            sui::types::ObjectReference::new(object_id, 10, sui::types::Digest::generate(&mut rng));
4851        let owner = sui::types::Owner::Shared(7);
4852        let mut input =
4853            typed_object_with_bcs(input_ref, owner, &object_type, &TestValue { value: 3 });
4854        input.set_previous_transaction(earlier_transaction);
4855        let mut output =
4856            typed_object_with_bcs(output_ref, owner, &object_type, &TestValue { value: 5 });
4857        output.set_previous_transaction(transaction);
4858
4859        let mut executed = sui::grpc::ExecutedTransaction::default();
4860        executed.set_digest(transaction);
4861        executed.set_objects(
4862            sui::grpc::ObjectSet::default().with_objects(vec![input.clone(), output.clone()]),
4863        );
4864        let crawler = Crawler::new(Arc::new(
4865            sui::grpc::client(sui_mocks::grpc::mock_server(Default::default()))
4866                .expect("mock client builds"),
4867        ));
4868
4869        assert_eq!(
4870            crawler
4871                .transaction_shared_initial_version(
4872                    &executed,
4873                    transaction,
4874                    object_id,
4875                    &object_type,
4876                )
4877                .expect("causal shared input validates"),
4878            7,
4879        );
4880
4881        output.set_owner(sui::grpc::Owner::from(sui::types::Owner::Address(
4882            sui::types::Address::generate(&mut rng),
4883        )));
4884        executed
4885            .set_objects(sui::grpc::ObjectSet::default().with_objects(vec![input.clone(), output]));
4886        assert!(crawler
4887            .transaction_shared_initial_version(&executed, transaction, object_id, &object_type,)
4888            .expect_err("address-owned output must be rejected")
4889            .to_string()
4890            .contains("is not shared"));
4891
4892        let wrong_type = sui::types::StructTag::gas_coin();
4893        assert!(crawler
4894            .transaction_shared_initial_version(&executed, transaction, object_id, &wrong_type,)
4895            .expect_err("wrong expected type must be rejected")
4896            .to_string()
4897            .contains("expected"));
4898    }
4899
4900    #[tokio::test]
4901    async fn transaction_dynamic_field_outputs_and_causal_values_select_root_output() {
4902        let mut rng = rand::thread_rng();
4903        let transaction = sui::types::Digest::generate(&mut rng);
4904        let earlier_transaction = sui::types::Digest::generate(&mut rng);
4905        let parent_id = sui::types::Address::generate(&mut rng);
4906        let wrong_parent = sui::types::Address::generate(&mut rng);
4907        let key = TestKey {
4908            name: "inner".to_owned(),
4909        };
4910        let key_type = sui::types::TypeTag::Struct(Box::new(sui::types::StructTag::new(
4911            sui::types::Address::from_static("0x1"),
4912            sui::types::Identifier::from_static("state"),
4913            sui::types::Identifier::from_static("Inner"),
4914            vec![],
4915        )));
4916        let value_type = sui::types::TypeTag::Struct(Box::new(test_value_tag()));
4917        let field_type = sui::types::StructTag::new(
4918            sui::types::Address::from_static("0x2"),
4919            sui::types::Identifier::from_static("dynamic_field"),
4920            sui::types::Identifier::from_static("Field"),
4921            vec![key_type.clone(), value_type.clone()],
4922        );
4923        let field_id = derive_dynamic_field_id(parent_id, &key, &key_type).unwrap();
4924        let input_ref =
4925            sui::types::ObjectReference::new(field_id, 1, sui::types::Digest::generate(&mut rng));
4926        let output_ref =
4927            sui::types::ObjectReference::new(field_id, 2, sui::types::Digest::generate(&mut rng));
4928        let mut input = typed_object_with_bcs(
4929            input_ref,
4930            sui::types::Owner::Object(parent_id),
4931            &field_type,
4932            &DynamicFieldValue {
4933                id: field_id,
4934                name: key.clone(),
4935                value: TestValue { value: 3 },
4936            },
4937        );
4938        input.set_previous_transaction(earlier_transaction);
4939        let inherited_input = input.clone();
4940        let mut output = typed_object_with_bcs(
4941            output_ref.clone(),
4942            sui::types::Owner::Object(parent_id),
4943            &field_type,
4944            &DynamicFieldValue {
4945                id: field_id,
4946                name: key.clone(),
4947                value: TestValue { value: 5 },
4948            },
4949        );
4950        output.set_previous_transaction(transaction);
4951
4952        let mut executed = sui::grpc::ExecutedTransaction::default();
4953        executed.set_digest(transaction);
4954        let mut objects = sui::grpc::ObjectSet::default();
4955        objects.set_objects(vec![input, output.clone()]);
4956        executed.set_objects(objects);
4957        let rpc_url = sui_mocks::grpc::mock_server(Default::default());
4958        let crawler = Crawler::new(Arc::new(
4959            sui::grpc::client(rpc_url).expect("mock client builds"),
4960        ));
4961
4962        let observed = crawler
4963            .transaction_dynamic_field_outputs::<TestKey, TestValue>(
4964                &executed,
4965                transaction,
4966                &key,
4967                &key_type,
4968                &value_type,
4969            )
4970            .expect("exact output decodes");
4971        assert_eq!(observed.len(), 1);
4972        assert_eq!(observed[0].object_id, parent_id);
4973        assert_eq!(observed[0].field_ref, output_ref);
4974        assert_eq!(observed[0].data, TestValue { value: 5 });
4975
4976        let causal = crawler
4977            .causal_dynamic_field_values::<TestKey, TestValue>(
4978                &executed,
4979                transaction,
4980                &key,
4981                &key_type,
4982                &value_type,
4983            )
4984            .expect("the root output is the visible causal value");
4985        assert_eq!(causal.len(), 1);
4986        assert_eq!(causal[0].object_id, parent_id);
4987        assert_eq!(causal[0].field_ref, output_ref);
4988        assert_eq!(causal[0].data, TestValue { value: 5 });
4989
4990        let mut inherited_output = output.clone();
4991        inherited_output.set_previous_transaction(earlier_transaction);
4992        executed.set_objects(
4993            sui::grpc::ObjectSet::default().with_objects(vec![inherited_input, inherited_output]),
4994        );
4995        let error = crawler
4996            .causal_dynamic_field_values::<TestKey, TestValue>(
4997                &executed,
4998                transaction,
4999                &key,
5000                &key_type,
5001                &value_type,
5002            )
5003            .expect_err("two inherited versions have no unique visible value");
5004        assert!(error.to_string().contains("ambiguous versions"));
5005
5006        output.set_owner(sui::grpc::Owner::from(sui::types::Owner::Object(
5007            wrong_parent,
5008        )));
5009        let mut objects = sui::grpc::ObjectSet::default();
5010        objects.set_objects(vec![output]);
5011        executed.set_objects(objects);
5012        let error = crawler
5013            .transaction_dynamic_field_outputs::<TestKey, TestValue>(
5014                &executed,
5015                transaction,
5016                &key,
5017                &key_type,
5018                &value_type,
5019            )
5020            .expect_err("wrong parent cannot validate the deterministic field ID");
5021        assert!(error.to_string().contains("does not derive from parent"));
5022    }
5023
5024    #[tokio::test]
5025    async fn causal_dynamic_field_values_include_inherited_state() {
5026        let mut rng = rand::thread_rng();
5027        let transaction = sui::types::Digest::generate(&mut rng);
5028        let ancestor = sui::types::Digest::generate(&mut rng);
5029        let parents = [
5030            sui::types::Address::generate(&mut rng),
5031            sui::types::Address::generate(&mut rng),
5032        ];
5033        let key = TestKey {
5034            name: "inner".to_owned(),
5035        };
5036        let key_type = sui::types::TypeTag::Struct(Box::new(sui::types::StructTag::new(
5037            sui::types::Address::from_static("0x1"),
5038            sui::types::Identifier::from_static("state"),
5039            sui::types::Identifier::from_static("Inner"),
5040            vec![],
5041        )));
5042        let value_type = sui::types::TypeTag::Struct(Box::new(test_value_tag()));
5043        let field_type = sui::types::StructTag::new(
5044            sui::types::Address::from_static("0x2"),
5045            sui::types::Identifier::from_static("dynamic_field"),
5046            sui::types::Identifier::from_static("Field"),
5047            vec![key_type.clone(), value_type.clone()],
5048        );
5049        let objects = parents
5050            .iter()
5051            .copied()
5052            .zip([ancestor, transaction])
5053            .zip([3, 5])
5054            .map(|((parent, previous), value)| {
5055                let field_id = derive_dynamic_field_id(parent, &key, &key_type).unwrap();
5056                let object_ref = sui::types::ObjectReference::new(
5057                    field_id,
5058                    1,
5059                    sui::types::Digest::generate(&mut rng),
5060                );
5061                let mut object = typed_object_with_bcs(
5062                    object_ref,
5063                    sui::types::Owner::Object(parent),
5064                    &field_type,
5065                    &DynamicFieldValue {
5066                        id: field_id,
5067                        name: key.clone(),
5068                        value: TestValue { value },
5069                    },
5070                );
5071                object.set_previous_transaction(previous);
5072                object
5073            })
5074            .collect::<Vec<_>>();
5075        let mut executed = sui::grpc::ExecutedTransaction::default();
5076        executed.set_digest(transaction);
5077        executed.set_objects(sui::grpc::ObjectSet::default().with_objects(objects));
5078        let rpc_url = sui_mocks::grpc::mock_server(Default::default());
5079        let crawler = Crawler::new(Arc::new(
5080            sui::grpc::client(rpc_url).expect("mock client builds"),
5081        ));
5082
5083        let values = crawler
5084            .causal_dynamic_field_values::<TestKey, TestValue>(
5085                &executed,
5086                transaction,
5087                &key,
5088                &key_type,
5089                &value_type,
5090            )
5091            .expect("complete causal view decodes");
5092        assert_eq!(
5093            values
5094                .iter()
5095                .map(|value| (value.object_id, value.data.value))
5096                .collect::<Vec<_>>(),
5097            vec![(parents[0], 3), (parents[1], 5)]
5098        );
5099    }
5100}