Skip to main content

iota_rpc_loadgen/payload/
rpc_command_processor.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2024 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{
6    fmt,
7    fs::{self, File},
8    path::PathBuf,
9    sync::Arc,
10    time::{Duration, Instant},
11};
12
13use anyhow::{Result, anyhow, bail};
14use async_trait::async_trait;
15use dashmap::{DashMap, DashSet};
16use futures::future::join_all;
17use iota_json_rpc_types::{
18    IotaExecutionStatus, IotaObjectDataOptions, IotaTransactionBlockDataAPI,
19    IotaTransactionBlockEffectsAPI, IotaTransactionBlockResponse,
20    IotaTransactionBlockResponseOptions,
21};
22use iota_sdk::{IotaClient, IotaClientBuilder};
23use iota_sdk_crypto::{ToFromBech32, simple::SimpleKeypair};
24use iota_sdk_types::{Address, ObjectId, ObjectReference, Transaction, TransactionDigest};
25use iota_types::{
26    crypto::{AccountPrivateKey, get_key_pair},
27    quorum_driver_types::ExecuteTransactionRequestType,
28    transaction::TransactionEnvelope,
29};
30use serde::{Serialize, de::DeserializeOwned};
31use tokio::{sync::RwLock, time::sleep};
32use tracing::{debug, info};
33
34use super::MultiGetTransactionBlocks;
35use crate::{
36    load_test::LoadTestConfig,
37    payload::{
38        Command, CommandData, DryRun, GetAllBalances, GetCheckpoints, GetObject, MultiGetObjects,
39        Payload, ProcessPayload, Processor, QueryTransactionBlocks, SignerInfo,
40        checkpoint_utils::get_latest_checkpoint_stats, validation::chunk_entities,
41    },
42};
43
44pub(crate) const DEFAULT_GAS_BUDGET: u64 = 500_000_000;
45pub(crate) const DEFAULT_LARGE_GAS_BUDGET: u64 = 50_000_000_000;
46pub(crate) const MAX_NUM_NEW_OBJECTS_IN_SINGLE_TRANSACTION: usize = 120;
47
48#[derive(Clone)]
49pub struct RpcCommandProcessor {
50    clients: Arc<RwLock<Vec<IotaClient>>>,
51    // for equivocation prevention in `WaitForEffectsCert` mode
52    object_ref_cache: Arc<DashMap<ObjectId, ObjectReference>>,
53    transaction_digests: Arc<DashSet<TransactionDigest>>,
54    addresses: Arc<DashSet<Address>>,
55    data_dir: String,
56}
57
58impl RpcCommandProcessor {
59    pub async fn new(urls: &[String], data_dir: String) -> Self {
60        let clients = join_all(urls.iter().map(|url| async {
61            IotaClientBuilder::default()
62                .max_concurrent_requests(usize::MAX)
63                .request_timeout(Duration::from_secs(60))
64                .build(url.clone())
65                .await
66                .unwrap()
67        }))
68        .await;
69
70        Self {
71            clients: Arc::new(RwLock::new(clients)),
72            object_ref_cache: Arc::new(DashMap::new()),
73            transaction_digests: Arc::new(DashSet::new()),
74            addresses: Arc::new(DashSet::new()),
75            data_dir,
76        }
77    }
78
79    async fn process_command_data(
80        &self,
81        command: &CommandData,
82        signer_info: &Option<SignerInfo>,
83    ) -> Result<()> {
84        match command {
85            CommandData::DryRun(ref v) => self.process(v, signer_info).await,
86            CommandData::GetCheckpoints(ref v) => self.process(v, signer_info).await,
87            CommandData::PayIota(ref v) => self.process(v, signer_info).await,
88            CommandData::QueryTransactionBlocks(ref v) => self.process(v, signer_info).await,
89            CommandData::MultiGetTransactionBlocks(ref v) => self.process(v, signer_info).await,
90            CommandData::MultiGetObjects(ref v) => self.process(v, signer_info).await,
91            CommandData::GetObject(ref v) => self.process(v, signer_info).await,
92            CommandData::GetAllBalances(ref v) => self.process(v, signer_info).await,
93            CommandData::GetReferenceGasPrice(ref v) => self.process(v, signer_info).await,
94        }
95    }
96
97    pub(crate) async fn get_clients(&self) -> Result<Vec<IotaClient>> {
98        let read = self.clients.read().await;
99        Ok(read.clone())
100    }
101
102    /// sign_and_execute transaction and update `object_ref_cache`
103    pub(crate) async fn sign_and_execute(
104        &self,
105        client: &IotaClient,
106        keypair: &SimpleKeypair,
107        tx: Transaction,
108        request_type: ExecuteTransactionRequestType,
109    ) -> IotaTransactionBlockResponse {
110        let resp = sign_and_execute(client, keypair, tx, request_type).await;
111        let effects = resp.effects.as_ref().unwrap();
112        let object_ref_cache = self.object_ref_cache.clone();
113        // NOTE: for now we don't need to care about deleted objects
114        for (owned_object_ref, _) in effects.all_changed_objects() {
115            let id = owned_object_ref.object_id();
116            let current = object_ref_cache.get_mut(&id);
117            match current {
118                Some(mut c) => {
119                    if c.version < owned_object_ref.version() {
120                        *c = owned_object_ref.reference;
121                    }
122                }
123                None => {
124                    object_ref_cache.insert(id, owned_object_ref.reference);
125                }
126            };
127        }
128        resp
129    }
130
131    /// get the latest object ref from local cache, and if not exist, fetch from
132    /// fullnode
133    pub(crate) async fn get_object_ref(
134        &self,
135        client: &IotaClient,
136        object_id: &ObjectId,
137    ) -> ObjectReference {
138        let object_ref_cache = self.object_ref_cache.clone();
139        let current = object_ref_cache.get_mut(object_id);
140        match current {
141            Some(c) => *c,
142            None => {
143                let resp = client
144                    .read_api()
145                    .get_object_with_options(*object_id, IotaObjectDataOptions::new())
146                    .await
147                    .unwrap_or_else(|_| panic!("unable to fetch object reference {object_id}"));
148                let object_ref = resp.object_ref_if_exists().unwrap_or_else(|| {
149                    panic!("unable to extract object reference {object_id} from response {resp:?}")
150                });
151                object_ref_cache.insert(*object_id, object_ref);
152                object_ref
153            }
154        }
155    }
156
157    pub(crate) fn add_transaction_digests(&self, digests: Vec<TransactionDigest>) {
158        // extend method requires mutable access to the underlying DashSet, which is not
159        // allowed by the Arc
160        for digest in digests {
161            self.transaction_digests.insert(digest);
162        }
163    }
164
165    pub(crate) fn add_addresses_from_response(&self, responses: &[IotaTransactionBlockResponse]) {
166        for response in responses {
167            let transaction = &response.transaction;
168            if let Some(transaction) = transaction {
169                let data = &transaction.data;
170                self.addresses.insert(*data.sender());
171            }
172        }
173    }
174
175    pub(crate) fn add_object_ids_from_response(&self, responses: &[IotaTransactionBlockResponse]) {
176        for response in responses {
177            let effects = &response.effects;
178            if let Some(effects) = effects {
179                let all_changed_objects = effects.all_changed_objects();
180                for (object_ref, _) in all_changed_objects {
181                    self.object_ref_cache
182                        .insert(object_ref.object_id(), object_ref.reference);
183                }
184            }
185        }
186    }
187
188    pub(crate) fn dump_cache_to_file(&self) {
189        // TODO: be more granular
190        let digests: Vec<TransactionDigest> = self.transaction_digests.iter().map(|x| *x).collect();
191        if !digests.is_empty() {
192            debug!("dumping transaction digests to file {:?}", digests.len());
193            write_data_to_file(
194                &digests,
195                &format!("{}/{}", self.data_dir, CacheType::TransactionDigest),
196            )
197            .unwrap();
198        }
199
200        let addresses: Vec<Address> = self.addresses.iter().map(|x| *x).collect();
201        if !addresses.is_empty() {
202            debug!("dumping addresses to file {:?}", addresses.len());
203            write_data_to_file(
204                &addresses,
205                &format!("{}/{}", self.data_dir, CacheType::Address),
206            )
207            .unwrap();
208        }
209
210        let mut object_ids: Vec<ObjectId> = Vec::new();
211        let cloned_object_cache = self.object_ref_cache.clone();
212
213        for item in cloned_object_cache.iter() {
214            let object_id = item.key();
215            object_ids.push(*object_id);
216        }
217
218        if !object_ids.is_empty() {
219            debug!("dumping object_ids to file {:?}", object_ids.len());
220            write_data_to_file(
221                &object_ids,
222                &format!("{}/{}", self.data_dir, CacheType::ObjectId),
223            )
224            .unwrap();
225        }
226    }
227}
228
229#[async_trait]
230impl Processor for RpcCommandProcessor {
231    async fn apply(&self, payload: &Payload) -> Result<()> {
232        let commands = &payload.commands;
233        for command in commands.iter() {
234            let repeat_interval = command.repeat_interval;
235            let repeat_n_times = command.repeat_n_times;
236            for i in 0..=repeat_n_times {
237                let start_time = Instant::now();
238
239                self.process_command_data(&command.data, &payload.signer_info)
240                    .await?;
241
242                let elapsed_time = start_time.elapsed();
243                if elapsed_time < repeat_interval {
244                    let sleep_duration = repeat_interval - elapsed_time;
245                    sleep(sleep_duration).await;
246                }
247                let clients = self.get_clients().await?;
248                let checkpoint_stats = get_latest_checkpoint_stats(&clients, None).await;
249                info!(
250                    "Repeat {i}: Checkpoint stats {checkpoint_stats}, elapse {:.4} since last repeat",
251                    elapsed_time.as_secs_f64()
252                );
253            }
254        }
255        Ok(())
256    }
257
258    async fn prepare(&self, config: &LoadTestConfig) -> Result<Vec<Payload>> {
259        let clients = self.get_clients().await?;
260        let Command {
261            repeat_n_times,
262            repeat_interval,
263            ..
264        } = &config.command;
265        let command_payloads = match &config.command.data {
266            CommandData::GetCheckpoints(data) => {
267                if !config.divide_tasks {
268                    vec![config.command.clone(); config.num_threads]
269                } else {
270                    divide_checkpoint_tasks(&clients, data, config.num_threads).await
271                }
272            }
273            CommandData::QueryTransactionBlocks(data) => {
274                if !config.divide_tasks {
275                    vec![config.command.clone(); config.num_threads]
276                } else {
277                    divide_query_transaction_blocks_tasks(data, config.num_threads).await
278                }
279            }
280            CommandData::MultiGetTransactionBlocks(data) => {
281                if !config.divide_tasks {
282                    vec![config.command.clone(); config.num_threads]
283                } else {
284                    divide_multi_get_transaction_blocks_tasks(data, config.num_threads).await
285                }
286            }
287            CommandData::GetAllBalances(data) => {
288                if !config.divide_tasks {
289                    vec![config.command.clone(); config.num_threads]
290                } else {
291                    divide_get_all_balances_tasks(data, config.num_threads).await
292                }
293            }
294            CommandData::MultiGetObjects(data) => {
295                if !config.divide_tasks {
296                    vec![config.command.clone(); config.num_threads]
297                } else {
298                    divide_multi_get_objects_tasks(data, config.num_threads).await
299                }
300            }
301            CommandData::GetObject(data) => {
302                if !config.divide_tasks {
303                    vec![config.command.clone(); config.num_threads]
304                } else {
305                    divide_get_object_tasks(data, config.num_threads).await
306                }
307            }
308            _ => vec![config.command.clone(); config.num_threads],
309        };
310
311        let command_payloads = command_payloads.into_iter().map(|command| {
312            command
313                .with_repeat_interval(*repeat_interval)
314                .with_repeat_n_times(*repeat_n_times)
315        });
316
317        let coins_and_keys = if let Some(signer_info) = &config.signer_info {
318            Some(
319                prepare_new_signer_and_coins(
320                    clients.first().unwrap(),
321                    signer_info,
322                    config.num_threads * config.num_chunks_per_thread,
323                    config.max_repeat as u64 + 1,
324                )
325                .await,
326            )
327        } else {
328            None
329        };
330
331        let num_chunks = config.num_chunks_per_thread;
332        Ok(command_payloads
333            .into_iter()
334            .enumerate()
335            .map(|(i, command)| Payload {
336                commands: vec![command], // note commands is also a vector
337                signer_info: coins_and_keys
338                    .as_ref()
339                    .map(|(coins, encoded_keypair)| SignerInfo {
340                        encoded_keypair: encoded_keypair.clone(),
341                        gas_payment: Some(coins[num_chunks * i..(i + 1) * num_chunks].to_vec()),
342                        gas_budget: None,
343                    }),
344            })
345            .collect())
346    }
347
348    fn dump_cache_to_file(&self, config: &LoadTestConfig) {
349        if let CommandData::GetCheckpoints(data) = &config.command.data {
350            if data.record {
351                self.dump_cache_to_file();
352            }
353        }
354    }
355}
356
357#[async_trait]
358impl<'a> ProcessPayload<'a, &'a DryRun> for RpcCommandProcessor {
359    async fn process(&'a self, _op: &'a DryRun, _signer_info: &Option<SignerInfo>) -> Result<()> {
360        debug!("DryRun");
361        Ok(())
362    }
363}
364
365fn write_data_to_file<T: Serialize>(data: &T, file_path: &str) -> Result<(), anyhow::Error> {
366    let mut path_buf = PathBuf::from(&file_path);
367    path_buf.pop();
368    fs::create_dir_all(&path_buf).map_err(|e| anyhow!("error creating directory: {e}"))?;
369
370    let file_name = format!("{file_path}.json");
371    let file = File::create(file_name).map_err(|e| anyhow!("error creating file: {e}"))?;
372    serde_json::to_writer(file, data).map_err(|e| anyhow!("error writing to file: {e}"))?;
373
374    Ok(())
375}
376
377pub enum CacheType {
378    Address,
379    TransactionDigest,
380    ObjectId,
381}
382
383impl fmt::Display for CacheType {
384    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
385        match self {
386            // This is kept `IotaAddress` (as opposed to `Address`) to not invalidate existing
387            // caches
388            CacheType::Address => write!(f, "IotaAddress"),
389            CacheType::TransactionDigest => write!(f, "TransactionDigest"),
390            // This is kept `ObjectID` (as opposed to `ObjectId`) to not invalidate existing caches
391            CacheType::ObjectId => write!(f, "ObjectID"),
392        }
393    }
394}
395
396// TODO(Will): Consider using enums for input and output? Would mean we need to
397// do checks any time we use generic load_cache_from_file
398pub fn load_addresses_from_file(filepath: String) -> Vec<Address> {
399    let path = format!("{}/{}", filepath, CacheType::Address);
400    let addresses: Vec<Address> = read_data_from_file(&path).expect("failed to read addresses");
401    addresses
402}
403
404pub fn load_objects_from_file(filepath: String) -> Vec<ObjectId> {
405    let path = format!("{}/{}", filepath, CacheType::ObjectId);
406    let objects: Vec<ObjectId> = read_data_from_file(&path).expect("failed to read objects");
407    objects
408}
409
410pub fn load_digests_from_file(filepath: String) -> Vec<TransactionDigest> {
411    let path = format!("{}/{}", filepath, CacheType::TransactionDigest);
412    let digests: Vec<TransactionDigest> =
413        read_data_from_file(&path).expect("failed to read transaction digests");
414    digests
415}
416
417fn read_data_from_file<T: DeserializeOwned>(file_path: &str) -> Result<T, anyhow::Error> {
418    let mut path_buf = PathBuf::from(file_path);
419
420    // Check if the file has a JSON extension
421    if path_buf.extension().is_none_or(|ext| ext != "json") {
422        // If not, add .json to the filename
423        path_buf.set_extension("json");
424    }
425
426    let path = path_buf.as_path();
427    if !path.exists() {
428        bail!("file not found: {file_path}");
429    }
430
431    let file = File::open(path).map_err(|e| anyhow!("error opening file: {e}"))?;
432    let deserialized_data: T =
433        serde_json::from_reader(file).map_err(|e| anyhow!("deserialization error: {e}"))?;
434
435    Ok(deserialized_data)
436}
437
438async fn divide_checkpoint_tasks(
439    clients: &[IotaClient],
440    data: &GetCheckpoints,
441    num_chunks: usize,
442) -> Vec<Command> {
443    let start = data.start;
444    let end = match data.end {
445        Some(end) => end,
446        None => {
447            let end_checkpoints = join_all(clients.iter().map(|client| async {
448                client
449                    .read_api()
450                    .get_latest_checkpoint_sequence_number()
451                    .await
452                    .expect("get_latest_checkpoint_sequence_number should not fail")
453            }))
454            .await;
455            *end_checkpoints
456                .iter()
457                .max()
458                .expect("get_latest_checkpoint_sequence_number should not return empty")
459        }
460    };
461
462    let chunk_size = (end - start) / num_chunks as u64;
463    (0..num_chunks)
464        .map(|i| {
465            let start_checkpoint = start + (i as u64) * chunk_size;
466            let end_checkpoint = end.min(start + ((i + 1) as u64) * chunk_size);
467            Command::new_get_checkpoints(
468                start_checkpoint,
469                Some(end_checkpoint),
470                data.verify_transactions,
471                data.verify_objects,
472                data.record,
473            )
474        })
475        .collect()
476}
477
478async fn divide_query_transaction_blocks_tasks(
479    data: &QueryTransactionBlocks,
480    num_chunks: usize,
481) -> Vec<Command> {
482    let chunk_size = if data.addresses.len() < num_chunks {
483        1
484    } else {
485        data.addresses.len() as u64 / num_chunks as u64
486    };
487    let chunked = chunk_entities(data.addresses.as_slice(), Some(chunk_size as usize));
488    chunked
489        .into_iter()
490        .map(|chunk| Command::new_query_transaction_blocks(data.address_type.clone(), chunk))
491        .collect()
492}
493
494async fn divide_multi_get_transaction_blocks_tasks(
495    data: &MultiGetTransactionBlocks,
496    num_chunks: usize,
497) -> Vec<Command> {
498    let chunk_size = if data.digests.len() < num_chunks {
499        1
500    } else {
501        data.digests.len() as u64 / num_chunks as u64
502    };
503    let chunked = chunk_entities(data.digests.as_slice(), Some(chunk_size as usize));
504    chunked
505        .into_iter()
506        .map(Command::new_multi_get_transaction_blocks)
507        .collect()
508}
509
510async fn divide_get_all_balances_tasks(data: &GetAllBalances, num_threads: usize) -> Vec<Command> {
511    let per_thread_size = if data.addresses.len() < num_threads {
512        1
513    } else {
514        data.addresses.len() / num_threads
515    };
516
517    let chunked = chunk_entities(data.addresses.as_slice(), Some(per_thread_size));
518    chunked
519        .into_iter()
520        .map(|chunk| Command::new_get_all_balances(chunk, data.chunk_size))
521        .collect()
522}
523
524// TODO: probs can do generic divide tasks
525async fn divide_multi_get_objects_tasks(data: &MultiGetObjects, num_chunks: usize) -> Vec<Command> {
526    let chunk_size = if data.object_ids.len() < num_chunks {
527        1
528    } else {
529        data.object_ids.len() as u64 / num_chunks as u64
530    };
531    let chunked = chunk_entities(data.object_ids.as_slice(), Some(chunk_size as usize));
532    chunked
533        .into_iter()
534        .map(Command::new_multi_get_objects)
535        .collect()
536}
537
538async fn divide_get_object_tasks(data: &GetObject, num_threads: usize) -> Vec<Command> {
539    let per_thread_size = if data.object_ids.len() < num_threads {
540        1
541    } else {
542        data.object_ids.len() / num_threads
543    };
544
545    let chunked = chunk_entities(data.object_ids.as_slice(), Some(per_thread_size));
546    chunked
547        .into_iter()
548        .map(|chunk| Command::new_get_object(chunk, data.chunk_size))
549        .collect()
550}
551
552async fn prepare_new_signer_and_coins(
553    client: &IotaClient,
554    signer_info: &SignerInfo,
555    num_coins: usize,
556    num_transactions_per_coin: u64,
557) -> (Vec<ObjectId>, String) {
558    // TODO(chris): consider reference gas price
559    let amount_per_coin = num_transactions_per_coin * DEFAULT_GAS_BUDGET;
560    let pay_amount = amount_per_coin * num_coins as u64;
561    let num_split_txns =
562        num_transactions_needed(num_coins, MAX_NUM_NEW_OBJECTS_IN_SINGLE_TRANSACTION);
563    let (gas_fee_for_split, gas_fee_for_pay_iota) = (
564        DEFAULT_LARGE_GAS_BUDGET * num_split_txns as u64,
565        DEFAULT_GAS_BUDGET,
566    );
567
568    let primary_keypair = SimpleKeypair::from_bech32(&signer_info.encoded_keypair)
569        .expect("decoding keypair should not fail");
570    let sender = primary_keypair.public_key().derive_address();
571    let (coin, balance) = get_coin_with_max_balance(client, sender).await;
572    // The balance needs to cover `pay_amount` plus
573    // 1. gas fee for pay_iota from the primary address to the burner address
574    // 2. gas fee for splitting the primary coin into `num_coins`
575    let required_balance = pay_amount + gas_fee_for_split + gas_fee_for_pay_iota;
576    if required_balance > balance {
577        panic!(
578            "current balance {balance} is smaller than require amount of NANOS to fund the operation {required_balance}"
579        );
580    }
581
582    // There is a limit for the number of new objects in a transactions, therefore
583    // we need multiple split transactions if the `num_coins` is large
584    let split_amounts = calculate_split_amounts(
585        num_coins,
586        amount_per_coin,
587        MAX_NUM_NEW_OBJECTS_IN_SINGLE_TRANSACTION,
588    );
589
590    debug!("split_amounts {split_amounts:?}");
591
592    // We don't want to split coins in our primary address because we want to avoid
593    // having a million coin objects in our address. We can also fetch directly
594    // from the faucet, but in some environment that might not be possible when
595    // faucet resource is scarce
596    let (burner_address, burner_key): (_, AccountPrivateKey) = get_key_pair();
597    let burner_keypair = SimpleKeypair::from(burner_key);
598    let pay_amounts = split_amounts
599        .iter()
600        .map(|(amount, _)| *amount)
601        .chain(std::iter::once(gas_fee_for_split))
602        .collect::<Vec<_>>();
603
604    debug!("pay_amounts {pay_amounts:?}");
605
606    pay_iota(
607        client,
608        &primary_keypair,
609        vec![coin],
610        DEFAULT_GAS_BUDGET,
611        vec![burner_address; pay_amounts.len()],
612        pay_amounts,
613    )
614    .await;
615
616    let coins = get_iota_coin_ids(client, burner_address).await;
617    let gas_coin_id = get_coin_with_balance(&coins, gas_fee_for_split);
618    let primary_coin = get_coin_with_balance(&coins, split_amounts[0].0);
619    assert!(!coins.is_empty());
620    let mut results: Vec<ObjectId> = vec![];
621    assert!(!split_amounts.is_empty());
622    if split_amounts.len() == 1 && split_amounts[0].1 == 0 {
623        results.push(get_coin_with_balance(&coins, split_amounts[0].0));
624    } else if split_amounts.len() == 1 {
625        results.extend(
626            split_coins(
627                client,
628                &burner_keypair,
629                primary_coin,
630                gas_coin_id,
631                split_amounts[0].1 as u64,
632            )
633            .await,
634        );
635    } else {
636        let (max_amount, max_split) = &split_amounts[0];
637        let (remainder_amount, remainder_split) = split_amounts.last().unwrap();
638        let primary_coins = coins
639            .iter()
640            .filter(|(_, balance)| balance == max_amount)
641            .map(|(id, _)| (*id, *max_split as u64))
642            .chain(
643                coins
644                    .iter()
645                    .filter(|(_, balance)| balance == remainder_amount)
646                    .map(|(id, _)| (*id, *remainder_split as u64)),
647            )
648            .collect::<Vec<_>>();
649
650        for (coin_id, splits) in primary_coins {
651            results
652                .extend(split_coins(client, &burner_keypair, coin_id, gas_coin_id, splits).await);
653        }
654    }
655    assert_eq!(results.len(), num_coins);
656    debug!("Split off {} coins for gas payment {results:?}", num_coins);
657    (
658        results,
659        burner_keypair
660            .to_bech32()
661            .expect("encoding keypair should not fail"),
662    )
663}
664
665/// Calculate the number of transactions needed to split the given number of
666/// coins. new_coins_per_txn must be greater than 0
667fn num_transactions_needed(num_coins: usize, new_coins_per_txn: usize) -> usize {
668    assert!(new_coins_per_txn > 0);
669    if num_coins == 1 {
670        return 0;
671    }
672    num_coins.div_ceil(new_coins_per_txn)
673}
674
675/// Calculate the split amounts for a given number of coins, amount per coin,
676/// and maximum number of coins per transaction. Returns a Vec of
677/// (primary_coin_amount, split_into_n_coins)
678fn calculate_split_amounts(
679    num_coins: usize,
680    amount_per_coin: u64,
681    max_coins_per_txn: usize,
682) -> Vec<(u64, usize)> {
683    let total_amount = amount_per_coin * num_coins as u64;
684    let num_transactions = num_transactions_needed(num_coins, max_coins_per_txn);
685
686    if num_transactions == 0 {
687        return vec![(total_amount, 0)];
688    }
689
690    let amount_per_transaction = max_coins_per_txn as u64 * amount_per_coin;
691    let remaining_amount = total_amount - amount_per_transaction * (num_transactions as u64 - 1);
692    let mut split_amounts: Vec<(u64, usize)> =
693        vec![(amount_per_transaction, max_coins_per_txn); num_transactions - 1];
694    split_amounts.push((
695        remaining_amount,
696        num_coins - max_coins_per_txn * (num_transactions - 1),
697    ));
698    split_amounts
699}
700
701async fn get_coin_with_max_balance(client: &IotaClient, address: Address) -> (ObjectId, u64) {
702    let coins = get_iota_coin_ids(client, address).await;
703    assert!(!coins.is_empty());
704    coins.into_iter().max_by(|a, b| a.1.cmp(&b.1)).unwrap()
705}
706
707fn get_coin_with_balance(coins: &[(ObjectId, u64)], target: u64) -> ObjectId {
708    coins.iter().find(|(_, b)| b == &target).unwrap().0
709}
710
711// TODO: move this to the Rust SDK
712async fn get_iota_coin_ids(client: &IotaClient, address: Address) -> Vec<(ObjectId, u64)> {
713    match client
714        .coin_read_api()
715        .get_coins(address, None, None, None)
716        .await
717    {
718        Ok(page) => page
719            .data
720            .into_iter()
721            .map(|c| (c.coin_object_id, c.balance))
722            .collect::<Vec<_>>(),
723        Err(e) => {
724            panic!("get_iota_coin_ids error for address {address} {e}")
725        }
726    }
727    // TODO: implement iteration over next page
728}
729
730async fn pay_iota(
731    client: &IotaClient,
732    keypair: &SimpleKeypair,
733    input_coins: Vec<ObjectId>,
734    gas_budget: u64,
735    recipients: Vec<Address>,
736    amounts: Vec<u64>,
737) -> IotaTransactionBlockResponse {
738    let sender = keypair.public_key().derive_address();
739    let tx = client
740        .transaction_builder()
741        .pay(sender, input_coins, recipients, amounts, None, gas_budget)
742        .await
743        .expect("failed to construct pay iota transaction");
744    sign_and_execute(
745        client,
746        keypair,
747        tx,
748        ExecuteTransactionRequestType::WaitForLocalExecution,
749    )
750    .await
751}
752
753async fn split_coins(
754    client: &IotaClient,
755    keypair: &SimpleKeypair,
756    coin_to_split: ObjectId,
757    gas_payment: ObjectId,
758    num_coins: u64,
759) -> Vec<ObjectId> {
760    let sender = keypair.public_key().derive_address();
761    let split_coin_tx = client
762        .transaction_builder()
763        .split_coin_equal(
764            sender,
765            coin_to_split,
766            num_coins,
767            Some(gas_payment),
768            DEFAULT_LARGE_GAS_BUDGET,
769        )
770        .await
771        .expect("failed to construct split coin transaction");
772    sign_and_execute(
773        client,
774        keypair,
775        split_coin_tx,
776        ExecuteTransactionRequestType::WaitForLocalExecution,
777    )
778    .await
779    .effects
780    .unwrap()
781    .created()
782    .iter()
783    .map(|owned_object_ref| owned_object_ref.reference.object_id)
784    .chain(std::iter::once(coin_to_split))
785    .collect::<Vec<_>>()
786}
787
788pub(crate) async fn sign_and_execute(
789    client: &IotaClient,
790    keypair: &SimpleKeypair,
791    tx: Transaction,
792    request_type: ExecuteTransactionRequestType,
793) -> IotaTransactionBlockResponse {
794    let transaction_response = match client
795        .quorum_driver_api()
796        .execute_transaction_block(
797            TransactionEnvelope::from_data_and_signer(tx, vec![keypair]),
798            IotaTransactionBlockResponseOptions::new().with_effects(),
799            Some(request_type),
800        )
801        .await
802    {
803        Ok(response) => response,
804        Err(e) => {
805            panic!("sign_and_execute error {e}")
806        }
807    };
808
809    match &transaction_response.effects {
810        Some(effects) => {
811            if let IotaExecutionStatus::Failure { error } = effects.status() {
812                panic!(
813                    "transaction {} failed with error: {}. Transaction Response: {:?}",
814                    transaction_response.digest, error, transaction_response
815                );
816            }
817        }
818        None => {
819            panic!(
820                "transaction {} has no effects. Response {:?}",
821                transaction_response.digest, transaction_response
822            );
823        }
824    };
825    transaction_response
826}
827
828#[cfg(test)]
829mod tests {
830    use std::{assert_eq, vec};
831
832    use super::*;
833
834    #[test]
835    fn test_calculate_split_amounts_no_split_needed() {
836        let num_coins = 10;
837        let amount_per_coin = 100;
838        let max_coins_per_txn = 20;
839        let expected = vec![(1000, 10)];
840        let result = calculate_split_amounts(num_coins, amount_per_coin, max_coins_per_txn);
841
842        assert_eq!(expected, result);
843    }
844
845    #[test]
846    fn test_calculate_split_amounts_exact_split() {
847        let num_coins = 10;
848        let amount_per_coin = 100;
849        let max_coins_per_txn = 5;
850        let expected = vec![(500, 5), (500, 5)];
851        let result = calculate_split_amounts(num_coins, amount_per_coin, max_coins_per_txn);
852
853        assert_eq!(expected, result);
854    }
855
856    #[test]
857    fn test_calculate_split_amounts_with_remainder() {
858        let num_coins = 12;
859        let amount_per_coin = 100;
860        let max_coins_per_txn = 5;
861        let expected = vec![(500, 5), (500, 5), (200, 2)];
862        let result = calculate_split_amounts(num_coins, amount_per_coin, max_coins_per_txn);
863
864        assert_eq!(expected, result);
865    }
866
867    #[test]
868    fn test_calculate_split_amounts_single_coin() {
869        let num_coins = 1;
870        let amount_per_coin = 100;
871        let max_coins_per_txn = 5;
872        let expected = vec![(100, 0)];
873        let result = calculate_split_amounts(num_coins, amount_per_coin, max_coins_per_txn);
874
875        assert_eq!(expected, result);
876    }
877
878    #[test]
879    fn test_calculate_split_amounts_max_coins_equals_num_coins() {
880        let num_coins = 5;
881        let amount_per_coin = 100;
882        let max_coins_per_txn = 5;
883        let expected = vec![(500, 5)];
884        let result = calculate_split_amounts(num_coins, amount_per_coin, max_coins_per_txn);
885
886        assert_eq!(expected, result);
887    }
888
889    #[test]
890    #[should_panic(expected = "assertion failed: new_coins_per_txn > 0")]
891    fn test_calculate_split_amounts_zero_max_coins() {
892        let num_coins = 5;
893        let amount_per_coin = 100;
894        let max_coins_per_txn = 0;
895
896        calculate_split_amounts(num_coins, amount_per_coin, max_coins_per_txn);
897    }
898}