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}