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