1use crate::{TestCaseImpl, TestContext};
5use async_trait::async_trait;
6use move_core_types::language_storage::{StructTag, TypeTag};
7use serde_json::json;
8use sui_json::SuiJsonValue;
9use sui_move_build::test_utils::compile_managed_coin_package;
10use sui_rpc_api::client::ExecutedTransaction;
11use sui_types::base_types::{ObjectID, ObjectRef, SuiAddress};
12use sui_types::coin::{COIN_MODULE_NAME, COIN_STRUCT_NAME};
13use sui_types::effects::TransactionEffectsAPI;
14use sui_types::gas_coin::{GAS, GasCoin};
15use sui_types::object::Owner;
16use sui_types::{Identifier, SUI_FRAMEWORK_ADDRESS};
17use tracing::info;
18
19pub struct CoinIndexTest;
20
21#[derive(Clone, Debug, PartialEq, Eq)]
24struct OwnedCoin {
25 id: ObjectID,
26 balance: u64,
27}
28
29#[async_trait]
30impl TestCaseImpl for CoinIndexTest {
31 fn name(&self) -> &'static str {
32 "CoinIndex"
33 }
34
35 fn description(&self) -> &'static str {
36 "Test coin index / owned-object enumeration via StateService"
37 }
38
39 async fn run(&self, ctx: &mut TestContext) -> Result<(), anyhow::Error> {
40 let account = ctx.get_wallet_address();
41
42 let coins = ctx.get_sui_from_faucet(Some(1)).await;
47 let gas_coin_id = *coins[0].id();
48
49 let mut old_total_balance = Self::sui_balance(ctx, account).await;
51 let mut old_coin_object_count = Self::sui_coins(ctx, account).await.len();
52
53 let recipient = SuiAddress::random_for_testing_only();
58 let gas_price = ctx.get_reference_gas_price().await;
59 let gas_ref = ctx.current_object_ref(gas_coin_id).await;
60 let txn_data = {
61 use sui_test_transaction_builder::TestTransactionBuilder;
62 TestTransactionBuilder::new(account, gas_ref, gas_price)
63 .transfer_sui(Some(1_000_000), recipient)
64 .build()
65 };
66 let response = ctx.sign_and_execute(txn_data, "transfer").await;
67 let (owner_change, recipient_change) = Self::split_sui_balance_changes(&response, account);
68
69 let total_balance = Self::sui_balance(ctx, account).await;
70 let coin_object_count = Self::sui_coins(ctx, account).await.len();
71 assert_eq!(coin_object_count, old_coin_object_count);
72 assert_eq!(
73 total_balance,
74 (old_total_balance as i128 + owner_change.1) as u128
75 );
76 old_coin_object_count = coin_object_count;
77 assert!(recipient_change.1 > 0);
83 let recipient_total = Self::balance_until(ctx, recipient, &GAS::type_(), |b| {
90 b == recipient_change.1 as u128
91 })
92 .await;
93 info!(
94 "recipient {recipient}: balance {recipient_total}, change {}",
95 recipient_change.1
96 );
97 assert_eq!(recipient_total, recipient_change.1 as u128);
98 let recipient_coins = Self::sui_coins(ctx, recipient).await;
99 assert_eq!(
100 recipient_coins.len(),
101 1,
102 "classic TransferSui must yield exactly one recipient Coin<SUI>"
103 );
104 assert_eq!(recipient_coins[0].balance, recipient_change.1 as u64);
105
106 let (package, cap, envelope) = publish_managed_coin_package(ctx, gas_coin_id).await?;
108 old_total_balance = Self::sui_balance(ctx, account).await;
109
110 info!("token package published, package: {package:?}, cap: {cap:?}");
111 let managed_type = managed_coin_type(package.0);
112
113 Self::mint_managed(ctx, package.0, cap.0, 10000, account, gas_coin_id).await;
115
116 let total_balance = Self::sui_balance(ctx, account).await;
117 let coin_object_count = Self::sui_coins(ctx, account).await.len();
118 assert_eq!(coin_object_count, old_coin_object_count);
119 assert!(total_balance <= old_total_balance);
121
122 let managed_inner = managed_inner_type(package.0);
126
127 let managed_coins = Self::coins_of_type(ctx, account, &managed_type).await;
128 assert_eq!(managed_coins.len(), 1); assert_eq!(managed_coins[0].balance, 10000);
130 assert_eq!(Self::coin_balance(ctx, account, &managed_type).await, 10000);
131 assert_eq!(
134 Self::balance_of_type(ctx, account, &managed_inner).await,
135 10000,
136 "GetBalance(MANAGED) should equal the minted amount",
137 );
138
139 let all_balances = Self::all_balances(ctx, account).await;
142 assert!(
143 all_balances
144 .iter()
145 .any(|(t, _)| Self::type_matches(t, &GAS::type_())),
146 "list_balances should include SUI",
147 );
148 let managed_reported = all_balances
149 .iter()
150 .find(|(t, _)| Self::type_matches(t, &managed_inner))
151 .map(|(_, b)| *b)
152 .expect("list_balances should include the MANAGED custom coin");
153 assert_eq!(
154 managed_reported, 10000,
155 "list_balances MANAGED should equal the minted amount",
156 );
157
158 Self::mint_managed(ctx, package.0, cap.0, 10, account, gas_coin_id).await;
160
161 let managed_coins = Self::coins_of_type(ctx, account, &managed_type).await;
162 assert_eq!(
163 Self::coin_balance(ctx, account, &managed_type).await,
164 10000 + 10
165 );
166 assert_eq!(
169 Self::balance_of_type(ctx, account, &managed_inner).await,
170 10000 + 10,
171 "GetBalance(MANAGED) should equal the sum of owned MANAGED coins",
172 );
173 assert_eq!(
174 Self::all_balances(ctx, account)
175 .await
176 .iter()
177 .find(|(t, _)| Self::type_matches(t, &managed_inner))
178 .map(|(_, b)| *b),
179 Some(10000 + 10),
180 "ListBalances(MANAGED) should equal the sum of owned MANAGED coins",
181 );
182 assert_eq!(managed_coins.len(), 2);
183 let managed_coin_id = managed_coins.iter().find(|c| c.balance == 10).unwrap().id;
184 let managed_coin_id_10k = managed_coins
185 .iter()
186 .find(|c| c.balance == 10000)
187 .unwrap()
188 .id;
189
190 add_to_envelope(ctx, package.0, envelope.0, managed_coin_id, gas_coin_id).await;
192 assert_eq!(Self::coin_balance(ctx, account, &managed_type).await, 10000);
193 assert_eq!(
194 Self::coins_of_type(ctx, account, &managed_type).await.len(),
195 1
196 );
197
198 Self::call_managed(
200 ctx,
201 package.0,
202 "take_from_envelope",
203 vec![SuiJsonValue::from_object_id(envelope.0)],
204 gas_coin_id,
205 )
206 .await;
207 assert_eq!(
208 Self::coin_balance(ctx, account, &managed_type).await,
209 10000 + 10
210 );
211 assert_eq!(
212 Self::coins_of_type(ctx, account, &managed_type).await.len(),
213 2
214 );
215
216 add_to_envelope(ctx, package.0, envelope.0, managed_coin_id, gas_coin_id).await;
218
219 Self::call_managed(
221 ctx,
222 package.0,
223 "take_from_envelope_and_burn",
224 vec![
225 SuiJsonValue::from_object_id(cap.0),
226 SuiJsonValue::from_object_id(envelope.0),
227 ],
228 gas_coin_id,
229 )
230 .await;
231 assert_eq!(Self::coin_balance(ctx, account, &managed_type).await, 10000);
232 assert_eq!(
233 Self::coins_of_type(ctx, account, &managed_type).await.len(),
234 1
235 );
236
237 Self::call_managed(
239 ctx,
240 package.0,
241 "burn",
242 vec![
243 SuiJsonValue::from_object_id(cap.0),
244 SuiJsonValue::from_object_id(managed_coin_id_10k),
245 ],
246 gas_coin_id,
247 )
248 .await;
249 assert_eq!(Self::coin_balance(ctx, account, &managed_type).await, 0);
250 assert_eq!(
252 Self::balance_of_type(ctx, account, &managed_inner).await,
253 0,
254 "GetBalance(MANAGED) should be zero after burning all MANAGED coins",
255 );
256 assert_eq!(
259 Self::all_balances(ctx, account)
260 .await
261 .iter()
262 .find(|(t, _)| Self::type_matches(t, &managed_inner))
263 .map(|(_, b)| *b)
264 .unwrap_or(0),
265 0,
266 "ListBalances(MANAGED) should be zero/absent after burning all MANAGED coins",
267 );
268 assert_eq!(
269 Self::coins_of_type(ctx, account, &managed_type).await.len(),
270 0
271 );
272
273 let sui_coins = Self::sui_coins(ctx, account).await;
277 let all_coins = Self::all_coins(ctx, account).await;
278 assert_eq!(
279 sui_coins
280 .iter()
281 .map(|c| c.id)
282 .collect::<std::collections::BTreeSet<_>>(),
283 all_coins
284 .iter()
285 .map(|c| c.id)
286 .collect::<std::collections::BTreeSet<_>>(),
287 "with only SUI left, all-coins should equal SUI-coins",
288 );
289 let sui_balance = Self::sui_balance(ctx, account).await;
290 assert_eq!(
291 sui_balance,
292 sui_coins.iter().map(|c| c.balance as u128).sum::<u128>(),
293 "SUI balance should equal the sum of SUI coin values",
294 );
295
296 Self::call_managed(
298 ctx,
299 package.0,
300 "mint_multi",
301 vec![
302 SuiJsonValue::from_object_id(cap.0),
303 SuiJsonValue::new(json!("5"))?, SuiJsonValue::new(json!("40"))?, SuiJsonValue::new(json!(account))?,
306 ],
307 gas_coin_id,
308 )
309 .await;
310
311 let managed_coins = Self::coins_of_type(ctx, account, &managed_type).await;
312 assert_eq!(managed_coins.len(), 40);
313 assert!(managed_coins.iter().all(|c| c.balance == 5));
314
315 let sui_coins = Self::sui_coins(ctx, account).await;
317 let all_coins = Self::all_coins(ctx, account).await;
318 assert_eq!(
319 sui_coins.len() + managed_coins.len(),
320 all_coins.len(),
321 "all-coins count should equal SUI + MANAGED counts",
322 );
323
324 let page_size = (sui_coins.len() + 1) as u32;
328 let paged = Self::all_coins_paginated(ctx, account, page_size).await;
329 assert_eq!(
330 paged.len(),
331 all_coins.len(),
332 "paginated all-coins should visit every coin",
333 );
334 assert_eq!(
335 paged
336 .iter()
337 .map(|c| c.id)
338 .collect::<std::collections::BTreeSet<_>>(),
339 all_coins
340 .iter()
341 .map(|c| c.id)
342 .collect::<std::collections::BTreeSet<_>>(),
343 "paginated all-coins should match the unpaginated set",
344 );
345
346 let removed_coin_id = managed_coins[20].id;
349 add_to_envelope(ctx, package.0, envelope.0, removed_coin_id, gas_coin_id).await;
350 let managed_after = Self::coins_of_type(ctx, account, &managed_type).await;
351 assert_eq!(managed_after.len(), 39);
352 assert!(
353 !managed_after.iter().any(|c| c.id == removed_coin_id),
354 "wrapped coin should be excluded from owned-coin enumeration",
355 );
356 assert_eq!(
357 Self::coin_balance(ctx, account, &managed_type).await,
358 39 * 5,
359 "balance should exclude the wrapped coin",
360 );
361
362 Ok(())
363 }
364}
365
366impl CoinIndexTest {
367 async fn sui_balance(ctx: &TestContext, owner: SuiAddress) -> u128 {
369 Self::balance_of_type(ctx, owner, &GAS::type_()).await as u128
370 }
371
372 async fn balance_of_type(ctx: &TestContext, owner: SuiAddress, coin_type: &StructTag) -> u64 {
373 ctx.get_grpc_client()
374 .get_balance(owner, coin_type)
375 .await
376 .unwrap()
377 .balance
378 .unwrap_or_default()
379 }
380
381 async fn coin_balance(ctx: &TestContext, owner: SuiAddress, coin_type: &StructTag) -> u64 {
386 Self::coins_of_type(ctx, owner, coin_type)
387 .await
388 .iter()
389 .map(|c| c.balance)
390 .sum()
391 }
392
393 async fn all_balances(ctx: &TestContext, owner: SuiAddress) -> Vec<(String, u64)> {
395 use futures::StreamExt;
396 let client = ctx.get_grpc_client();
397 let mut stream = Box::pin(client.list_balances(owner));
398 let mut out = Vec::new();
399 while let Some(balance) = stream.next().await {
400 let balance = balance.unwrap();
401 out.push((
402 balance.coin_type.clone().unwrap_or_default(),
403 balance.balance.unwrap_or_default(),
404 ));
405 }
406 out
407 }
408
409 async fn sui_coins(ctx: &TestContext, owner: SuiAddress) -> Vec<OwnedCoin> {
415 Self::coins_of_type(ctx, owner, &GasCoin::type_()).await
416 }
417
418 async fn all_coins(ctx: &TestContext, owner: SuiAddress) -> Vec<OwnedCoin> {
421 Self::coins_of_type(ctx, owner, &all_coin_filter()).await
422 }
423
424 async fn coins_of_type(
427 ctx: &TestContext,
428 owner: SuiAddress,
429 coin_type: &StructTag,
430 ) -> Vec<OwnedCoin> {
431 let client = ctx.get_grpc_client();
432 let mut out = Vec::new();
433 let mut token = None;
434 loop {
435 let page = client
436 .get_owned_objects(owner, Some(coin_type.clone()), Some(50), token)
437 .await
438 .unwrap();
439 for object in &page.items {
440 let balance = sui_types::coin::Coin::extract_balance_if_coin(object)
441 .unwrap()
442 .expect("owned object should be a coin")
443 .1;
444 out.push(OwnedCoin {
445 id: object.id(),
446 balance,
447 });
448 }
449 token = page.next_page_token;
450 if token.is_none() {
451 break;
452 }
453 }
454 out
455 }
456
457 async fn balance_until(
462 ctx: &TestContext,
463 owner: SuiAddress,
464 coin_type: &StructTag,
465 predicate: impl Fn(u128) -> bool,
466 ) -> u128 {
467 for _ in 0..20 {
468 let balance = Self::balance_of_type(ctx, owner, coin_type).await as u128;
469 if predicate(balance) {
470 return balance;
471 }
472 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
473 }
474 Self::balance_of_type(ctx, owner, coin_type).await as u128
475 }
476
477 async fn all_coins_paginated(
479 ctx: &TestContext,
480 owner: SuiAddress,
481 page_size: u32,
482 ) -> Vec<OwnedCoin> {
483 let client = ctx.get_grpc_client();
484 let filter = all_coin_filter();
485 let mut out = Vec::new();
486 let mut token = None;
487 let mut first_page = true;
488 loop {
489 let page = client
490 .get_owned_objects(owner, Some(filter.clone()), Some(page_size), token)
491 .await
492 .unwrap();
493 if first_page {
494 assert_eq!(
495 page.items.len() as u32,
496 page_size,
497 "first page should be full",
498 );
499 assert!(
500 page.next_page_token.is_some(),
501 "a partial enumeration should return a continuation token",
502 );
503 first_page = false;
504 }
505 for object in &page.items {
506 let balance = sui_types::coin::Coin::extract_balance_if_coin(object)
507 .unwrap()
508 .expect("owned object should be a coin")
509 .1;
510 out.push(OwnedCoin {
511 id: object.id(),
512 balance,
513 });
514 }
515 token = page.next_page_token;
516 if token.is_none() {
517 break;
518 }
519 }
520 out
521 }
522
523 fn split_sui_balance_changes(
526 response: &ExecutedTransaction,
527 account: SuiAddress,
528 ) -> ((SuiAddress, i128), (SuiAddress, i128)) {
529 let account_sdk: sui_sdk_types::Address = account.into();
530 let owner = response
531 .balance_changes
532 .iter()
533 .find(|b| b.address == account_sdk)
534 .expect("owner balance change");
535 let recipient = response
536 .balance_changes
537 .iter()
538 .find(|b| b.address != account_sdk)
539 .expect("recipient balance change");
540 (
541 (sdk_addr_to_sui(&owner.address), owner.amount),
542 (sdk_addr_to_sui(&recipient.address), recipient.amount),
543 )
544 }
545
546 fn type_matches(coin_type_str: &str, expected: &StructTag) -> bool {
547 sui_types::parse_sui_struct_tag(coin_type_str)
548 .map(|t| &t == expected)
549 .unwrap_or(false)
550 }
551
552 async fn mint_managed(
553 ctx: &TestContext,
554 pkg: ObjectID,
555 cap: ObjectID,
556 amount: u64,
557 recipient: SuiAddress,
558 gas_coin_id: ObjectID,
559 ) {
560 Self::call_managed(
561 ctx,
562 pkg,
563 "mint",
564 vec![
565 SuiJsonValue::from_object_id(cap),
566 SuiJsonValue::new(json!(amount.to_string())).unwrap(),
567 SuiJsonValue::new(json!(recipient)).unwrap(),
568 ],
569 gas_coin_id,
570 )
571 .await;
572 }
573
574 async fn call_managed(
579 ctx: &TestContext,
580 pkg: ObjectID,
581 function: &str,
582 args: Vec<SuiJsonValue>,
583 gas_coin_id: ObjectID,
584 ) -> ExecutedTransaction {
585 let account = ctx.get_wallet_address();
586 let rgp = ctx.get_reference_gas_price().await;
587 let gas_ref = ctx.current_object_ref(gas_coin_id).await;
588 let builder = ctx.get_grpc_client().transaction_builder();
589 let data = builder
590 .move_call(
591 account,
592 pkg,
593 "managed",
594 function,
595 vec![],
596 args,
597 Some(gas_ref.0),
598 rgp * 2_000_000,
599 None,
600 )
601 .await
602 .unwrap();
603 let response = ctx.sign_and_execute(data, function).await;
604 assert!(response.effects.status().is_ok());
605 response
606 }
607}
608
609fn sdk_addr_to_sui(addr: &sui_sdk_types::Address) -> SuiAddress {
610 (*addr).into()
611}
612
613fn all_coin_filter() -> StructTag {
615 StructTag {
616 address: SUI_FRAMEWORK_ADDRESS,
617 module: COIN_MODULE_NAME.to_owned(),
618 name: COIN_STRUCT_NAME.to_owned(),
619 type_params: vec![],
620 }
621}
622
623fn managed_coin_type(pkg: ObjectID) -> StructTag {
627 sui_types::coin::Coin::type_(TypeTag::Struct(Box::new(managed_inner_type(pkg))))
628}
629
630fn managed_inner_type(pkg: ObjectID) -> StructTag {
634 StructTag {
635 address: pkg.into(),
636 module: Identifier::new("managed").unwrap(),
637 name: Identifier::new("MANAGED").unwrap(),
638 type_params: vec![],
639 }
640}
641
642async fn publish_managed_coin_package(
643 ctx: &mut TestContext,
644 gas_coin_id: ObjectID,
645) -> Result<(ObjectRef, ObjectRef, ObjectRef), anyhow::Error> {
646 let signer = ctx.get_wallet_address();
647 let gas_ref = ctx.current_object_ref(gas_coin_id).await;
648
649 let compiled_package = compile_managed_coin_package().await;
650 let compiled_modules =
651 compiled_package.get_package_bytes(false);
652 let dependencies = compiled_package.get_dependency_storage_package_ids();
653
654 let builder = ctx.get_grpc_client().transaction_builder();
655 let data = builder
656 .publish(
657 signer,
658 compiled_modules,
659 dependencies,
660 Some(gas_ref.0),
661 500_000_000,
662 )
663 .await?;
664 let response = ctx.sign_and_execute(data, "publish ft package").await;
665
666 let created = response.effects.created();
669 let pkg = response
670 .get_new_package_obj()
671 .expect("publish should create a package");
672
673 let mut client = ctx.get_grpc_client();
674 let mut treasury_cap = None;
675 let mut envelope = None;
676 for (obj_ref, owner) in &created {
677 let object = client.get_object(obj_ref.0).await?;
678 let type_name = object.type_().map(|t| t.name().to_string());
679 match (type_name.as_deref(), owner) {
680 (Some("TreasuryCap"), Owner::AddressOwner(_)) => treasury_cap = Some(*obj_ref),
681 (Some("PublicRedEnvelope"), Owner::Shared { .. }) => envelope = Some(*obj_ref),
682 _ => {}
683 }
684 }
685 let treasury_cap = treasury_cap.expect("publish should create a TreasuryCap");
686 let envelope = envelope.expect("publish should create a shared PublicRedEnvelope");
687 info!("published package {pkg:?}, cap {treasury_cap:?}, envelope {envelope:?}");
688 Ok((pkg, treasury_cap, envelope))
689}
690
691async fn add_to_envelope(
692 ctx: &mut TestContext,
693 pkg_id: ObjectID,
694 envelope: ObjectID,
695 coin: ObjectID,
696 gas_coin_id: ObjectID,
697) -> ExecutedTransaction {
698 CoinIndexTest::call_managed(
699 ctx,
700 pkg_id,
701 "add_to_envelope",
702 vec![
703 SuiJsonValue::from_object_id(envelope),
704 SuiJsonValue::from_object_id(coin),
705 ],
706 gas_coin_id,
707 )
708 .await
709}