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(crate) const XDR_DEPTH_LIMIT: u32 = 500;
22
23pub fn contract_hash(contract: &[u8]) -> Result<Hash, xdr::Error> {
27 Ok(Hash(Sha256::digest(contract).into()))
28}
29
30pub 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
49pub 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
63pub 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
104pub 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 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
125pub 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#[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 pub fn client() -> reqwest::Client {
250 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 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 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 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 #[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 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
444fn 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 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}