sui_transaction_builder/intent/
coin_with_balance.rs

1//! [`Coin`] and [`Balance`] intents and their shared resolver.
2//!
3//! Sui accounts hold funds in two forms: **coin objects** (`Coin<T>`)
4//! and **address balances** (fungible balances attached directly to an
5//! address). Move contracts can accept either form, so transactions
6//! need a way to automatically source the right one.
7//!
8//! The [`Coin`] and [`Balance`] intents let callers declare how much of
9//! a given type they need and whether they need a `Coin<T>` or a
10//! `Balance<T>`. During transaction building the resolver replaces each
11//! intent with concrete PTB commands that source funds from owned coins
12//! and/or address balances.
13//!
14//! # Resolution algorithm
15//!
16//! The resolver prefers address balances over coins -- it only touches
17//! coin objects when the address balance is insufficient. This enables
18//! parallel execution of transactions. When coins are needed, the
19//! resolver consolidates as many as possible (up to 500) to reduce
20//! dust.
21//!
22//! Resolution runs once per coin type. It collects every [`Coin`] and
23//! [`Balance`] intent for that type, loads available funds, and picks
24//! one of two paths.
25//!
26//! ## Zero-balance intents
27//!
28//! Handled before path selection:
29//!
30//! - `balance::zero<T>()` for [`Balance`] intents.
31//! - `coin::zero<T>()` for [`Coin`] intents.
32//!
33//! Zero-balance intents do not participate in path selection or the
34//! combined split.
35//!
36//! ## Path selection
37//!
38//! 1. Collect all non-zero intents for the type, compute
39//!    `total_required`.
40//! 2. Fetch `address_balance` via `getBalance()`.
41//! 3. Verify sufficient funds.
42//! 4. If `address_balance >= total_required` --> **path 1**.
43//! 5. Otherwise --> **path 2**.
44//!
45//! ## Path 1: individual withdrawal
46//!
47//! When the address balance covers everything, each intent becomes a
48//! single direct withdrawal. No coins are touched, which enables
49//! parallel execution.
50//!
51//! ```text
52//! for each intent:
53//!     Coin    -> coin::redeem_funds(withdrawal(amount))
54//!     Balance -> balance::redeem_funds(withdrawal(amount))
55//! ```
56//!
57//! ## Path 2: merge and split
58//!
59//! Used when the address balance alone is insufficient. Coins are
60//! loaded first (up to 500 for dust consolidation), and address
61//! balance covers whatever the coins can't.
62//!
63//! ### Step 1 -- build sources and merge
64//!
65//! ```text
66//! coins = select_coins(total_required)   // loads up to 500
67//! shortfall = max(0, total_required - loaded_coin_balance)
68//!
69//! if SUI and gas coin enabled:
70//!     // coins added as gas payment objects
71//!     sources = [tx.gas]
72//!     if shortfall > 0:
73//!         sources.push(coin::redeem_funds(withdrawal(shortfall)))
74//! else:
75//!     sources = [...coins]
76//!     if shortfall > 0:
77//!         sources.push(coin::redeem_funds(withdrawal(shortfall)))
78//!
79//! base_coin = sources[0]
80//! MergeCoins(base_coin, sources[1..])   // chunked at 500
81//! ```
82//!
83//! ### Step 2 -- split and convert
84//!
85//! ```text
86//! SplitCoins(base_coin, [amt_1, amt_2, ..., amt_n])
87//!     -> results mapped to intents
88//!
89//! for each Balance intent:
90//!     coin::into_balance(split_result)  ->  intent result
91//! ```
92//!
93//! ### Step 3 -- remainder
94//!
95//! After splitting, the merged coin may have leftover balance from
96//! coin consolidation.
97//!
98//! - **AB was used or Balance intents exist**: send the remainder
99//!   back to the sender's address balance via `coin::send_funds`.
100//! - **Coin-only, no AB**: the merged coin stays with the sender as
101//!   an owned object.
102//! - **Gas coin**: no remainder handling -- the leftover stays in
103//!   the gas coin.
104
105use crate::Argument;
106use crate::Function;
107use crate::ObjectInput;
108use crate::TransactionBuilder;
109use crate::builder::ResolvedArgument;
110use crate::intent::BoxError;
111use crate::intent::Intent;
112use crate::intent::IntentResolver;
113use crate::intent::MAX_ARGUMENTS;
114use crate::intent::MAX_GAS_OBJECTS;
115use futures::StreamExt;
116use std::collections::BTreeMap;
117use std::collections::HashSet;
118use sui_rpc::field::FieldMask;
119use sui_rpc::field::FieldMaskUtil;
120use sui_rpc::proto::sui::rpc::v2::GetBalanceRequest;
121use sui_rpc::proto::sui::rpc::v2::ListOwnedObjectsRequest;
122use sui_sdk_types::Address;
123use sui_sdk_types::Identifier;
124use sui_sdk_types::StructTag;
125
126/// An intent requesting a `Coin<T>` of a specific type and balance.
127///
128/// When resolved via [`TransactionBuilder::build`](crate::TransactionBuilder::build), the
129/// resolver selects coins owned by the sender, merges them if necessary, and splits off the
130/// requested amount.
131///
132/// # Examples
133///
134/// ```
135/// use sui_transaction_builder::intent::Coin;
136///
137/// // Request 1 SUI (uses gas coin by default)
138/// let coin = Coin::sui(1_000_000_000);
139///
140/// // Request a custom coin type
141/// use sui_sdk_types::StructTag;
142/// let usdc = Coin::new(
143///     "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC"
144///         .parse()
145///         .unwrap(),
146///     1_000_000,
147/// );
148/// ```
149pub struct Coin {
150    coin_type: StructTag,
151    balance: u64,
152    use_gas_coin: bool,
153}
154
155/// Backward-compatible alias for [`Coin`].
156pub type CoinWithBalance = Coin;
157
158impl Coin {
159    /// Create a new [`Coin`] intent for the given coin type and amount (in base units).
160    ///
161    /// ```
162    /// use sui_sdk_types::StructTag;
163    /// use sui_transaction_builder::intent::Coin;
164    ///
165    /// let coin = Coin::new(StructTag::sui(), 1_000_000_000);
166    /// ```
167    pub fn new(coin_type: StructTag, balance: u64) -> Self {
168        Self {
169            coin_type,
170            balance,
171            use_gas_coin: true,
172        }
173    }
174
175    /// Shorthand for requesting a SUI coin with the given balance.
176    ///
177    /// By default the resolver will draw from the gas coin. Call
178    /// [`with_use_gas_coin(false)`](Self::with_use_gas_coin) to opt out.
179    ///
180    /// ```
181    /// use sui_transaction_builder::intent::Coin;
182    ///
183    /// let one_sui = Coin::sui(1_000_000_000);
184    /// ```
185    pub fn sui(balance: u64) -> Self {
186        Self {
187            coin_type: StructTag::sui(),
188            balance,
189            use_gas_coin: true,
190        }
191    }
192
193    /// Control whether the resolver should draw from the gas coin.
194    ///
195    /// This is only meaningful when the coin type is SUI. Pass `false` to force the resolver
196    /// to select non-gas SUI coins instead.
197    ///
198    /// ```
199    /// use sui_transaction_builder::intent::Coin;
200    ///
201    /// // Don't touch the gas coin -- select other SUI coins instead
202    /// let coin = Coin::sui(1_000_000_000).with_use_gas_coin(false);
203    /// ```
204    pub fn with_use_gas_coin(self, use_gas_coin: bool) -> Self {
205        Self {
206            use_gas_coin,
207            ..self
208        }
209    }
210}
211
212/// An intent requesting a `Balance<T>` of a specific type and amount.
213///
214/// When resolved via [`TransactionBuilder::build`](crate::TransactionBuilder::build), the
215/// resolver prefers withdrawing directly from the sender's address balance when possible,
216/// falling back to coin selection and conversion when necessary.
217///
218/// # Examples
219///
220/// ```
221/// use sui_transaction_builder::intent::Balance;
222///
223/// // Request 1 SUI as a Balance<SUI>
224/// let bal = Balance::sui(1_000_000_000);
225///
226/// // Request a custom type
227/// use sui_sdk_types::StructTag;
228/// let bal = Balance::new(
229///     "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC"
230///         .parse()
231///         .unwrap(),
232///     1_000_000,
233/// );
234/// ```
235pub struct Balance {
236    coin_type: StructTag,
237    balance: u64,
238    use_gas_coin: bool,
239}
240
241impl Balance {
242    /// Create a new [`Balance`] intent for the given coin type and amount (in base units).
243    ///
244    /// ```
245    /// use sui_sdk_types::StructTag;
246    /// use sui_transaction_builder::intent::Balance;
247    ///
248    /// let bal = Balance::new(StructTag::sui(), 1_000_000_000);
249    /// ```
250    pub fn new(coin_type: StructTag, balance: u64) -> Self {
251        Self {
252            coin_type,
253            balance,
254            use_gas_coin: true,
255        }
256    }
257
258    /// Shorthand for requesting a SUI balance with the given amount.
259    ///
260    /// By default the resolver will draw from the gas coin when falling back to the
261    /// merge-and-split path. Call [`with_use_gas_coin(false)`](Self::with_use_gas_coin) to
262    /// opt out.
263    ///
264    /// ```
265    /// use sui_transaction_builder::intent::Balance;
266    ///
267    /// let one_sui = Balance::sui(1_000_000_000);
268    /// ```
269    pub fn sui(balance: u64) -> Self {
270        Self {
271            coin_type: StructTag::sui(),
272            balance,
273            use_gas_coin: true,
274        }
275    }
276
277    /// Control whether the resolver should draw from the gas coin when the merge-and-split
278    /// path is used.
279    ///
280    /// This is only meaningful when the coin type is SUI. Pass `false` to force the resolver
281    /// to select non-gas SUI coins instead.
282    ///
283    /// ```
284    /// use sui_transaction_builder::intent::Balance;
285    ///
286    /// let bal = Balance::sui(1_000_000_000).with_use_gas_coin(false);
287    /// ```
288    pub fn with_use_gas_coin(self, use_gas_coin: bool) -> Self {
289        Self {
290            use_gas_coin,
291            ..self
292        }
293    }
294}
295
296impl Intent for Coin {
297    fn register(self, builder: &mut TransactionBuilder) -> Argument {
298        builder.register_resolver(Resolver);
299        builder.unresolved(self)
300    }
301}
302
303impl Intent for Balance {
304    fn register(self, builder: &mut TransactionBuilder) -> Argument {
305        builder.register_resolver(Resolver);
306        builder.unresolved(self)
307    }
308}
309
310#[derive(Debug)]
311struct Resolver;
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314enum IntentKind {
315    Coin,
316    Balance,
317}
318
319#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
320enum CoinType {
321    Gas,
322    Coin(StructTag),
323}
324
325struct Request {
326    id: usize,
327    balance: u64,
328    kind: IntentKind,
329}
330
331#[async_trait::async_trait]
332impl IntentResolver for Resolver {
333    async fn resolve(
334        &self,
335        builder: &mut TransactionBuilder,
336        client: &mut sui_rpc::Client,
337    ) -> Result<(), BoxError> {
338        // Collect all the requests (both Coin and Balance intents).
339        let mut requests: BTreeMap<CoinType, Vec<Request>> = BTreeMap::new();
340        let mut zero_values = Vec::new();
341
342        for (id, intent) in builder.intents.extract_if(.., |_id, intent| {
343            intent.downcast_ref::<Coin>().is_some() || intent.downcast_ref::<Balance>().is_some()
344        }) {
345            if let Some(coin) = intent.downcast_ref::<Coin>() {
346                if coin.balance == 0 {
347                    zero_values.push((id, coin.coin_type.clone(), IntentKind::Coin));
348                } else {
349                    let coin_type = if coin.coin_type == StructTag::sui() && coin.use_gas_coin {
350                        CoinType::Gas
351                    } else {
352                        CoinType::Coin(coin.coin_type.clone())
353                    };
354                    requests.entry(coin_type).or_default().push(Request {
355                        id,
356                        balance: coin.balance,
357                        kind: IntentKind::Coin,
358                    });
359                }
360            } else if let Some(bal) = intent.downcast_ref::<Balance>() {
361                if bal.balance == 0 {
362                    zero_values.push((id, bal.coin_type.clone(), IntentKind::Balance));
363                } else {
364                    let coin_type = if bal.coin_type == StructTag::sui() && bal.use_gas_coin {
365                        CoinType::Gas
366                    } else {
367                        CoinType::Coin(bal.coin_type.clone())
368                    };
369                    requests.entry(coin_type).or_default().push(Request {
370                        id,
371                        balance: bal.balance,
372                        kind: IntentKind::Balance,
373                    });
374                }
375            }
376        }
377
378        for (id, coin_type, kind) in zero_values {
379            Resolver::resolve_zero(builder, coin_type, id, kind);
380        }
381
382        for (coin_type, requests) in requests {
383            Resolver::resolve_merge_and_split(builder, client, coin_type, &requests).await?;
384        }
385
386        Ok(())
387    }
388}
389
390impl Resolver {
391    fn resolve_zero(
392        builder: &mut TransactionBuilder,
393        coin_type: StructTag,
394        request_id: usize,
395        kind: IntentKind,
396    ) {
397        let (module, function) = match kind {
398            IntentKind::Coin => ("coin", "zero"),
399            IntentKind::Balance => ("balance", "zero"),
400        };
401
402        let result = builder.move_call(
403            Function::new(
404                Address::TWO,
405                Identifier::from_static(module),
406                Identifier::from_static(function),
407            )
408            .with_type_args(vec![coin_type.into()]),
409            vec![],
410        );
411
412        *builder.arguments.get_mut(&request_id).unwrap() = ResolvedArgument::ReplaceWith(result);
413    }
414
415    /// Convert a `Coin<T>` into a `Balance<T>` via `0x2::coin::into_balance`.
416    fn into_balance(
417        builder: &mut TransactionBuilder,
418        coin_type: &StructTag,
419        coin_arg: Argument,
420    ) -> Argument {
421        builder.move_call(
422            Function::new(
423                Address::TWO,
424                Identifier::from_static("coin"),
425                Identifier::from_static("into_balance"),
426            )
427            .with_type_args(vec![coin_type.clone().into()]),
428            vec![coin_arg],
429        )
430    }
431
432    /// Send a coin's remaining value back to an address's address balance via
433    /// `0x2::coin::send_funds`.
434    fn send_funds(
435        builder: &mut TransactionBuilder,
436        coin_type: &StructTag,
437        coin_arg: Argument,
438        owner: Address,
439    ) {
440        let owner_arg = builder.pure(&owner);
441        builder.move_call(
442            Function::new(
443                Address::TWO,
444                Identifier::from_static("coin"),
445                Identifier::from_static("send_funds"),
446            )
447            .with_type_args(vec![coin_type.clone().into()]),
448            vec![coin_arg, owner_arg],
449        );
450    }
451
452    /// Path 1: AB covers everything -- individual withdrawals per intent.
453    fn resolve_individual_withdrawals(
454        builder: &mut TransactionBuilder,
455        coin_type: &StructTag,
456        requests: &[Request],
457    ) {
458        for request in requests {
459            let result = match request.kind {
460                IntentKind::Coin => {
461                    builder.funds_withdrawal_coin(coin_type.clone().into(), request.balance)
462                }
463                IntentKind::Balance => {
464                    builder.funds_withdrawal_balance(coin_type.clone().into(), request.balance)
465                }
466            };
467            *builder.arguments.get_mut(&request.id).unwrap() =
468                ResolvedArgument::ReplaceWith(result);
469        }
470    }
471
472    /// Resolve all intents for a single coin type via path 2 (merge and
473    /// split). The `use_gas_coin` flag controls how coins become sources:
474    ///
475    /// - **gas coin**: coins are added as gas payment objects and the gas
476    ///   coin (`tx.gas`) is the merge base. AB shortfall includes a ~0.5
477    ///   SUI buffer for gas fees. No remainder handling.
478    /// - **non-gas**: first loaded coin is the merge base. Remainder is
479    ///   sent back to AB when AB was used or Balance intents are present.
480    async fn resolve_merge_and_split(
481        builder: &mut TransactionBuilder,
482        client: &mut sui_rpc::Client,
483        coin_type: CoinType,
484        requests: &[Request],
485    ) -> Result<(), BoxError> {
486        let (coin_type, use_gas_coin) = match coin_type {
487            CoinType::Gas => (StructTag::sui(), true),
488            CoinType::Coin(t) => (t, false),
489        };
490        let coin_type = &coin_type;
491        let sender = builder
492            .sender()
493            .ok_or("Sender must be set to resolve Coin/Balance intents")?;
494
495        if requests.is_empty() {
496            return Err("BUG: requests is empty".into());
497        }
498
499        let sum = requests.iter().map(|r| r.balance).sum();
500
501        let balance_response = client
502            .state_client()
503            .get_balance(
504                GetBalanceRequest::default()
505                    .with_owner(sender)
506                    .with_coin_type(coin_type),
507            )
508            .await?;
509
510        // Extract chain_id and epoch from the response metadata for the
511        // gas coin reservation ref.
512        let _chain_id = {
513            use sui_rpc::client::ResponseExt;
514            (balance_response.chain_id(), balance_response.epoch())
515        };
516
517        let balance = balance_response
518            .into_inner()
519            .balance
520            .take()
521            .unwrap_or_default();
522
523        // Early return with an error if the sender does not have sufficient balance.
524        if balance.balance() < sum {
525            return Err(format!(
526                "address {} does not have sufficient balance of {}: requested {} available {}",
527                sender,
528                coin_type,
529                sum,
530                balance.balance()
531            )
532            .into());
533        }
534
535        // Path 1: AB covers everything -- individual withdrawals per intent.
536        // No coins are touched, enabling parallel execution.
537        if balance.address_balance() >= sum {
538            Self::resolve_individual_withdrawals(builder, coin_type, requests);
539            return Ok(());
540        }
541
542        // Path 2: AB insufficient, need coins. Coins are loaded first (up
543        // to 500 for consolidation), AB covers whatever the coins can't.
544        let excludes = builder.used_object_ids();
545        let (coins, remaining) =
546            Self::select_coins(client, &sender, coin_type, sum, &excludes).await?;
547
548        if balance.address_balance() < remaining {
549            return Err(format!(
550                "unable to find sufficient coins of type {}. \
551                 requested {} found {} and AB of {} is insufficient to cover difference.",
552                coin_type,
553                sum,
554                sum - remaining,
555                balance.address_balance(),
556            )
557            .into());
558        }
559
560        if coins.is_empty() {
561            // No coins found but AB covers the full amount (caught by
562            // path 1 above in normal flow). Included for completeness.
563            Self::resolve_individual_withdrawals(builder, coin_type, requests);
564            return Ok(());
565        }
566
567        // Build the merge base and fold all sources into it.
568        let has_balance_intents = requests.iter().any(|r| r.kind == IntentKind::Balance);
569        let used_ab = remaining > 0;
570
571        let (base, deps) = if use_gas_coin {
572            Self::prepare_gas_coin_sources(
573                builder,
574                &coins,
575                remaining,
576                coin_type,
577                // sender,
578                balance.address_balance(),
579                // chain_id,
580            )
581        } else {
582            Self::prepare_coin_sources(builder, coins, remaining, coin_type)
583        };
584
585        // Split all requested amounts from the merged base.
586        let amounts = requests.iter().map(|r| builder.pure(&r.balance)).collect();
587        let outputs = builder.split_coins(base, amounts);
588        if !deps.is_empty() {
589            builder
590                .commands
591                .last_entry()
592                .unwrap()
593                .get_mut()
594                .dependencies
595                .extend(deps);
596        }
597
598        // Remainder handling.
599        if !use_gas_coin && (used_ab || has_balance_intents) {
600            Self::send_funds(builder, coin_type, base, sender);
601        }
602
603        // Assign results, converting Balance intents via coin::into_balance.
604        for (result, request) in outputs.into_iter().zip(requests.iter()) {
605            let final_arg = match request.kind {
606                IntentKind::Balance => Self::into_balance(builder, coin_type, result),
607                IntentKind::Coin => result,
608            };
609            *builder.arguments.get_mut(&request.id).unwrap() =
610                ResolvedArgument::ReplaceWith(final_arg);
611        }
612
613        Ok(())
614    }
615
616    /// Prepare coin sources for the **non-gas** path. The first loaded coin
617    /// becomes the merge base and all remaining coins (plus an optional AB
618    /// shortfall withdrawal) are merged into it.
619    fn prepare_coin_sources(
620        builder: &mut TransactionBuilder,
621        coins: Vec<ObjectInput>,
622        remaining: u64,
623        coin_type: &StructTag,
624    ) -> (Argument, Vec<Argument>) {
625        let coin_args: Vec<Argument> = coins.into_iter().map(|c| builder.object(c)).collect();
626        let (&first, rest) = coin_args.split_first().expect("coins must not be empty");
627        let mut deps = Vec::new();
628
629        for chunk in rest.chunks(MAX_ARGUMENTS) {
630            builder.merge_coins(first, chunk.to_vec());
631            deps.push(Argument::new(*builder.commands.last_key_value().unwrap().0));
632        }
633
634        if remaining > 0 {
635            let ab_coin = builder.funds_withdrawal_coin(coin_type.clone().into(), remaining);
636            deps.push(Argument::new(*builder.commands.last_key_value().unwrap().0));
637            builder.merge_coins(first, vec![ab_coin]);
638            deps.push(Argument::new(*builder.commands.last_key_value().unwrap().0));
639        }
640
641        (first, deps)
642    }
643
644    /// Prepare coin sources for the **gas coin** path. Coins are added as
645    /// gas payment objects (up to 250); the excess is merged into the gas
646    /// coin. If there is an AB shortfall, it is withdrawn via
647    /// `FundsWithdrawal` and a synthetic gas reservation ref is prepended
648    /// to the gas payment array so the server reserves the remaining AB
649    /// for gas.
650    fn prepare_gas_coin_sources(
651        builder: &mut TransactionBuilder,
652        coins: &[ObjectInput],
653        remaining: u64,
654        coin_type: &StructTag,
655        // sender: Address,
656        address_balance: u64,
657        // chain_info: (Option<sui_sdk_types::Digest>, Option<u64>),
658    ) -> (Argument, Vec<Argument>) {
659        let gas = builder.gas();
660        let mut deps = Vec::new();
661
662        // Insert a gas coin reservation ref so the server reserves
663        // address balance for gas payment.
664        // let reservation_amount = address_balance.saturating_sub(remaining);
665        // if let (Some(chain_id), Some(epoch)) = chain_info
666        //     && reservation_amount > 0
667        // {
668        //     let reservation = sui_sdk_types::ObjectReference::coin_reservation(
669        //         coin_type,
670        //         reservation_amount,
671        //         epoch,
672        //         chain_id,
673        //         sender,
674        //     );
675        //     let (id, version, digest) = reservation.into_parts();
676        //     builder.add_gas_objects([ObjectInput::owned(id, version, digest)]);
677        // }
678
679        let (use_as_gas, excess) = coins.split_at(std::cmp::min(
680            coins.len(),
681            MAX_GAS_OBJECTS.saturating_sub(builder.gas.len()),
682        ));
683        builder.add_gas_objects(use_as_gas.iter().cloned());
684
685        for chunk in excess
686            .iter()
687            .map(|coin| builder.object(coin.clone()))
688            .collect::<Vec<_>>()
689            .chunks(MAX_ARGUMENTS)
690        {
691            builder.merge_coins(gas, chunk.to_vec());
692            deps.push(Argument::new(*builder.commands.last_key_value().unwrap().0));
693        }
694
695        if remaining > 0 {
696            // Reserve the remaining AB to account for gas
697            let ab_coin = builder.funds_withdrawal_coin(coin_type.clone().into(), address_balance);
698            deps.push(Argument::new(*builder.commands.last_key_value().unwrap().0));
699            builder.merge_coins(gas, vec![ab_coin]);
700            deps.push(Argument::new(*builder.commands.last_key_value().unwrap().0));
701        }
702
703        (gas, deps)
704    }
705
706    async fn select_coins(
707        client: &mut sui_rpc::Client,
708        owner_address: &Address,
709        coin_type: &StructTag,
710        amount: u64,
711        excludes: &HashSet<Address>,
712    ) -> Result<(Vec<ObjectInput>, u64), BoxError> {
713        let coin_struct = StructTag::coin(coin_type.clone().into());
714        let list_request = ListOwnedObjectsRequest::default()
715            .with_owner(owner_address)
716            .with_object_type(&coin_struct)
717            .with_page_size(500u32)
718            .with_read_mask(FieldMask::from_paths([
719                "object_id",
720                "version",
721                "digest",
722                "balance",
723                "owner",
724            ]));
725
726        let mut coin_stream = Box::pin(client.list_owned_objects(list_request));
727        let mut selected_coins = Vec::new();
728        let mut remaining = amount;
729
730        while let Some(object_result) = coin_stream.next().await {
731            let object = object_result?;
732            let coin = ObjectInput::try_from_object_proto(&object)?;
733
734            if excludes.contains(&coin.object_id()) {
735                continue;
736            }
737
738            remaining = remaining.saturating_sub(object.balance());
739            selected_coins.push(coin);
740
741            // If we've found enough, continue collecting coins to smash up to ~500.
742            if remaining == 0 && selected_coins.len() >= 500 {
743                break;
744            }
745        }
746
747        Ok((selected_coins, remaining))
748    }
749}