Skip to main content

iota_sdk_graphql_client/api/
dry_run.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2026 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5//! Dry Run API implementation.
6
7use base64ct::Encoding;
8use cynic::QueryBuilder;
9use iota_types::{Address, ObjectReference, Transaction, TransactionEffects, TransactionKind};
10
11use crate::{
12    DryRunEffect, DryRunResult, GraphQLClient,
13    api::define_query,
14    error::GraphQLResult,
15    query_types::{DryRunArgs, DryRunQueryFragment, ObjectRef, TransactionMetadata},
16};
17
18define_query! {
19    /// Query for [`GraphQLClient::dry_run_transaction`]. Await it to send the
20    /// request.
21    pub struct DryRunTransactionQuery {
22        client: GraphQLClient,
23        transaction: Transaction,
24        skip_checks: bool,
25    }
26    output: GraphQLResult<DryRunResult>;
27}
28
29impl DryRunTransactionQuery {
30    /// Disable the usual verification checks that prevent access to objects
31    /// that are owned by addresses other than the sender, and calling
32    /// non-public, non-entry functions, and some other checks. Defaults to
33    /// `false`.
34    pub fn skip_checks(mut self, skip_checks: bool) -> Self {
35        self.skip_checks = skip_checks;
36        self
37    }
38
39    async fn send(self) -> GraphQLResult<DryRunResult> {
40        let Transaction::V1(v1) = &self.transaction else {
41            unimplemented!("a new Transaction enum variant was added and needs to be handled")
42        };
43        let mut query = self
44            .client
45            .dry_run_transaction_kind(&v1.kind)
46            .sender(v1.sender)
47            .gas_budget(v1.gas_payment.budget)
48            .gas_price(v1.gas_payment.price)
49            .gas_sponsor(v1.gas_payment.owner)
50            .skip_checks(self.skip_checks);
51        if !v1.gas_payment.objects.is_empty() {
52            query = query.gas_objects(v1.gas_payment.objects.clone());
53        }
54        query.await
55    }
56}
57
58define_query! {
59    /// Query for [`GraphQLClient::dry_run_transaction_kind`]. Await it to send
60    /// the request.
61    pub struct DryRunTransactionKindQuery {
62        client: GraphQLClient,
63        transaction_kind: TransactionKind,
64        transaction_metadata: TransactionMetadata,
65        skip_checks: bool,
66    }
67    output: GraphQLResult<DryRunResult>;
68}
69
70impl DryRunTransactionKindQuery {
71    /// Set the sender of the transaction.
72    pub fn sender(mut self, sender: Address) -> Self {
73        self.transaction_metadata.sender = Some(sender);
74        self
75    }
76
77    /// Set the gas budget of the transaction.
78    pub fn gas_budget(mut self, gas_budget: u64) -> Self {
79        self.transaction_metadata.gas_budget = Some(gas_budget);
80        self
81    }
82
83    /// Set the gas price of the transaction.
84    pub fn gas_price(mut self, gas_price: u64) -> Self {
85        self.transaction_metadata.gas_price = Some(gas_price);
86        self
87    }
88
89    /// Set the objects that pay for the gas.
90    pub fn gas_objects(mut self, gas_objects: Vec<ObjectReference>) -> Self {
91        self.transaction_metadata.gas_objects =
92            Some(gas_objects.into_iter().map(ObjectRef::from).collect());
93        self
94    }
95
96    /// Set the sponsor that pays for the gas.
97    pub fn gas_sponsor(mut self, gas_sponsor: Address) -> Self {
98        self.transaction_metadata.gas_sponsor = Some(gas_sponsor);
99        self
100    }
101
102    /// Disable the usual verification checks that prevent access to objects
103    /// that are owned by addresses other than the sender, and calling
104    /// non-public, non-entry functions, and some other checks. Defaults to
105    /// `false`.
106    pub fn skip_checks(mut self, skip_checks: bool) -> Self {
107        self.skip_checks = skip_checks;
108        self
109    }
110
111    async fn send(self) -> GraphQLResult<DryRunResult> {
112        let tx_bytes = base64ct::Base64::encode_string(
113            &bcs::to_bytes(&self.transaction_kind).map_err(iota_types::BcsError::new)?,
114        );
115        self.client
116            .dry_run(tx_bytes, self.skip_checks, self.transaction_metadata)
117            .await
118    }
119}
120
121impl GraphQLClient {
122    /// Dry run a [`Transaction`] and return the transaction effects and dry
123    /// run error (if any).
124    pub fn dry_run_transaction(&self, transaction: &Transaction) -> DryRunTransactionQuery {
125        DryRunTransactionQuery {
126            client: self.clone(),
127            transaction: transaction.clone(),
128            skip_checks: false,
129        }
130    }
131
132    /// Dry run a [`TransactionKind`] and return the transaction effects and
133    /// dry run error (if any).
134    pub fn dry_run_transaction_kind(
135        &self,
136        transaction_kind: &TransactionKind,
137    ) -> DryRunTransactionKindQuery {
138        DryRunTransactionKindQuery {
139            client: self.clone(),
140            transaction_kind: transaction_kind.clone(),
141            transaction_metadata: TransactionMetadata::default(),
142            skip_checks: false,
143        }
144    }
145
146    /// Internal implementation of the dry run API.
147    pub(crate) async fn dry_run(
148        &self,
149        tx_bytes: String,
150        skip_checks: bool,
151        tx_meta: impl Into<Option<TransactionMetadata>>,
152    ) -> GraphQLResult<DryRunResult> {
153        let operation = DryRunQueryFragment::build(DryRunArgs {
154            tx_bytes,
155            skip_checks,
156            tx_meta: tx_meta.into(),
157        });
158        let response = self.run_query(&operation).await?;
159
160        // Convert DryRunEffect to DryRunEffect
161        let results = response
162            .dry_run_transaction_block
163            .results
164            .iter()
165            .flatten()
166            .map(DryRunEffect::try_from)
167            .collect::<GraphQLResult<Vec<_>>>()?;
168
169        let txn_block = &response.dry_run_transaction_block.transaction;
170
171        let effects = txn_block
172            .as_ref()
173            .and_then(|tx| tx.effects.as_ref())
174            .and_then(|tx| tx.bcs.as_ref())
175            .map(|bcs| crate::base64::decode(bcs.0.as_str()))
176            .transpose()?
177            .map(|bcs| {
178                bcs::from_bytes::<TransactionEffects>(&bcs).map_err(iota_types::BcsError::new)
179            })
180            .transpose()?;
181
182        // Extract transaction
183        let transaction = txn_block
184            .as_ref()
185            .and_then(|tx| tx.bcs_unsigned.as_ref())
186            .map(|bcs| crate::base64::decode(bcs.0.as_str()))
187            .transpose()?
188            .map(|bcs| bcs::from_bytes::<Transaction>(&bcs).map_err(iota_types::BcsError::new))
189            .transpose()?;
190
191        let suggested_gas_price = response
192            .dry_run_transaction_block
193            .suggested_gas_price
194            .map(u64::try_from)
195            .transpose()?;
196
197        Ok(DryRunResult {
198            error: response.dry_run_transaction_block.error,
199            results,
200            transaction,
201            effects,
202            suggested_gas_price,
203        })
204    }
205}
206
207#[cfg(all(test, not(target_arch = "wasm32")))]
208mod tests {
209    use base64ct::Encoding;
210    use iota_types::{Address, ObjectDigest, ObjectId, ObjectReference, Transaction, Version};
211
212    use crate::{
213        GraphQLClient,
214        test_utils::{sent_variables, test_transaction},
215    };
216
217    #[tokio::test]
218    async fn dry_run_transaction_sends_the_kind_metadata_and_skip_checks() {
219        let transaction = test_transaction();
220        let Transaction::V1(v1) = &transaction else {
221            unreachable!()
222        };
223        let expected_tx_bytes = base64ct::Base64::encode_string(&bcs::to_bytes(&v1.kind).unwrap());
224        let vars = sent_variables("DryRunQueryFragment", |client| async move {
225            let _ = client
226                .dry_run_transaction(&transaction)
227                .skip_checks(true)
228                .await;
229        })
230        .await;
231        assert_eq!(vars["txBytes"], expected_tx_bytes);
232        assert_eq!(vars["skipChecks"], true);
233        assert_eq!(vars["txMeta"]["sender"], Address::STD.to_string());
234        assert_eq!(vars["txMeta"]["gasBudget"], 5_000_000);
235        assert_eq!(vars["txMeta"]["gasPrice"], 1000);
236        assert_eq!(vars["txMeta"]["gasSponsor"], Address::FRAMEWORK.to_string());
237        let gas_object = &vars["txMeta"]["gasObjects"][0];
238        assert_eq!(gas_object["address"], ObjectId::SYSTEM_STATE.to_string());
239        assert_eq!(gas_object["version"], 3);
240        assert_eq!(gas_object["digest"], ObjectDigest::ZERO.to_base58());
241
242        let transaction = test_transaction();
243        let vars = sent_variables("DryRunQueryFragment", |client| async move {
244            let _ = client.dry_run_transaction(&transaction).await;
245        })
246        .await;
247        assert_eq!(vars["skipChecks"], false);
248    }
249
250    #[tokio::test]
251    async fn dry_run_transaction_kind_sends_the_kind_metadata_and_skip_checks() {
252        let Transaction::V1(v1) = test_transaction() else {
253            unreachable!()
254        };
255        let expected_tx_bytes = base64ct::Base64::encode_string(&bcs::to_bytes(&v1.kind).unwrap());
256        let gas_object = ObjectReference::new(
257            ObjectId::SYSTEM_STATE,
258            Version::from_u64(3),
259            ObjectDigest::ZERO,
260        );
261        let kind = v1.kind.clone();
262        let vars = sent_variables("DryRunQueryFragment", |client| async move {
263            let _ = client
264                .dry_run_transaction_kind(&kind)
265                .sender(Address::STD)
266                .gas_budget(5_000_000)
267                .gas_price(1000)
268                .gas_objects(vec![gas_object])
269                .gas_sponsor(Address::FRAMEWORK)
270                .skip_checks(true)
271                .await;
272        })
273        .await;
274        assert_eq!(vars["txBytes"], expected_tx_bytes);
275        assert_eq!(vars["skipChecks"], true);
276        assert_eq!(vars["txMeta"]["sender"], Address::STD.to_string());
277        assert_eq!(vars["txMeta"]["gasBudget"], 5_000_000);
278        assert_eq!(vars["txMeta"]["gasPrice"], 1000);
279        assert_eq!(vars["txMeta"]["gasSponsor"], Address::FRAMEWORK.to_string());
280        let gas_object = &vars["txMeta"]["gasObjects"][0];
281        assert_eq!(gas_object["address"], ObjectId::SYSTEM_STATE.to_string());
282        assert_eq!(gas_object["version"], 3);
283        assert_eq!(gas_object["digest"], ObjectDigest::ZERO.to_base58());
284
285        let vars = sent_variables("DryRunQueryFragment", |client| async move {
286            let _ = client.dry_run_transaction_kind(&v1.kind).await;
287        })
288        .await;
289        assert_eq!(vars["skipChecks"], false);
290        for field in [
291            "sender",
292            "gasBudget",
293            "gasPrice",
294            "gasObjects",
295            "gasSponsor",
296        ] {
297            assert!(vars["txMeta"][field].is_null(), "{field} is set by default");
298        }
299    }
300
301    // This needs the transaction builder to be able to be tested properly
302    #[tokio::test]
303    async fn test_dry_run() {
304        let client = GraphQLClient::new_testnet().unwrap();
305        let tx_bytes = "AAACAAgAypo7AAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAgABAQAAAQEDAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACg9WqbvnpQmublI1+/dnonzEvhVPHnGEX++ianEHLIZmoiqRAAAAAAAgmrviNLnSJMjhRUZ8il2SFFjZ60cdJWv9v3M7pTsTQaA0FjZwX1JlYTftfc/+nF7J1QTfVacG+5wc2teKJoJHBDf/BgAAAAAAIOFdV7nQyvw+7AJpDmJFifAa4SqrI5qqXqAq1IKZsSxKVTI1Cd7yJVFzIqi4nnPX1ShmHEJWweFl5BId7OSkHXViNQ0AAAAAACA4U7t1jiQwTs87xenAvOkQWAAMWbElg0Exz1annhowtXPQJaMX5mcenWnm/aFAXhUM2rGsvqqa2zM2OOQyEKqbNP8GAAAAAAAg7pHVs4Z58mP71Y53cDuY3X/TbTgfmBHkDWe16J+kBOqhnfl+yRNiYZ3fpWvyc4rB2u+a2qjUGqcw7yFnlhJAj1w00w8AAAAAIDEjW30S0iN4lnDXpigCjEmOA0tUYKf339ZayYUU9PG6s1wmB/dndlMUdTZGe5MOz1baxXMESHbVd5L7XTObgECAQpEAAAAAACBCkCOAwD6Dl2DkdXj/eFRBTsNPWg3XYATTPxeThLuhzrTmcYf4XqT8ceMAoKbQBjtzyaTv+xb0K0MzHfvJR1NFgUKRAAAAAAAgxUVPvQUU/R1jcC2+AxZ7uC3ls+09G7xAk0xusdBSUkXPNNWDsV8xzw6ipjnf5pk9W3R9P0RD6iORRe+0JKaLtmE1DQAAAAAAIPhsUoriBlzhLc4SHds72JTbjeI37VhyjlFVtQurLY+26e+jqKb2TsdARpYEvxPl31WAelj2RMuUyK8S5NeluEWjKpEAAAAAACCR/0nc3l5UIXpl6I6SEpWABP/vJewHhZ5iMDpIDXdMqf0VCu+y2k/TZIpRFMDRiBO0oUW+L8+06uAi3pZkwpbFNf8GAAAAAAAgyIfExjdHxdt7+eiOLRh4N4/iSMZCrHf2t5iYI+Kl8ysAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOgDAAAAAAAA4G88AAAAAAAA";
306
307        client
308            .dry_run(tx_bytes.to_string(), false, None)
309            .await
310            .map_err(|e| {
311                format!(
312                    "Dry run failed for {} network. Error: {e}",
313                    client.rpc_server()
314                )
315            })
316            .unwrap();
317    }
318}