Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,52 @@ starforge contract generate-bindings ./my_contract.wasm --lang rust
starforge contract generate-bindings ./my_contract.wasm --lang ts
```

### Upgrade safety analysis

Compare the currently deployed build with a candidate before creating an
upgrade proposal:

```bash
starforge upgrade analyze \
--current artifacts/current.wasm \
--candidate target/wasm32-unknown-unknown/release/contract.wasm

# Machine-readable report for CI and an audit artifact
starforge upgrade analyze \
--current artifacts/current.wasm \
--candidate target/wasm32-unknown-unknown/release/contract.wasm \
--format json \
--output upgrade-analysis.json
```

The command exits non-zero when it finds a breaking change. Interface findings
come from Soroban's embedded contract specification and have `confirmed`
confidence. Storage findings are `heuristic`: standard Soroban metadata does
not record storage durability or stored value types, so StarForge infers likely
keys from contract types conventionally named `DataKey` or `StorageKey` and
always asks you to verify the storage layout manually.

The versioned JSON format is documented by
[`docs/upgrade-analysis.schema.json`](docs/upgrade-analysis.schema.json).

Recommended GitHub Actions gate for an upgrade pull request:

```yaml
- name: Analyze Soroban upgrade safety
run: |
starforge upgrade analyze \
--current artifacts/production.wasm \
--candidate target/wasm32-unknown-unknown/release/contract.wasm \
--format json \
--output upgrade-analysis.json
- name: Upload upgrade analysis
if: always()
uses: actions/upload-artifact@v4
with:
name: upgrade-analysis
path: upgrade-analysis.json
```

### Environment info

```bash
Expand Down
71 changes: 71 additions & 0 deletions docs/upgrade-analysis.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/StarsForges/StarForge/blob/master/docs/upgrade-analysis.schema.json",
"title": "StarForge Soroban Upgrade Analysis Report",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "current", "candidate", "summary", "findings"],
"properties": {
"schema_version": {
"const": "1.0"
},
"current": {
"$ref": "#/$defs/artifact"
},
"candidate": {
"$ref": "#/$defs/artifact"
},
"summary": {
"type": "object",
"additionalProperties": false,
"required": ["breaking", "warnings", "info", "safe_to_upgrade"],
"properties": {
"breaking": { "type": "integer", "minimum": 0 },
"warnings": { "type": "integer", "minimum": 0 },
"info": { "type": "integer", "minimum": 0 },
"safe_to_upgrade": { "type": "boolean" }
}
},
"findings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"category",
"risk",
"confidence",
"code",
"subject",
"message",
"current",
"candidate"
],
"properties": {
"category": { "enum": ["interface", "storage"] },
"risk": { "enum": ["breaking", "warning", "info"] },
"confidence": { "enum": ["confirmed", "heuristic"] },
"code": { "type": "string", "minLength": 1 },
"subject": { "type": "string", "minLength": 1 },
"message": { "type": "string", "minLength": 1 },
"current": { "type": ["string", "null"] },
"candidate": { "type": ["string", "null"] }
}
}
}
},
"$defs": {
"artifact": {
"type": "object",
"additionalProperties": false,
"required": ["path", "sha256"],
"properties": {
"path": { "type": "string", "minLength": 1 },
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}
}
}
103 changes: 101 additions & 2 deletions src/commands/upgrade.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::utils::{config, confirmation, horizon, print as p, soroban};
use anyhow::Result;
use crate::utils::{config, confirmation, horizon, print as p, soroban, upgrade_analyzer};
use anyhow::{Context, Result};
use chrono::Utc;
use clap::{Args, Subcommand};
use colored::*;
Expand All @@ -12,6 +12,8 @@ use std::path::PathBuf;

