1use crate::client::SorobanRpcClient;
2use crate::error::RpcError;
3use base64::Engine;
4use serde::Serialize;
5use stellar_xdr::{LedgerEntryData, ReadXdr, SorobanTransactionData};
6
7#[derive(Debug, Clone, PartialEq, Serialize)]
9pub struct TtlInfo {
10 pub contract_id: String,
11 pub entries: Vec<TtlEntry>,
12}
13
14#[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
24pub fn calculate_extension_cost(ledger_delta: u32) -> u64 {
27 ledger_delta as u64 * 100
28}
29
30pub(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
46pub async fn get_ttl_info_for_keys(
52 client: &SorobanRpcClient,
53 contract_id: &str,
54 extra_keys: &[String],
55) -> Result<TtlInfo, RpcError> {
56 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 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
91pub 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#[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#[derive(Debug, Clone, PartialEq, Serialize)]
112pub struct RestoreResult {
113 pub contract_id: String,
114 pub footprint_keys: Vec<String>,
116 pub restored_keys: usize,
118 pub hash: Option<String>,
120 pub status: String,
122 pub fee: u32,
124 pub min_resource_fee: u32,
126}
127
128fn 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
139pub 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
151pub 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
247const RESTORE_INCLUSION_FEE: u32 = 100;
249
250pub 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
312pub 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(¶ms.soroban_data)?;
337
338 let envelope = sdkt_xdr::build_restore_footprint_tx(¶ms)
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
379fn 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
395pub 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
412pub 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#[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
463pub 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 #[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 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 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 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 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 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 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 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 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}