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