#[derive(Subcommand)]
pub enum UpgradeCommands {
/// Compare two contract WASMs for upgrade-breaking changes
Analyze(AnalyzeArgs),
/// Prepare and validate a contract upgrade
Prepare(PrepareArgs),
/// Create a governance proposal for a contract upgrade
Expand All @@ -30,6 +32,22 @@ pub enum UpgradeCommands {
History(HistoryArgs),
}

#[derive(Args)]
pub struct AnalyzeArgs {
/// Path to the currently deployed contract WASM
#[arg(long)]
pub current: PathBuf,
/// Path to the candidate contract WASM
#[arg(long)]
pub candidate: PathBuf,
/// Report format
#[arg(long, default_value = "human", value_parser = ["human", "json"])]
pub format: String,
/// Save the report to a file
#[arg(long)]
pub output: Option<PathBuf>,
}

#[derive(Args)]
pub struct PrepareArgs {
/// Contract ID to upgrade
Expand Down Expand Up @@ -271,6 +289,7 @@ fn short_id(id: &str) -> String {

pub fn handle(cmd: UpgradeCommands) -> Result<()> {
match cmd {
UpgradeCommands::Analyze(args) => handle_analyze(args),
UpgradeCommands::Prepare(args) => handle_prepare(args),
UpgradeCommands::Propose(args) => handle_propose(args),
UpgradeCommands::List(args) => handle_list(args),
Expand All @@ -282,6 +301,86 @@ pub fn handle(cmd: UpgradeCommands) -> Result<()> {
}
}

fn handle_analyze(args: AnalyzeArgs) -> Result<()> {
let report = upgrade_analyzer::analyze_paths(&args.current, &args.candidate)?;
let rendered = if args.format == "json" {
serde_json::to_string_pretty(&report)?
} else {
render_analysis_table(&report)
};

println!("{rendered}");
if let Some(path) = args.output {
fs::write(&path, format!("{rendered}\n"))
.with_context(|| format!("Failed to write analysis report {}", path.display()))?;
if args.format != "json" {
p::info(&format!("Report saved to {}", path.display()));
}
}

if report.summary.breaking > 0 {
anyhow::bail!(
"upgrade analysis found {} breaking finding(s)",
report.summary.breaking
);
}
Ok(())
}

fn render_analysis_table(report: &upgrade_analyzer::UpgradeReport) -> String {
use upgrade_analyzer::{Confidence, FindingCategory, Risk};

let mut output = String::new();
output.push_str("Soroban Contract Upgrade Safety Report\n");
output.push_str(&format!("Current: {}\n", report.current.path));
output.push_str(&format!("Candidate: {}\n\n", report.candidate.path));
output.push_str(&format!(
"{:<10} {:<10} {:<11} {:<28} {}\n",
"RISK", "AREA", "CONFIDENCE", "SUBJECT", "CHANGE"
));
output.push_str(&format!("{}\n", "─".repeat(100)));

for finding in &report.findings {
let risk = match finding.risk {
Risk::Breaking => "breaking",
Risk::Warning => "warning",
Risk::Info => "info",
};
let area = match finding.category {
FindingCategory::Interface => "interface",
FindingCategory::Storage => "storage",
};
let confidence = match finding.confidence {
Confidence::Confirmed => "confirmed",
Confidence::Heuristic => "heuristic",
};
output.push_str(&format!(
"{:<10} {:<10} {:<11} {:<28} {}\n",
risk, area, confidence, finding.subject, finding.message
));
if finding.current.is_some() || finding.candidate.is_some() {
output.push_str(&format!(
" current: {} | candidate: {}\n",
finding.current.as_deref().unwrap_or("—"),
finding.candidate.as_deref().unwrap_or("—")
));
}
}

output.push_str(&format!(
"\nSummary: {} breaking, {} warning, {} info — {}\n",
report.summary.breaking,
report.summary.warnings,
report.summary.info,
if report.summary.safe_to_upgrade {
"no breaking changes found"
} else {
"upgrade blocked"
}
));
output
}

fn handle_prepare(args: PrepareArgs) -> Result<()> {
p::header("Prepare Contract Upgrade");

Expand Down
9 changes: 7 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ enum Commands {

fn main() {
let cli = Cli::parse();
let machine_readable = matches!(
&cli.command,
Commands::Upgrade(commands::upgrade::UpgradeCommands::Analyze(args))
if args.format == "json"
);

// Initialise structured logging before anything else runs.
let log_cfg =
Expand All @@ -127,12 +132,12 @@ fn main() {
eprintln!("Warning: failed to initialise logger: {}", e);
}

if !cli.quiet {
if !cli.quiet && !machine_readable {
print_banner();
}

// On first run after a schema version change, re-display the telemetry notice.
if !cli.quiet {
if !cli.quiet && !machine_readable {
if let Ok(true) = utils::telemetry::schema_version_changed() {
eprintln!(
"\n {} Telemetry schema updated to v{}. starforge stores only: \
Expand Down
4 changes: 2 additions & 2 deletions src/utils/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ pub fn generate_bindings(wasm_path: &Path, language: BindingLanguage) -> Result<
}
}

fn read_spec_entries(wasm: &[u8]) -> Result<Vec<ScSpecEntry>> {
pub(crate) fn read_spec_entries(wasm: &[u8]) -> Result<Vec<ScSpecEntry>> {
let spec = contract_spec_section(wasm)?;
let cursor = Cursor::new(spec);
let entries = ScSpecEntry::read_xdr_iter(&mut Limited::new(
Expand Down Expand Up @@ -131,7 +131,7 @@ fn contract_function(function: &ScSpecFunctionV0) -> ContractFunction {
}
}

fn spec_type_name(type_def: &ScSpecTypeDef) -> String {
pub(crate) fn spec_type_name(type_def: &ScSpecTypeDef) -> String {
match type_def {
ScSpecTypeDef::Val => "Val".to_string(),
ScSpecTypeDef::Bool => "bool".to_string(),
Expand Down
1 change: 1 addition & 0 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ pub mod templates;
pub mod test_runner;
pub mod tutorial_engine;
pub mod tx_batch;
pub mod upgrade_analyzer;
Loading
Loading