sui_graphql/client/
dynamic_fields.rs

1//! Dynamic field related convenience methods.
2
3use futures::Stream;
4use serde::Serialize;
5use sui_graphql_macros::Response;
6use sui_sdk_types::Address;
7use sui_sdk_types::TypeTag;
8
9use super::Client;
10use crate::bcs::Bcs;
11use crate::error::Error;
12use crate::move_value::MoveObject;
13use crate::move_value::MoveValue;
14use crate::pagination::Page;
15use crate::pagination::PageInfo;
16use crate::pagination::paginate;
17
18/// The format to fetch for Move values.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Format {
21    /// JSON representation.
22    Json,
23    /// BCS (Binary Canonical Serialization) representation.
24    Bcs,
25}
26
27// ============================================================================
28// Request builders
29// ============================================================================
30
31/// Builder for listing dynamic fields on an object.
32pub struct DynamicFieldsRequest<'a> {
33    client: &'a Client,
34    parent: Address,
35    formats: Vec<Format>,
36}
37
38impl<'a> DynamicFieldsRequest<'a> {
39    /// Add a format to fetch. Can be called multiple times.
40    /// If not called, defaults to BCS.
41    pub fn format(mut self, f: Format) -> Self {
42        if !self.formats.contains(&f) {
43            self.formats.push(f);
44        }
45        self
46    }
47
48    /// Execute the request and return a stream of dynamic fields.
49    pub fn list(self) -> impl Stream<Item = Result<DynamicField, Error>> + 'a {
50        let client = self.client.clone();
51        let formats = self.formats;
52        let parent = self.parent;
53
54        paginate(move |cursor| {
55            let client = client.clone();
56            let formats = formats.clone();
57            async move {
58                client
59                    .fetch_dynamic_fields_page_with_formats(parent, cursor.as_deref(), &formats)
60                    .await
61            }
62        })
63    }
64}
65
66/// Builder for fetching a single dynamic field by name.
67pub struct DynamicFieldRequest<'a, N> {
68    client: &'a Client,
69    parent: Address,
70    name_type: TypeTag,
71    name: Bcs<N>,
72    field_type: DynamicFieldType,
73    formats: Vec<Format>,
74}
75
76impl<'a, N: Serialize> DynamicFieldRequest<'a, N> {
77    /// Add a format to fetch. Can be called multiple times.
78    /// If not called, defaults to BCS.
79    pub fn format(mut self, f: Format) -> Self {
80        if !self.formats.contains(&f) {
81            self.formats.push(f);
82        }
83        self
84    }
85
86    /// Execute the request and return the dynamic field if found.
87    pub async fn get(self) -> Result<Option<DynamicField>, Error> {
88        self.client
89            .fetch_single_dynamic_field(
90                self.parent,
91                self.name_type,
92                self.name,
93                self.field_type,
94                &self.formats,
95            )
96            .await
97    }
98}
99
100// ============================================================================
101// Dynamic field types
102// ============================================================================
103
104/// The type of a dynamic field.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106enum DynamicFieldType {
107    /// A regular dynamic field (value is wrapped, not accessible by ID).
108    Field,
109    /// A dynamic object field (child object remains accessible by ID).
110    Object,
111}
112
113/// The value of a dynamic field, dispatched by `__typename`.
114#[derive(Debug, Clone, Response)]
115#[response(root_type = "DynamicFieldValue")]
116pub enum DynamicFieldValue {
117    MoveValue(MoveValue),
118    MoveObject(MoveObject),
119}
120
121/// A dynamic field entry with its name and value.
122#[derive(Debug, Clone, Response)]
123#[response(root_type = "DynamicField")]
124#[non_exhaustive]
125pub struct DynamicField {
126    /// The field name (includes type_tag and optional json/bcs).
127    #[field(path = "name")]
128    pub name: MoveValue,
129    /// The field value (includes field_type and the underlying MoveValue).
130    #[field(path = "value")]
131    pub value: DynamicFieldValue,
132}
133
134impl Client {
135    /// Create a request builder for listing dynamic fields on an object.
136    pub fn dynamic_fields(&self, parent: Address) -> DynamicFieldsRequest<'_> {
137        DynamicFieldsRequest {
138            client: self,
139            parent,
140            formats: vec![],
141        }
142    }
143
144    /// Create a request builder for fetching a single dynamic field by name.
145    pub fn dynamic_field<N: Serialize>(
146        &self,
147        parent: Address,
148        name_type: TypeTag,
149        name: Bcs<N>,
150    ) -> DynamicFieldRequest<'_, N> {
151        DynamicFieldRequest {
152            client: self,
153            parent,
154            name_type,
155            name,
156            field_type: DynamicFieldType::Field,
157            formats: vec![],
158        }
159    }
160
161    /// Create a request builder for fetching a single dynamic object field by name.
162    pub fn dynamic_object_field<N: Serialize>(
163        &self,
164        parent: Address,
165        name_type: TypeTag,
166        name: Bcs<N>,
167    ) -> DynamicFieldRequest<'_, N> {
168        DynamicFieldRequest {
169            client: self,
170            parent,
171            name_type,
172            name,
173            field_type: DynamicFieldType::Object,
174            formats: vec![],
175        }
176    }
177
178    /// Fetch a page of dynamic fields with format selection.
179    async fn fetch_dynamic_fields_page_with_formats(
180        &self,
181        parent: Address,
182        cursor: Option<&str>,
183        formats: &[Format],
184    ) -> Result<Page<DynamicField>, Error> {
185        #[derive(Response)]
186        struct Response {
187            #[field(path = "object?.dynamicFields?.nodes?[]")]
188            nodes: Option<Vec<DynamicField>>,
189            #[field(path = "object?.dynamicFields?.pageInfo?")]
190            page_info: Option<PageInfo>,
191        }
192
193        const QUERY: &str = r#"
194            fragment MoveValueFields on MoveValue {
195                type { repr }
196                json @include(if: $withJson)
197                bcs @include(if: $withBcs)
198            }
199            query($parent: SuiAddress!, $cursor: String, $withJson: Boolean!, $withBcs: Boolean!) {
200                object(address: $parent) {
201                    dynamicFields(after: $cursor) {
202                        nodes {
203                            name { ...MoveValueFields }
204                            value {
205                                __typename
206                                ... on MoveValue { ...MoveValueFields }
207                                ... on MoveObject {
208                                    address
209                                    contents { ...MoveValueFields }
210                                }
211                            }
212                        }
213                        pageInfo {
214                            hasNextPage
215                            endCursor
216                        }
217                    }
218                }
219            }
220        "#;
221
222        let with_json = formats.contains(&Format::Json);
223        let with_bcs = formats.is_empty() || formats.contains(&Format::Bcs);
224        let variables = serde_json::json!({
225            "parent": parent,
226            "cursor": cursor,
227            "withJson": with_json,
228            "withBcs": with_bcs,
229        });
230
231        let response = self.query::<Response>(QUERY, variables).await?;
232
233        let Some(data) = response.into_data() else {
234            return Ok(Page::default());
235        };
236
237        let page_info = data.page_info.unwrap_or_default();
238
239        Ok(Page {
240            items: data.nodes.unwrap_or_default(),
241            has_next_page: page_info.has_next_page,
242            end_cursor: page_info.end_cursor,
243            ..Default::default()
244        })
245    }
246
247    /// Fetch a single dynamic field with format selection.
248    async fn fetch_single_dynamic_field<N: Serialize>(
249        &self,
250        parent: Address,
251        name_type: TypeTag,
252        name: Bcs<N>,
253        field_type: DynamicFieldType,
254        formats: &[Format],
255    ) -> Result<Option<DynamicField>, Error> {
256        #[derive(Response)]
257        struct DynamicFieldResponse {
258            #[field(path = "object?.dynamicField?")]
259            field: Option<DynamicField>,
260        }
261
262        #[derive(Response)]
263        struct DynamicObjectFieldResponse {
264            #[field(path = "object?.dynamicObjectField?")]
265            field: Option<DynamicField>,
266        }
267
268        const DYNAMIC_FIELD_QUERY: &str = r#"
269            fragment MoveValueFields on MoveValue {
270                type { repr }
271                json @include(if: $withJson)
272                bcs @include(if: $withBcs)
273            }
274            query($parent: SuiAddress!, $name: DynamicFieldName!, $withJson: Boolean!, $withBcs: Boolean!) {
275                object(address: $parent) {
276                    dynamicField(name: $name) {
277                        name { ...MoveValueFields }
278                        value {
279                            ... on MoveValue { ...MoveValueFields }
280                            ... on MoveObject {
281                                contents { ...MoveValueFields }
282                            }
283                        }
284                    }
285                }
286            }
287        "#;
288
289        const DYNAMIC_OBJECT_FIELD_QUERY: &str = r#"
290            fragment MoveValueFields on MoveValue {
291                type { repr }
292                json @include(if: $withJson)
293                bcs @include(if: $withBcs)
294            }
295            query($parent: SuiAddress!, $name: DynamicFieldName!, $withJson: Boolean!, $withBcs: Boolean!) {
296                object(address: $parent) {
297                    dynamicObjectField(name: $name) {
298                        name { ...MoveValueFields }
299                        value {
300                            ... on MoveValue { ...MoveValueFields }
301                            ... on MoveObject {
302                                contents { ...MoveValueFields }
303                            }
304                        }
305                    }
306                }
307            }
308        "#;
309
310        let with_json = formats.contains(&Format::Json);
311        let with_bcs = formats.is_empty() || formats.contains(&Format::Bcs);
312        let variables = serde_json::json!({
313            "parent": parent,
314            "name": {
315                "type": name_type.to_string(),
316                "bcs": name,
317            },
318            "withJson": with_json,
319            "withBcs": with_bcs,
320        });
321
322        match field_type {
323            DynamicFieldType::Field => {
324                let response = self
325                    .query::<DynamicFieldResponse>(DYNAMIC_FIELD_QUERY, variables)
326                    .await?;
327                Ok(response.into_data().and_then(|d| d.field))
328            }
329            DynamicFieldType::Object => {
330                let response = self
331                    .query::<DynamicObjectFieldResponse>(DYNAMIC_OBJECT_FIELD_QUERY, variables)
332                    .await?;
333                Ok(response.into_data().and_then(|d| d.field))
334            }
335        }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use futures::StreamExt;
343    use std::pin::pin;
344    use sui_sdk_types::TypeTag;
345    use wiremock::Mock;
346    use wiremock::MockServer;
347    use wiremock::ResponseTemplate;
348    use wiremock::matchers::method;
349    use wiremock::matchers::path;
350
351    #[tokio::test]
352    async fn test_dynamic_fields_empty() {
353        let mock_server = MockServer::start().await;
354
355        Mock::given(method("POST"))
356            .and(path("/"))
357            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
358                "data": {
359                    "object": {
360                        "dynamicFields": {
361                            "nodes": [],
362                            "pageInfo": {
363                                "hasNextPage": false,
364                                "endCursor": null
365                            }
366                        }
367                    }
368                }
369            })))
370            .mount(&mock_server)
371            .await;
372
373        let client = Client::new(&mock_server.uri()).unwrap();
374
375        let parent: Address = "0x123".parse().unwrap();
376        let mut stream = pin!(client.dynamic_fields(parent).list());
377        let result = stream.next().await;
378        assert!(result.is_none());
379    }
380
381    #[tokio::test]
382    async fn test_dynamic_fields_with_json_format() {
383        let mock_server = MockServer::start().await;
384
385        Mock::given(method("POST"))
386            .and(path("/"))
387            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
388                "data": {
389                    "object": {
390                        "dynamicFields": {
391                            "nodes": [
392                                {
393                                    "name": {
394                                        "type": { "repr": "u64" },
395                                        "json": "123"
396                                    },
397                                    "value": {
398                                        "__typename": "MoveValue",
399                                        "type": { "repr": "0x2::coin::Coin<0x2::sui::SUI>" },
400                                        "json": { "balance": "1000" }
401                                    }
402                                },
403                                {
404                                    "name": {
405                                        "type": { "repr": "0x2::kiosk::Listing" },
406                                        "json": { "id": "0xabc" }
407                                    },
408                                    "value": {
409                                        "__typename": "MoveObject",
410                                        "address": "0x0000000000000000000000000000000000000000000000000000000000000def",
411                                        "contents": {
412                                            "type": { "repr": "0x2::kiosk::Item" },
413                                            "json": { "price": "500" }
414                                        }
415                                    }
416                                }
417                            ],
418                            "pageInfo": {
419                                "hasNextPage": false,
420                                "endCursor": null
421                            }
422                        }
423                    }
424                }
425            })))
426            .mount(&mock_server)
427            .await;
428
429        let client = Client::new(&mock_server.uri()).unwrap();
430
431        let parent: Address = "0x123".parse().unwrap();
432        let mut stream = pin!(client.dynamic_fields(parent).format(Format::Json).list());
433
434        // First field - MoveValue
435        let field1 = stream.next().await.unwrap().unwrap();
436        assert_eq!(field1.name.type_tag, TypeTag::U64);
437        assert!(field1.name.json.is_some());
438        assert!(field1.name.bcs.is_none()); // BCS not requested
439        assert!(matches!(field1.value, DynamicFieldValue::MoveValue(_)));
440
441        // Second field - MoveObject
442        let field2 = stream.next().await.unwrap().unwrap();
443        assert_eq!(
444            field2.name.type_tag,
445            "0x2::kiosk::Listing".parse::<TypeTag>().unwrap()
446        );
447        assert!(matches!(field2.value, DynamicFieldValue::MoveObject(_)));
448
449        // No more fields
450        assert!(stream.next().await.is_none());
451    }
452
453    #[tokio::test]
454    async fn test_dynamic_fields_with_default_bcs() {
455        let mock_server = MockServer::start().await;
456
457        Mock::given(method("POST"))
458            .and(path("/"))
459            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
460                "data": {
461                    "object": {
462                        "dynamicFields": {
463                            "nodes": [
464                                {
465                                    "name": {
466                                        "type": { "repr": "u64" },
467                                        "bcs": "ewAAAAAAAAA="
468                                    },
469                                    "value": {
470                                        "__typename": "MoveValue",
471                                        "type": { "repr": "bool" },
472                                        "bcs": "AQ=="
473                                    }
474                                }
475                            ],
476                            "pageInfo": {
477                                "hasNextPage": false,
478                                "endCursor": null
479                            }
480                        }
481                    }
482                }
483            })))
484            .mount(&mock_server)
485            .await;
486
487        let client = Client::new(&mock_server.uri()).unwrap();
488
489        let parent: Address = "0x123".parse().unwrap();
490        // Default - no format specified
491        let mut stream = pin!(client.dynamic_fields(parent).list());
492
493        let field = stream.next().await.unwrap().unwrap();
494        assert_eq!(field.name.type_tag, TypeTag::U64);
495        assert!(field.name.bcs.is_some());
496        assert!(field.name.json.is_none()); // JSON not requested
497    }
498
499    #[tokio::test]
500    async fn test_dynamic_fields_object_not_found() {
501        let mock_server = MockServer::start().await;
502
503        Mock::given(method("POST"))
504            .and(path("/"))
505            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
506                "data": {
507                    "object": null
508                }
509            })))
510            .mount(&mock_server)
511            .await;
512
513        let client = Client::new(&mock_server.uri()).unwrap();
514
515        let parent: Address = "0x999".parse().unwrap();
516        let mut stream = pin!(client.dynamic_fields(parent).list());
517        let result = stream.next().await;
518        assert!(result.is_none());
519    }
520
521    #[tokio::test]
522    async fn test_dynamic_field_fetch() {
523        let mock_server = MockServer::start().await;
524
525        Mock::given(method("POST"))
526            .and(path("/"))
527            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
528                "data": {
529                    "object": {
530                        "dynamicField": {
531                            "name": {
532                                "type": { "repr": "u64" },
533                                "json": "123",
534                                "bcs": "ewAAAAAAAAA="
535                            },
536                            "value": {
537                                "__typename": "MoveValue",
538                                "type": { "repr": "bool" },
539                                "json": true,
540                                "bcs": "AQ=="
541                            }
542                        }
543                    }
544                }
545            })))
546            .mount(&mock_server)
547            .await;
548
549        let client = Client::new(&mock_server.uri()).unwrap();
550        let parent: Address = "0x123".parse().unwrap();
551        let name_type: TypeTag = "u64".parse().unwrap();
552
553        let field = client
554            .dynamic_field(parent, name_type, Bcs(123u64))
555            .format(Format::Json)
556            .format(Format::Bcs)
557            .get()
558            .await
559            .unwrap();
560
561        assert!(field.is_some());
562        let field = field.unwrap();
563        assert_eq!(field.name.type_tag, TypeTag::U64);
564        assert!(field.name.json.is_some());
565        assert!(field.name.bcs.is_some());
566        assert!(matches!(field.value, DynamicFieldValue::MoveValue(_)));
567    }
568
569    #[tokio::test]
570    async fn test_dynamic_field_object_type() {
571        let mock_server = MockServer::start().await;
572
573        Mock::given(method("POST"))
574            .and(path("/"))
575            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
576                "data": {
577                    "object": {
578                        "dynamicObjectField": {
579                            "name": {
580                                "type": { "repr": "0x1::string::String" },
581                                "json": "my_key"
582                            },
583                            "value": {
584                                "__typename": "MoveObject",
585                                "address": "0x0000000000000000000000000000000000000000000000000000000000000abc",
586                                "contents": {
587                                    "type": { "repr": "0x2::coin::Coin<0x2::sui::SUI>" },
588                                    "json": { "balance": "1000" }
589                                }
590                            }
591                        }
592                    }
593                }
594            })))
595            .mount(&mock_server)
596            .await;
597
598        let client = Client::new(&mock_server.uri()).unwrap();
599        let parent: Address = "0x123".parse().unwrap();
600        let name_type: TypeTag = "0x1::string::String".parse().unwrap();
601
602        let field = client
603            .dynamic_object_field(parent, name_type, Bcs("my_key"))
604            .format(Format::Json)
605            .get()
606            .await
607            .unwrap();
608
609        assert!(field.is_some());
610        let field = field.unwrap();
611        let DynamicFieldValue::MoveObject(ref obj) = field.value else {
612            panic!("expected MoveObject variant");
613        };
614        assert_eq!(
615            obj.contents.type_tag,
616            "0x2::coin::Coin<0x2::sui::SUI>".parse::<TypeTag>().unwrap()
617        );
618    }
619
620    #[tokio::test]
621    async fn test_dynamic_field_not_found() {
622        let mock_server = MockServer::start().await;
623
624        Mock::given(method("POST"))
625            .and(path("/"))
626            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
627                "data": {
628                    "object": {
629                        "dynamicField": null
630                    }
631                }
632            })))
633            .mount(&mock_server)
634            .await;
635
636        let client = Client::new(&mock_server.uri()).unwrap();
637        let parent: Address = "0x123".parse().unwrap();
638        let name_type: TypeTag = "u64".parse().unwrap();
639
640        let field = client
641            .dynamic_field(parent, name_type, Bcs(999u64))
642            .get()
643            .await
644            .unwrap();
645
646        assert!(field.is_none());
647    }
648}