use crate::commands::network::resolve_target_network;
use crate::NetworkArgs;
use sdkt_rpc::probe_deployed_executable;
use sdkt_xdr::ContractExecutableRef;
use serde::Serialize;
pub const DEPLOYMENT_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeploymentVerdict {
Match,
Drift,
Unknown,
NotFound,
}
impl std::fmt::Display for DeploymentVerdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let token = match self {
DeploymentVerdict::Match => "MATCH",
DeploymentVerdict::Drift => "DRIFT",
DeploymentVerdict::Unknown => "UNKNOWN",
DeploymentVerdict::NotFound => "NOT_FOUND",
};
f.write_str(token)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutableKind {
Wasm,
StellarAsset,
ExternalRef,
Undecodable,
None,
}
impl std::fmt::Display for ExecutableKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let token = match self {
ExecutableKind::Wasm => "wasm",
ExecutableKind::StellarAsset => "stellar_asset",
ExecutableKind::ExternalRef => "external_ref",
ExecutableKind::Undecodable => "undecodable",
ExecutableKind::None => "none",
};
f.write_str(token)
}
}
#[derive(Debug, Serialize)]
pub struct DeploymentVerificationReport {
pub schema_version: u32,
pub contract_id: String,
pub network: String,
pub rpc_url: String,
pub verdict: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub local_wasm_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub local_wasm_size_bytes: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployed_executable: Option<ExecutableKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployed_wasm_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub verified: bool,
}
pub fn compare_deployment(
executable: ExecutableKind,
deployed_hash: Option<String>,
local: Option<(String, usize)>,
instance_found: bool,
) -> (
ExecutableKind,
Option<String>,
DeploymentVerdict,
Option<String>,
) {
if !instance_found {
return (
ExecutableKind::None,
None,
DeploymentVerdict::NotFound,
Some("no contract instance ledger entry exists at this network target".to_string()),
);
}
match executable {
ExecutableKind::Wasm => match (deployed_hash.clone(), local) {
(_, None) => (
executable,
deployed_hash,
DeploymentVerdict::Unknown,
Some(
"no local WASM supplied; the deployed hash was read but there is nothing to \
compare it against — pass --wasm <file> for a verdict"
.to_string(),
),
),
(None, Some(_)) => (
executable,
None,
DeploymentVerdict::Unknown,
Some(
"the deployed WASM code entry could not be read from this endpoint, so the \
hash is not authoritatively available"
.to_string(),
),
),
(Some(dh), Some((lh, _))) if dh == lh => {
(executable, deployed_hash, DeploymentVerdict::Match, None)
}
(Some(dh), Some((lh, _))) => (
executable,
deployed_hash,
DeploymentVerdict::Drift,
Some(format!("deployed WASM {dh} differs from local WASM {lh}")),
),
},
ExecutableKind::StellarAsset => (
executable,
None,
DeploymentVerdict::Unknown,
Some(
"the contract executable is `stellar_asset` (protocol 28): protocol-defined code \
with no WASM artifact, so there is no hash to compare"
.to_string(),
),
),
ExecutableKind::ExternalRef => match (deployed_hash.clone(), local) {
(Some(dh), Some((lh, _))) if dh == lh => {
(executable, deployed_hash, DeploymentVerdict::Match, None)
}
(Some(dh), Some((lh, _))) => (
executable,
deployed_hash,
DeploymentVerdict::Drift,
Some(format!("deployed WASM {dh} differs from local WASM {lh}")),
),
(Some(dh), None) => (
executable,
deployed_hash,
DeploymentVerdict::Unknown,
Some(format!(
"resolved external executable hash is {dh} but no local WASM was supplied"
)),
),
(None, _) => (
executable,
None,
DeploymentVerdict::Unknown,
Some(
"the CAP-85 external executable could not be resolved to a WASM hash \
through the owner-entry lookup"
.to_string(),
),
),
},
ExecutableKind::Undecodable => (
executable,
None,
DeploymentVerdict::Unknown,
Some("the contract instance ledger entry could not be decoded".to_string()),
),
ExecutableKind::None => (
executable,
None,
DeploymentVerdict::Unknown,
Some("no deployed executable information was produced by the probe".to_string()),
),
}
}
pub async fn run_deployment_verify(
contract: &str,
wasm: Option<&str>,
network: Option<String>,
net: &NetworkArgs,
format: &sdkt_core::OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let local = match wasm {
Some(path) => {
let bytes =
std::fs::read(path).map_err(|e| format!("Error reading WASM file {path}: {e}"))?;
let meta = sdkt_wasm::parse_metadata(&bytes)
.map_err(|e| format!("{path} is not valid WASM: {e}"))?;
Some((meta.hash, meta.size_bytes))
}
None => None,
};
let target = match resolve_target_network(network.as_deref(), net) {
Ok(t) => t,
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
};
let network_name = target.network_name;
let probe = match probe_deployed_executable(&target.client, contract).await {
Ok(p) => p,
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
};
let executable = match &probe.executable {
Some(ContractExecutableRef::Wasm(_)) => ExecutableKind::Wasm,
Some(ContractExecutableRef::StellarAsset) => ExecutableKind::StellarAsset,
Some(ContractExecutableRef::ExternalRef(_)) => ExecutableKind::ExternalRef,
None => ExecutableKind::Undecodable,
};
let (executable, deployed_hash, verdict, reason) = compare_deployment(
executable,
probe.wasm_hash,
local.clone(),
probe.instance_found,
);
let report = DeploymentVerificationReport {
schema_version: DEPLOYMENT_SCHEMA_VERSION,
contract_id: contract.to_string(),
network: network_name,
rpc_url: target.config.rpc_url.clone(),
verdict: verdict.to_string(),
local_wasm_hash: local.as_ref().map(|(h, _)| h.clone()),
local_wasm_size_bytes: local.as_ref().map(|(_, s)| *s),
deployed_executable: Some(executable),
deployed_wasm_hash: deployed_hash,
reason,
verified: verdict == DeploymentVerdict::Match,
};
if *format == sdkt_core::OutputFormat::Json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| format!("serializing report: {e}"))?
);
} else {
print_deployment_pretty(&report);
}
if !report.verified {
std::process::exit(1);
}
Ok(())
}
fn print_deployment_pretty(r: &DeploymentVerificationReport) {
println!("Deployment Verification Report");
println!("==============================");
println!("Contract ID : {}", r.contract_id);
println!("Network : {}", r.network);
println!("Verdict : {}", r.verdict);
if let (Some(lh), Some(sz)) = (&r.local_wasm_hash, r.local_wasm_size_bytes) {
println!("Local WASM : {lh} ({sz} bytes)");
}
if let (Some(kind), Some(dh)) = (&r.deployed_executable, &r.deployed_wasm_hash) {
println!("Deployed : {kind} {dh}");
} else {
println!(
"Deployed : {}",
r.deployed_executable
.map(|k| k.to_string())
.unwrap_or_else(|| "none".to_string())
);
}
if let Some(reason) = &r.reason {
println!();
println!("{reason}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equal_hashes_are_match() {
let (_, _, verdict, reason) = compare_deployment(
ExecutableKind::Wasm,
Some("abc123".into()),
Some(("abc123".into(), 42)),
true,
);
assert_eq!(verdict, DeploymentVerdict::Match);
assert_eq!(verdict.to_string(), "MATCH");
assert!(reason.is_none());
}
#[test]
fn different_hashes_are_drift() {
let (_, hash, verdict, reason) = compare_deployment(
ExecutableKind::Wasm,
Some("abc123".into()),
Some(("def456".into(), 42)),
true,
);
assert_eq!(verdict, DeploymentVerdict::Drift);
assert_eq!(hash.as_deref(), Some("abc123"));
let reason = reason.expect("drift carries a reason");
assert!(
reason.contains("abc123") && reason.contains("def456"),
"{reason}"
);
}
#[test]
fn missing_instance_is_not_found_not_unknown() {
let (exec, hash, verdict, reason) =
compare_deployment(ExecutableKind::None, None, Some(("abc".into(), 1)), false);
assert_eq!(verdict, DeploymentVerdict::NotFound);
assert_eq!(exec, ExecutableKind::None);
assert!(hash.is_none());
assert!(reason.expect("reason").contains("no contract instance"));
}
#[test]
fn stellar_asset_is_unknown_not_drift() {
for kind in [
ExecutableKind::StellarAsset,
ExecutableKind::ExternalRef,
ExecutableKind::Undecodable,
] {
let (_, _, verdict, reason) =
compare_deployment(kind, None, Some(("abc".into(), 1)), true);
assert_eq!(verdict, DeploymentVerdict::Unknown, "{kind:?}");
assert!(reason.is_some());
}
}
#[test]
fn unreadable_deployed_code_is_unknown() {
let (_, _, verdict, reason) =
compare_deployment(ExecutableKind::Wasm, None, Some(("abc".into(), 1)), true);
assert_eq!(verdict, DeploymentVerdict::Unknown);
assert!(
reason.expect("reason").contains("could not be read"),
"RPC reachable must not become a verification"
);
}
#[test]
fn missing_local_artifact_is_unknown() {
let (_, hash, verdict, _) =
compare_deployment(ExecutableKind::Wasm, Some("abc".into()), None, true);
assert_eq!(verdict, DeploymentVerdict::Unknown);
assert_eq!(hash.as_deref(), Some("abc"));
}
#[test]
fn verdict_tokens_are_stable() {
assert_eq!(DeploymentVerdict::Match.to_string(), "MATCH");
assert_eq!(DeploymentVerdict::Drift.to_string(), "DRIFT");
assert_eq!(DeploymentVerdict::Unknown.to_string(), "UNKNOWN");
assert_eq!(DeploymentVerdict::NotFound.to_string(), "NOT_FOUND");
}
}