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
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ starforge new contract my-dex --template uniswap-v2 --from marketplace
starforge template publish ./my-template
```

### ?? Contract Deployment
Validate, size-check, and deploy compiled Soroban `.wasm` files to Testnet or Mainnet. Verifies account balance on-chain, calculates WASM hash, and generates the exact `stellar contract deploy` command to complete the deployment.
### 🚀 Contract Deployment
Validate, size-check, and deploy compiled Soroban `.wasm` files to Testnet or Mainnet. Verifies account balance on-chain, calculates the Soroban WASM hash as a SHA-256 digest of the raw file bytes, and generates the exact `stellar contract deploy` command to complete the deployment.

The local hash shown by `starforge deploy` is intended to match the value reported by `stellar contract inspect --wasm <file>` for the same bytecode.

---

Expand Down Expand Up @@ -300,6 +302,17 @@ starforge wallet show mykey --reveal

Unencrypted keys (without `--encrypt`) are stored in plaintext and are suitable only for testnet or throwaway accounts. **Do not use plaintext keys on mainnet with real funds.**

### Test Environment Secret

Some tests validate secret-key parsing without embedding a secret in the repository. Set the value at runtime before running the test suite:

```powershell
$env:STARFORGE_TEST_SECRET_KEY = "S..." # 56-character Stellar secret key
cargo test
```

Generate this value outside the codebase using your preferred secure workflow, such as a local Stellar key generation command or an existing throwaway test wallet. The key should live only in your shell environment or secret manager, not in source control.

---

## Contract Templates
Expand Down
125 changes: 100 additions & 25 deletions src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::utils::{config, horizon, optimizer, print as p};
use anyhow::Result;
use clap::Args;
use colored::*;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
Expand Down Expand Up @@ -34,11 +35,14 @@ fn is_wasm_above_size_limit(wasm_size_kb: f64) -> bool {
wasm_size_kb > SOROBAN_WASM_LIMIT_KB
}

/// Compute the Soroban WASM hash (SHA-256 over raw `.wasm` file bytes)
/// and return it as a 64-character lowercase hex string.
///
/// This matches the hash that `stellar contract inspect --wasm <file>` reports
/// and that Soroban uses to identify uploaded contract bytecode on-chain.
fn compute_local_wasm_hash(wasm_bytes: &[u8]) -> String {
let hash_val = wasm_bytes.iter().enumerate().fold(0u64, |acc, (i, &b)| {
acc.wrapping_add((b as u64).wrapping_mul(i as u64 + 1))
});
format!("{:016x}", hash_val)
let digest = Sha256::digest(wasm_bytes);
hex::encode(digest)
}

fn build_stellar_deploy_command(wasm: &std::path::Path, source: &str, network: &str) -> String {
Expand Down Expand Up @@ -197,14 +201,17 @@ pub fn handle(args: DeployArgs) -> Result<()> {
.unwrap_or("0");

pb.inc(1);
pb.set_message("Calculating WASM hash...");
pb.set_message("Calculating WASM SHA-256 hash...");

let wasm_hash = compute_local_wasm_hash(&wasm_bytes);

pb.inc(1);
pb.set_message("Generating stellar CLI command...");
pb.finish_with_message("Deployment preparation complete!");

println!();
p::kv_accent("XLM Balance", &format!("{} XLM", xlm));
p::kv("WASM hash (local)", &wasm_hash);
p::kv("WASM Hash (local SHA-256)", &wasm_hash);

println!();
p::separator();
Expand Down Expand Up @@ -261,21 +268,100 @@ mod tests {
use std::fs;
use tempfile::tempdir;

// ---------------------------------------------------------------------------
// SHA-256 hash tests
// ---------------------------------------------------------------------------

/// The output must always be a 64-character lowercase hex string (256 bits).
#[test]
fn computes_stable_hash_for_same_input() {
fn sha256_output_is_64_hex_chars() {
let hash = compute_local_wasm_hash(b"hello-starforge");
assert_eq!(hash.len(), 64, "SHA-256 hex digest must be 64 characters");
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"digest must be lowercase hex"
);
}

/// Same bytes → same digest (deterministic).
#[test]
fn sha256_is_deterministic() {
let bytes = b"hello-starforge";
let first = compute_local_wasm_hash(bytes);
let second = compute_local_wasm_hash(bytes);
assert_eq!(first, second);
assert_eq!(compute_local_wasm_hash(bytes), compute_local_wasm_hash(bytes));
}

/// Different bytes → different digest (collision-resistance sanity check).
#[test]
fn hash_changes_when_input_changes() {
let first = compute_local_wasm_hash(b"abc");
let second = compute_local_wasm_hash(b"abd");
assert_ne!(first, second);
fn sha256_differs_for_different_inputs() {
assert_ne!(compute_local_wasm_hash(b"abc"), compute_local_wasm_hash(b"abd"));
}

/// Known-answer test: SHA-256("abc") == the FIPS 180-4 test vector.
///
/// Expected value verified against `echo -n abc | sha256sum` and the
/// NIST FIPS 180-4 published test vector.
#[test]
fn sha256_known_answer_abc() {
let hash = compute_local_wasm_hash(b"abc");
assert_eq!(
hash,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
// SHA-256("abc") as computed by sha2 0.10 / FIPS 180-4.
);
}

/// Known-answer test against `tests/fixtures/minimal.wasm`.
///
/// Expected value: `sha256sum tests/fixtures/minimal.wasm`
/// → 93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476
#[test]
fn sha256_minimal_wasm_fixture() {
let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("minimal.wasm");
let wasm_bytes = fs::read(&fixture_path).expect("failed to read minimal.wasm fixture");
let hash = compute_local_wasm_hash(&wasm_bytes);
assert_eq!(
hash,
"93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476"
);
assert_eq!(hash.len(), 64);
}

/// Hashing an empty slice must not panic and must equal the well-known
/// SHA-256 digest of the empty string.
#[test]
fn sha256_empty_input() {
let hash = compute_local_wasm_hash(b"");
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}

/// Round-trip via a real (temporary) file to confirm fs::read → SHA-256
/// produces a 64-char hex string.
#[test]
fn sha256_real_file_round_trip() {
let dir = tempdir().expect("failed to create temp dir");
let wasm_path = dir.path().join("token.wasm");
let wasm_magic: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
fs::write(&wasm_path, wasm_magic).expect("failed to write wasm");
let bytes = fs::read(&wasm_path).expect("failed to read wasm");

let hash = compute_local_wasm_hash(&bytes);
assert_eq!(hash.len(), 64);
assert_eq!(
hash,
"93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476"
);
}

// ---------------------------------------------------------------------------
// Unchanged helper tests
// ---------------------------------------------------------------------------

#[test]
fn builds_expected_deploy_command() {
let command = build_stellar_deploy_command(
Expand Down Expand Up @@ -318,15 +404,4 @@ mod tests {
assert!(!is_wasm_above_size_limit(128.0));
assert!(is_wasm_above_size_limit(128.1));
}

#[test]
fn can_hash_real_wasm_file_contents() {
let dir = tempdir().expect("failed to create temp dir");
let wasm_path = dir.path().join("token.wasm");
fs::write(&wasm_path, [0, 97, 115, 109, 1, 0, 0, 0]).expect("failed to write wasm");
let bytes = fs::read(&wasm_path).expect("failed to read wasm");

let hash = compute_local_wasm_hash(&bytes);
assert_eq!(hash.len(), 16);
}
}
11 changes: 9 additions & 2 deletions src/commands/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,15 @@ pub fn handle(cmd: NewCommands) -> Result<()> {
}
}

fn search_templates(query: &str) -> Result<()> {
let results = templates::search_templates(query, None)?;
fn search_templates(query: &str, tags: Option<&str>) -> Result<()> {
let tag_list: Option<Vec<String>> = tags.map(|t| {
t.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
});

let results = templates::search_templates(query, tag_list.as_deref())?;
p::header(&format!("Template search results for '{}'", query));

if let Some(ref tags) = tag_list {
Expand Down
100 changes: 50 additions & 50 deletions src/commands/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ pub enum TemplateCommands {

pub fn handle(cmd: TemplateCommands) -> Result<()> {
match cmd {
TemplateCommands::Publish {
path,
name,
description,
author,
tags,
version
TemplateCommands::Publish {
path,
name,
description,
author,
tags,
version,
} => publish(path, name, description, author, tags, version),
TemplateCommands::List => list(),
TemplateCommands::Search { query, tags } => search(query, tags),
Expand All @@ -69,55 +69,55 @@ pub fn handle(cmd: TemplateCommands) -> Result<()> {
}

fn publish(
path: PathBuf,
name: Option<String>,
description: Option<String>,
author: Option<String>,
tags: Option<String>,
version: String
path: PathBuf,
name: Option<String>,
description: Option<String>,
author: Option<String>,
tags: Option<String>,
version: String,
) -> Result<()> {
// Prompt for missing information
let name = name.unwrap_or_else(|| {
Input::new()
// Resolve name interactively if not provided
let name = match name {
Some(n) => n,
None => Input::new()
.with_prompt("Template name")
.interact_text()
.unwrap()
});

let description = description.unwrap_or_else(|| {
Input::new()
.with_prompt("Template description")
.interact_text()
.unwrap()
});

let author = author.unwrap_or_else(|| {
Input::new()
.with_prompt("Author name")
.interact_text()
.unwrap()
});

let tags_str = tags.unwrap_or_else(|| {
Input::new()
.with_prompt("Tags (comma-separated)")
.interact_text()
.unwrap_or_default()
});

let tags: Vec<String> = tags_str.split(',')
.interact_text()?,
};
let description = match description {
Some(d) => d,
None => Input::new()
.with_prompt("Description")
.interact_text()?,
};
let author = match author {
Some(a) => a,
None => Input::new()
.with_prompt("Author")
.default("unknown".to_string())
.interact_text()?,
};
let tag_list: Vec<String> = tags
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let version_clone = version.clone();
templates::publish_template(&path, name.clone(), description, author, tags, version)?;

let template =
templates::publish_template(&path, name, description, author, tag_list, version)?;

p::header("Template Publish");
p::success("Template registered successfully");
p::kv_accent("Name", &name);
p::kv("Version", &version_clone);

p::kv_accent("Name", &template.name);
p::kv("Version", &template.version);
p::kv("Source", &template.source.to_string());
if !template.tags.is_empty() {
p::kv("Tags", &template.tags.join(", "));
}
if let Some(ref path) = template.path {
p::kv("Path", path);
}

Ok(())
}

Expand All @@ -136,7 +136,7 @@ fn list() -> Result<()> {
if !template.tags.is_empty() {
p::kv("Tags", &template.tags.join(", "));
}
if let Some(path) = template.path.as_ref() {
if let Some(ref path) = template.path {
p::kv("Path", path);
}
if i + 1 < registry.templates.len() {
Expand Down Expand Up @@ -217,4 +217,4 @@ fn init() -> Result<()> {
// This would initialize with default templates
p::success("Template registry initialized");
Ok(())
}
}
9 changes: 7 additions & 2 deletions src/utils/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,8 +431,13 @@ mod tests {

#[test]
fn test_valid_plain_secret_key() {
let secret = "SAW46Z7TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNT";
assert!(validate_secret_key(secret).is_ok());
let Ok(secret) = std::env::var("STARFORGE_TEST_SECRET_KEY") else {
eprintln!(
"skipping test_valid_plain_secret_key: STARFORGE_TEST_SECRET_KEY is not set"
);
return;
};
assert!(validate_secret_key(&secret).is_ok());
}

#[test]
Expand Down
Loading