Skip to main content

soroban_cli/
utils.rs

1use phf::phf_map;
2use sha2::{Digest, Sha256};
3use stellar_strkey::ed25519::PrivateKey;
4
5use crate::{
6    print::Print,
7    xdr::{
8        self, Asset, ContractIdPreimage, Hash, HashIdPreimage, HashIdPreimageContractId, Limits,
9        ScMap, ScMapEntry, ScVal, Transaction, TransactionEnvelope, TransactionSignaturePayload,
10        TransactionSignaturePayloadTaggedTransaction, WriteXdr,
11    },
12};
13
14pub use soroban_spec_tools::contract as contract_spec;
15
16use crate::config::network::Network;
17
18/// # Errors
19///
20/// Might return an error
21pub fn contract_hash(contract: &[u8]) -> Result<Hash, xdr::Error> {
22    Ok(Hash(Sha256::digest(contract).into()))
23}
24
25/// Compute the transaction hash for a given transaction envelope.
26///
27/// # Errors
28///
29/// If the transaction envelope contains unsupported types (e.g., TxV0), this function will return an error.
30/// If an XDR error is encountered during processing, it will be propagated.
31pub fn transaction_env_hash(
32    tx_env: &TransactionEnvelope,
33    network_passphrase: &str,
34) -> Result<[u8; 32], xdr::Error> {
35    match tx_env {
36        TransactionEnvelope::Tx(ref v1_env) => transaction_hash(&v1_env.tx, network_passphrase),
37        TransactionEnvelope::TxFeeBump(ref fee_bump_env) => {
38            fee_bump_transaction_hash(&fee_bump_env.tx, network_passphrase)
39        }
40        TransactionEnvelope::TxV0(_) => Err(xdr::Error::Unsupported),
41    }
42}
43
44/// # Errors
45///
46/// Might return an error
47pub fn transaction_hash(
48    tx: &Transaction,
49    network_passphrase: &str,
50) -> Result<[u8; 32], xdr::Error> {
51    let signature_payload = TransactionSignaturePayload {
52        network_id: Hash(Sha256::digest(network_passphrase).into()),
53        tagged_transaction: TransactionSignaturePayloadTaggedTransaction::Tx(tx.clone()),
54    };
55    Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into())
56}
57
58/// # Errors
59///
60/// Might return an error
61pub fn fee_bump_transaction_hash(
62    fee_bump_tx: &xdr::FeeBumpTransaction,
63    network_passphrase: &str,
64) -> Result<[u8; 32], xdr::Error> {
65    let signature_payload = TransactionSignaturePayload {
66        network_id: Hash(Sha256::digest(network_passphrase).into()),
67        tagged_transaction: TransactionSignaturePayloadTaggedTransaction::TxFeeBump(
68            fee_bump_tx.clone(),
69        ),
70    };
71    Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into())
72}
73
74static EXPLORERS: phf::Map<&'static str, &'static str> = phf_map! {
75    "Test SDF Network ; September 2015" => "https://stellar.expert/explorer/testnet",
76    "Public Global Stellar Network ; September 2015" => "https://stellar.expert/explorer/public",
77};
78
79static LAB_CONTRACT_URLS: phf::Map<&'static str, &'static str> = phf_map! {
80    "Test SDF Network ; September 2015" => "https://lab.stellar.org/r/testnet/contract/{contract_id}",
81    "Public Global Stellar Network ; September 2015" => "https://lab.stellar.org/r/mainnet/contract/{contract_id}",
82};
83
84pub fn explorer_url_for_transaction(network: &Network, tx_hash: &str) -> Option<String> {
85    EXPLORERS
86        .get(&network.network_passphrase)
87        .map(|base_url| format!("{base_url}/tx/{tx_hash}"))
88}
89
90pub fn lab_url_for_contract(
91    network: &Network,
92    contract_id: &stellar_strkey::Contract,
93) -> Option<String> {
94    LAB_CONTRACT_URLS
95        .get(&network.network_passphrase)
96        .map(|base_url| base_url.replace("{contract_id}", &contract_id.to_string()))
97}
98
99/// # Errors
100///
101/// Might return an error
102pub fn contract_id_from_str(
103    contract_id: &str,
104) -> Result<stellar_strkey::Contract, stellar_strkey::DecodeError> {
105    Ok(
106        if let Ok(strkey) = stellar_strkey::Contract::from_string(contract_id) {
107            strkey
108        } else {
109            // strkey failed, try to parse it as a hex string, for backwards compatibility.
110            stellar_strkey::Contract(
111                soroban_spec_tools::utils::padded_hex_from_str(contract_id, 32)
112                    .map_err(|_| stellar_strkey::DecodeError::Invalid)?
113                    .try_into()
114                    .map_err(|_| stellar_strkey::DecodeError::Invalid)?,
115            )
116        },
117    )
118}
119
120/// # Errors
121/// May not find a config dir
122pub fn find_config_dir(mut pwd: std::path::PathBuf) -> std::io::Result<std::path::PathBuf> {
123    loop {
124        let stellar_dir = pwd.join(".stellar");
125        let stellar_exists = stellar_dir.exists();
126
127        let soroban_dir = pwd.join(".soroban");
128        let soroban_exists = soroban_dir.exists();
129
130        if stellar_exists && soroban_exists {
131            tracing::warn!("the .stellar and .soroban config directories exist at path {pwd:?}, using the .stellar");
132        }
133
134        if stellar_exists {
135            return Ok(stellar_dir);
136        }
137
138        if soroban_exists {
139            return Ok(soroban_dir);
140        }
141
142        if !pwd.pop() {
143            break;
144        }
145    }
146
147    Err(std::io::Error::other("stellar directory not found"))
148}
149
150pub(crate) fn into_signing_key(key: &PrivateKey) -> ed25519_dalek::SigningKey {
151    let secret: ed25519_dalek::SecretKey = key.0;
152    ed25519_dalek::SigningKey::from_bytes(&secret)
153}
154
155pub fn deprecate_message(print: Print, arg: &str, hint: &str) {
156    print.warnln(
157        format!("`{arg}` is deprecated and will be removed in future versions of the CLI. {hint}")
158            .trim(),
159    );
160}
161
162/// Used in tests
163#[allow(unused)]
164pub(crate) fn parse_secret_key(
165    s: &str,
166) -> Result<ed25519_dalek::SigningKey, stellar_strkey::DecodeError> {
167    Ok(into_signing_key(&PrivateKey::from_string(s)?))
168}
169
170pub fn is_hex_string(s: &str) -> bool {
171    s.chars().all(|s| s.is_ascii_hexdigit())
172}
173
174pub fn escape_control_characters(s: &str) -> String {
175    use std::fmt::Write as _;
176    let mut result = String::with_capacity(s.len());
177    for c in s.chars() {
178        if c.is_control() {
179            let mut buf = [0u8; 4];
180            for &byte in c.encode_utf8(&mut buf).as_bytes() {
181                write!(result, "\\x{byte:02x}").unwrap();
182            }
183        } else {
184            result.push(c);
185        }
186    }
187    result
188}
189
190pub fn contract_id_hash_from_asset(
191    asset: &Asset,
192    network_passphrase: &str,
193) -> stellar_strkey::Contract {
194    let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into());
195    let preimage = HashIdPreimage::ContractId(HashIdPreimageContractId {
196        network_id,
197        contract_id_preimage: ContractIdPreimage::Asset(asset.clone()),
198    });
199    let preimage_xdr = preimage
200        .to_xdr(Limits::none())
201        .expect("HashIdPreimage should not fail encoding to xdr");
202    stellar_strkey::Contract(Sha256::digest(preimage_xdr).into())
203}
204
205pub fn get_name_from_stellar_asset_contract_storage(storage: &ScMap) -> Option<String> {
206    if let Some(ScMapEntry {
207        val: ScVal::Map(Some(map)),
208        ..
209    }) = storage
210        .iter()
211        .find(|ScMapEntry { key, .. }| key == &ScVal::Symbol("METADATA".try_into().unwrap()))
212    {
213        if let Some(ScMapEntry {
214            val: ScVal::String(name),
215            ..
216        }) = map
217            .iter()
218            .find(|ScMapEntry { key, .. }| key == &ScVal::Symbol("name".try_into().unwrap()))
219        {
220            Some(name.to_string())
221        } else {
222            None
223        }
224    } else {
225        None
226    }
227}
228
229pub mod http {
230    use std::time::Duration;
231
232    use crate::commands::version;
233    fn user_agent() -> String {
234        format!("{}/{}", env!("CARGO_PKG_NAME"), version::pkg())
235    }
236
237    const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
238
239    /// Creates and returns a configured `reqwest::Client`.
240    ///
241    /// # Panics
242    ///
243    /// Panics if the Client initialization fails.
244    pub fn client() -> reqwest::Client {
245        // Why we panic here:
246        // 1. Client initialization failures are rare and usually indicate serious issues.
247        // 2. The application cannot function properly without a working HTTP client.
248        // 3. This simplifies error handling for callers, as they can assume a valid client.
249        reqwest::Client::builder()
250            .user_agent(user_agent())
251            .connect_timeout(CONNECT_TIMEOUT)
252            .build()
253            .expect("Failed to build reqwest client")
254    }
255
256    /// Creates and returns a configured `reqwest::blocking::Client`.
257    ///
258    /// # Panics
259    ///
260    /// Panics if the Client initialization fails.
261    pub fn blocking_client() -> reqwest::blocking::Client {
262        reqwest::blocking::Client::builder()
263            .user_agent(user_agent())
264            .connect_timeout(CONNECT_TIMEOUT)
265            .build()
266            .expect("Failed to build reqwest blocking client")
267    }
268}
269
270pub mod url {
271    use url::Url;
272
273    /// Returns the given URL with any password component replaced by the literal
274    /// `redacted`. If the URL is not parseable, it is returned unchanged.
275    pub fn redact_url(url: &str) -> String {
276        let Ok(mut url) = Url::parse(url) else {
277            return url.to_string();
278        };
279        if url.password().is_some() {
280            let _ = url.set_password(Some("redacted"));
281        }
282        url.to_string()
283    }
284
285    #[cfg(test)]
286    mod tests {
287        use super::*;
288
289        #[test]
290        fn leaves_url_without_password_unchanged() {
291            let plain = "https://rpc.example.com/soroban";
292            assert_eq!(redact_url(plain), plain);
293
294            let user_only = "https://alice@rpc.example.com/soroban";
295            assert_eq!(redact_url(user_only), user_only);
296        }
297
298        #[test]
299        fn replaces_password_with_placeholder() {
300            let with_password = "https://alice:supersecret@rpc.example.com/soroban";
301            let redacted = redact_url(with_password);
302            assert!(
303                !redacted.contains("supersecret"),
304                "password leaked: {redacted}"
305            );
306            assert!(
307                redacted.contains("alice:redacted"),
308                "expected `alice:redacted`: {redacted}"
309            );
310            assert!(
311                redacted.contains("rpc.example.com/soroban"),
312                "expected host and path preserved: {redacted}"
313            );
314        }
315
316        #[test]
317        fn returns_input_when_unparseable() {
318            let bad = "not a url";
319            assert_eq!(redact_url(bad), bad);
320        }
321    }
322}
323
324pub mod args {
325    #[derive(thiserror::Error, Debug)]
326    pub enum DeprecatedError<'a> {
327        #[error("This argument has been removed and will be not be recognized by the future versions of CLI: {0}"
328        )]
329        RemovedArgument(&'a str),
330    }
331
332    #[macro_export]
333    /// Mark argument as removed with an error to be printed when it's used.
334    macro_rules! error_on_use_of_removed_arg {
335        ($_type:ident, $message: expr) => {
336            |a: &str| {
337                Err::<$_type, utils::args::DeprecatedError>(
338                    utils::args::DeprecatedError::RemovedArgument($message),
339                )
340            }
341        };
342    }
343
344    /// Mark argument as deprecated with warning to be printed when it's used.
345    #[macro_export]
346    macro_rules! deprecated_arg {
347        (bool, $message: expr) => {
348            <_ as clap::builder::TypedValueParser>::map(
349                clap::builder::BoolValueParser::new(),
350                |x| {
351                    if (x) {
352                        $crate::print::Print::new(false).warnln($message);
353                    }
354                    x
355                },
356            )
357        };
358    }
359}
360
361pub mod rpc {
362    use crate::xdr;
363    use soroban_rpc::{Client, Error};
364    use stellar_xdr::curr::{Hash, LedgerEntryData, LedgerKey, Limits, ReadXdr};
365
366    pub async fn get_remote_wasm_from_hash(client: &Client, hash: &Hash) -> Result<Vec<u8>, Error> {
367        let code_key = LedgerKey::ContractCode(xdr::LedgerKeyContractCode { hash: hash.clone() });
368        let contract_data = client.get_ledger_entries(&[code_key]).await?;
369        let entries = contract_data.entries.unwrap_or_default();
370        if entries.is_empty() {
371            return Err(Error::NotFound(
372                "Contract Code".to_string(),
373                hex::encode(hash),
374            ));
375        }
376        let contract_data_entry = &entries[0];
377        let code = match LedgerEntryData::from_xdr_base64(&contract_data_entry.xdr, Limits::none())?
378        {
379            LedgerEntryData::ContractCode(xdr::ContractCodeEntry { code, .. }) => Vec::from(code),
380            scval => return Err(Error::UnexpectedContractCodeDataType(scval)),
381        };
382        super::verify_wasm_hash(&code, hash)?;
383        Ok(code)
384    }
385}
386
387// Uses `Error::NotFound` because `soroban_rpc::Error` has no integrity/mismatch
388// variant. The message makes the actual failure reason clear.
389fn verify_wasm_hash(code: &[u8], expected_hash: &Hash) -> Result<(), soroban_rpc::Error> {
390    let computed_hash = Hash(Sha256::digest(code).into());
391    if computed_hash != *expected_hash {
392        return Err(soroban_rpc::Error::NotFound(
393            "WASM hash mismatch".to_string(),
394            format!(
395                "expected {}, got {}",
396                hex::encode(expected_hash.0),
397                hex::encode(computed_hash.0),
398            ),
399        ));
400    }
401    Ok(())
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn test_contract_id_from_str() {
410        // strkey
411        match contract_id_from_str("CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE") {
412            Ok(contract_id) => assert_eq!(
413                contract_id.0,
414                [
415                    0x36, 0x3e, 0xaa, 0x38, 0x67, 0x84, 0x1f, 0xba, 0xd0, 0xf4, 0xed, 0x88, 0xc7,
416                    0x79, 0xe4, 0xfe, 0x66, 0xe5, 0x6a, 0x24, 0x70, 0xdc, 0x98, 0xc0, 0xec, 0x9c,
417                    0x07, 0x3d, 0x05, 0xc7, 0xb1, 0x03,
418                ]
419            ),
420            Err(err) => panic!("Failed to parse contract id: {err}"),
421        }
422    }
423
424    #[test]
425    fn test_verify_wasm_hash_matching() {
426        use sha2::{Digest, Sha256};
427        use stellar_xdr::curr::Hash;
428
429        let wasm_bytes = b"\0asm fake wasm content";
430        let correct_hash = Hash(Sha256::digest(wasm_bytes).into());
431        assert!(verify_wasm_hash(wasm_bytes, &correct_hash).is_ok());
432    }
433
434    #[test]
435    fn test_verify_wasm_hash_mismatch() {
436        use stellar_xdr::curr::Hash;
437
438        let wasm_bytes = b"\0asm fake wasm content";
439        let wrong_hash = Hash([0xAB; 32]);
440        let err = verify_wasm_hash(wasm_bytes, &wrong_hash).unwrap_err();
441        let err_msg = err.to_string();
442        assert!(
443            err_msg.contains("WASM hash mismatch"),
444            "expected 'WASM hash mismatch' in error: {err_msg}"
445        );
446        assert!(
447            err_msg.contains("abababababababababababababababababababababababababababababababab"),
448            "expected expected-hash in error: {err_msg}"
449        );
450        assert!(
451            err_msg.contains("501dc4e05f47c4713c4a27e89a5b07ed769bb2cc858bcf46de9bed13ae65af29"),
452            "expected computed-hash in error: {err_msg}"
453        );
454    }
455}