sui_indexer_alt_jsonrpc/api/
coin.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::str::FromStr;
6
7use anyhow::Context as _;
8use futures::future;
9use jsonrpsee::core::RpcResult;
10use jsonrpsee::proc_macros::rpc;
11use move_core_types::language_storage::StructTag;
12use move_core_types::language_storage::TypeTag;
13use sui_indexer_alt_reader::consistent_reader::proto::Balance as ProtoBalance;
14use sui_indexer_alt_reader::consistent_reader::proto::owner::OwnerKind;
15use sui_json_rpc_types::Balance;
16use sui_json_rpc_types::Coin;
17use sui_json_rpc_types::Page as PageResponse;
18use sui_json_rpc_types::SuiCoinMetadata;
19use sui_open_rpc::Module;
20use sui_open_rpc_macros::open_rpc;
21use sui_types::SUI_FRAMEWORK_ADDRESS;
22use sui_types::base_types::ObjectID;
23use sui_types::base_types::SuiAddress;
24use sui_types::coin::COIN_METADATA_STRUCT_NAME;
25use sui_types::coin::COIN_MODULE_NAME;
26use sui_types::coin::COIN_STRUCT_NAME;
27use sui_types::coin::CoinMetadata;
28use sui_types::coin_registry::Currency;
29use sui_types::gas_coin::GAS;
30use sui_types::object::Object;
31
32use crate::api::rpc_module::RpcModule;
33use crate::context::Context;
34use crate::data::load_live;
35use crate::error::InternalContext;
36use crate::error::RpcError;
37use crate::error::invalid_params;
38use crate::paginate::BcsCursor;
39use crate::paginate::Cursor as _;
40use crate::paginate::Page;
41
42#[open_rpc(namespace = "suix", tag = "Coin API")]
43#[rpc(server, namespace = "suix")]
44trait CoinsApi {
45    /// Return Coin objects owned by an address with a specified coin type.
46    /// If no coin type is specified, SUI coins are returned.
47    #[method(name = "getCoins")]
48    async fn get_coins(
49        &self,
50        /// the owner's Sui address
51        owner: SuiAddress,
52        /// optional coin type
53        coin_type: Option<String>,
54        /// optional paging cursor
55        cursor: Option<String>,
56        /// maximum number of items per page
57        limit: Option<usize>,
58    ) -> RpcResult<PageResponse<Coin, String>>;
59
60    /// Return metadata (e.g., symbol, decimals) for a coin. Note that if the coin's metadata was
61    /// wrapped in the transaction that published its marker type, or the latest version of the
62    /// metadata object is wrapped or deleted, it will not be found.
63    #[method(name = "getCoinMetadata")]
64    async fn get_coin_metadata(
65        &self,
66        /// type name for the coin (e.g., 0x168da5bf1f48dafc111b0a488fa454aca95e0b5e::usdc::USDC)
67        coin_type: String,
68    ) -> RpcResult<Option<SuiCoinMetadata>>;
69
70    /// Return the total coin balance for all coin types, owned by the address owner.
71    #[method(name = "getAllBalances")]
72    async fn get_all_balances(
73        &self,
74        /// the owner's Sui address
75        owner: SuiAddress,
76    ) -> RpcResult<Vec<Balance>>;
77
78    /// Return the total coin balance for one coin type, owned by the address.
79    /// If no coin type is specified, SUI coin balance is returned.
80    #[method(name = "getBalance")]
81    async fn get_balance(
82        &self,
83        /// the owner's Sui address
84        owner: SuiAddress,
85        /// optional type names for the coin (e.g., 0x168da5bf1f48dafc111b0a488fa454aca95e0b5e::usdc::USDC), default to 0x2::sui::SUI if not specified.
86        coin_type: Option<String>,
87    ) -> RpcResult<Balance>;
88}
89
90pub(crate) struct Coins(pub Context);
91
92#[derive(thiserror::Error, Debug)]
93pub(crate) enum Error {
94    #[error("Pagination issue: {0}")]
95    Pagination(#[from] crate::paginate::Error),
96
97    #[error("Failed to parse type {0:?}: {1}")]
98    BadType(String, anyhow::Error),
99}
100
101type Cursor = BcsCursor<Vec<u8>>;
102
103#[async_trait::async_trait]
104impl CoinsApiServer for Coins {
105    async fn get_coins(
106        &self,
107        owner: SuiAddress,
108        coin_type: Option<String>,
109        cursor: Option<String>,
110        limit: Option<usize>,
111    ) -> RpcResult<PageResponse<Coin, String>> {
112        let inner = if let Some(coin_type) = coin_type {
113            TypeTag::from_str(&coin_type)
114                .map_err(|e| invalid_params(Error::BadType(coin_type, e)))?
115        } else {
116            GAS::type_tag()
117        };
118
119        let object_type = StructTag {
120            address: SUI_FRAMEWORK_ADDRESS,
121            module: COIN_MODULE_NAME.to_owned(),
122            name: COIN_STRUCT_NAME.to_owned(),
123            type_params: vec![inner],
124        };
125
126        let Self(ctx) = self;
127        let config = &ctx.config().coins;
128
129        let page: Page<Cursor> = Page::from_params::<Error>(
130            config.default_page_size,
131            config.max_page_size,
132            cursor,
133            limit,
134            None,
135        )?;
136
137        let consistent_reader = ctx.consistent_reader();
138
139        // Coin balances are stored as bitwise negation, so iterating in regular (forward) order
140        // yields highest balances first.
141        let results = consistent_reader
142            .list_owned_objects(
143                None, /* checkpoint */
144                OwnerKind::Address,
145                Some(owner.to_string()),
146                Some(object_type.to_canonical_string(/* with_prefix */ true)),
147                Some(page.limit as u32),
148                page.cursor.as_ref().map(|c| c.0.clone()),
149                None,
150                true,
151            )
152            .await
153            .context("Failed to list owned coin objects")
154            .map_err(RpcError::<Error>::from)?;
155
156        let coin_ids: Vec<_> = results
157            .results
158            .iter()
159            .map(|obj_ref| obj_ref.value.0)
160            .collect();
161
162        let next_cursor = results
163            .results
164            .last()
165            .map(|edge| BcsCursor(edge.token.clone()).encode())
166            .transpose()
167            .context("Failed to encode cursor")
168            .map_err(RpcError::<Error>::from)?;
169
170        let coin_futures = coin_ids.iter().map(|id| coin_response(ctx, *id));
171
172        let coins = future::join_all(coin_futures)
173            .await
174            .into_iter()
175            .zip(coin_ids)
176            .map(|(r, id)| r.with_internal_context(|| format!("Failed to get object {id}")))
177            .collect::<Result<Vec<_>, _>>()?;
178
179        Ok(PageResponse {
180            data: coins,
181            next_cursor,
182            has_next_page: results.has_next_page,
183        })
184    }
185
186    async fn get_coin_metadata(&self, coin_type: String) -> RpcResult<Option<SuiCoinMetadata>> {
187        let Self(ctx) = self;
188
189        if let Some(currency) = coin_registry_response(ctx, &coin_type)
190            .await
191            .with_internal_context(|| format!("Failed to fetch Currency for {coin_type:?}"))?
192        {
193            return Ok(Some(currency));
194        }
195
196        if let Some(metadata) = coin_metadata_response(ctx, &coin_type)
197            .await
198            .with_internal_context(|| format!("Failed to fetch CoinMetadata for {coin_type:?}"))?
199        {
200            return Ok(Some(metadata));
201        }
202
203        Ok(None)
204    }
205
206    async fn get_all_balances(&self, owner: SuiAddress) -> RpcResult<Vec<Balance>> {
207        let Self(ctx) = self;
208        let consistent_reader = ctx.consistent_reader();
209        let config = &ctx.config().coins;
210
211        let mut all_balances = Vec::new();
212        let mut after_token: Option<Vec<u8>> = None;
213
214        loop {
215            let page = consistent_reader
216                .list_balances(
217                    None,
218                    owner.to_string(),
219                    Some(config.max_page_size as u32),
220                    after_token.clone(),
221                    None,
222                    true,
223                )
224                .await
225                .context("Failed to get all balances")
226                .map_err(RpcError::<Error>::from)?;
227
228            for edge in &page.results {
229                all_balances.push(try_from_proto(edge.value.clone())?);
230            }
231
232            if page.has_next_page {
233                after_token = page.results.last().map(|edge| edge.token.clone());
234            } else {
235                break;
236            }
237        }
238
239        Ok(all_balances)
240    }
241
242    async fn get_balance(
243        &self,
244        owner: SuiAddress,
245        coin_type: Option<String>,
246    ) -> RpcResult<Balance> {
247        let Self(ctx) = self;
248        let consistent_reader = ctx.consistent_reader();
249
250        let inner_coin_type = if let Some(coin_type) = coin_type {
251            TypeTag::from_str(&coin_type)
252                .map_err(|e| invalid_params(Error::BadType(coin_type, e)))?
253        } else {
254            GAS::type_tag()
255        };
256
257        let response = consistent_reader
258            .get_balance(
259                None,
260                owner.to_string(),
261                inner_coin_type.to_canonical_string(/* with_prefix */ true),
262            )
263            .await
264            .context("Failed to get balance")
265            .map_err(RpcError::<Error>::from)?;
266
267        Ok(try_from_proto(response)?)
268    }
269}
270
271impl RpcModule for Coins {
272    fn schema(&self) -> Module {
273        CoinsApiOpenRpc::module_doc()
274    }
275
276    fn into_impl(self) -> jsonrpsee::RpcModule<Self> {
277        self.into_rpc()
278    }
279}
280
281fn try_from_proto(proto: ProtoBalance) -> Result<Balance, RpcError<Error>> {
282    let coin_type: TypeTag = proto
283        .coin_type
284        .context("coin type missing")?
285        .parse()
286        .context("invalid coin type")?;
287    Ok(Balance {
288        coin_type: coin_type.to_canonical_string(/* with_prefix */ true),
289        total_balance: proto.total_balance.unwrap_or(0) as u128,
290        // The Consistent Store does not track coin object counts, so the rpc will
291        // always return 1.
292        coin_object_count: 1,
293        locked_balance: HashMap::new(),
294        funds_in_address_balance: proto.address_balance.unwrap_or(0) as u128,
295    })
296}
297
298async fn coin_response(ctx: &Context, id: ObjectID) -> Result<Coin, RpcError<Error>> {
299    let (object, coin_type, balance) = object_with_coin_data(ctx, id).await?;
300
301    let coin_object_id = object.id();
302    let digest = object.digest();
303    let version = object.version();
304    let previous_transaction = object.as_inner().previous_transaction;
305
306    Ok(Coin {
307        coin_type,
308        coin_object_id,
309        version,
310        digest,
311        balance,
312        previous_transaction,
313    })
314}
315
316async fn coin_registry_response(
317    ctx: &Context,
318    coin_type: &str,
319) -> Result<Option<SuiCoinMetadata>, RpcError<Error>> {
320    let coin_type = TypeTag::from_str(coin_type)
321        .map_err(|e| invalid_params(Error::BadType(coin_type.to_owned(), e)))?;
322
323    let currency_id = Currency::derive_object_id(coin_type)
324        .context("Failed to derive object id for coin registry Currency")?;
325
326    let Some(object) = load_live(ctx, currency_id)
327        .await
328        .context("Failed to load Currency object")?
329    else {
330        return Ok(None);
331    };
332
333    let Some(move_object) = object.data.try_as_move() else {
334        return Ok(None);
335    };
336
337    let currency: Currency =
338        bcs::from_bytes(move_object.contents()).context("Failed to parse Currency object")?;
339
340    Ok(Some(currency.into()))
341}
342
343/// Given the inner coin type, i.e 0x2::sui::SUI, load the CoinMetadata object.
344async fn coin_metadata_response(
345    ctx: &Context,
346    coin_type: &str,
347) -> Result<Option<SuiCoinMetadata>, RpcError<Error>> {
348    let inner = TypeTag::from_str(coin_type)
349        .map_err(|e| invalid_params(Error::BadType(coin_type.to_owned(), e)))?;
350
351    let object_type = StructTag {
352        address: SUI_FRAMEWORK_ADDRESS,
353        module: COIN_MODULE_NAME.to_owned(),
354        name: COIN_METADATA_STRUCT_NAME.to_owned(),
355        type_params: vec![inner],
356    };
357
358    let Some(obj_ref) = ctx
359        .consistent_reader()
360        .list_objects_by_type(
361            None,
362            object_type.to_canonical_string(/* with_prefix */ true),
363            Some(1),
364            None,
365            None,
366            false,
367        )
368        .await
369        .context("Failed to load object reference for CoinMetadata")?
370        .results
371        .into_iter()
372        .next()
373    else {
374        return Ok(None);
375    };
376
377    let id = obj_ref.value.0;
378
379    let Some(object) = load_live(ctx, id)
380        .await
381        .context("Failed to load latest version of CoinMetadata")?
382    else {
383        return Ok(None);
384    };
385
386    let Some(move_object) = object.data.try_as_move() else {
387        return Ok(None);
388    };
389
390    let coin_metadata: CoinMetadata =
391        bcs::from_bytes(move_object.contents()).context("Failed to parse Currency object")?;
392
393    Ok(Some(coin_metadata.into()))
394}
395
396async fn object_with_coin_data(
397    ctx: &Context,
398    id: ObjectID,
399) -> Result<(Object, String, u64), RpcError<Error>> {
400    let object = load_live(ctx, id)
401        .await?
402        .with_context(|| format!("Failed to load latest object {id}"))?;
403
404    let coin = object
405        .as_coin_maybe()
406        .context("Object is expected to be a coin")?;
407    let coin_type = object
408        .coin_type_maybe()
409        .context("Object is expected to have a coin type")?
410        .to_canonical_string(/* with_prefix */ true);
411    Ok((object, coin_type, coin.balance.value()))
412}