Skip to main content

sdkt_rpc/
storage.rs

1use crate::client::SorobanRpcClient;
2use crate::error::RpcError;
3use base64::Engine;
4use serde::Serialize;
5use stellar_xdr::{LedgerEntryData, ReadXdr, SorobanTransactionData};
6
7/// Storage TTL info for a contract.
8#[derive(Debug, Clone, PartialEq, Serialize)]
9pub struct TtlInfo {
10    pub contract_id: String,
11    pub entries: Vec<TtlEntry>,
12}
13
14/// A single storage entry with human-readable TTL information.
15#[derive(Debug, Clone, PartialEq, Serialize)]
16pub struct TtlEntry {
17    pub key: String,
18    pub current_ttl: u32,
19    pub expiration_time: String,
20    pub days_remaining: u32,
21    pub extension_cost_stroops: u64,
22}
23
24/// Calculates extension cost using a simple placeholder formula
25/// because the exact design logic is unspecified.
26pub fn calculate_extension_cost(ledger_delta: u32) -> u64 {
27    ledger_delta as u64 * 100
28}
29
30/// Build the ledger key for a contract's **instance singleton** — the
31/// `LedgerKey::ContractData` entry whose `key` is `ScVal::LedgerKeyContractInstance`.
32///
33/// This key is always present for a deployed contract, so it is the minimal valid
34/// key to pass to `getLedgerEntries` when no explicit storage keys are known. Soroban
35/// RPC requires explicit keys and cannot enumerate a contract's full storage, so the
36/// instance entry is the guaranteed baseline (carrying the contract's WASM hash).
37///
38/// `contract_id` may be a StrKey `C...` or a raw 32-byte hex string; both are
39/// normalized via [`crate::inspect::contract_id_to_hex`].
40pub(crate) fn instance_ledger_key(contract_id: &str) -> Result<String, RpcError> {
41    let contract_id_hex = crate::inspect::contract_id_to_hex(contract_id)?;
42    sdkt_xdr::encode_ledger_key(&sdkt_xdr::LedgerKeyParams::ContractData(contract_id_hex))
43        .map_err(|e| RpcError::Rpc(format!("Failed to encode instance ledger key: {e}")))
44}
45
46/// Fetches TTL info from the RPC node for a contract's instance entry plus any
47/// explicit extra keys, and transforms it into a `TtlInfo` representation.
48///
49/// Validates `contract_id` and all `extra_keys` before any RPC call via
50/// [`collect_extend_keys`].
51pub async fn get_ttl_info_for_keys(
52    client: &SorobanRpcClient,
53    contract_id: &str,
54    extra_keys: &[String],
55) -> Result<TtlInfo, RpcError> {
56    // Validate contract ID and all keys before contacting RPC.
57    let keys = collect_extend_keys(contract_id, extra_keys)?;
58
59    let ledger_info = client.get_ledger().await?;
60    let current_ledger = ledger_info.sequence;
61
62    let storage_resp = client.get_contract_storage(contract_id, &keys).await?;
63
64    let mut entries = Vec::new();
65    for entry in storage_resp.entries {
66        let current_ttl = if let Some(live_until) = entry.live_until_ledger_seq {
67            live_until.saturating_sub(current_ledger)
68        } else {
69            0
70        };
71
72        // Rough estimation: 1 ledger ≈ 5 seconds
73        let days_remaining = (current_ttl * 5) / (24 * 3600);
74        let extension_cost_stroops = calculate_extension_cost(current_ttl);
75
76        entries.push(TtlEntry {
77            key: entry.key,
78            current_ttl,
79            expiration_time: format!("~{} days", days_remaining),
80            days_remaining,
81            extension_cost_stroops,
82        });
83    }
84
85    Ok(TtlInfo {
86        contract_id: contract_id.to_string(),
87        entries,
88    })
89}
90
91/// Fetches TTL info from the RPC node and transforms it into a `TtlInfo` representation.
92pub async fn get_ttl_info(
93    client: &SorobanRpcClient,
94    contract_id: &str,
95) -> Result<TtlInfo, RpcError> {
96    get_ttl_info_for_keys(client, contract_id, &[]).await
97}
98
99/// Result of a successful `ExtendFootprintTtl` submission.
100#[derive(Debug, Clone, PartialEq, Serialize)]
101pub struct ExtendResult {
102    pub contract_id: String,
103    pub extend_to: u32,
104    pub footprint_keys: Vec<String>,
105    pub hash: String,
106    pub status: String,
107    pub fee: u32,
108}
109
110/// Result of a `RestoreFootprint` submission, or of a `--dry-run`.
111#[derive(Debug, Clone, PartialEq, Serialize)]
112pub struct RestoreResult {
113    pub contract_id: String,
114    /// Ledger keys (base64 XDR) from the preamble footprint, read-only then read-write.
115    pub footprint_keys: Vec<String>,
116    /// Number of ledger keys the restore covers.
117    pub restored_keys: usize,
118    /// Transaction hash. `None` for a dry run, which submits nothing.
119    pub hash: Option<String>,
120    /// `SUCCESS` after a confirmed submission, `DRY_RUN` otherwise.
121    pub status: String,
122    /// Total transaction fee in stroops (inclusion fee + resource fee).
123    pub fee: u32,
124    /// `minResourceFee` reported by the restore preamble.
125    pub min_resource_fee: u32,
126}
127
128/// Parse `min_resource_fee` from simulation. Never silently default to zero.
129fn parse_min_resource_fee(raw: &str) -> Result<u32, RpcError> {
130    let parsed: u64 = raw.parse().map_err(|_| {
131        RpcError::Rpc(format!(
132            "simulation returned invalid min_resource_fee: {raw:?}"
133        ))
134    })?;
135    u32::try_from(parsed)
136        .map_err(|_| RpcError::Rpc(format!("simulation min_resource_fee overflowed u32: {raw}")))
137}
138
139/// Merge the contract instance key with extra operator-supplied keys.
140/// Extra keys may be base64 XDR or even-length hex XDR. Duplicates are dropped.
141pub fn collect_extend_keys(
142    contract_id: &str,
143    extra_keys: &[String],
144) -> Result<Vec<String>, RpcError> {
145    let mut keys = vec![instance_ledger_key(contract_id)?];
146    keys.extend(extra_keys.iter().cloned());
147    sdkt_xdr::merge_footprint_keys(&keys)
148        .map_err(|e| RpcError::Rpc(format!("invalid LedgerKey: {e}")))
149}
150
151/// Extend the TTL of known footprint keys via `ExtendFootprintTtl`.
152///
153/// Always includes the contract instance singleton. Additional keys may be
154/// supplied; they are not discovered automatically.
155///
156/// `extend_to` is relative, as the protocol defines it: the entries will live
157/// at least `extend_to` ledgers past the last closed ledger. It is passed to
158/// the operation unchanged; adding the current ledger would overshoot and
159/// exceed the network's maximum TTL.
160pub async fn extend_footprint(
161    client: &SorobanRpcClient,
162    contract_id: &str,
163    extra_keys: &[String],
164    extend_to: u32,
165    source_account: &str,
166    signer: &sdkt_xdr::sign::Ed25519Signer,
167    network: sdkt_xdr::sign::Network,
168) -> Result<ExtendResult, RpcError> {
169    if extend_to == 0 {
170        return Err(RpcError::Rpc(
171            "--ledgers / extend_to must be greater than 0".into(),
172        ));
173    }
174
175    let footprint_keys = collect_extend_keys(contract_id, extra_keys)?;
176    let sequence = crate::account::get_next_sequence(client, source_account).await?;
177
178    let sim_params = sdkt_xdr::ExtendFootprintParams {
179        source_account: source_account.to_string(),
180        sequence,
181        fee: 100,
182        extend_to,
183        footprint_keys: footprint_keys.clone(),
184    };
185
186    let initial_envelope = sdkt_xdr::build_extend_footprint_tx(&sim_params)
187        .map_err(|e| RpcError::Rpc(format!("Failed to build extend transaction: {e}")))?;
188
189    let simulation = crate::simulate::simulate_transaction(client, &initial_envelope)
190        .await
191        .map_err(|e| RpcError::Rpc(format!("Extend simulation failed: {e}")))?;
192
193    if let Some(err) = &simulation.error {
194        return Err(RpcError::Rpc(format!("Extend simulation error: {err}")));
195    }
196    if simulation.transaction_data.is_empty() {
197        return Err(RpcError::Rpc(
198            "Simulation did not return SorobanTransactionData".into(),
199        ));
200    }
201
202    let soroban_data: SorobanTransactionData =
203        sdkt_xdr::parse_soroban_transaction_data(&simulation.transaction_data)
204            .map_err(|e| RpcError::Rpc(format!("Failed to parse SorobanTransactionData: {e}")))?;
205
206    let min_resource_fee = parse_min_resource_fee(&simulation.min_resource_fee)?;
207    let inclusion_fee: u32 = 100;
208    let total_fee = inclusion_fee.saturating_add(min_resource_fee);
209
210    let final_params = sdkt_xdr::ExtendFootprintParams {
211        fee: total_fee,
212        ..sim_params
213    };
214    let final_envelope = sdkt_xdr::build_extend_footprint_tx_with_data(&final_params, soroban_data)
215        .map_err(|e| RpcError::Rpc(format!("Failed to build final extend transaction: {e}")))?;
216
217    let signing_opts = sdkt_xdr::sign::SigningOptions::with(network);
218    let signed_envelope = sdkt_xdr::sign_transaction(&final_envelope, signer, &signing_opts)
219        .map_err(|e| RpcError::Rpc(format!("Failed to sign extend transaction: {e}")))?;
220
221    let submission = crate::submission::submit_and_wait(
222        client,
223        &signed_envelope,
224        true,
225        &crate::submission::PollConfig::default(),
226    )
227    .await?;
228
229    if submission.status != crate::submission::TransactionStatus::Success {
230        let code = submission.error_code.as_deref().unwrap_or("unknown");
231        let diag = submission.error_result_xdr.as_deref().unwrap_or("");
232        return Err(RpcError::Rpc(format!(
233            "Extend transaction failed: code={code} {diag}"
234        )));
235    }
236
237    Ok(ExtendResult {
238        contract_id: contract_id.to_string(),
239        extend_to,
240        footprint_keys,
241        hash: submission.hash,
242        status: "SUCCESS".into(),
243        fee: total_fee,
244    })
245}
246
247/// Inclusion fee added on top of the resource fee, matching `extend_footprint`.
248const RESTORE_INCLUSION_FEE: u32 = 100;
249
250/// Turn a simulation's `restorePreamble` into `RestoreFootprint` parameters.
251///
252/// Pure: no network access. The preamble's `transactionData` is adopted as-is
253/// (authoritative footprint and resource bounds), except that its
254/// `resource_fee` is raised to `minResourceFee` if it is lower, so the fee
255/// floor always holds. Returns the parameters and the reported
256/// `minResourceFee`.
257///
258/// # Errors
259/// Returns an actionable [`RpcError`] when there is no preamble (state is
260/// live), when the preamble is malformed, or when its footprint is empty.
261pub fn restore_params_from_simulation(
262    simulation: &crate::simulate::SimulateResponse,
263    source_account: &str,
264    sequence: i64,
265) -> Result<(sdkt_xdr::RestoreFootprintParams, u32), RpcError> {
266    let Some(preamble) = &simulation.restore_preamble else {
267        return Err(RpcError::Rpc(match &simulation.error {
268            Some(err) => {
269                format!("Nothing to restore: simulation failed without a restore preamble: {err}")
270            }
271            None => "Nothing to restore: simulation returned no restore preamble, \
272                     so the contract state this invocation touches is live"
273                .into(),
274        }));
275    };
276
277    let mut soroban_data: SorobanTransactionData =
278        sdkt_xdr::parse_soroban_transaction_data(&preamble.transaction_data).map_err(|e| {
279            RpcError::Rpc(format!(
280                "Restore preamble transactionData is malformed ({e}); \
281                 re-run the simulation against a healthy RPC node"
282            ))
283        })?;
284
285    let footprint = &soroban_data.resources.footprint;
286    if footprint.read_only.is_empty() && footprint.read_write.is_empty() {
287        return Err(RpcError::Rpc(
288            "Restore preamble footprint is empty: no archived entries to restore".into(),
289        ));
290    }
291
292    let min_resource_fee = parse_min_resource_fee(&preamble.min_resource_fee)?;
293    if soroban_data.resource_fee < i64::from(min_resource_fee) {
294        soroban_data.resource_fee = i64::from(min_resource_fee);
295    }
296    let resource_fee = u32::try_from(soroban_data.resource_fee).map_err(|_| {
297        RpcError::Rpc(format!(
298            "Restore preamble resource fee out of range: {}",
299            soroban_data.resource_fee
300        ))
301    })?;
302
303    let params = sdkt_xdr::RestoreFootprintParams {
304        source_account: source_account.to_string(),
305        sequence,
306        fee: RESTORE_INCLUSION_FEE.saturating_add(resource_fee),
307        soroban_data,
308    };
309    Ok((params, min_resource_fee))
310}
311
312/// Restore archived contract storage via `RestoreFootprint`.
313///
314/// Simulates `invocation_envelope` (typically the invocation that failed on
315/// archived state) to obtain its `restorePreamble`, then builds a
316/// `RestoreFootprint` transaction from the preamble's footprint and fee. With
317/// `dry_run` the transaction is built but neither signed nor submitted;
318/// otherwise it is signed and submitted, waiting for confirmation like
319/// `extend_footprint`.
320pub async fn restore_footprint(
321    client: &SorobanRpcClient,
322    contract_id: &str,
323    invocation_envelope: &str,
324    source_account: &str,
325    signer: &sdkt_xdr::sign::Ed25519Signer,
326    network: sdkt_xdr::sign::Network,
327    dry_run: bool,
328) -> Result<RestoreResult, RpcError> {
329    let simulation = crate::simulate::simulate_transaction(client, invocation_envelope)
330        .await
331        .map_err(|e| RpcError::Rpc(format!("Restore simulation failed: {e}")))?;
332
333    let sequence = crate::account::get_next_sequence(client, source_account).await?;
334    let (params, min_resource_fee) =
335        restore_params_from_simulation(&simulation, source_account, sequence)?;
336    let footprint_keys = extract_footprint_keys(&params.soroban_data)?;
337
338    let envelope = sdkt_xdr::build_restore_footprint_tx(&params)
339        .map_err(|e| RpcError::Rpc(format!("Failed to build restore transaction: {e}")))?;
340
341    let mut result = RestoreResult {
342        contract_id: contract_id.to_string(),
343        restored_keys: footprint_keys.len(),
344        footprint_keys,
345        hash: None,
346        status: "DRY_RUN".into(),
347        fee: params.fee,
348        min_resource_fee,
349    };
350    if dry_run {
351        return Ok(result);
352    }
353
354    let signing_opts = sdkt_xdr::sign::SigningOptions::with(network);
355    let signed_envelope = sdkt_xdr::sign_transaction(&envelope, signer, &signing_opts)
356        .map_err(|e| RpcError::Rpc(format!("Failed to sign restore transaction: {e}")))?;
357
358    let submission = crate::submission::submit_and_wait(
359        client,
360        &signed_envelope,
361        true,
362        &crate::submission::PollConfig::default(),
363    )
364    .await?;
365
366    if submission.status != crate::submission::TransactionStatus::Success {
367        let code = submission.error_code.as_deref().unwrap_or("unknown");
368        let diag = submission.error_result_xdr.as_deref().unwrap_or("");
369        return Err(RpcError::Rpc(format!(
370            "Restore transaction failed: code={code} {diag}"
371        )));
372    }
373
374    result.hash = Some(submission.hash);
375    result.status = "SUCCESS".into();
376    Ok(result)
377}
378
379/// Encode the footprint's ledger keys (read-only, then read-write) as base64 XDR.
380fn extract_footprint_keys(soroban_data: &SorobanTransactionData) -> Result<Vec<String>, RpcError> {
381    use stellar_xdr::{Limits, WriteXdr};
382    let footprint = &soroban_data.resources.footprint;
383    footprint
384        .read_only
385        .iter()
386        .chain(footprint.read_write.iter())
387        .map(|key| {
388            key.to_xdr(Limits::none())
389                .map(|raw| base64::engine::general_purpose::STANDARD.encode(raw))
390                .map_err(|e| RpcError::Rpc(format!("Failed to encode LedgerKey: {e}")))
391        })
392        .collect()
393}
394
395/// Check whether a contract is live on-chain at `contract_id`.
396///
397/// Uses the cheapest possible existence probe: a single `getLedgerEntries`
398/// call for the contract's instance singleton key (see [`instance_ledger_key`]).
399/// Returns `Ok(true)` when the ledger responds with the instance entry and
400/// `Ok(false)` when the entry is absent. This is the on-chain verification used
401/// by `sdkt project deploy --skip-deployed` so a stale `.sdkt-deployments.json`
402/// entry (contract deleted / never existed) does not cause a skip.
403pub async fn contract_exists(
404    client: &SorobanRpcClient,
405    contract_id: &str,
406) -> Result<bool, RpcError> {
407    let key = instance_ledger_key(contract_id)?;
408    let response = client.get_contract_storage("", &[key]).await?;
409    Ok(!response.entries.is_empty())
410}
411
412/// Read a single ledger entry by its `LedgerKey` (base64 XDR).
413///
414/// Calls `getLedgerEntries` and returns the decoded `LedgerEntry`. If the
415/// requested entry is absent, returns a clear error rather than an empty value.
416pub async fn read_ledger_entry(
417    client: &SorobanRpcClient,
418    key_b64: &str,
419) -> Result<stellar_xdr::LedgerEntry, RpcError> {
420    read_ledger_entry_with_ttl(client, key_b64)
421        .await
422        .map(|(entry, _)| entry)
423}
424
425async fn read_ledger_entry_with_ttl(
426    client: &SorobanRpcClient,
427    key_b64: &str,
428) -> Result<(stellar_xdr::LedgerEntry, Option<u32>), RpcError> {
429    let keys = vec![key_b64.to_string()];
430    let response = client.get_contract_storage("", &keys).await?;
431
432    if response.entries.is_empty() {
433        return Err(RpcError::Rpc(
434            "Ledger entry not found for the requested key".into(),
435        ));
436    }
437
438    let result = &response.entries[0];
439    let live_until_ledger = result.live_until_ledger_seq;
440    let entry_xdr = &result.xdr;
441    let entry_bytes = base64::engine::general_purpose::STANDARD
442        .decode(entry_xdr.trim())
443        .map_err(|e| RpcError::Rpc(format!("Failed to decode LedgerEntry XDR: {e}")))?;
444
445    let mut cursor = std::io::Cursor::new(&entry_bytes);
446    let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
447    let entry = stellar_xdr::LedgerEntry::read_xdr(&mut l)
448        .map_err(|e| RpcError::Rpc(format!("Failed to parse LedgerEntry: {e}")))?;
449    Ok((entry, live_until_ledger))
450}
451
452/// Result of a successful `storage read` call.
453#[derive(Debug, Clone, PartialEq, Serialize)]
454pub struct StateReadResult {
455    pub contract_id: String,
456    pub key: String,
457    pub entry_type: String,
458    pub durability: Option<String>,
459    pub value: serde_json::Value,
460    pub live_until_ledger: Option<u32>,
461}
462
463/// Read a contract's storage entry by its `LedgerKey` (base64 XDR).
464///
465/// The instance key is NOT included automatically — the caller must supply
466/// the complete `LedgerKey` via `--key-xdr`. ABI formatting is applied to the
467/// returned `val` only when `--abi` is supplied; it is NOT used to discover
468/// or construct the key.
469pub async fn read_contract_state(
470    client: &SorobanRpcClient,
471    contract_id: &str,
472    key_b64: &str,
473    abi: Option<&sdkt_wasm::ContractSpec>,
474) -> Result<StateReadResult, RpcError> {
475    use stellar_xdr::WriteXdr;
476    let decoded_key = sdkt_xdr::decode_ledger_key(key_b64)
477        .map_err(|e| RpcError::Rpc(format!("Invalid LedgerKey: {e}")))?;
478    let mut buf = Vec::new();
479    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
480    decoded_key
481        .write_xdr(&mut l)
482        .map_err(|e| RpcError::Rpc(format!("Failed to re-encode LedgerKey: {e}")))?;
483    let canonical_key_b64 = base64::engine::general_purpose::STANDARD.encode(&buf);
484
485    let (entry, live_until_ledger) = read_ledger_entry_with_ttl(client, &canonical_key_b64).await?;
486
487    let (entry_type, durability, value_json) = match &entry.data {
488        LedgerEntryData::ContractData(cd) => {
489            let durability_str = match cd.durability {
490                stellar_xdr::ContractDataDurability::Persistent => "persistent",
491                stellar_xdr::ContractDataDurability::Temporary => "temporary",
492            };
493
494            let value_json = match abi {
495                Some(spec) => {
496                    let decoded = sdkt_xdr::abi_decode::decode_with_abi(spec, &cd.val, None);
497                    serde_json::json!({
498                        "raw": decoded.raw,
499                        "label": decoded.label,
500                        "matched_type": decoded.matched_type,
501                        "fields": decoded.fields,
502                    })
503                }
504                None => {
505                    let mut val_buf = Vec::new();
506                    let mut val_cursor = std::io::Cursor::new(&mut val_buf);
507                    let mut val_l =
508                        stellar_xdr::Limited::new(&mut val_cursor, stellar_xdr::Limits::none());
509                    cd.val
510                        .write_xdr(&mut val_l)
511                        .map_err(|e| RpcError::Rpc(format!("Failed to encode ScVal: {e}")))?;
512                    let val_b64 = base64::engine::general_purpose::STANDARD.encode(&val_buf);
513                    serde_json::json!({ "xdr": val_b64 })
514                }
515            };
516
517            (
518                "contract_data".into(),
519                Some(durability_str.to_string()),
520                value_json,
521            )
522        }
523        LedgerEntryData::ContractCode(_cc) => (
524            "contract_code".into(),
525            None,
526            serde_json::json!({ "note": "contract code entry" }),
527        ),
528        _ => {
529            return Err(RpcError::Rpc(
530                "Ledger entry is not a ContractData or ContractCode entry".into(),
531            ));
532        }
533    };
534
535    Ok(StateReadResult {
536        contract_id: contract_id.to_string(),
537        key: canonical_key_b64,
538        entry_type,
539        durability,
540        value: value_json,
541        live_until_ledger,
542    })
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use base64::Engine;
549    use stellar_xdr::{LedgerKey, ReadXdr};
550
551    #[test]
552    fn test_calculate_extension_cost() {
553        const SINGLE_ENTRY_TTL_30: u32 = 30;
554        const SINGLE_ENTRY_TTL_100: u32 = 100;
555
556        assert_eq!(calculate_extension_cost(SINGLE_ENTRY_TTL_30), 3000);
557        assert_eq!(calculate_extension_cost(SINGLE_ENTRY_TTL_100), 10000);
558        assert_eq!(calculate_extension_cost(0), 0);
559    }
560
561    /// Proves the instance-ledger-key derivation never produces an empty/placeholder
562    /// key: for a real contract id it returns a non-empty base64 XDR `LedgerKey`
563    /// encoding the contract's instance singleton, and the StrKey and hex forms of the
564    /// same contract yield an identical key.
565    #[test]
566    fn test_instance_ledger_key_is_valid_and_stable() {
567        let c = "CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC";
568        let hex = "09ba7d2a24a36c9de487f43ab4ce87acf07cf27c32bee2bcf35e22726ca3c06c";
569
570        let key_from_strkey = instance_ledger_key(c).expect("StrKey C... should derive a key");
571        let key_from_hex = instance_ledger_key(hex).expect("hex should derive a key");
572
573        // Never empty / never a placeholder.
574        assert!(!key_from_strkey.is_empty());
575        assert_eq!(
576            key_from_strkey, key_from_hex,
577            "StrKey and hex must map to the same contract instance key"
578        );
579
580        // Decodes to a ContractData ledger key (the instance singleton).
581        let bytes = base64::engine::general_purpose::STANDARD
582            .decode(key_from_strkey.trim())
583            .expect("key must be valid base64");
584        let mut cursor = std::io::Cursor::new(bytes);
585        let mut limited = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
586        let ledger_key = LedgerKey::read_xdr(&mut limited).expect("key must decode to a LedgerKey");
587        match ledger_key {
588            LedgerKey::ContractData(cd) => {
589                assert!(
590                    matches!(cd.key, stellar_xdr::ScVal::LedgerKeyContractInstance),
591                    "instance key must target the contract instance singleton"
592                );
593            }
594            other => panic!("expected LedgerKey::ContractData, got {other:?}"),
595        }
596    }
597
598    #[test]
599    fn test_instance_ledger_key_rejects_garbage() {
600        // Garbage must error rather than silently yielding an empty/placeholder key.
601        assert!(instance_ledger_key("not-a-contract-id").is_err());
602    }
603
604    #[test]
605    fn test_parse_min_resource_fee_valid() {
606        assert_eq!(parse_min_resource_fee("1000").unwrap(), 1000);
607        assert_eq!(parse_min_resource_fee("0").unwrap(), 0);
608    }
609
610    #[test]
611    fn test_parse_min_resource_fee_rejects_invalid() {
612        assert!(parse_min_resource_fee("").is_err());
613        assert!(parse_min_resource_fee("abc").is_err());
614        assert!(parse_min_resource_fee("-1").is_err());
615    }
616
617    #[test]
618    fn test_parse_min_resource_fee_rejects_overflow() {
619        let huge = (u64::from(u32::MAX) + 1).to_string();
620        assert!(parse_min_resource_fee(&huge).is_err());
621    }
622
623    #[test]
624    fn test_collect_extend_keys_includes_instance() {
625        let keys = collect_extend_keys(
626            "CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC",
627            &[],
628        )
629        .unwrap();
630        assert_eq!(keys.len(), 1);
631    }
632
633    #[test]
634    fn test_collect_extend_keys_dedupes_instance() {
635        // Supplying the instance key explicitly should not duplicate it.
636        let instance =
637            instance_ledger_key("CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC")
638                .unwrap();
639        let keys = collect_extend_keys(
640            "CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC",
641            &[instance.clone(), instance],
642        )
643        .unwrap();
644        assert_eq!(keys.len(), 1);
645    }
646
647    #[test]
648    fn test_collect_extend_keys_rejects_invalid_extra() {
649        let result = collect_extend_keys(
650            "CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC",
651            &["not-a-valid-key".to_string()],
652        );
653        assert!(result.is_err());
654    }
655
656    #[test]
657    fn test_collect_extend_keys_rejects_bad_contract() {
658        assert!(collect_extend_keys("not-a-contract", &[]).is_err());
659    }
660
661    #[tokio::test]
662    async fn test_get_ttl_info_for_keys_rejects_invalid_key_before_rpc() {
663        // Point to an invalid/unreachable address. If validation happens before RPC,
664        // it fails immediately with invalid LedgerKey error, not a connection error.
665        let client = SorobanRpcClient::new("http://127.0.0.1:1");
666        let result = get_ttl_info_for_keys(
667            &client,
668            "CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC",
669            &["not-a-valid-key".to_string()],
670        )
671        .await;
672        assert!(result.is_err());
673        let err_msg = result.unwrap_err().to_string();
674        assert!(err_msg.contains("invalid LedgerKey"));
675    }
676
677    #[tokio::test]
678    async fn test_contract_exists_checks_on_chain_not_record_file() {
679        use std::io::{Read, Write};
680        use std::net::TcpListener;
681        use std::thread;
682
683        // Mock `getLedgerEntries` that returns an entry (contract live).
684        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
685        let addr = listener.local_addr().unwrap();
686        let url = format!("http://{addr}");
687
688        thread::spawn(move || {
689            for conn in listener.incoming() {
690                let mut sock = match conn {
691                    Ok(s) => s,
692                    Err(_) => break,
693                };
694                let mut buf = [0u8; 16384];
695                let n = sock.read(&mut buf).unwrap_or(0);
696                let req = String::from_utf8_lossy(&buf[..n]).to_string();
697                let body = if req.contains("getLedgerEntries") {
698                    let entry = "AAAAAQAAAABpc25nAAAA".to_string();
699                    format!(
700                        r#"{{"jsonrpc":"2.0","id":1,"result":{{"entries":[{{"key":"AAAAAA==","xdr":"{entry}","lastModifiedLedgerSeq":1}}],"latestLedger":100}}}}"#
701                    )
702                } else {
703                    r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}"#
704                        .to_string()
705                };
706                let resp = format!(
707                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
708                    body.len(),
709                    body
710                );
711                let _ = sock.write_all(resp.as_bytes());
712            }
713        });
714
715        let c = "CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC";
716        let client = crate::SorobanRpcClient::new(&url);
717        assert!(
718            contract_exists(&client, c).await.unwrap(),
719            "instance entry present at recorded address -> exists"
720        );
721
722        // Mock that serves an empty entries array -> recorded contract is gone.
723        let listener2 = TcpListener::bind("127.0.0.1:0").unwrap();
724        let addr2 = listener2.local_addr().unwrap();
725        let url2 = format!("http://{addr2}");
726        thread::spawn(move || {
727            for conn in listener2.incoming() {
728                let mut sock = match conn {
729                    Ok(s) => s,
730                    Err(_) => break,
731                };
732                let mut buf = [0u8; 16384];
733                let n = sock.read(&mut buf).unwrap_or(0);
734                let _req = String::from_utf8_lossy(&buf[..n]).to_string();
735                let body = r#"{"jsonrpc":"2.0","id":1,"result":{"entries":[],"latestLedger":100}}"#;
736                let resp = format!(
737                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
738                    body.len(),
739                    body
740                );
741                let _ = sock.write_all(resp.as_bytes());
742            }
743        });
744
745        let client2 = crate::SorobanRpcClient::new(&url2);
746        assert!(
747            !contract_exists(&client2, c).await.unwrap(),
748            "empty entries at recorded address -> contract no longer exists"
749        );
750    }
751
752    #[test]
753    fn test_extend_result_json_is_valid_and_contains_key_fields() {
754        // Verifies the structured JSON the CLI emits for `--format json`.
755        let result = ExtendResult {
756            contract_id: "CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC".into(),
757            extend_to: 17280,
758            footprint_keys: vec![
759                "AAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".into(),
760                "AAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY=".into(),
761            ],
762            hash: "abc123".into(),
763            status: "SUCCESS".into(),
764            fee: 12345,
765        };
766
767        let json = serde_json::to_string(&result).unwrap();
768        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
769
770        assert_eq!(
771            parsed["contract_id"],
772            "CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC"
773        );
774        assert_eq!(parsed["extend_to"], 17280);
775        assert_eq!(parsed["hash"], "abc123");
776        assert_eq!(parsed["status"], "SUCCESS");
777        assert_eq!(parsed["fee"], 12345);
778
779        let keys = parsed["footprint_keys"].as_array().unwrap();
780        assert_eq!(keys.len(), 2);
781    }
782}
783
784#[cfg(test)]
785mod restore_tests {
786    use super::*;
787    use crate::simulate::SimulateResponse;
788    use stellar_xdr::{
789        ContractDataDurability, ContractId, Hash, LedgerFootprint, LedgerKey,
790        LedgerKeyContractData, Limits, ScAddress, ScVal, SorobanResources,
791        SorobanTransactionDataExt, VecM, WriteXdr,
792    };
793
794    const SOURCE: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
795
796    fn preamble_data(read_write: Vec<LedgerKey>, resource_fee: i64) -> String {
797        let data = SorobanTransactionData {
798            ext: SorobanTransactionDataExt::V0,
799            resources: SorobanResources {
800                footprint: LedgerFootprint {
801                    read_only: VecM::default(),
802                    read_write: VecM::try_from(read_write).unwrap(),
803                },
804                instructions: 0,
805                disk_read_bytes: 1_000,
806                write_bytes: 1_000,
807            },
808            resource_fee,
809        };
810        base64::engine::general_purpose::STANDARD.encode(data.to_xdr(Limits::none()).unwrap())
811    }
812
813    fn instance_key() -> LedgerKey {
814        LedgerKey::ContractData(LedgerKeyContractData {
815            contract: ScAddress::Contract(ContractId(Hash([7; 32]))),
816            key: ScVal::LedgerKeyContractInstance,
817            durability: ContractDataDurability::Persistent,
818        })
819    }
820
821    fn simulation(preamble: Option<(&str, &str)>, error: Option<&str>) -> SimulateResponse {
822        let mut v = serde_json::json!({ "latestLedger": 100 });
823        if let Some((data, fee)) = preamble {
824            v["restorePreamble"] = serde_json::json!({
825                "transactionData": data,
826                "minResourceFee": fee,
827            });
828        }
829        if let Some(err) = error {
830            v["error"] = serde_json::json!(err);
831        }
832        serde_json::from_value(v).unwrap()
833    }
834
835    #[test]
836    fn adopts_preamble_footprint_unchanged() {
837        let data = preamble_data(vec![instance_key()], 5_000);
838        let sim = simulation(Some((&data, "5000")), None);
839        let (params, min_fee) = restore_params_from_simulation(&sim, SOURCE, 42).unwrap();
840
841        assert_eq!(min_fee, 5_000);
842        assert_eq!(params.sequence, 42);
843        assert_eq!(params.fee, RESTORE_INCLUSION_FEE + 5_000);
844        let expected = sdkt_xdr::parse_soroban_transaction_data(&data).unwrap();
845        assert_eq!(params.soroban_data, expected);
846    }
847
848    #[test]
849    fn raises_resource_fee_to_min_resource_fee() {
850        let data = preamble_data(vec![instance_key()], 1_000);
851        let sim = simulation(Some((&data, "7500")), None);
852        let (params, _) = restore_params_from_simulation(&sim, SOURCE, 1).unwrap();
853
854        assert_eq!(params.soroban_data.resource_fee, 7_500);
855        assert_eq!(params.fee, RESTORE_INCLUSION_FEE + 7_500);
856    }
857
858    #[test]
859    fn missing_preamble_means_nothing_to_restore() {
860        let err = restore_params_from_simulation(&simulation(None, None), SOURCE, 1).unwrap_err();
861        assert!(err.to_string().contains("Nothing to restore"), "{err}");
862        assert!(err.to_string().contains("live"), "{err}");
863    }
864
865    #[test]
866    fn missing_preamble_surfaces_simulation_error() {
867        let sim = simulation(None, Some("HostError: bad arg"));
868        let err = restore_params_from_simulation(&sim, SOURCE, 1).unwrap_err();
869        assert!(err.to_string().contains("HostError: bad arg"), "{err}");
870    }
871
872    #[test]
873    fn empty_preamble_footprint_is_rejected() {
874        let data = preamble_data(vec![], 100);
875        let sim = simulation(Some((&data, "100")), None);
876        let err = restore_params_from_simulation(&sim, SOURCE, 1).unwrap_err();
877        assert!(err.to_string().contains("footprint is empty"), "{err}");
878    }
879
880    #[test]
881    fn malformed_preamble_is_rejected() {
882        let sim = simulation(Some(("not-xdr", "100")), None);
883        let err = restore_params_from_simulation(&sim, SOURCE, 1).unwrap_err();
884        assert!(err.to_string().contains("malformed"), "{err}");
885    }
886
887    #[test]
888    fn footprint_keys_are_base64_ledger_keys() {
889        let data = preamble_data(vec![instance_key()], 100);
890        let parsed = sdkt_xdr::parse_soroban_transaction_data(&data).unwrap();
891        let keys = extract_footprint_keys(&parsed).unwrap();
892        let expected = base64::engine::general_purpose::STANDARD
893            .encode(instance_key().to_xdr(Limits::none()).unwrap());
894        assert_eq!(keys, vec![expected]);
895    }
896}