iota_sdk_transaction_builder/
lib.rs

1// Copyright 2025 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! # IOTA Transaction Builder
5//!
6//! This crate contains the [TransactionBuilder], which allows for simple
7//! construction of Programmable Transactions which can be executed on the IOTA
8//! network.
9//!
10//! The builder is designed to allow for a lot of flexibility while also
11//! reducing the necessary boilerplate code. It uses a type-state pattern to
12//! ensure the proper flow through the various functions. It is chainable via
13//! mutable references.
14//!
15//! ## Online vs. Offline Builder
16//!
17//! The Transaction Builder can be used with or without a
18//! [GraphQLClient](iota_graphql_client::Client). When one is provided via the
19//! [with_client](TransactionBuilder::with_client) method, the resulting builder
20//! will use it to find and validate provided IDs.
21//!
22//! ### Example with Client Resolution
23//!
24//! ```
25//! # use std::str::FromStr;
26//! use iota_graphql_client::Client;
27//! use iota_sdk_transaction_builder::TransactionBuilder;
28//! use iota_types::{Address, ObjectId, Transaction};
29//!
30//! # #[tokio::main(flavor = "current_thread")]
31//! # async fn main() -> eyre::Result<()> {
32//!
33//! let sender =
34//!     Address::from_str("0x611830d3641a68f94a690dcc25d1f4b0dac948325ac18f6dd32564371735f32c")?;
35//! let to_address =
36//!     Address::from_str("0x0000a4984bd495d4346fa208ddff4f5d5e5ad48c21dec631ddebc99809f16900")?;
37//!
38//! let mut builder = TransactionBuilder::new(sender).with_client(Client::new_devnet());
39//!
40//! let coin =
41//!     ObjectId::from_str("0x8ef4259fa2a3499826fa4b8aebeb1d8e478cf5397d05361c96438940b43d28c9")?;
42//!
43//! builder.send_coins([coin], to_address, 50000000000u64);
44//!
45//! let txn: Transaction = builder.finish().await?;
46//! # Ok(())
47//! # }
48//! ```
49//!
50//! ### Example without Client Resolution
51//!
52//! ```
53//! # use std::str::FromStr;
54//! use iota_sdk_transaction_builder::TransactionBuilder;
55//! use iota_types::{Address, Digest, ObjectId, ObjectReference, Transaction};
56//!
57//! let sender =
58//!     Address::from_str("0x611830d3641a68f94a690dcc25d1f4b0dac948325ac18f6dd32564371735f32c")?;
59//! let to_address =
60//!     Address::from_str("0x0000a4984bd495d4346fa208ddff4f5d5e5ad48c21dec631ddebc99809f16900")?;
61//!
62//! let mut builder = TransactionBuilder::new(sender);
63//!
64//! let coin = ObjectReference {
65//!     object_id: ObjectId::from_str(
66//!         "0x8ef4259fa2a3499826fa4b8aebeb1d8e478cf5397d05361c96438940b43d28c9",
67//!     )?,
68//!     digest: Digest::from_str("4jJMQScR4z5kK3vchvDEFYTiCkZPEYdvttpi3iTj1gEW")?,
69//!     version: 435090179,
70//! };
71//! let gas_coin = ObjectReference {
72//!     object_id: ObjectId::from_str(
73//!         "0xd04077fe3b6fad13b3d4ed0d535b7ca92afcac8f0f2a0e0925fb9f4f0b30c699",
74//!     )?,
75//!     digest: Digest::from_str("8ahH5RXFnK1jttQEWTypYX7MRzLuQDEXk7fhMHCyZekX")?,
76//!     version: 473053810,
77//! };
78//!
79//! builder
80//!     .send_coins([coin], to_address, 50000000000u64)
81//!     .gas([gas_coin])
82//!     .gas_budget(1000000000)
83//!     .gas_price(100);
84//!
85//! let txn: Transaction = builder.finish()?;
86//! # Result::<_, eyre::Error>::Ok(())
87//! ```
88//!
89//! NOTE: It is possible to provide an [ObjectId](iota_types::ObjectId) to an
90//! offline client builder, but this will cause the builder to fail when calling
91//! `finish`.
92//!
93//! ## Methods
94//!
95//! There are three kinds of methods available:
96//!
97//! ### Commands
98//!
99//! Each command method adds one or more commands to the final transaction. Some
100//! commands have optional follow-up methods. All command results can be named
101//! via [name](TransactionBuilder::name). Naming a command allows them to be
102//! used later in the transaction via the [res] method.
103//!
104//! - [move_call](TransactionBuilder::move_call): Call a move function.
105//!     - `arguments`: Add arguments to the move call.
106//!     - `generics`: Add generic types to the move call using types that
107//!       implement [MoveType](types::MoveType).
108//!     - `type_tags`: Add generic types directly using the
109//!       [TypeTag](iota_types::TypeTag).
110//! - [send_iota](TransactionBuilder::send_iota): Send IOTA coins to a recipient
111//!   address.
112//! - [send_coins](TransactionBuilder::send_coins): Send coins of any type to a
113//!   recipient address.
114//! - [merge_coins](TransactionBuilder::merge_coins): Merge a list of coins into
115//!   a single primary coin.
116//! - [split_coins](TransactionBuilder::split_coins): Split a coin into coins of
117//!   various amounts.
118//! - [transfer_objects](TransactionBuilder::transfer_objects): Send objects to
119//!   a recipient address.
120//! - [publish](TransactionBuilder::publish): Publish a move package.
121//!     - `package_id`: Name the package ID returned by the publish call.
122//! - [upgrade](TransactionBuilder::upgrade): Upgrade a move package.
123//! - [make_move_vec](TransactionBuilder::make_move_vec): Create a move
124//!   `vector`.
125//!
126//! ### Metadata
127//!
128//! These methods set various metadata which may be needed for the execution.
129//!
130//! - [gas](TransactionBuilder::gas): Add gas coins to pay for the execution.
131//! - [gas_budget](TransactionBuilder::gas_budget): Set the maximum gas budget
132//!   to spend.
133//! - [gas_price](TransactionBuilder::gas_price): Set the gas price.
134//! - [sponsor](TransactionBuilder::sponsor): Set the gas sponsor address.
135//! - [gas_station_sponsor](TransactionBuilder::gas_station_sponsor): Set the
136//!   gas station URL. See [Gas Station](crate#gas-station) for more info.
137//! - [expiration](TransactionBuilder::expiration): Set the transaction
138//!   expiration epoch.
139//!
140//! ### Other
141//!
142//! Many other methods exist, either to get data or allow for development on top
143//! of the builder. Typically, these methods should not be needed, but they are
144//! made available for special circumstances.
145//!
146//! - [apply_argument](TransactionBuilder::apply_argument)
147//! - [apply_arguments](TransactionBuilder::apply_arguments)
148//! - [input](TransactionBuilder::input)
149//! - [pure_bytes](TransactionBuilder::pure_bytes)
150//! - [pure](TransactionBuilder::pure)
151//! - [command](TransactionBuilder::command)
152//! - [named_command](TransactionBuilder::named_command)
153//!
154//! ## Finalization and Execution
155//!
156//! There are several ways to finish the builder. First, the
157//! [finish](TransactionBuilder::finish) method can be used to return the
158//! resulting [Transaction](iota_types::Transaction), which can be manually
159//! serialized, executed, etc.
160//!
161//! Additionally, when a client is provided, the builder can directly
162//! [dry_run](TransactionBuilder::dry_run) or
163//! [execute](TransactionBuilder::execute) the transaction.
164//!
165//! When the transaction is resolved, the builder will try to ensure a valid
166//! state by de-duplicating and converting appropriate inputs into references to
167//! the gas coin. This means that the same input can be passed multiple times
168//! and the final transaction will only contain one instance. However, in some
169//! cases an invalid state can still be reached. For instance, if a coin is used
170//! both for gas and as part of a group of coins, i.e. when transferring
171//! objects, the transaction can not possibly be valid.
172//!
173//! ### Defaults
174//!
175//! When a client is provided, the builder can set some values by default. The
176//! following are the default behaviors for each metadata value.
177//!
178//! - Gas: One page of coins owned by the sender.
179//! - Gas Budget: A dry run will be used to estimate.
180//! - Gas Price: The current reference gas price.
181//!
182//! ## Gas Station
183//!
184//! The Transaction Builder supports executing via a
185//! [Gas Station](https://github.com/iotaledger/gas-station). To do so, the URL
186//! must be provided via
187//! [gas_station_sponsor](TransactionBuilder::gas_station_sponsor). Additional
188//! configuration can then be provided via
189//! [gas_reservation_duration](TransactionBuilder::gas_reservation_duration) and
190//! [add_gas_station_header](TransactionBuilder::add_gas_station_header).
191//!
192//! By default the request will contain the header `Content-Type:
193//! application/json`
194//!
195//! When this data has been set, calling [execute](TransactionBuilder::execute)
196//! will request gas from and send the resulting transaction to this endpoint
197//! instead of using the GraphQL client.
198//!
199//! ## Traits and Helpers
200//!
201//! This crate provides several traits which enable the functionality of the
202//! builder. Often, when providing arguments, functions will accept either a
203//! single [PTBArgument] or a [PTBArgumentList].
204//!
205//! [PTBArgument] is implemented for any type implementing
206//! [MoveArg](types::MoveArg) as well as:
207//! - [unresolved::Argument]: Arguments returned by various builder functions.
208//!   Distinct from [iota_types::Argument], which cannot be used.
209//! - [Input](iota_types::Input): A resolved input.
210//! - [ObjectId](iota_types::ObjectId): An object's ID. Can only be used when a
211//!   client is provided. This will be assumed immutable or owned.
212//! - [ObjectReference](iota_types::ObjectReference): An object's reference.
213//!   This will be assumed immutable or owned.
214//! - [Res](builder::ptb_arguments::Res): A reference to the result of a
215//!   previous named command, set with [name](TransactionBuilder::name).
216//! - [Shared]: Allows specifying shared immutable move objects.
217//! - [SharedMut]: Allows specifying shared mutable move objects.
218//! - [Receiving]: Allows specifying receiving move objects.
219//!
220//! [PTBArgumentList] is implemented for collection types, and represents a set
221//! of arguments. For move calls, this enables tuples of rust values to
222//! represent the parameters defined in the smart contract. For calls like
223//! [merge_coins](TransactionBuilder::merge_coins), this can represent a list of
224//! coins.
225//!
226//! [MoveArg](types::MoveArg) represents types that can be serialized and
227//! provided to the transaction as pure bytes.
228//!
229//! [MoveType](types::MoveType) defines the type tag for a rust type, so that it
230//! can be used for generic arguments.
231//!
232//! ### Example
233//!
234//! The following function is defined in move in `vec_map`:
235//!
236//! ```ignore
237//! public fun from_keys_values<K: copy, V>(mut keys: vector<K>, mut values: vector<V>): VecMap<K, V>
238//! ```
239//!
240//! ```ignore
241//! builder
242//!     .move_call(Address::TWO, "vec_map", "from_keys_values")
243//!     .generics::<(Address, u64)>()
244//!     .arguments((vec![address1, address2], vec![10000000u64, 20000000u64]));
245//! ```
246//!
247//! ### Custom Type
248//!
249//! In order to use a custom type, implement [MoveType](types::MoveType) and
250//! [MoveArg](types::MoveArg).
251//!
252//! ```
253//! # use std::str::FromStr;
254//! # use iota_sdk_transaction_builder::types::{MoveArg, MoveType, PureBytes};
255//! # use iota_types::TypeTag;
256//! #[derive(serde::Serialize)]
257//! struct MyStruct {
258//!     val1: String,
259//!     val2: u64,
260//! }
261//!
262//! impl MoveType for MyStruct {
263//!     fn type_tag() -> TypeTag {
264//!         TypeTag::from_str("0x0::my_module::MyStruct").unwrap()
265//!     }
266//! }
267//!
268//! impl MoveArg for MyStruct {
269//!     fn pure_bytes(self) -> PureBytes {
270//!         PureBytes(bcs::to_bytes(&self).unwrap())
271//!     }
272//! }
273//! ```
274
275#![warn(missing_docs)]
276#![deny(unreachable_pub)]
277
278pub mod builder;
279pub mod error;
280pub mod types;
281#[allow(missing_docs)]
282pub mod unresolved;
283
284pub use iota_graphql_client::WaitForTx;
285
286pub use self::{
287    builder::{
288        TransactionBuilder,
289        client_methods::ClientMethods,
290        ptb_arguments::{PTBArgument, PTBArgumentList, Receiving, Shared, SharedMut, res},
291    },
292    types::PureBytes,
293};
294
295#[cfg(test)]
296mod tests {
297    use eyre::Context;
298    use iota_crypto::ed25519::Ed25519PrivateKey;
299    use iota_graphql_client::{
300        Client, WaitForTx,
301        faucet::{CoinInfo, FaucetClient},
302        pagination::PaginationFilter,
303    };
304    use iota_types::{
305        Address, Digest, ExecutionStatus, IdOperation, MovePackageData, ObjectId, ObjectReference,
306        ObjectType, TransactionEffects, UpgradePolicy,
307    };
308
309    use crate::{TransactionBuilder, error::Error, res};
310
311    /// This is used to read the json file that contains the modules/deps/digest
312    /// generated with iota move build --dump-bytecode-as-base64 on the
313    /// `test_example_v1 and test_example_v2` projects in the tests
314    /// directory. The json files are generated automatically when running
315    /// `make test-with-localnet` in the root of the
316    /// iota-sdk-transaction-builder crate.
317    fn move_package_data(file: &str) -> MovePackageData {
318        let data = std::fs::read_to_string(file)
319            .with_context(|| {
320                format!(
321                    "Failed to read {file}. \
322                    Run `make test-with-localnet` from the root of the repository that will \
323                    generate the right json files with the package data and then run the tests."
324                )
325            })
326            .unwrap();
327        serde_json::from_str(&data).unwrap()
328    }
329
330    /// Generate a random private key and its corresponding address
331    fn helper_address_pk() -> (Address, Ed25519PrivateKey) {
332        let pk = Ed25519PrivateKey::generate(rand::thread_rng());
333        let address = pk.public_key().derive_address();
334        (address, pk)
335    }
336
337    /// Helper to:
338    /// - generate a private key and its corresponding address
339    /// - set the sender for the tx to this newly created address
340    /// - set gas price
341    /// - set gas budget
342    /// - call faucet which returns 5 coin objects
343    /// - set the gas object (last coin from the list of the 5 objects returned
344    ///   by faucet)
345    /// - return the address, private key, and coins.
346    ///
347    /// NB! This assumes that these tests run on a network whose faucet returns
348    /// 5 coins per each faucet request.
349    async fn helper_setup() -> (
350        TransactionBuilder<Client>,
351        Address,
352        Ed25519PrivateKey,
353        Vec<CoinInfo>,
354    ) {
355        let (address, pk) = helper_address_pk();
356        let client = Client::new_localnet();
357        let mut tx = TransactionBuilder::new(address).with_client(client.clone());
358        let coins = FaucetClient::new_localnet()
359            .request_and_wait(address)
360            .await
361            .unwrap()
362            .unwrap()
363            .sent;
364        let tx_digest = coins.first().unwrap().transfer_tx_digest;
365        wait_for_tx(&client, tx_digest).await;
366
367        let gas = coins.last().unwrap().id;
368        tx.gas([gas]);
369
370        (tx, address, pk, coins)
371    }
372
373    /// Wait for the transaction to be finalized and indexed. This queries the
374    /// GraphQL server until it retrieves the requested transaction.
375    async fn wait_for_tx(client: &Client, digest: Digest) {
376        while client.transaction(digest).await.unwrap().is_none() {
377            tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
378        }
379    }
380
381    /// Wait for the transaction to be finalized and indexed, and check the
382    /// effects' to ensure the transaction was successfully executed.
383    async fn check_effects_status_success(effects: Result<TransactionEffects, Error>) {
384        assert!(effects.is_ok(), "Execution failed. Effects: {effects:?}");
385        // check that it succeeded
386        let status = effects.unwrap();
387        let expected_status = ExecutionStatus::Success;
388        assert_eq!(&expected_status, status.status());
389    }
390
391    #[tokio::test]
392    async fn test_finish() {
393        let mut tx = TransactionBuilder::new(
394            "0xc574ea804d9c1a27c886312e96c0e2c9cfd71923ebaeb3000d04b5e65fca2793"
395                .parse()
396                .unwrap(),
397        );
398        let coin_obj_id = "0x19406ea4d9609cd9422b85e6bf2486908f790b778c757aff805241f3f609f9b4";
399        let coin_digest = "7opR9rFUYivSTqoJHvFb9p6p54THyHTatMG6id4JKZR9";
400        let coin_version = 2;
401        let coin = ObjectReference::new(
402            coin_obj_id.parse().unwrap(),
403            coin_version,
404            coin_digest.parse().unwrap(),
405        );
406
407        let recipient = Address::generate(rand::thread_rng());
408
409        let result = tx.clone().finish();
410        assert!(result.is_err());
411
412        tx.transfer_objects(recipient, vec![coin]);
413        tx.gas([ObjectReference::new(
414            "0xd8792bce2743e002673752902c0e7348dfffd78638cb5367b0b85857bceb9821"
415                .parse()
416                .unwrap(),
417            2,
418            "2ZigdvsZn5BMeszscPQZq9z8ebnS2FpmAuRbAi9ednCk"
419                .parse()
420                .unwrap(),
421        )]);
422        tx.gas_price(1000);
423
424        tx.finish().unwrap();
425    }
426
427    #[tokio::test]
428    async fn test_transfer_obj_execution() {
429        let (mut tx, _, pk, coins) = helper_setup().await;
430
431        // get the object information from the client
432        let client = Client::new_localnet();
433        let coin = coins.first().unwrap().id;
434        let recipient = Address::generate(rand::thread_rng());
435        tx.transfer_objects(recipient, [coin]);
436
437        let effects = tx.execute(&pk.into(), WaitForTx::Finalized).await;
438        check_effects_status_success(effects).await;
439
440        // check that recipient has 1 coin
441        let recipient_coins = client
442            .coins(recipient, None, PaginationFilter::default())
443            .await
444            .unwrap();
445        assert_eq!(recipient_coins.data().len(), 1);
446    }
447
448    #[tokio::test]
449    async fn test_move_call() {
450        // Check that `0x1::option::is_none` move call works when passing `1`
451        // set up the sender, gas object, gas budget, and gas price and return the pk to
452        // sign
453        let (mut tx, _, pk, _) = helper_setup().await;
454        tx.move_call(Address::STD_LIB, "option", "is_none")
455            .generics::<u64>()
456            .arguments([Some(1u64)]);
457
458        let effects = tx.execute(&pk.into(), WaitForTx::Indexed).await;
459        check_effects_status_success(effects).await;
460    }
461
462    #[tokio::test]
463    async fn test_split_transfer() {
464        let client = Client::new_localnet();
465        let (mut tx, _, pk, _) = helper_setup().await;
466
467        // transfer 1 IOTA from Gas coin
468        let gas = tx.get_gas()[0];
469        tx.split_coins(gas, [1_000_000_000u64]).name("coin");
470        let recipient = Address::generate(rand::thread_rng());
471        tx.transfer_objects(recipient, [res("coin")]);
472
473        let effects = tx.execute(&pk.into(), WaitForTx::Finalized).await;
474        check_effects_status_success(effects).await;
475
476        // check that recipient has 1 coin
477        let recipient_coins = client
478            .coins(recipient, None, PaginationFilter::default())
479            .await
480            .unwrap();
481        assert_eq!(recipient_coins.data().len(), 1);
482    }
483
484    #[tokio::test]
485    async fn test_split_without_transfer_should_fail() {
486        let (mut tx, _, pk, coins) = helper_setup().await;
487
488        let coin = coins.first().unwrap().id;
489
490        // transfer 1 IOTA
491        tx.split_coins(coin, [1_000_000_000u64]);
492
493        let effects = tx.execute(&pk.into(), WaitForTx::Indexed).await.unwrap();
494
495        let expected_status = ExecutionStatus::Success;
496        // The tx failed, so we expect Failure instead of Success
497        assert_ne!(&expected_status, effects.status());
498    }
499
500    #[tokio::test]
501    async fn test_merge_coins() {
502        let (mut tx, address, pk, coins) = helper_setup().await;
503
504        let coin1 = coins.first().unwrap().id;
505
506        let mut coins_to_merge = vec![];
507        // last coin is used for gas, first coin is the one we merge into
508        for c in coins[1..&coins.len() - 1].iter() {
509            coins_to_merge.push(c.id);
510        }
511
512        tx.merge_coins(coin1, coins_to_merge);
513        let client = tx.get_client().clone();
514
515        let effects = tx.execute(&pk.into(), WaitForTx::Finalized).await;
516        check_effects_status_success(effects).await;
517
518        // check that there are two coins
519        let coins_after = client
520            .coins(address, None, PaginationFilter::default())
521            .await
522            .unwrap();
523        assert_eq!(coins_after.data().len(), 2);
524    }
525
526    #[tokio::test]
527    async fn test_make_move_vec() {
528        let (mut tx, _, pk, _) = helper_setup().await;
529
530        tx.make_move_vec([1u64]);
531
532        let effects = tx.execute(&pk.into(), WaitForTx::Indexed).await;
533        check_effects_status_success(effects).await;
534    }
535
536    #[tokio::test]
537    async fn test_publish() {
538        let (mut tx, address, pk, _) = helper_setup().await;
539
540        let package = move_package_data("package_test_example_v1.json");
541        tx.publish(package)
542            .upgrade_cap("cap")
543            .transfer_objects(address, [res("cap")]);
544
545        let effects = tx.execute(&pk.into(), WaitForTx::Indexed).await;
546        check_effects_status_success(effects).await;
547    }
548
549    #[tokio::test]
550    async fn test_upgrade() {
551        let (mut tx, address, pk, coins) = helper_setup().await;
552        let key = pk.into();
553
554        let package = move_package_data("package_test_example_v2.json");
555        tx.publish(package)
556            .upgrade_cap("cap")
557            .transfer_objects(address, [res("cap")]);
558
559        let effects = tx.execute(&key, WaitForTx::Finalized).await;
560        let mut package_id: Option<ObjectId> = None;
561        let mut created_objs = vec![];
562        if let Ok(ref effects) = effects {
563            match effects {
564                TransactionEffects::V1(e) => {
565                    for obj in e.changed_objects.clone() {
566                        if obj.id_operation == IdOperation::Created {
567                            let change = obj.output_state;
568                            match change {
569                                iota_types::ObjectOut::PackageWrite { .. } => {
570                                    package_id = Some(obj.object_id);
571                                }
572                                iota_types::ObjectOut::ObjectWrite { .. } => {
573                                    created_objs.push(obj.object_id);
574                                }
575                                _ => {}
576                            }
577                        }
578                    }
579                }
580            }
581        }
582        check_effects_status_success(effects).await;
583
584        let client = Client::new_localnet();
585        let mut tx = TransactionBuilder::new(address).with_client(&client);
586        let mut upgrade_cap = None;
587        for o in created_objs {
588            let obj = client.object(o, None).await.unwrap().unwrap();
589            match obj.object_type() {
590                ObjectType::Struct(x) if x.name.to_string() == "UpgradeCap" => {
591                    upgrade_cap = Some(obj.object_id());
592                    break;
593                }
594                _ => {}
595            };
596        }
597
598        let updated_package = move_package_data("package_test_example_v2.json");
599
600        // we need this ticket to authorize the upgrade
601        tx.move_call(Address::FRAMEWORK, "package", "authorize_upgrade")
602            .arguments((
603                upgrade_cap.unwrap(),
604                UpgradePolicy::Compatible as u8,
605                updated_package.digest,
606            ))
607            .name("ticket");
608        // now we can upgrade the package
609        let receipt = tx
610            .upgrade(package_id.unwrap(), updated_package, res("ticket"))
611            .arg();
612
613        // commit the upgrade
614        tx.move_call(Address::FRAMEWORK, "package", "commit_upgrade")
615            .arguments((upgrade_cap.unwrap(), receipt));
616
617        tx.gas([coins.last().unwrap().id]);
618
619        let effects = tx.execute(&key, WaitForTx::Indexed).await;
620        check_effects_status_success(effects).await;
621    }
622}