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
18pub fn contract_hash(contract: &[u8]) -> Result<Hash, xdr::Error> {
22 Ok(Hash(Sha256::digest(contract).into()))
23}
24
25pub 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
44pub 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
58pub 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
99pub 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 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
120pub 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#[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 pub fn client() -> reqwest::Client {
245 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 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 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 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 #[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
387fn 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 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}