Skip to main content

sdkt_core/
deployment.rs

1//! Persistent deployment records for `sdkt project deploy`.
2//!
3//! Every contract deployed by `sdkt project deploy` is recorded in a
4//! `.sdkt-deployments.json` file next to `.sdkt.toml`, mapping each contract
5//! alias to its on-chain contract ID. The record is written incrementally at
6//! loop exit, so a deploy that fails halfway still preserves the contracts that
7//! did land on-chain (see issue #74).
8//!
9//! Records are scoped **per network**: deployments to testnet and mainnet live
10//! under different keys and never overwrite each other. A re-run with
11//! `--skip-deployed` reads the record and skips any alias whose recorded
12//! contract ID still exists on-chain (verified via `getLedgerEntries`, not just
13//! by the presence of a file entry).
14
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::path::Path;
18
19/// Default name of the deployment record file (written next to `.sdkt.toml`).
20pub const DEPLOYMENT_RECORD_FILE: &str = ".sdkt-deployments.json";
21
22/// Well-known passphrases mapped to human-readable network keys.
23pub const TESTNET_PASSPHRASE: &str = "Test SDF Network ; September 2015";
24pub const MAINNET_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015";
25pub const FUTURENET_PASSPHRASE: &str = "Test SDF Future Network ; October 2022";
26
27/// A single deployed-contract record for one alias on one network.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct DeploymentRecord {
30    /// On-chain contract ID (StrKey `C...`).
31    pub contract_id: String,
32    /// SHA-256 WASM hash of the deployed artifact.
33    pub wasm_hash: String,
34    /// Network key this contract was deployed to (see [`network_key`]).
35    pub network: String,
36    /// Unix epoch seconds when the record was written.
37    pub timestamp: u64,
38    /// Deployment salt used to derive the contract ID.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub salt: Option<String>,
41}
42
43/// The on-disk record file: a map of network key → (alias → record).
44#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
45pub struct DeploymentRecordFile {
46    pub profiles: HashMap<String, HashMap<String, DeploymentRecord>>,
47}
48
49impl DeploymentRecordFile {
50    /// Read the record file at `path`. A missing or empty file is treated as a
51    /// fresh (empty) record set; a present-but-corrupt file is an error so the
52    /// operator is told to inspect it rather than silently overwriting it.
53    pub fn read<P: AsRef<Path>>(path: P) -> Result<Self, String> {
54        let path = path.as_ref();
55        if !path.exists() {
56            return Ok(DeploymentRecordFile::default());
57        }
58        let raw = std::fs::read_to_string(path)
59            .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
60        if raw.trim().is_empty() {
61            return Ok(DeploymentRecordFile::default());
62        }
63        serde_json::from_str(&raw).map_err(|e| format!("Failed to parse {}: {e}", path.display()))
64    }
65
66    /// Write the record file to `path`. Uses an atomic write: serializes to a
67    /// sibling temp file, syncs to disk, then renames over the target. This
68    /// prevents a crash or full-disk mid-write from leaving a partial or empty
69    /// file that would block subsequent deploys with a parse error.
70    ///
71    /// Serialization errors (none expected for this type) and I/O errors are
72    /// returned to the caller, which warns but never fails the deployment on a
73    /// record-write problem.
74    pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<(), String> {
75        use std::io::Write as _;
76
77        let path = path.as_ref();
78        let json = serde_json::to_string_pretty(self)
79            .map_err(|e| format!("Failed to serialize deployment record: {e}"))?;
80
81        // Write to a temp file in the same directory so the rename is
82        // guaranteed to be atomic on the same filesystem.
83        let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
84        let tmp_path = parent.join(format!(".sdkt-deployments.tmp.{}", std::process::id()));
85
86        let mut tmp_file = std::fs::File::create(&tmp_path).map_err(|e| {
87            format!(
88                "Failed to create temp record file {}: {e}",
89                tmp_path.display()
90            )
91        })?;
92        tmp_file.write_all(json.as_bytes()).map_err(|e| {
93            format!(
94                "Failed to write temp record file {}: {e}",
95                tmp_path.display()
96            )
97        })?;
98        tmp_file.sync_all().map_err(|e| {
99            format!(
100                "Failed to sync temp record file {}: {e}",
101                tmp_path.display()
102            )
103        })?;
104        drop(tmp_file);
105
106        std::fs::rename(&tmp_path, path).map_err(|e| {
107            // Best-effort cleanup; ignore secondary error.
108            let _ = std::fs::remove_file(&tmp_path);
109            format!(
110                "Failed to rename temp record file to {}: {e}",
111                path.display()
112            )
113        })
114    }
115
116    /// Records for a single network key, if any exist.
117    pub fn records_for(&self, network: &str) -> Option<&HashMap<String, DeploymentRecord>> {
118        self.profiles.get(network)
119    }
120
121    /// Record for a single alias on a network, if present.
122    pub fn record_for(&self, network: &str, alias: &str) -> Option<&DeploymentRecord> {
123        self.records_for(network)
124            .and_then(|records| records.get(alias))
125    }
126
127    /// Insert (or replace) a record for an alias on a network.
128    pub fn set_record(&mut self, network: &str, alias: &str, record: DeploymentRecord) {
129        self.profiles
130            .entry(network.to_string())
131            .or_default()
132            .insert(alias.to_string(), record);
133    }
134
135    /// Remove a record for an alias on a network (used when a recorded contract
136    /// is found to no longer exist on-chain and gets re-deployed).
137    pub fn remove_record(&mut self, network: &str, alias: &str) {
138        if let Some(records) = self.profiles.get_mut(network) {
139            records.remove(alias);
140        }
141    }
142}
143
144/// Derive the stable per-network record key from the resolved network settings.
145///
146/// When an explicit `--network-profile` was selected, the profile name is used
147/// verbatim (a user can have distinct profiles for shared testnet RPC hosts).
148/// Otherwise the key is derived from the network passphrase — the exact value
149/// that determines the transaction signature hash — so testnet, mainnet,
150/// futurenet, and any custom network never share a record scope. Custom
151/// passphrases get a deterministic hash suffix so two different custom networks
152/// cannot collide on a truncated slug.
153pub fn network_key(network_profile: Option<&str>, passphrase: &str) -> String {
154    if let Some(profile) = network_profile {
155        if !profile.trim().is_empty() {
156            return profile.to_string();
157        }
158    }
159
160    match passphrase {
161        TESTNET_PASSPHRASE => "testnet".to_string(),
162        MAINNET_PASSPHRASE => "mainnet".to_string(),
163        FUTURENET_PASSPHRASE => "futurenet".to_string(),
164        other => {
165            use sha2::Digest;
166            let digest = sha2::Sha256::digest(other.as_bytes());
167            let prefix: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
168            format!("custom-{prefix}")
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    fn record(alias: &str, contract_id: &str, network: &str) -> DeploymentRecord {
178        DeploymentRecord {
179            contract_id: contract_id.to_string(),
180            wasm_hash: "ab12cd34".to_string(),
181            network: network.to_string(),
182            timestamp: 1_700_000_000,
183            salt: Some(alias.to_string()),
184        }
185    }
186
187    #[test]
188    fn record_file_write_read_round_trip() {
189        let dir = tempfile::tempdir().unwrap();
190        let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
191
192        let mut file = DeploymentRecordFile::default();
193        file.set_record("testnet", "token", record("token", "CTOKEN", "testnet"));
194        file.set_record("testnet", "vault", record("vault", "CVAULT", "testnet"));
195        file.write(&path).unwrap();
196
197        let loaded = DeploymentRecordFile::read(&path).unwrap();
198        assert_eq!(loaded, file);
199        assert_eq!(
200            loaded.record_for("testnet", "token").unwrap().contract_id,
201            "CTOKEN"
202        );
203        assert_eq!(
204            loaded.record_for("testnet", "vault").unwrap().contract_id,
205            "CVAULT"
206        );
207    }
208
209    #[test]
210    fn missing_record_file_reads_empty() {
211        let dir = tempfile::tempdir().unwrap();
212        let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
213        let file = DeploymentRecordFile::read(&path).unwrap();
214        assert!(file.profiles.is_empty());
215    }
216
217    #[test]
218    fn corrupt_record_file_is_an_error_not_a_silent_reset() {
219        let dir = tempfile::tempdir().unwrap();
220        let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
221        std::fs::write(&path, "not json {").unwrap();
222        assert!(DeploymentRecordFile::read(&path).is_err());
223    }
224
225    #[test]
226    fn records_are_scoped_per_network_profile() {
227        let dir = tempfile::tempdir().unwrap();
228        let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
229
230        let mut file = DeploymentRecordFile::default();
231        file.set_record("testnet", "token", record("token", "C_TESTNET", "testnet"));
232        file.set_record("mainnet", "token", record("token", "C_MAINNET", "mainnet"));
233        file.write(&path).unwrap();
234
235        let loaded = DeploymentRecordFile::read(&path).unwrap();
236        // Same alias, different networks -> different records that do not clash.
237        assert_eq!(
238            loaded.record_for("testnet", "token").unwrap().contract_id,
239            "C_TESTNET"
240        );
241        assert_eq!(
242            loaded.record_for("mainnet", "token").unwrap().contract_id,
243            "C_MAINNET"
244        );
245        assert_eq!(loaded.records_for("testnet").unwrap().len(), 1);
246        assert_eq!(loaded.records_for("mainnet").unwrap().len(), 1);
247    }
248
249    #[test]
250    fn writing_testnet_then_mainnet_does_not_overwrite() {
251        // Second write must merge, not replace, the other network's records.
252        let dir = tempfile::tempdir().unwrap();
253        let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
254
255        let mut testnet = DeploymentRecordFile::default();
256        testnet.set_record("testnet", "token", record("token", "C_T", "testnet"));
257        testnet.write(&path).unwrap();
258
259        let mut mainnet = DeploymentRecordFile::read(&path).unwrap();
260        mainnet.set_record("mainnet", "token", record("token", "C_M", "mainnet"));
261        mainnet.write(&path).unwrap();
262
263        let loaded = DeploymentRecordFile::read(&path).unwrap();
264        assert_eq!(
265            loaded.record_for("testnet", "token").unwrap().contract_id,
266            "C_T"
267        );
268        assert_eq!(
269            loaded.record_for("mainnet", "token").unwrap().contract_id,
270            "C_M"
271        );
272    }
273
274    #[test]
275    fn record_lookup_and_replace_semantics() {
276        let mut file = DeploymentRecordFile::default();
277        assert!(file.record_for("testnet", "token").is_none());
278        file.set_record("testnet", "token", record("token", "C1", "testnet"));
279        assert_eq!(
280            file.record_for("testnet", "token").unwrap().contract_id,
281            "C1"
282        );
283        file.set_record("testnet", "token", record("token", "C2", "testnet"));
284        assert_eq!(
285            file.record_for("testnet", "token").unwrap().contract_id,
286            "C2"
287        );
288        file.remove_record("testnet", "token");
289        assert!(file.record_for("testnet", "token").is_none());
290    }
291
292    #[test]
293    fn network_key_prefers_explicit_profile() {
294        assert_eq!(
295            network_key(Some("my-testnet"), TESTNET_PASSPHRASE),
296            "my-testnet"
297        );
298        // Empty profile name falls through to the passphrase mapping.
299        assert_eq!(network_key(Some("  "), TESTNET_PASSPHRASE), "testnet");
300    }
301
302    #[test]
303    fn network_key_maps_known_passphrases() {
304        assert_eq!(network_key(None, TESTNET_PASSPHRASE), "testnet");
305        assert_eq!(network_key(None, MAINNET_PASSPHRASE), "mainnet");
306        assert_eq!(network_key(None, FUTURENET_PASSPHRASE), "futurenet");
307    }
308
309    #[test]
310    fn network_key_for_custom_passphrases_is_deterministic_and_distinct() {
311        let a = network_key(None, "Standalone Network ; 2024");
312        let b = network_key(None, "Standalone Network ; 2024");
313        let c = network_key(None, "Custom Net / 2025");
314        assert_eq!(a, b);
315        assert_ne!(a, c);
316        // Testnet and custom never collide even when slugs overlap.
317        assert_ne!(network_key(None, TESTNET_PASSPHRASE), a);
318    }
319}