sui_transaction_builder/intent/
coin_with_balance.rs

1use crate::Argument;
2use crate::Function;
3use crate::ObjectInput;
4use crate::TransactionBuilder;
5use crate::builder::ResolvedArgument;
6use crate::intent::BoxError;
7use crate::intent::Intent;
8use crate::intent::IntentResolver;
9use crate::intent::MAX_ARGUMENTS;
10use crate::intent::MAX_GAS_OBJECTS;
11use futures::StreamExt;
12use std::collections::BTreeMap;
13use std::collections::HashSet;
14use sui_rpc::field::FieldMask;
15use sui_rpc::field::FieldMaskUtil;
16use sui_rpc::proto::sui::rpc::v2::GetBalanceRequest;
17use sui_rpc::proto::sui::rpc::v2::ListOwnedObjectsRequest;
18use sui_sdk_types::Address;
19use sui_sdk_types::Identifier;
20use sui_sdk_types::StructTag;
21
22/// An intent requesting a coin of a specific type and balance.
23///
24/// When resolved via [`TransactionBuilder::build`](crate::TransactionBuilder::build), the
25/// resolver selects coins owned by the sender, merges them if necessary, and splits off the
26/// requested amount.
27///
28/// # Examples
29///
30/// ```
31/// use sui_transaction_builder::intent::CoinWithBalance;
32///
33/// // Request 1 SUI (uses gas coin by default)
34/// let coin = CoinWithBalance::sui(1_000_000_000);
35///
36/// // Request a custom coin type
37/// use sui_sdk_types::StructTag;
38/// let usdc = CoinWithBalance::new(
39///     "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC"
40///         .parse()
41///         .unwrap(),
42///     1_000_000,
43/// );
44/// ```
45pub struct CoinWithBalance {
46    coin_type: StructTag,
47    balance: u64,
48    use_gas_coin: bool,
49}
50
51impl CoinWithBalance {
52    /// Create a new [`CoinWithBalance`] intent for the given coin type and amount (in base
53    /// units).
54    ///
55    /// ```
56    /// use sui_sdk_types::StructTag;
57    /// use sui_transaction_builder::intent::CoinWithBalance;
58    ///
59    /// let coin = CoinWithBalance::new(StructTag::sui(), 1_000_000_000);
60    /// ```
61    pub fn new(coin_type: StructTag, balance: u64) -> Self {
62        Self {
63            coin_type,
64            balance,
65            use_gas_coin: true,
66        }
67    }
68
69    /// Shorthand for requesting a SUI coin with the given balance.
70    ///
71    /// By default the resolver will draw from the gas coin. Call
72    /// [`with_use_gas_coin(false)`](Self::with_use_gas_coin) to opt out.
73    ///
74    /// ```
75    /// use sui_transaction_builder::intent::CoinWithBalance;
76    ///
77    /// let one_sui = CoinWithBalance::sui(1_000_000_000);
78    /// ```
79    pub fn sui(balance: u64) -> Self {
80        Self {
81            coin_type: StructTag::sui(),
82            balance,
83            use_gas_coin: true,
84        }
85    }
86
87    /// Control whether the resolver should draw from the gas coin.
88    ///
89    /// This is only meaningful when the coin type is SUI. Pass `false` to force the resolver
90    /// to select non-gas SUI coins instead.
91    ///
92    /// ```
93    /// use sui_transaction_builder::intent::CoinWithBalance;
94    ///
95    /// // Don't touch the gas coin — select other SUI coins instead
96    /// let coin = CoinWithBalance::sui(1_000_000_000).with_use_gas_coin(false);
97    /// ```
98    pub fn with_use_gas_coin(self, use_gas_coin: bool) -> Self {
99        Self {
100            use_gas_coin,
101            ..self
102        }
103    }
104}
105
106impl Intent for CoinWithBalance {
107    fn register(self, builder: &mut TransactionBuilder) -> Argument {
108        builder.register_resolver(CoinWithBalanceResolver);
109        builder.unresolved(self)
110    }
111}
112
113#[derive(Debug)]
114struct CoinWithBalanceResolver;
115
116#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
117enum CoinType {
118    Gas,
119    Coin(StructTag),
120}
121
122#[async_trait::async_trait]
123impl IntentResolver for CoinWithBalanceResolver {
124    async fn resolve(
125        &self,
126        builder: &mut TransactionBuilder,
127        client: &mut sui_rpc::Client,
128    ) -> Result<(), BoxError> {
129        // Collect all the requests
130        let mut requests: BTreeMap<CoinType, Vec<(usize, u64)>> = BTreeMap::new();
131        let mut zero_values = Vec::new();
132
133        for (id, intent) in builder.intents.extract_if(.., |_id, intent| {
134            intent.downcast_ref::<CoinWithBalance>().is_some()
135        }) {
136            let request = intent.downcast_ref::<CoinWithBalance>().unwrap();
137
138            if request.balance == 0 {
139                zero_values.push((id, request.coin_type.clone()));
140            } else {
141                let coin_type = if request.coin_type == StructTag::sui() && request.use_gas_coin {
142                    CoinType::Gas
143                } else {
144                    CoinType::Coin(request.coin_type.clone())
145                };
146                requests
147                    .entry(coin_type)
148                    .or_default()
149                    .push((id, request.balance));
150            }
151        }
152
153        for (id, coin_type) in zero_values {
154            CoinWithBalanceResolver::resolve_zero_balance_coin(builder, coin_type, id);
155        }
156
157        for (coin_type, requests) in requests {
158            match coin_type {
159                CoinType::Gas => {
160                    CoinWithBalanceResolver::resolve_gas_coin(builder, client, &requests).await?;
161                }
162                CoinType::Coin(coin_type) => {
163                    CoinWithBalanceResolver::resolve_coin_type(
164                        builder, client, &coin_type, &requests,
165                    )
166                    .await?;
167                }
168            }
169        }
170
171        Ok(())
172    }
173}
174
175impl CoinWithBalanceResolver {
176    fn resolve_zero_balance_coin(
177        builder: &mut TransactionBuilder,
178        coin_type: StructTag,
179        request_id: usize,
180    ) {
181        let coin = builder.move_call(
182            Function::new(
183                Address::TWO,
184                Identifier::from_static("coin"),
185                Identifier::from_static("zero"),
186            )
187            .with_type_args(vec![coin_type.into()]),
188            vec![],
189        );
190
191        *builder.arguments.get_mut(&request_id).unwrap() = ResolvedArgument::ReplaceWith(coin);
192    }
193
194    async fn resolve_coin_type(
195        builder: &mut TransactionBuilder,
196        client: &mut sui_rpc::Client,
197        coin_type: &StructTag,
198        requests: &[(usize, u64)],
199    ) -> Result<(), BoxError> {
200        let sender = builder
201            .sender()
202            .ok_or("Sender must be set to resolve CoinWithBalance")?;
203
204        if requests.is_empty() {
205            return Err("BUG: requests is empty".into());
206        }
207
208        let sum = requests.iter().map(|(_, balance)| *balance).sum();
209
210        let balance = client
211            .state_client()
212            .get_balance(
213                GetBalanceRequest::default()
214                    .with_owner(sender)
215                    .with_coin_type(coin_type),
216            )
217            .await?
218            .into_inner()
219            .balance
220            .take()
221            .unwrap_or_default();
222
223        // Early return with an error if the sender does not have sufficient balance
224        if balance.balance() < sum {
225            return Err(format!(
226                "address {} does not have sufficient balance of {}: requested {} available {}",
227                sender,
228                coin_type,
229                sum,
230                balance.balance()
231            )
232            .into());
233        }
234
235        let excludes = builder.used_object_ids();
236        let (coins, remaining) =
237            Self::select_coins(client, &sender, coin_type, sum, &excludes).await?;
238
239        // If address balance amount isn't enough to cover the remaining requested amount we need
240        // to bail
241        if balance.address_balance() < remaining {
242            return Err(format!(
243                "unable to find sufficient coins of type {}. requested {} found {} and AB of {} is insufficient to cover difference.",
244                coin_type,
245                sum,
246                sum - remaining,
247                balance.address_balance(),
248            )
249            .into());
250        }
251
252        let split_coin_args = if let [first, rest @ ..] = coins
253            .into_iter()
254            .map(|coin| builder.object(coin))
255            .collect::<Vec<_>>()
256            .as_slice()
257        {
258            // We have at least 1 coin
259
260            let mut deps = Vec::new();
261            for chunk in rest.chunks(MAX_ARGUMENTS) {
262                builder.merge_coins(*first, chunk.to_vec());
263                deps.push(Argument::new(*builder.commands.last_key_value().unwrap().0));
264            }
265
266            // If the coins we selected were not enough we need to pull from AB for the remaining
267            // amount and merge it into the first coin
268            if remaining > 0 {
269                let ab_coin = builder.funds_withdrawal_coin(coin_type.clone().into(), remaining);
270                deps.push(Argument::new(*builder.commands.last_key_value().unwrap().0));
271                builder.merge_coins(*first, vec![ab_coin]);
272                deps.push(Argument::new(*builder.commands.last_key_value().unwrap().0));
273            }
274
275            let amounts = requests
276                .iter()
277                .map(|(_, balance)| builder.pure(balance))
278                .collect();
279            let coin_outputs = builder.split_coins(*first, amounts);
280            if !deps.is_empty() {
281                builder
282                    .commands
283                    .last_entry()
284                    .unwrap()
285                    .get_mut()
286                    .dependencies
287                    .extend(deps);
288            }
289
290            //TODO send remaining to AB
291
292            coin_outputs
293        } else {
294            // We have no coins, but have sufficient AB to cover all requested amounts
295            requests
296                .iter()
297                .map(|(_, balance)| {
298                    builder.funds_withdrawal_coin(coin_type.clone().into(), *balance)
299                })
300                .collect()
301        };
302
303        for (coin, request_index) in split_coin_args
304            .into_iter()
305            .zip(requests.iter().map(|(index, _)| *index))
306        {
307            *builder.arguments.get_mut(&request_index).unwrap() =
308                ResolvedArgument::ReplaceWith(coin);
309        }
310
311        Ok(())
312    }
313
314    async fn resolve_gas_coin(
315        builder: &mut TransactionBuilder,
316        client: &mut sui_rpc::Client,
317        requests: &[(usize, u64)],
318    ) -> Result<(), BoxError> {
319        let sender = builder
320            .sender()
321            .ok_or("Sender must be set to resolve CoinWithBalance")?;
322
323        if requests.is_empty() {
324            return Err("BUG: requests is empty".into());
325        }
326
327        let coin_type = StructTag::sui();
328
329        let sum = requests.iter().map(|(_, balance)| *balance).sum();
330
331        let balance = client
332            .state_client()
333            .get_balance(
334                GetBalanceRequest::default()
335                    .with_owner(sender)
336                    .with_coin_type(&coin_type),
337            )
338            .await?
339            .into_inner()
340            .balance
341            .take()
342            .unwrap_or_default();
343
344        // Early return with an error if the sender does not have sufficient balance
345        if balance.balance() < sum {
346            return Err(format!(
347                "address {} does not have sufficient balance of {}: requested {} available {}",
348                sender,
349                coin_type,
350                sum,
351                balance.balance()
352            )
353            .into());
354        }
355
356        let excludes = builder.used_object_ids();
357        let (coins, remaining) =
358            Self::select_coins(client, &sender, &coin_type, sum, &excludes).await?;
359
360        // If address balance amount isn't enough to cover the remaining requested amount we need
361        // to bail
362        if balance.address_balance() < remaining {
363            return Err(format!(
364                "unable to find sufficient coins of type {}. requested {} found {} and AB of {} is insufficient to cover difference.",
365                coin_type,
366                sum,
367                sum - remaining,
368                balance.address_balance(),
369            )
370            .into());
371        }
372
373        let coin_args = if coins.is_empty() {
374            // We have no coins, but have sufficient AB to cover all requested amounts
375            requests
376                .iter()
377                .map(|(_, balance)| {
378                    builder.funds_withdrawal_coin(coin_type.clone().into(), *balance)
379                })
380                .collect()
381        } else {
382            let gas = builder.gas();
383            let mut deps = Vec::new();
384
385            // Append to gas coin up to 250 coins
386            let (use_as_gas, remaining_coins) = coins.split_at(std::cmp::min(
387                coins.len(),
388                MAX_GAS_OBJECTS.saturating_sub(builder.gas.len()),
389            ));
390            builder.add_gas_objects(use_as_gas.iter().cloned());
391
392            // Any remaining do a merge coins
393            for chunk in remaining_coins
394                .iter()
395                .map(|coin| builder.object(coin.clone()))
396                .collect::<Vec<_>>()
397                .chunks(MAX_ARGUMENTS)
398            {
399                builder.merge_coins(gas, chunk.to_vec());
400                deps.push(Argument::new(*builder.commands.last_key_value().unwrap().0));
401            }
402
403            // If the coins we selected were not enough we need to pull from AB for the remaining
404            // amount and merge it into the gas coin
405            if remaining > 0 {
406                // reserve a small amount more to account for budget ~.5 SUI's worth
407                let ab_coin = builder
408                    .funds_withdrawal_coin(coin_type.clone().into(), remaining + 500_000_000);
409                deps.push(Argument::new(*builder.commands.last_key_value().unwrap().0));
410                builder.merge_coins(gas, vec![ab_coin]);
411                deps.push(Argument::new(*builder.commands.last_key_value().unwrap().0));
412            }
413
414            let amounts = requests
415                .iter()
416                .map(|(_, balance)| builder.pure(balance))
417                .collect();
418            let split_coin_args = builder.split_coins(gas, amounts);
419            if !deps.is_empty() {
420                builder
421                    .commands
422                    .last_entry()
423                    .unwrap()
424                    .get_mut()
425                    .dependencies
426                    .extend(deps);
427            }
428
429            // We can't send gas coin to AB so we'll leave as-is
430
431            split_coin_args
432        };
433
434        for (coin, request_index) in coin_args
435            .into_iter()
436            .zip(requests.iter().map(|(index, _)| *index))
437        {
438            *builder.arguments.get_mut(&request_index).unwrap() =
439                ResolvedArgument::ReplaceWith(coin);
440        }
441
442        Ok(())
443    }
444
445    async fn select_coins(
446        client: &mut sui_rpc::Client,
447        owner_address: &Address,
448        coin_type: &StructTag,
449        amount: u64,
450        excludes: &HashSet<Address>,
451    ) -> Result<(Vec<ObjectInput>, u64), BoxError> {
452        let coin_struct = StructTag::coin(coin_type.clone().into());
453        let list_request = ListOwnedObjectsRequest::default()
454            .with_owner(owner_address)
455            .with_object_type(&coin_struct)
456            .with_page_size(500u32)
457            .with_read_mask(FieldMask::from_paths([
458                "object_id",
459                "version",
460                "digest",
461                "balance",
462                "owner",
463            ]));
464
465        let mut coin_stream = Box::pin(client.list_owned_objects(list_request));
466        let mut selected_coins = Vec::new();
467        let mut remaining = amount;
468
469        while let Some(object_result) = coin_stream.next().await {
470            let object = object_result?;
471            let coin = ObjectInput::try_from_object_proto(&object)?;
472
473            if excludes.contains(&coin.object_id()) {
474                continue;
475            }
476
477            remaining = remaining.saturating_sub(object.balance());
478            selected_coins.push(coin);
479
480            // if we've found enough, continue collecting coins to smash up to ~500
481            if remaining == 0 && selected_coins.len() >= 500 {
482                break;
483            }
484        }
485
486        Ok((selected_coins, remaining))
487    }
488}