Skip to main content

sdkt_rpc/
deploy.rs

1use crate::error::RpcError;
2use crate::simulate::simulate_transaction;
3use crate::submission::{submit_and_wait, PollConfig, TransactionStatus};
4use crate::SorobanRpcClient;
5use sdkt_xdr::sign::{Ed25519Signer, Network, SigningOptions};
6use sdkt_xdr::sign_transaction;
7use stellar_xdr::{
8    LedgerFootprint, SorobanResources, SorobanTransactionData, SorobanTransactionDataExt, VecM,
9};
10
11/// Deployment result for a single contract.
12#[derive(Debug, Clone, PartialEq)]
13pub struct DeployResult {
14    pub wasm_hash: String,
15    pub contract_id: String,
16    pub upload_hash: String,
17    pub create_hash: String,
18    pub status: String,
19    pub salt: String,
20    pub upload_fee: u32,
21    pub create_fee: u32,
22    pub total_fee: u64,
23}
24
25/// Partial deployment result when upload succeeds but create fails.
26#[derive(Debug, Clone, PartialEq)]
27pub struct PartialDeployResult {
28    pub wasm_hash: String,
29    pub upload_hash: String,
30    pub error: String,
31}
32
33/// Deployment outcome that distinguishes success from partial failure.
34#[derive(Debug, Clone, PartialEq)]
35pub enum DeployOutcome {
36    Success(DeployResult),
37    Partial(PartialDeployResult),
38    Failure(String),
39}
40
41impl std::fmt::Display for DeployOutcome {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            DeployOutcome::Success(r) => {
45                write!(
46                    f,
47                    "Deployment successful!\n  WASM Hash: {}\n  Contract ID: {}\n  Upload TX: {}\n  Create TX: {}\n  Salt: {}\n  Status: {}\n  Upload Fee: {}\n  Create Fee: {}\n  Total Fee: {}",
48                    r.wasm_hash, r.contract_id, r.upload_hash, r.create_hash, r.salt, r.status, r.upload_fee, r.create_fee, r.total_fee
49                )
50            }
51            DeployOutcome::Partial(p) => {
52                write!(
53                    f,
54                    "Partial deployment: upload succeeded but create failed.\n  WASM Hash: {}\n  Upload TX: {}\n  Error: {}",
55                    p.wasm_hash, p.upload_hash, p.error
56                )
57            }
58            DeployOutcome::Failure(e) => {
59                write!(f, "Deployment failed: {}", e)
60            }
61        }
62    }
63}
64
65/// Parse a hex-encoded WASM hash string into a 32-byte array.
66pub fn parse_wasm_hash(hash_str: &str) -> Result<[u8; 32], RpcError> {
67    let bytes = hex::decode(hash_str)
68        .map_err(|e| RpcError::Rpc(format!("Invalid WASM hash hex: {}", e)))?;
69    if bytes.len() != 32 {
70        return Err(RpcError::Rpc("WASM hash must be 32 bytes".into()));
71    }
72    let mut result = [0u8; 32];
73    result.copy_from_slice(&bytes);
74    Ok(result)
75}
76
77/// Parse the `min_resource_fee` returned by a simulation into a `u32` stroop
78/// amount.
79///
80/// The value arrives as a decimal string. A missing, empty, or non-numeric
81/// value is a hard error rather than a silent `0`: defaulting to zero would
82/// build a transaction whose fee is just the 100-stroop inclusion fee, which
83/// the network rejects with an opaque `txINSUFFICIENT_FEE` while hiding the
84/// fact that fee parsing failed.
85fn parse_min_resource_fee(raw: &str) -> Result<u32, RpcError> {
86    let trimmed = raw.trim();
87    let value: u64 = trimmed.parse().map_err(|_| {
88        RpcError::Rpc(format!(
89            "Simulation returned an invalid min_resource_fee: {trimmed:?} \
90             (expected a numeric stroop value)"
91        ))
92    })?;
93    u32::try_from(value).map_err(|_| {
94        RpcError::Rpc(format!(
95            "Simulation min_resource_fee {value} exceeds the supported u32 fee range"
96        ))
97    })
98}
99
100/// Generate a random 20-byte salt for contract ID derivation.
101pub fn generate_salt() -> [u8; 20] {
102    let mut salt = [0u8; 20];
103    getrandom::fill(&mut salt).expect("Failed to generate random salt");
104    salt
105}
106
107/// Upload a WASM binary to the network.
108pub async fn upload_wasm(
109    client: &SorobanRpcClient,
110    wasm_bytes: &[u8],
111    source_account: &str,
112    sequence: i64,
113    fee: u32,
114    network: Network,
115    signer: &Ed25519Signer,
116) -> Result<(String, String, u32), RpcError> {
117    use sdkt_xdr::builder::UploadWasmParams;
118
119    if wasm_bytes.is_empty() {
120        return Err(RpcError::Rpc("WASM bytes are empty".into()));
121    }
122
123    // Parse metadata to get WASM hash
124    let meta = sdkt_wasm::parse_metadata(wasm_bytes)
125        .map_err(|e| RpcError::Rpc(format!("Failed to parse WASM metadata: {}", e)))?;
126    let wasm_hash = meta.hash.clone();
127
128    // Build initial V1 transaction for simulation (Soroban requires V1)
129    let initial_soroban_data = SorobanTransactionData {
130        ext: SorobanTransactionDataExt::V0,
131        resources: SorobanResources {
132            footprint: LedgerFootprint {
133                read_only: VecM::default(),
134                read_write: VecM::default(),
135            },
136            instructions: 0,
137            disk_read_bytes: 0,
138            write_bytes: 0,
139        },
140        resource_fee: 0,
141    };
142
143    let initial_envelope = sdkt_xdr::builder::build_upload_wasm_tx_with_data(
144        &UploadWasmParams {
145            source_account: source_account.to_string(),
146            sequence,
147            fee,
148            wasm_bytes: wasm_bytes.to_vec(),
149        },
150        initial_soroban_data,
151    )
152    .map_err(|e| RpcError::Rpc(format!("Failed to build upload transaction: {}", e)))?;
153
154    // Simulate transaction
155    let simulation = simulate_transaction(client, &initial_envelope)
156        .await
157        .map_err(|e| RpcError::Rpc(format!("Upload simulation failed: {}", e)))?;
158
159    // Check for simulation errors
160    if let Some(err) = &simulation.error {
161        return Err(RpcError::Rpc(format!("Upload simulation error: {}", err)));
162    }
163
164    // Parse SorobanTransactionData from simulation
165    let soroban_data = if simulation.transaction_data.is_empty() {
166        return Err(RpcError::Rpc(
167            "Simulation did not return SorobanTransactionData".into(),
168        ));
169    } else {
170        sdkt_xdr::builder::parse_soroban_transaction_data(&simulation.transaction_data)
171            .map_err(|e| RpcError::Rpc(format!("Failed to parse SorobanTransactionData: {}", e)))?
172    };
173
174    // Calculate fee from simulation
175    let min_resource_fee: u32 = parse_min_resource_fee(&simulation.min_resource_fee)?;
176
177    let inclusion_fee: u32 = 100;
178    let total_fee = inclusion_fee + min_resource_fee;
179
180    // Parse authorization entries from simulation
181    let auth_entries = if simulation.results.is_empty() {
182        Vec::new()
183    } else {
184        sdkt_xdr::builder::parse_soroban_authorization_entries(&simulation.results[0].auth)
185            .map_err(|e| RpcError::Rpc(format!("Failed to parse auth entries: {}", e)))?
186    };
187
188    // Build final transaction with SorobanTransactionData, auth entries, and proper fee
189    let final_envelope = sdkt_xdr::builder::build_upload_wasm_tx_with_data_and_auth(
190        &UploadWasmParams {
191            source_account: source_account.to_string(),
192            sequence,
193            fee: total_fee,
194            wasm_bytes: wasm_bytes.to_vec(),
195        },
196        soroban_data,
197        auth_entries,
198    )
199    .map_err(|e| RpcError::Rpc(format!("Failed to build final upload transaction: {}", e)))?;
200
201    // Sign the final envelope
202    let signing_opts = SigningOptions::with(network.clone());
203    let signed_envelope = sign_transaction(&final_envelope, signer, &signing_opts)
204        .map_err(|e| RpcError::Rpc(format!("Failed to sign upload transaction: {}", e)))?;
205
206    // Submit and poll for confirmation. `submit_and_wait` owns the single
207    // sendTransaction call; keeping submission in one place avoids sending the
208    // same signed create transaction twice.
209    let submission_result =
210        submit_and_wait(client, &signed_envelope, true, &PollConfig::default()).await?;
211
212    if submission_result.status != TransactionStatus::Success {
213        let diag = if let Some(ref xdr) = submission_result.error_result_xdr {
214            format!(" | error_result_xdr={}", xdr)
215        } else {
216            String::new()
217        };
218        let events = if !submission_result.diagnostic_events.is_empty() {
219            format!(
220                " | diagnostic_events={:?}",
221                submission_result.diagnostic_events
222            )
223        } else {
224            String::new()
225        };
226        let code = submission_result.error_code.as_deref().unwrap_or("unknown");
227        return Err(RpcError::Rpc(format!(
228            "Upload transaction failed: code={}{}{}",
229            code, diag, events
230        )));
231    }
232
233    Ok((wasm_hash, submission_result.hash, total_fee))
234}
235
236/// Create a contract instance from an uploaded WASM.
237pub async fn create_contract(
238    client: &SorobanRpcClient,
239    args: &CreateContractArgs,
240    sequence: i64,
241    fee: u32,
242    network: Network,
243    signer: &Ed25519Signer,
244) -> Result<(String, String, u32), RpcError> {
245    use sdkt_xdr::builder::{
246        build_create_contract_tx_with_data, build_create_contract_tx_with_data_and_auth,
247        build_create_contract_v2_tx_with_data, build_create_contract_v2_tx_with_data_and_auth,
248        CreateContractParams, CreateContractV2Params,
249    };
250
251    // Build initial transaction for simulation (Soroban requires V1 or V2)
252    let initial_soroban_data = SorobanTransactionData {
253        ext: SorobanTransactionDataExt::V0,
254        resources: SorobanResources {
255            footprint: LedgerFootprint {
256                read_only: VecM::default(),
257                read_write: VecM::default(),
258            },
259            instructions: 0,
260            disk_read_bytes: 0,
261            write_bytes: 0,
262        },
263        resource_fee: 0,
264    };
265
266    let initial_envelope = if args.constructor_args.is_empty() {
267        build_create_contract_tx_with_data(
268            &CreateContractParams {
269                source_account: args.deployer_address.clone(),
270                sequence,
271                fee,
272                wasm_hash: args.wasm_hash,
273                deployer_address: args.deployer_address.clone(),
274                salt: args.salt,
275            },
276            initial_soroban_data,
277        )
278        .map_err(|e| RpcError::Rpc(format!("Failed to build create transaction: {}", e)))?
279    } else {
280        build_create_contract_v2_tx_with_data(
281            &CreateContractV2Params {
282                source_account: args.deployer_address.clone(),
283                sequence,
284                fee,
285                wasm_hash: args.wasm_hash,
286                deployer_address: args.deployer_address.clone(),
287                salt: args.salt,
288                constructor_args: args.constructor_args.clone(),
289            },
290            initial_soroban_data,
291        )
292        .map_err(|e| RpcError::Rpc(format!("Failed to build create v2 transaction: {}", e)))?
293    };
294
295    // Simulate transaction
296    let simulation = simulate_transaction(client, &initial_envelope)
297        .await
298        .map_err(|e| RpcError::Rpc(format!("Create simulation failed: {}", e)))?;
299
300    // Check for simulation errors
301    if let Some(err) = &simulation.error {
302        return Err(RpcError::Rpc(format!("Create simulation error: {}", err)));
303    }
304
305    // Parse SorobanTransactionData from simulation
306    let soroban_data = if simulation.transaction_data.is_empty() {
307        return Err(RpcError::Rpc(
308            "Simulation did not return SorobanTransactionData".into(),
309        ));
310    } else {
311        sdkt_xdr::builder::parse_soroban_transaction_data(&simulation.transaction_data)
312            .map_err(|e| RpcError::Rpc(format!("Failed to parse SorobanTransactionData: {}", e)))?
313    };
314
315    // Calculate fee from simulation
316    let min_resource_fee: u32 = parse_min_resource_fee(&simulation.min_resource_fee)?;
317
318    let inclusion_fee: u32 = 100;
319    let total_fee = inclusion_fee + min_resource_fee;
320
321    // Parse authorization entries from simulation
322    let auth_entries = if simulation.results.is_empty() {
323        Vec::new()
324    } else {
325        sdkt_xdr::builder::parse_soroban_authorization_entries(&simulation.results[0].auth)
326            .map_err(|e| RpcError::Rpc(format!("Failed to parse auth entries: {}", e)))?
327    };
328
329    // Build final transaction with SorobanTransactionData, auth entries, and proper fee
330    let final_envelope = if args.constructor_args.is_empty() {
331        build_create_contract_tx_with_data_and_auth(
332            &CreateContractParams {
333                source_account: args.deployer_address.clone(),
334                sequence,
335                fee: total_fee,
336                wasm_hash: args.wasm_hash,
337                deployer_address: args.deployer_address.clone(),
338                salt: args.salt,
339            },
340            soroban_data,
341            auth_entries,
342        )
343        .map_err(|e| RpcError::Rpc(format!("Failed to build final create transaction: {}", e)))?
344    } else {
345        build_create_contract_v2_tx_with_data_and_auth(
346            &CreateContractV2Params {
347                source_account: args.deployer_address.clone(),
348                sequence,
349                fee: total_fee,
350                wasm_hash: args.wasm_hash,
351                deployer_address: args.deployer_address.clone(),
352                salt: args.salt,
353                constructor_args: args.constructor_args.clone(),
354            },
355            soroban_data,
356            auth_entries,
357        )
358        .map_err(|e| {
359            RpcError::Rpc(format!(
360                "Failed to build final create v2 transaction: {}",
361                e
362            ))
363        })?
364    };
365
366    // Sign the final envelope
367    let signing_opts = SigningOptions::with(network.clone());
368    let signed_envelope = sign_transaction(&final_envelope, signer, &signing_opts)
369        .map_err(|e| RpcError::Rpc(format!("Failed to sign create transaction: {}", e)))?;
370
371    // Submit and poll for confirmation. `submit_and_wait` owns the single
372    // sendTransaction call; keeping submission in one place avoids sending the
373    // same signed create transaction twice.
374    let submission_result =
375        submit_and_wait(client, &signed_envelope, true, &PollConfig::default()).await?;
376
377    if submission_result.status != TransactionStatus::Success {
378        let diag = if let Some(ref xdr) = submission_result.error_result_xdr {
379            format!(" | error_result_xdr={}", xdr)
380        } else {
381            String::new()
382        };
383        let events = if !submission_result.diagnostic_events.is_empty() {
384            format!(
385                " | diagnostic_events={:?}",
386                submission_result.diagnostic_events
387            )
388        } else {
389            String::new()
390        };
391        let code = submission_result.error_code.as_deref().unwrap_or("unknown");
392        return Err(RpcError::Rpc(format!(
393            "Create transaction failed: code={}{}{}",
394            code, diag, events
395        )));
396    }
397
398    // Derive contract ID
399    let network_id = network.network_id();
400    let contract_id = sdkt_xdr::builder::derive_contract_id(
401        &network_id,
402        &args.deployer_address,
403        &args.salt,
404        &args.wasm_hash,
405    )
406    .map_err(|e| RpcError::Rpc(format!("Failed to derive contract ID: {}", e)))?;
407
408    Ok((contract_id, submission_result.hash, total_fee))
409}
410
411/// Deploy a contract: upload WASM, then create contract instance.
412pub async fn deploy_contract(
413    client: &SorobanRpcClient,
414    wasm_bytes: &[u8],
415    source_account: &str,
416    signer: &Ed25519Signer,
417    network: Network,
418    user_salt: Option<[u8; 20]>,
419) -> Result<DeployOutcome, RpcError> {
420    deploy_contract_with_args(
421        client,
422        wasm_bytes,
423        source_account,
424        signer,
425        network,
426        user_salt,
427        Vec::new(),
428    )
429    .await
430}
431
432/// Deploy a contract with constructor arguments: upload WASM, then create contract instance.
433pub async fn deploy_contract_with_args(
434    client: &SorobanRpcClient,
435    wasm_bytes: &[u8],
436    source_account: &str,
437    signer: &Ed25519Signer,
438    network: Network,
439    user_salt: Option<[u8; 20]>,
440    constructor_args: Vec<String>,
441) -> Result<DeployOutcome, RpcError> {
442    use crate::account::get_next_sequence;
443
444    if wasm_bytes.is_empty() {
445        return Err(RpcError::Rpc("WASM bytes are empty".into()));
446    }
447
448    // Validate constructor arguments early before uploading WASM
449    sdkt_xdr::parse_scval_args(&constructor_args)
450        .map_err(|e| RpcError::Rpc(format!("Invalid constructor argument: {}", e)))?;
451
452    // Parse WASM hash
453    let meta = sdkt_wasm::parse_metadata(wasm_bytes)
454        .map_err(|e| RpcError::Rpc(format!("Failed to parse WASM metadata: {}", e)))?;
455    let wasm_hash_str = meta.hash.clone();
456    let wasm_hash = parse_wasm_hash(&wasm_hash_str)?;
457
458    // Select salt: user-provided (if any) or generated
459    let salt = match user_salt {
460        Some(s) => s,
461        None => generate_salt(),
462    };
463
464    // Fetch initial sequence
465    let mut sequence = get_next_sequence(client, source_account).await?;
466
467    // Step 1: Upload WASM
468    let (_uploaded_wasm_hash, upload_hash, upload_fee) = match upload_wasm(
469        client,
470        wasm_bytes,
471        source_account,
472        sequence,
473        100,
474        network.clone(),
475        signer,
476    )
477    .await
478    {
479        Ok(result) => result,
480        Err(e) => return Ok(DeployOutcome::Failure(format!("Upload failed: {}", e))),
481    };
482
483    // Refresh sequence after upload
484    sequence = get_next_sequence(client, source_account).await?;
485
486    // Step 2: Create contract
487    let create_args = CreateContractArgs {
488        wasm_hash,
489        deployer_address: source_account.to_string(),
490        salt,
491        constructor_args,
492    };
493
494    let (contract_id, create_hash, create_fee) =
495        match create_contract(client, &create_args, sequence, 100, network, signer).await {
496            Ok(result) => result,
497            Err(e) => {
498                return Ok(DeployOutcome::Partial(PartialDeployResult {
499                    wasm_hash: wasm_hash_str,
500                    upload_hash,
501                    error: e.to_string(),
502                }));
503            }
504        };
505
506    let total_fee = upload_fee as u64 + create_fee as u64;
507
508    Ok(DeployOutcome::Success(DeployResult {
509        wasm_hash: wasm_hash_str,
510        contract_id,
511        upload_hash,
512        create_hash,
513        status: "SUCCESS".into(),
514        salt: hex::encode(salt),
515        upload_fee,
516        create_fee,
517        total_fee,
518    }))
519}
520
521/// Create a contract from already-uploaded WASM code (create-only, no upload).
522///
523/// Skips `upload_wasm` entirely and drives `create_contract` with a
524/// caller-supplied on-chain `wasm_hash` (64-character hex), reusing the same
525/// simulate → sign → submit → poll flow as the full deploy. With the same salt
526/// and the same wasm hash the derived contract ID is identical to what the full
527/// path would have produced, so this resumes a deployment whose upload succeeded
528/// but whose create step failed — without paying the upload fee again.
529///
530/// Never returns `DeployOutcome::Partial` (there is no upload step); a create
531/// failure surfaces as `DeployOutcome::Failure`. The returned `DeployResult`
532/// reports `upload_hash = ""` and `upload_fee = 0`.
533pub async fn deploy_contract_from_hash(
534    client: &SorobanRpcClient,
535    wasm_hash_hex: &str,
536    source_account: &str,
537    signer: &Ed25519Signer,
538    network: Network,
539    user_salt: Option<[u8; 20]>,
540    constructor_args: Vec<String>,
541) -> Result<DeployOutcome, RpcError> {
542    use crate::account::get_next_sequence;
543
544    // Validate the supplied hash and constructor args up front.
545    let wasm_hash = parse_wasm_hash(wasm_hash_hex)?;
546    sdkt_xdr::parse_scval_args(&constructor_args)
547        .map_err(|e| RpcError::Rpc(format!("Invalid constructor argument: {}", e)))?;
548
549    // Same salt selection as the full deploy path: user-provided or generated.
550    let salt = match user_salt {
551        Some(s) => s,
552        None => generate_salt(),
553    };
554
555    let sequence = get_next_sequence(client, source_account).await?;
556
557    let create_args = CreateContractArgs {
558        wasm_hash,
559        deployer_address: source_account.to_string(),
560        salt,
561        constructor_args,
562    };
563
564    let (contract_id, create_hash, create_fee) =
565        match create_contract(client, &create_args, sequence, 100, network, signer).await {
566            Ok(result) => result,
567            Err(e) => return Ok(DeployOutcome::Failure(format!("Create failed: {}", e))),
568        };
569
570    Ok(DeployOutcome::Success(DeployResult {
571        wasm_hash: hex::encode(wasm_hash),
572        contract_id,
573        upload_hash: String::new(),
574        create_hash,
575        status: "SUCCESS".into(),
576        salt: hex::encode(salt),
577        upload_fee: 0,
578        create_fee,
579        total_fee: create_fee as u64,
580    }))
581}
582
583/// Pretty-print a deployment result.
584pub fn format_pretty(res: &DeployResult) -> String {
585    format!(
586        "Deployment Result:\n  WASM Hash: {}\n  Contract ID: {}\n  Upload Hash: {}\n  Create Hash: {}\n  Salt: {}\n  Status: {}\n  Upload Fee: {}\n  Create Fee: {}\n  Total Fee: {}",
587        res.wasm_hash, res.contract_id, res.upload_hash, res.create_hash, res.salt, res.status, res.upload_fee, res.create_fee, res.total_fee
588    )
589}
590
591/// JSON-print a deployment result.
592pub fn format_json(res: &DeployResult) -> String {
593    serde_json::json!({
594        "wasmHash": res.wasm_hash,
595        "contractId": res.contract_id,
596        "uploadHash": res.upload_hash,
597        "createHash": res.create_hash,
598        "salt": res.salt,
599        "status": res.status,
600        "uploadFee": res.upload_fee,
601        "createFee": res.create_fee,
602        "totalFee": res.total_fee,
603    })
604    .to_string()
605}
606
607/// Parameters for contract creation.
608#[derive(Debug, Clone, Default)]
609pub struct CreateContractArgs {
610    pub wasm_hash: [u8; 32],
611    pub deployer_address: String,
612    pub salt: [u8; 20],
613    pub constructor_args: Vec<String>,
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn test_parse_wasm_hash_valid() {
622        let hash_str = "0101010101010101010101010101010101010101010101010101010101010101";
623        let result = parse_wasm_hash(hash_str).unwrap();
624        assert_eq!(result.len(), 32);
625        assert_eq!(result[0], 1);
626    }
627
628    #[test]
629    fn test_parse_wasm_hash_invalid_length() {
630        let hash_str = "0101";
631        assert!(parse_wasm_hash(hash_str).is_err());
632    }
633
634    #[test]
635    fn test_parse_wasm_hash_invalid_hex() {
636        let hash_str = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
637        assert!(parse_wasm_hash(hash_str).is_err());
638    }
639
640    #[test]
641    fn test_parse_min_resource_fee_valid() {
642        assert_eq!(parse_min_resource_fee("0").unwrap(), 0);
643        assert_eq!(parse_min_resource_fee("1000").unwrap(), 1000);
644        assert_eq!(parse_min_resource_fee("  1234  ").unwrap(), 1234);
645        assert_eq!(
646            parse_min_resource_fee(&u32::MAX.to_string()).unwrap(),
647            u32::MAX
648        );
649    }
650
651    #[test]
652    fn test_parse_min_resource_fee_empty_is_error() {
653        let err = parse_min_resource_fee("").unwrap_err();
654        assert!(err.to_string().contains("invalid min_resource_fee"));
655    }
656
657    #[test]
658    fn test_parse_min_resource_fee_non_numeric_is_error() {
659        for raw in ["N/A", "unavailable", "12.5", "abc", "-1"] {
660            assert!(
661                parse_min_resource_fee(raw).is_err(),
662                "expected error for {raw:?}"
663            );
664        }
665    }
666
667    #[test]
668    fn test_parse_min_resource_fee_overflow_is_error() {
669        // Fits in u64 but exceeds u32 — must error, never silently truncate to 0.
670        let raw = (u32::MAX as u64 + 1).to_string();
671        let err = parse_min_resource_fee(&raw).unwrap_err();
672        assert!(err
673            .to_string()
674            .contains("exceeds the supported u32 fee range"));
675    }
676
677    #[test]
678    fn test_generate_salt_produces_unique_values() {
679        let salt1 = generate_salt();
680        let salt2 = generate_salt();
681        assert_ne!(salt1, salt2);
682        assert_eq!(salt1.len(), 20);
683    }
684
685    #[test]
686    fn test_salt_selection_user_provided() {
687        // user_salt path: explicit salt is used
688        let user = [7u8; 20];
689        let selected = match Some(user) {
690            Some(s) => s,
691            None => generate_salt(),
692        };
693        assert_eq!(selected, user);
694    }
695
696    #[test]
697    fn test_salt_selection_auto_generate() {
698        // user_salt None path: generated salt is used
699        let selected: [u8; 20] = match None {
700            Some(s) => s,
701            None => generate_salt(),
702        };
703        assert_eq!(selected.len(), 20);
704        // Non-zero with overwhelming probability
705        assert!(selected.iter().any(|&b| b != 0));
706    }
707
708    #[test]
709    fn test_salt_deterministic_contract_id_inputs() {
710        // Same salt must produce same Uint256 bytes when padded
711        let salt = [5u8; 20];
712        let mut padded = [0u8; 32];
713        padded[..20].copy_from_slice(&salt);
714        let mut padded2 = [0u8; 32];
715        padded2[..20].copy_from_slice(&salt);
716        assert_eq!(padded, padded2);
717    }
718
719    #[test]
720    fn test_deploy_result_contains_salt_field() {
721        // Verify DeployResult has a salt field that can be set and read
722        let result = DeployResult {
723            wasm_hash: "abc123".into(),
724            contract_id: "C...".into(),
725            upload_hash: "u1".into(),
726            create_hash: "c1".into(),
727            status: "SUCCESS".into(),
728            salt: "00112233445566778899aabbccddeeff00112233".into(),
729            upload_fee: 100,
730            create_fee: 200,
731            total_fee: 300,
732        };
733        assert_eq!(result.salt, "00112233445566778899aabbccddeeff00112233");
734    }
735
736    #[test]
737    fn test_deploy_result_fee_fields_and_total() {
738        let result = DeployResult {
739            wasm_hash: "abc123".into(),
740            contract_id: "C...".into(),
741            upload_hash: "u1".into(),
742            create_hash: "c1".into(),
743            status: "SUCCESS".into(),
744            salt: "00112233445566778899aabbccddeeff00112233".into(),
745            upload_fee: 1500,
746            create_fee: 2500,
747            total_fee: 4000,
748        };
749        assert_eq!(result.upload_fee, 1500);
750        assert_eq!(result.create_fee, 2500);
751        assert_eq!(result.total_fee, 4000);
752        assert_eq!(
753            result.total_fee,
754            result.upload_fee as u64 + result.create_fee as u64
755        );
756    }
757
758    #[test]
759    fn test_deploy_result_fee_overflow_safety() {
760        let upload_fee = u32::MAX;
761        let create_fee = u32::MAX;
762        let total_fee = upload_fee as u64 + create_fee as u64;
763        let result = DeployResult {
764            wasm_hash: "abc123".into(),
765            contract_id: "C...".into(),
766            upload_hash: "u1".into(),
767            create_hash: "c1".into(),
768            status: "SUCCESS".into(),
769            salt: "00112233445566778899aabbccddeeff00112233".into(),
770            upload_fee,
771            create_fee,
772            total_fee,
773        };
774        assert_eq!(result.upload_fee, u32::MAX);
775        assert_eq!(result.create_fee, u32::MAX);
776        assert_eq!(result.total_fee, 8589934590);
777        assert!(result.total_fee > u32::MAX as u64);
778        assert_eq!(
779            result.total_fee,
780            result.upload_fee as u64 + result.create_fee as u64
781        );
782    }
783
784    #[test]
785    fn test_explicit_salt_returned_in_result() {
786        // User-provided salt must be preserved end-to-end
787        let explicit = [7u8; 20];
788        let selected = match Some(explicit) {
789            Some(s) => s,
790            None => generate_salt(),
791        };
792        assert_eq!(selected, explicit);
793        let hex_salt = hex::encode(selected);
794        assert_eq!(hex_salt.len(), 40);
795        assert_eq!(hex_salt, "0707070707070707070707070707070707070707");
796    }
797
798    #[test]
799    fn test_auto_salt_is_20_bytes_and_hex_encoded() {
800        // Auto-generated salt must be 20 bytes → 40 hex chars
801        let selected: [u8; 20] = match None {
802            Some(s) => s,
803            None => generate_salt(),
804        };
805        let hex_salt = hex::encode(selected);
806        assert_eq!(hex_salt.len(), 40);
807        // Must be valid hex
808        assert!(hex_salt.chars().all(|c| c.is_ascii_hexdigit()));
809    }
810
811    #[test]
812    fn test_salt_in_contract_id_derivation_matches_result() {
813        // Same salt must be used for contract_id derivation and result
814        let salt = [3u8; 20];
815        let hex_before = hex::encode(salt);
816
817        // Simulate derivation (simplified - just verify salt is used)
818        let mut salt_bytes = [0u8; 32];
819        salt_bytes[..20].copy_from_slice(&salt);
820
821        let hex_after = hex::encode(salt);
822        assert_eq!(hex_before, hex_after);
823        assert_eq!(hex_after, "0303030303030303030303030303030303030303");
824    }
825
826    #[test]
827    fn test_format_pretty_includes_salt_and_fees() {
828        let result = DeployResult {
829            wasm_hash: "abc".into(),
830            contract_id: "C123".into(),
831            upload_hash: "u1".into(),
832            create_hash: "c1".into(),
833            status: "SUCCESS".into(),
834            salt: "00112233445566778899aabbccddeeff00112233".into(),
835            upload_fee: 150,
836            create_fee: 250,
837            total_fee: 400,
838        };
839        let pretty = format_pretty(&result);
840        assert!(pretty.contains("Salt:"));
841        assert!(pretty.contains("00112233445566778899aabbccddeeff00112233"));
842        assert!(pretty.contains("Upload Fee: 150"));
843        assert!(pretty.contains("Create Fee: 250"));
844        assert!(pretty.contains("Total Fee: 400"));
845    }
846
847    #[test]
848    fn test_format_json_includes_salt_and_fees() {
849        let result = DeployResult {
850            wasm_hash: "abc".into(),
851            contract_id: "C123".into(),
852            upload_hash: "u1".into(),
853            create_hash: "c1".into(),
854            status: "SUCCESS".into(),
855            salt: "00112233445566778899aabbccddeeff00112233".into(),
856            upload_fee: 150,
857            create_fee: 250,
858            total_fee: 400,
859        };
860        let json = format_json(&result);
861        assert!(json.contains("\"salt\""));
862        assert!(json.contains("00112233445566778899aabbccddeeff00112233"));
863        assert!(json.contains("\"uploadFee\":150"));
864        assert!(json.contains("\"createFee\":250"));
865        assert!(json.contains("\"totalFee\":400"));
866    }
867
868    #[test]
869    fn test_deploy_outcome_display_includes_fees() {
870        let result = DeployResult {
871            wasm_hash: "abc".into(),
872            contract_id: "C123".into(),
873            upload_hash: "u1".into(),
874            create_hash: "c1".into(),
875            status: "SUCCESS".into(),
876            salt: "00112233445566778899aabbccddeeff00112233".into(),
877            upload_fee: 150,
878            create_fee: 250,
879            total_fee: 400,
880        };
881        let outcome = DeployOutcome::Success(result);
882        let display = format!("{}", outcome);
883        assert!(display.contains("Upload Fee: 150"));
884        assert!(display.contains("Create Fee: 250"));
885        assert!(display.contains("Total Fee: 400"));
886    }
887
888    #[test]
889    fn test_create_contract_args_constructor_args() {
890        let args = CreateContractArgs {
891            wasm_hash: [1u8; 32],
892            deployer_address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".into(),
893            salt: [2u8; 20],
894            constructor_args: vec!["AAAAAQAAAAoAAAAA".into()],
895        };
896        assert_eq!(args.constructor_args.len(), 1);
897        assert_eq!(args.constructor_args[0], "AAAAAQAAAAoAAAAA");
898
899        let default_args = CreateContractArgs::default();
900        assert!(default_args.constructor_args.is_empty());
901    }
902
903    #[tokio::test]
904    async fn test_deploy_contract_with_invalid_constructor_args_fails_before_upload() {
905        let client = SorobanRpcClient::new("http://127.0.0.1:9999");
906        let signer = Ed25519Signer::from_seed(&[1u8; 32]);
907        let wasm = b"\0asm\x01\0\0\0";
908        let res = deploy_contract_with_args(
909            &client,
910            wasm,
911            "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
912            &signer,
913            Network::Testnet,
914            None,
915            vec!["not_valid_base64_scval".into()],
916        )
917        .await;
918
919        let err = res.unwrap_err();
920        match err {
921            RpcError::Rpc(msg) => {
922                assert!(msg.contains("Invalid constructor argument"));
923            }
924            other => panic!("Unexpected error variant: {other:?}"),
925        }
926    }
927}