sui_indexer_alt_jsonrpc/api/
coin.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::str::FromStr;
5
6use anyhow::Context as _;
7use diesel::prelude::*;
8use diesel::sql_types::Bool;
9use futures::future;
10use jsonrpsee::core::RpcResult;
11use jsonrpsee::http_client::HttpClient;
12use jsonrpsee::proc_macros::rpc;
13use move_core_types::language_storage::StructTag;
14use move_core_types::language_storage::TypeTag;
15use serde::Deserialize;
16use serde::Serialize;
17use sui_indexer_alt_reader::coin_metadata::CoinMetadataKey;
18use sui_indexer_alt_schema::objects::StoredCoinOwnerKind;
19use sui_indexer_alt_schema::schema::coin_balance_buckets;
20use sui_json_rpc_types::Balance;
21use sui_json_rpc_types::Coin;
22use sui_json_rpc_types::Page as PageResponse;
23use sui_json_rpc_types::SuiCoinMetadata;
24use sui_open_rpc::Module;
25use sui_open_rpc_macros::open_rpc;
26use sui_sql_macro::sql;
27use sui_types::base_types::ObjectID;
28use sui_types::base_types::SuiAddress;
29use sui_types::coin::CoinMetadata;
30use sui_types::coin_registry::Currency;
31use sui_types::gas_coin::GAS;
32use sui_types::object::Object;
33
34use crate::api::rpc_module::RpcModule;
35use crate::context::Context;
36use crate::data::load_live;
37use crate::error::InternalContext;
38use crate::error::RpcError;
39use crate::error::client_error_to_error_object;
40use crate::error::invalid_params;
41use crate::paginate::BcsCursor;
42use crate::paginate::Cursor as _;
43use crate::paginate::Page;
44
45#[open_rpc(namespace = "suix", tag = "Coin API")]
46#[rpc(server, namespace = "suix")]
47trait CoinsApi {
48    /// Return Coin objects owned by an address with a specified coin type.
49    /// If no coin type is specified, SUI coins are returned.
50    #[method(name = "getCoins")]
51    async fn get_coins(
52        &self,
53        /// the owner's Sui address
54        owner: SuiAddress,
55        /// optional coin type
56        coin_type: Option<String>,
57        /// optional paging cursor
58        cursor: Option<String>,
59        /// maximum number of items per page
60        limit: Option<usize>,
61    ) -> RpcResult<PageResponse<Coin, String>>;
62
63    /// Return metadata (e.g., symbol, decimals) for a coin. Note that if the coin's metadata was
64    /// wrapped in the transaction that published its marker type, or the latest version of the
65    /// metadata object is wrapped or deleted, it will not be found.
66    #[method(name = "getCoinMetadata")]
67    async fn get_coin_metadata(
68        &self,
69        /// type name for the coin (e.g., 0x168da5bf1f48dafc111b0a488fa454aca95e0b5e::usdc::USDC)
70        coin_type: String,
71    ) -> RpcResult<Option<SuiCoinMetadata>>;
72}
73
74/// Delegation Coin API for endpoints that are delegated to FN RPC
75#[open_rpc(namespace = "suix", tag = "Delegation Coin API")]
76#[rpc(server, client, namespace = "suix")]
77trait DelegationCoinsApi {
78    /// Return the total coin balance for all coin types, owned by the address owner.
79    #[method(name = "getAllBalances")]
80    async fn get_all_balances(
81        &self,
82        /// the owner's Sui address
83        owner: SuiAddress,
84    ) -> RpcResult<Vec<Balance>>;
85
86    /// Return the total coin balance for one coin type, owned by the address.
87    /// If no coin type is specified, SUI coin balance is returned.
88    #[method(name = "getBalance")]
89    async fn get_balance(
90        &self,
91        /// the owner's Sui address
92        owner: SuiAddress,
93        /// optional type names for the coin (e.g., 0x168da5bf1f48dafc111b0a488fa454aca95e0b5e::usdc::USDC), default to 0x2::sui::SUI if not specified.
94        coin_type: Option<String>,
95    ) -> RpcResult<Balance>;
96}
97
98pub(crate) struct Coins(pub Context);
99pub(crate) struct DelegationCoins(HttpClient);
100
101#[derive(thiserror::Error, Debug)]
102pub(crate) enum Error {
103    #[error("Pagination issue: {0}")]
104    Pagination(#[from] crate::paginate::Error),
105
106    #[error("Failed to parse type {0:?}: {1}")]
107    BadType(String, anyhow::Error),
108}
109
110#[derive(Queryable, Debug, Serialize, Deserialize)]
111#[diesel(table_name = coin_balance_buckets)]
112struct BalanceCursor {
113    object_id: Vec<u8>,
114    cp_sequence_number: u64,
115    coin_balance_bucket: u64,
116}
117
118type Cursor = BcsCursor<BalanceCursor>;
119
120impl DelegationCoins {
121    pub(crate) fn new(client: HttpClient) -> Self {
122        Self(client)
123    }
124}
125
126#[async_trait::async_trait]
127impl CoinsApiServer for Coins {
128    async fn get_coins(
129        &self,
130        owner: SuiAddress,
131        coin_type: Option<String>,
132        cursor: Option<String>,
133        limit: Option<usize>,
134    ) -> RpcResult<PageResponse<Coin, String>> {
135        let coin_type_tag = if let Some(coin_type) = coin_type {
136            sui_types::parse_sui_type_tag(&coin_type)
137                .map_err(|e| invalid_params(Error::BadType(coin_type, e)))?
138        } else {
139            GAS::type_tag()
140        };
141
142        let Self(ctx) = self;
143        let config = &ctx.config().coins;
144
145        let page: Page<Cursor> = Page::from_params::<Error>(
146            config.default_page_size,
147            config.max_page_size,
148            cursor,
149            limit,
150            None,
151        )?;
152
153        // We get all the qualified coin ids first.
154        let coin_id_page = filter_coins(ctx, owner, Some(coin_type_tag), Some(page)).await?;
155
156        let coin_futures = coin_id_page.data.iter().map(|id| coin_response(ctx, *id));
157
158        let coins = future::join_all(coin_futures)
159            .await
160            .into_iter()
161            .zip(coin_id_page.data)
162            .map(|(r, id)| r.with_internal_context(|| format!("Failed to get object {id}")))
163            .collect::<Result<Vec<_>, _>>()?;
164
165        Ok(PageResponse {
166            data: coins,
167            next_cursor: coin_id_page.next_cursor,
168            has_next_page: coin_id_page.has_next_page,
169        })
170    }
171
172    async fn get_coin_metadata(&self, coin_type: String) -> RpcResult<Option<SuiCoinMetadata>> {
173        let Self(ctx) = self;
174
175        if let Some(currency) = coin_registry_response(ctx, &coin_type)
176            .await
177            .with_internal_context(|| format!("Failed to fetch Currency for {coin_type:?}"))?
178        {
179            return Ok(Some(currency));
180        }
181
182        if let Some(metadata) = coin_metadata_response(ctx, &coin_type)
183            .await
184            .with_internal_context(|| format!("Failed to fetch CoinMetadata for {coin_type:?}"))?
185        {
186            return Ok(Some(metadata));
187        }
188
189        Ok(None)
190    }
191}
192
193#[async_trait::async_trait]
194impl DelegationCoinsApiServer for DelegationCoins {
195    async fn get_all_balances(&self, owner: SuiAddress) -> RpcResult<Vec<Balance>> {
196        let Self(client) = self;
197
198        client
199            .get_all_balances(owner)
200            .await
201            .map_err(client_error_to_error_object)
202    }
203
204    async fn get_balance(
205        &self,
206        owner: SuiAddress,
207        coin_type: Option<String>,
208    ) -> RpcResult<Balance> {
209        let Self(client) = self;
210
211        client
212            .get_balance(owner, coin_type)
213            .await
214            .map_err(client_error_to_error_object)
215    }
216}
217
218impl RpcModule for Coins {
219    fn schema(&self) -> Module {
220        CoinsApiOpenRpc::module_doc()
221    }
222
223    fn into_impl(self) -> jsonrpsee::RpcModule<Self> {
224        self.into_rpc()
225    }
226}
227
228impl RpcModule for DelegationCoins {
229    fn schema(&self) -> Module {
230        DelegationCoinsApiOpenRpc::module_doc()
231    }
232
233    fn into_impl(self) -> jsonrpsee::RpcModule<Self> {
234        self.into_rpc()
235    }
236}
237
238async fn filter_coins(
239    ctx: &Context,
240    owner: SuiAddress,
241    coin_type_tag: Option<TypeTag>,
242    page: Option<Page<Cursor>>,
243) -> Result<PageResponse<ObjectID, String>, RpcError<Error>> {
244    use coin_balance_buckets::dsl as cb;
245
246    let mut conn = ctx
247        .pg_reader()
248        .connect()
249        .await
250        .context("Failed to connect to database")?;
251
252    // We use two aliases of coin_balance_buckets to make the query more readable.
253    let (candidates, newer) = diesel::alias!(
254        coin_balance_buckets as candidates,
255        coin_balance_buckets as newer
256    );
257
258    // Macros to make the query more readable.
259    macro_rules! candidates {
260        ($field:ident) => {
261            candidates.field(cb::$field)
262        };
263    }
264
265    macro_rules! newer {
266        ($field:ident) => {
267            newer.field(cb::$field)
268        };
269    }
270
271    // Construct the basic query first to filter by owner, not deleted and newest rows.
272    let mut query = candidates
273        .select((
274            candidates!(object_id),
275            candidates!(cp_sequence_number),
276            candidates!(coin_balance_bucket).assume_not_null(),
277        ))
278        .left_join(
279            newer.on(candidates!(object_id)
280                .eq(newer!(object_id))
281                .and(candidates!(cp_sequence_number).lt(newer!(cp_sequence_number)))),
282        )
283        .filter(newer!(object_id).is_null())
284        .filter(candidates!(owner_kind).eq(StoredCoinOwnerKind::Fastpath))
285        .filter(candidates!(owner_id).eq(owner.to_vec()))
286        .into_boxed();
287
288    if let Some(coin_type_tag) = coin_type_tag {
289        let serialized_coin_type =
290            bcs::to_bytes(&coin_type_tag).context("Failed to serialize coin type tag")?;
291        query = query.filter(candidates!(coin_type).eq(serialized_coin_type));
292    }
293
294    let (cursor, limit) = page.map_or((None, None), |p| (p.cursor, Some(p.limit)));
295
296    // If the cursor is specified, we filter by it.
297    if let Some(c) = cursor {
298        query = query.filter(sql!(as Bool,
299            "(candidates.coin_balance_bucket, candidates.cp_sequence_number, candidates.object_id) < ({SmallInt}, {BigInt}, {Bytea})",
300            c.coin_balance_bucket as i16,
301            c.cp_sequence_number as i64,
302            c.object_id.clone(),
303        ));
304    }
305
306    // Finally we order by coin_balance_bucket, then by cp_sequence_number, and then by object_id.
307    query = query
308        .order_by(candidates!(coin_balance_bucket).desc())
309        .then_order_by(candidates!(cp_sequence_number).desc())
310        .then_order_by(candidates!(object_id).desc());
311
312    if let Some(limit) = limit {
313        query = query.limit(limit + 1);
314    }
315
316    let mut buckets: Vec<(Vec<u8>, i64, i16)> =
317        conn.results(query).await.context("Failed to query coins")?;
318
319    let mut has_next_page = false;
320
321    if let Some(limit) = limit {
322        // Now gather pagination info.
323        has_next_page = buckets.len() > limit as usize;
324        if has_next_page {
325            buckets.truncate(limit as usize);
326        }
327    }
328
329    let next_cursor = buckets
330        .last()
331        .map(|(object_id, cp_sequence_number, coin_balance_bucket)| {
332            BcsCursor(BalanceCursor {
333                object_id: object_id.clone(),
334                cp_sequence_number: *cp_sequence_number as u64,
335                coin_balance_bucket: *coin_balance_bucket as u64,
336            })
337            .encode()
338        })
339        .transpose()
340        .context("Failed to encode cursor")?;
341
342    let ids = buckets
343        .iter()
344        .map(|(object_id, _, _)| ObjectID::from_bytes(object_id))
345        .collect::<Result<Vec<_>, _>>()
346        .context("Failed to parse object id")?;
347
348    Ok(PageResponse {
349        data: ids,
350        next_cursor,
351        has_next_page,
352    })
353}
354
355async fn coin_response(ctx: &Context, id: ObjectID) -> Result<Coin, RpcError<Error>> {
356    let (object, coin_type, balance) = object_with_coin_data(ctx, id).await?;
357
358    let coin_object_id = object.id();
359    let digest = object.digest();
360    let version = object.version();
361    let previous_transaction = object.as_inner().previous_transaction;
362
363    Ok(Coin {
364        coin_type,
365        coin_object_id,
366        version,
367        digest,
368        balance,
369        previous_transaction,
370    })
371}
372
373async fn coin_registry_response(
374    ctx: &Context,
375    coin_type: &str,
376) -> Result<Option<SuiCoinMetadata>, RpcError<Error>> {
377    let coin_type = TypeTag::from_str(coin_type)
378        .map_err(|e| invalid_params(Error::BadType(coin_type.to_owned(), e)))?;
379
380    let currency_id = Currency::derive_object_id(coin_type)
381        .context("Failed to derive object id for coin registry Currency")?;
382
383    let Some(object) = load_live(ctx, currency_id)
384        .await
385        .context("Failed to load Currency object")?
386    else {
387        return Ok(None);
388    };
389
390    let Some(move_object) = object.data.try_as_move() else {
391        return Ok(None);
392    };
393
394    let currency: Currency =
395        bcs::from_bytes(move_object.contents()).context("Failed to parse Currency object")?;
396
397    Ok(Some(currency.into()))
398}
399
400async fn coin_metadata_response(
401    ctx: &Context,
402    coin_type: &str,
403) -> Result<Option<SuiCoinMetadata>, RpcError<Error>> {
404    let coin_type = StructTag::from_str(coin_type)
405        .map_err(|e| invalid_params(Error::BadType(coin_type.to_owned(), e)))?;
406
407    let Some(stored) = ctx
408        .pg_loader()
409        .load_one(CoinMetadataKey(coin_type))
410        .await
411        .context("Failed to load info for CoinMetadata")?
412    else {
413        return Ok(None);
414    };
415
416    let id = ObjectID::from_bytes(&stored.object_id).context("Failed to parse ObjectID")?;
417
418    let Some(object) = load_live(ctx, id)
419        .await
420        .context("Failed to load latest version of CoinMetadata")?
421    else {
422        return Ok(None);
423    };
424
425    let Some(move_object) = object.data.try_as_move() else {
426        return Ok(None);
427    };
428
429    let coin_metadata: CoinMetadata =
430        bcs::from_bytes(move_object.contents()).context("Failed to parse Currency object")?;
431
432    Ok(Some(coin_metadata.into()))
433}
434
435async fn object_with_coin_data(
436    ctx: &Context,
437    id: ObjectID,
438) -> Result<(Object, String, u64), RpcError<Error>> {
439    let object = load_live(ctx, id)
440        .await?
441        .with_context(|| format!("Failed to load latest object {id}"))?;
442
443    let coin = object
444        .as_coin_maybe()
445        .context("Object is expected to be a coin")?;
446    let coin_type = object
447        .coin_type_maybe()
448        .context("Object is expected to have a coin type")?
449        .to_canonical_string(/* with_prefix */ true);
450    Ok((object, coin_type, coin.balance.value()))
451}