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