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_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 #[method(name = "getCoins")]
52 async fn get_coins(
53 &self,
54 owner: SuiAddress,
56 coin_type: Option<String>,
58 cursor: Option<String>,
60 limit: Option<usize>,
62 ) -> RpcResult<PageResponse<Coin, String>>;
63
64 #[method(name = "getCoinMetadata")]
68 async fn get_coin_metadata(
69 &self,
70 coin_type: String,
72 ) -> RpcResult<Option<SuiCoinMetadata>>;
73
74 #[method(name = "getAllBalances")]
76 async fn get_all_balances(
77 &self,
78 owner: SuiAddress,
80 ) -> RpcResult<Vec<Balance>>;
81
82 #[method(name = "getBalance")]
85 async fn get_balance(
86 &self,
87 owner: SuiAddress,
89 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 let results = consistent_reader
146 .list_owned_objects(
147 None, OwnerKind::Address,
149 Some(owner.to_string()),
150 Some(object_type.to_canonical_string(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 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 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(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(true),
333 total_balance: proto.total_balance.unwrap_or(0) as u128,
334 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
387async 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(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
440async 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(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(true);
476 Ok((object, coin_type, coin.balance.value()))
477}