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}