Skip to main content

sui_cluster_test/test_case/
coin_index_test.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use 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/// A decoded coin object owned by an address: its ID and current balance in
22/// MIST (the coin's `Coin::value`).
23#[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        // 0. Get some coins first. The coin used below is funded up front so it
43        //    is already counted in the initial snapshot; the transfer splits a
44        //    small amount off it (as gas coin) to a fresh recipient, leaving the
45        //    account's coin count unchanged.
46        let coins = ctx.get_sui_from_faucet(Some(1)).await;
47        let gas_coin_id = *coins[0].id();
48
49        // Record initial SUI balance + coin count (StateService).
50        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        // 1. Execute one transfer coin transaction (to another address). A small
54        //    amount is split off an already-owned coin (also the gas coin) and
55        //    sent to the recipient; the account keeps the (now-smaller) coin, so
56        //    its coin count is unchanged.
57        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        // `old_total_balance` is refreshed after the publish below (its value
78        // here would be overwritten before use).
79
80        // The recipient balance change comes from the (already-checkpointed)
81        // effects and is authoritative.
82        assert!(recipient_change.1 > 0);
83        // Classic `TransferSui` sends the recipient a distinct `Coin<SUI>`
84        // object (address-balance accumulator crediting only applies to
85        // `send_funds`-style transfers), so the legacy assertion holds: the
86        // recipient owns exactly one SUI coin worth the transferred amount.
87        // The index may lag the checkpoint by a moment, so settle on the
88        // balance first, then enumerate the owned coins.
89        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        // 3. Publish a new token package MANAGED.
107        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        // 4. Mint 1 MANAGED coin to account, balance 10000.
114        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        // The mint's gas cost reduces the SUI balance; just check it did not grow.
120        assert!(total_balance <= old_total_balance);
121
122        // Balance APIs (`GetBalance`/`ListBalances`) key on the coin's INNER type
123        // `pkg::managed::MANAGED`, whereas owned-object enumeration keys on the
124        // object type `Coin<MANAGED>`.
125        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); // minted one object
129        assert_eq!(managed_coins[0].balance, 10000);
130        assert_eq!(Self::coin_balance(ctx, account, &managed_type).await, 10000);
131        // Require the StateService coin-balance index to reflect the freshly
132        // published custom coin (verified live: settles at t=0, no lag).
133        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        // ListBalances (StateService::ListBalances) must report both SUI and the
140        // MANAGED custom coin (each keyed on its inner type).
141        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        // 5. Mint another MANAGED coin to account, balance 10.
159        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        // StateService GetBalance and ListBalances must also reflect the two
167        // owned MANAGED coins.
168        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        // 6. Put the balance-10 MANAGED coin into the envelope (wrap).
191        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        // 7. Take back the balance-10 MANAGED coin (unwrap).
199        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        // 8. Put the balance-10 MANAGED coin back into the envelope.
217        add_to_envelope(ctx, package.0, envelope.0, managed_coin_id, gas_coin_id).await;
218
219        // 9. Take from envelope and burn.
220        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        // 10. Burn the balance-10000 MANAGED coin.
238        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        // GetBalance must report zero once all MANAGED coins are burned.
251        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        // ListBalances must agree: MANAGED is either absent from the listing or
257        // reported as zero.
258        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        // =========================== All-coins vs SUI-coins ===========================
274        // With no MANAGED coins left, the "all coins" enumeration (parameterless
275        // `0x2::coin::Coin` filter) must equal the SUI-only enumeration.
276        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        // 11. Mint 40 MANAGED coins with balance 5.
297        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"))?,  // balance = 5
304                SuiJsonValue::new(json!("40"))?, // num = 40
305                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        // Completeness: all-coins == sui-coins + managed-coins (counts).
316        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        // Pagination: a page smaller than the full set reports a continuation
325        // token, and paging through with opaque tokens visits every coin exactly
326        // once.
327        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        // 12. Wrap one MANAGED coin into the envelope; it must disappear from the
347        //     owned-coin enumeration (excluded because it is now wrapped).
348        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    /// Total SUI balance (`StateService::GetBalance`).
368    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    /// Balance of a coin *object* type (e.g. `Coin<MANAGED>`) derived by summing
382    /// the BCS-decoded values of owned coin objects (`ListOwnedObjects`). Used to
383    /// cross-check the `GetBalance`/`ListBalances` aggregates against the actual
384    /// owned objects; both must agree.
385    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    /// All balances for an owner (`StateService::ListBalances`), as (type-string, total).
394    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    /// SUI (`Coin<0x2::sui::SUI>`) coins owned by `owner`.
410    ///
411    /// NOTE: the owned-object filter is the *object* type `Coin<SUI>`
412    /// (`GasCoin::type_()`), not the coin's inner type `0x2::sui::SUI`
413    /// (`GAS::type_()`, which `GetBalance`/`ListBalances` use as their `T`).
414    async fn sui_coins(ctx: &TestContext, owner: SuiAddress) -> Vec<OwnedCoin> {
415        Self::coins_of_type(ctx, owner, &GasCoin::type_()).await
416    }
417
418    /// All `Coin<T>` objects owned by `owner`, using the parameterless
419    /// `0x2::coin::Coin` filter.
420    async fn all_coins(ctx: &TestContext, owner: SuiAddress) -> Vec<OwnedCoin> {
421        Self::coins_of_type(ctx, owner, &all_coin_filter()).await
422    }
423
424    /// Enumerate all coins of a given type via `StateService::ListOwnedObjects`,
425    /// following opaque page tokens to completion.
426    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    /// Read a balance, retrying until `predicate` holds or a bounded number of
458    /// attempts is exhausted. Absorbs the brief lag between a transaction being
459    /// checkpointed and a freshly-touched owner's balance being reflected in
460    /// `StateService`.
461    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    /// Enumerate all coins using an explicit page size to exercise pagination.
478    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    /// Split the two SUI balance changes of a simple transfer into
524    /// (owner_change, recipient_change) as (address, amount).
525    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    /// Invoke a `managed` module entry function via the gRPC Move-call builder,
575    /// paying with the caller-supplied, already-counted gas coin (its ref is
576    /// refreshed each call). Reusing a stable gas coin keeps the account's SUI
577    /// coin count and balance accounting well-defined across the test.
578    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
613/// The parameterless `0x2::coin::Coin` StructTag that matches all `Coin<T>`.
614fn 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
623/// `Coin<pkg::managed::MANAGED>`.
624/// The object type `Coin<pkg::managed::MANAGED>` (used for owned-object
625/// enumeration via `ListOwnedObjects`).
626fn managed_coin_type(pkg: ObjectID) -> StructTag {
627    sui_types::coin::Coin::type_(TypeTag::Struct(Box::new(managed_inner_type(pkg))))
628}
629
630/// The inner coin type `pkg::managed::MANAGED` (the `T` used by
631/// `GetBalance`/`ListBalances`, which key on the coin's inner type, not
632/// `Coin<T>`).
633fn 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(/* with_unpublished_deps */ 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    // Find the published package (from the effects/changed objects), the
667    // TreasuryCap and the shared envelope.
668    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}