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 contract_id_hash_from_asset(
175    asset: &Asset,
176    network_passphrase: &str,
177) -> stellar_strkey::Contract {
178    let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into());
179    let preimage = HashIdPreimage::ContractId(HashIdPreimageContractId {
180        network_id,
181        contract_id_preimage: ContractIdPreimage::Asset(asset.clone()),
182    });
183    let preimage_xdr = preimage
184        .to_xdr(Limits::none())
185        .expect("HashIdPreimage should not fail encoding to xdr");
186    stellar_strkey::Contract(Sha256::digest(preimage_xdr).into())
187}
188
189pub fn get_name_from_stellar_asset_contract_storage(storage: &ScMap) -> Option<String> {
190    if let Some(ScMapEntry {
191        val: ScVal::Map(Some(map)),
192        ..
193    }) = storage
194        .iter()
195        .find(|ScMapEntry { key, .. }| key == &ScVal::Symbol("METADATA".try_into().unwrap()))
196    {
197        if let Some(ScMapEntry {
198            val: ScVal::String(name),
199            ..
200        }) = map
201            .iter()
202            .find(|ScMapEntry { key, .. }| key == &ScVal::Symbol("name".try_into().unwrap()))
203        {
204            Some(name.to_string())
205        } else {
206            None
207        }
208    } else {
209        None
210    }
211}
212
213pub mod http {
214    use crate::commands::version;
215    fn user_agent() -> String {
216        format!("{}/{}", env!("CARGO_PKG_NAME"), version::pkg())
217    }
218
219    /// Creates and returns a configured `reqwest::Client`.
220    ///
221    /// # Panics
222    ///
223    /// Panics if the Client initialization fails.
224    pub fn client() -> reqwest::Client {
225        // Why we panic here:
226        // 1. Client initialization failures are rare and usually indicate serious issues.
227        // 2. The application cannot function properly without a working HTTP client.
228        // 3. This simplifies error handling for callers, as they can assume a valid client.
229        reqwest::Client::builder()
230            .user_agent(user_agent())
231            .build()
232            .expect("Failed to build reqwest client")
233    }
234
235    /// Creates and returns a configured `reqwest::blocking::Client`.
236    ///
237    /// # Panics
238    ///
239    /// Panics if the Client initialization fails.
240    pub fn blocking_client() -> reqwest::blocking::Client {
241        reqwest::blocking::Client::builder()
242            .user_agent(user_agent())
243            .build()
244            .expect("Failed to build reqwest blocking client")
245    }
246}
247
248pub mod args {
249    #[derive(thiserror::Error, Debug)]
250    pub enum DeprecatedError<'a> {
251        #[error("This argument has been removed and will be not be recognized by the future versions of CLI: {0}"
252        )]
253        RemovedArgument(&'a str),
254    }
255
256    #[macro_export]
257    /// Mark argument as removed with an error to be printed when it's used.
258    macro_rules! error_on_use_of_removed_arg {
259        ($_type:ident, $message: expr) => {
260            |a: &str| {
261                Err::<$_type, utils::args::DeprecatedError>(
262                    utils::args::DeprecatedError::RemovedArgument($message),
263                )
264            }
265        };
266    }
267
268    /// Mark argument as deprecated with warning to be printed when it's used.
269    #[macro_export]
270    macro_rules! deprecated_arg {
271        (bool, $message: expr) => {
272            <_ as clap::builder::TypedValueParser>::map(
273                clap::builder::BoolValueParser::new(),
274                |x| {
275                    if (x) {
276                        $crate::print::Print::new(false).warnln($message);
277                    }
278                    x
279                },
280            )
281        };
282    }
283}
284
285pub mod rpc {
286    use crate::xdr;
287    use soroban_rpc::{Client, Error};
288    use stellar_xdr::curr::{Hash, LedgerEntryData, LedgerKey, Limits, ReadXdr};
289
290    pub async fn get_remote_wasm_from_hash(client: &Client, hash: &Hash) -> Result<Vec<u8>, Error> {
291        let code_key = LedgerKey::ContractCode(xdr::LedgerKeyContractCode { hash: hash.clone() });
292        let contract_data = client.get_ledger_entries(&[code_key]).await?;
293        let entries = contract_data.entries.unwrap_or_default();
294        if entries.is_empty() {
295            return Err(Error::NotFound(
296                "Contract Code".to_string(),
297                hex::encode(hash),
298            ));
299        }
300        let contract_data_entry = &entries[0];
301        let code = match LedgerEntryData::from_xdr_base64(&contract_data_entry.xdr, Limits::none())?
302        {
303            LedgerEntryData::ContractCode(xdr::ContractCodeEntry { code, .. }) => Vec::from(code),
304            scval => return Err(Error::UnexpectedContractCodeDataType(scval)),
305        };
306        super::verify_wasm_hash(&code, hash)?;
307        Ok(code)
308    }
309}
310
311// Uses `Error::NotFound` because `soroban_rpc::Error` has no integrity/mismatch
312// variant. The message makes the actual failure reason clear.
313fn verify_wasm_hash(code: &[u8], expected_hash: &Hash) -> Result<(), soroban_rpc::Error> {
314    let computed_hash = Hash(Sha256::digest(code).into());
315    if computed_hash != *expected_hash {
316        return Err(soroban_rpc::Error::NotFound(
317            "WASM hash mismatch".to_string(),
318            format!(
319                "expected {}, got {}",
320                hex::encode(expected_hash.0),
321                hex::encode(computed_hash.0),
322            ),
323        ));
324    }
325    Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn test_contract_id_from_str() {
334        // strkey
335        match contract_id_from_str("CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE") {
336            Ok(contract_id) => assert_eq!(
337                contract_id.0,
338                [
339                    0x36, 0x3e, 0xaa, 0x38, 0x67, 0x84, 0x1f, 0xba, 0xd0, 0xf4, 0xed, 0x88, 0xc7,
340                    0x79, 0xe4, 0xfe, 0x66, 0xe5, 0x6a, 0x24, 0x70, 0xdc, 0x98, 0xc0, 0xec, 0x9c,
341                    0x07, 0x3d, 0x05, 0xc7, 0xb1, 0x03,
342                ]
343            ),
344            Err(err) => panic!("Failed to parse contract id: {err}"),
345        }
346    }
347
348    #[test]
349    fn test_verify_wasm_hash_matching() {
350        use sha2::{Digest, Sha256};
351        use stellar_xdr::curr::Hash;
352
353        let wasm_bytes = b"\0asm fake wasm content";
354        let correct_hash = Hash(Sha256::digest(wasm_bytes).into());
355        assert!(verify_wasm_hash(wasm_bytes, &correct_hash).is_ok());
356    }
357
358    #[test]
359    fn test_verify_wasm_hash_mismatch() {
360        use stellar_xdr::curr::Hash;
361
362        let wasm_bytes = b"\0asm fake wasm content";
363        let wrong_hash = Hash([0xAB; 32]);
364        let err = verify_wasm_hash(wasm_bytes, &wrong_hash).unwrap_err();
365        let err_msg = err.to_string();
366        assert!(
367            err_msg.contains("WASM hash mismatch"),
368            "expected 'WASM hash mismatch' in error: {err_msg}"
369        );
370        assert!(
371            err_msg.contains("abababababababababababababababababababababababababababababababab"),
372            "expected expected-hash in error: {err_msg}"
373        );
374        assert!(
375            err_msg.contains("501dc4e05f47c4713c4a27e89a5b07ed769bb2cc858bcf46de9bed13ae65af29"),
376            "expected computed-hash in error: {err_msg}"
377        );
378    }
379}