sui_indexer_alt_jsonrpc/api/
coin.rs1use 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 #[method(name = "getCoins")]
49 async fn get_coins(
50 &self,
51 owner: SuiAddress,
53 coin_type: Option<String>,
55 cursor: Option<String>,
57 limit: Option<usize>,
59 ) -> RpcResult<PageResponse<Coin, String>>;
60
61 #[method(name = "getCoinMetadata")]
65 async fn get_coin_metadata(
66 &self,
67 coin_type: String,
69 ) -> RpcResult<Option<SuiCoinMetadata>>;
70
71 #[method(name = "getAllBalances")]
73 async fn get_all_balances(
74 &self,
75 owner: SuiAddress,
77 ) -> RpcResult<Vec<Balance>>;
78
79 #[method(name = "getBalance")]
82 async fn get_balance(
83 &self,
84 owner: SuiAddress,
86 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 let results = consistent_reader
143 .list_owned_objects(
144 None, OwnerKind::Address,
146 Some(owner.to_string()),
147 Some(object_type.to_canonical_string(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 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 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(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(true),
330 total_balance: proto.total_balance.unwrap_or(0) as u128,
331 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
384async 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(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(true);
452 Ok((object, coin_type, coin.balance.value()))
453}