Skip to main content

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