From 4f8c4e3a3fbfbf2fe97ba84312710edd1bffd8f7 Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Wed, 27 May 2026 07:49:23 +0100 Subject: [PATCH 1/3] feat(wallet): add multisig setup guidance (#102) --- src/commands/wallet.rs | 167 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 152 insertions(+), 15 deletions(-) diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index ec235cdc..f1b4f994 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -81,6 +81,20 @@ pub enum WalletCommands { }, /// Rename a wallet Rename { old_name: String, new_name: String }, + /// Rotate a wallet in place while keeping the same logical name + Rotate { + /// Wallet name to rotate + name: String, + /// Fund the new wallet via Friendbot immediately (testnet only) + #[arg(long, default_value = "false")] + fund: bool, + /// Network to associate with the rotated wallet (overrides stored wallet network) + #[arg(long, value_parser = ["testnet", "mainnet"])] + network: Option, + /// Encrypt the replacement secret key with a passphrase at rest + #[arg(long, default_value = "false")] + encrypt: bool, + }, /// Export a wallet to a JSON backup file Export { /// Wallet name to export @@ -151,6 +165,9 @@ pub enum MultisigCommands { /// Override network for this config #[arg(long)] network: Option, + /// Optional file path to write a setup transaction JSON/XDR payload + #[arg(long)] + xdr_output: Option, }, /// Sign a multi-sig transaction JSON with all available local signer keys /// @@ -199,6 +216,12 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { WalletCommands::Fund { name } => fund_wallet(name), WalletCommands::Remove { name } => remove(name), WalletCommands::Rename { old_name, new_name } => rename(old_name, new_name), + WalletCommands::Rotate { + name, + fund, + network, + encrypt, + } => rotate_wallet(name, fund, network, encrypt), WalletCommands::Export { name, output } => export_wallet(name, output), WalletCommands::Import { file } => import_wallets(file), WalletCommands::Connect { device } => connect_hardware(device), @@ -214,8 +237,8 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { } fn connect_hardware(device: hardware_wallet::HardwareWalletKind) -> Result<()> { - p::header("Hardware Wallet — Connect"); - p::step(1, 3, &format!("Initializing HID subsystem for {}…", device)); + p::header("Hardware Wallet — Connect"); + p::step(1, 3, &format!("Initializing HID subsystem for {}…", device)); let info = hardware_wallet::connect(device)?; p::step( 2, @@ -232,7 +255,7 @@ fn connect_hardware(device: hardware_wallet::HardwareWalletKind) -> Result<()> { } fn hw_address(device: hardware_wallet::HardwareWalletKind, path: &str) -> Result<()> { - p::header("Hardware Wallet — Stellar Address"); + p::header("Hardware Wallet — Stellar Address"); p::step( 1, 2, @@ -248,7 +271,7 @@ fn hw_address(device: hardware_wallet::HardwareWalletKind, path: &str) -> Result } fn hw_status(device: hardware_wallet::HardwareWalletKind) -> Result<()> { - p::header("Hardware Wallet — Status"); + p::header("Hardware Wallet — Status"); let status = hardware_wallet::device_status(device)?; p::kv("Status", &status); Ok(()) @@ -333,7 +356,7 @@ fn create(name: String, fund: bool, network_override: Option, encrypt: b let steps = if fund { 3 } else { 2 }; p::header(&format!("Creating wallet '{}'", name)); - p::step(1, steps, "Generating keypair…"); + p::step(1, steps, "Generating keypair…"); let (public_key, secret_key) = generate_keypair(); println!(); p::kv_accent("Public Key", &public_key); @@ -354,7 +377,7 @@ fn create(name: String, fund: bool, network_override: Option, encrypt: b p::kv("Secret Key", status); println!(); - p::step(2, steps, "Saving to ~/.starforge/config.toml…"); + p::step(2, steps, "Saving to ~/.starforge/config.toml…"); let wallet = config::WalletEntry { name: name.clone(), public_key: public_key.clone(), @@ -369,7 +392,7 @@ fn create(name: String, fund: bool, network_override: Option, encrypt: b if network == "mainnet" { p::warn("Friendbot is not available on Mainnet. Skipping fund step."); } else { - p::step(3, steps, "Funding via Friendbot…"); + p::step(3, steps, "Funding via Friendbot…"); match horizon::fund_account(&public_key) { Ok(_) => { if let Some(w) = cfg.wallets.iter_mut().find(|w| w.name == name) { @@ -426,7 +449,7 @@ fn list() -> Result<()> { p::separator(); p::kv( &format!("{} wallet(s)", cfg.wallets.len()), - &format!("on {} — {}", cfg.network, config::config_path().display()), + &format!("on {} — {}", cfg.network, config::config_path().display()), ); Ok(()) @@ -471,9 +494,16 @@ fn show(name: String, reveal: bool) -> Result<()> { p::kv("Network", &w.network); p::kv("Funded", if w.funded { "yes" } else { "no" }); p::kv("Created", &w.created_at); + if !w.rotation_history.is_empty() { + p::kv("Rotations", &w.rotation_history.len().to_string()); + if let Some(last_rotation) = w.rotation_history.last() { + p::kv("Previous Key", &last_rotation.previous_public_key); + p::kv("Rotated At", &last_rotation.rotated_at); + } + } p::separator(); - p::info(&format!("Fetching live balance on {}…", w.network)); + p::info(&format!("Fetching live balance on {}…", w.network)); match horizon::fetch_account(&w.public_key, &w.network) { Ok(account) => { println!(); @@ -504,7 +534,7 @@ fn fund_wallet(name: String) -> Result<()> { .map(|w| w.public_key.clone()) .ok_or_else(|| anyhow::anyhow!("Wallet '{}' not found", name))?; - p::info(&format!("Funding '{}' via Friendbot…", name)); + p::info(&format!("Funding '{}' via Friendbot…", name)); horizon::fund_account(&public_key)?; if let Some(w) = cfg.wallets.iter_mut().find(|w| w.name == name) { @@ -550,7 +580,7 @@ fn rename(old_name: String, new_name: String) -> Result<()> { config::save(&cfg)?; println!(); - p::success(&format!("Wallet renamed: '{}' → '{}'", old_name, new_name)); + p::success(&format!("Wallet renamed: '{}' ? '{}'", old_name, new_name)); p::info(&format!( "View it with: {}", format!("starforge wallet show {}", new_name).cyan() @@ -558,6 +588,88 @@ fn rename(old_name: String, new_name: String) -> Result<()> { Ok(()) } +fn rotate_wallet( + name: String, + fund: bool, + network_override: Option, + encrypt: bool, +) -> Result<()> { + config::validate_wallet_name(&name)?; + let mut cfg = config::load()?; + let wallet_index = cfg + .wallets + .iter() + .position(|wallet| wallet.name == name) + .ok_or_else(|| anyhow::anyhow!("Wallet '{}' not found", name))?; + + let stored_network = cfg.wallets[wallet_index].network.clone(); + let original_public_key = cfg.wallets[wallet_index].public_key.clone(); + let original_funded = cfg.wallets[wallet_index].funded; + let network = network_override.unwrap_or(stored_network); + + let steps = if fund { 3 } else { 2 }; + p::header(&format!("Rotating wallet '{}'", name)); + p::kv("Old Public Key", &original_public_key); + p::kv("Network", &network); + + p::step(1, steps, "Generating replacement keypair..."); + let (public_key, secret_key) = generate_keypair(); + + let secret_to_store = if encrypt { + let pwd = + crypto::prompt_password("Set a secure passphrase to encrypt the rotated wallet", true)?; + crypto::encrypt_secret(&pwd, &secret_key)? + } else { + secret_key.clone() + }; + + p::step(2, steps, "Archiving previous public key in config metadata..."); + { + let wallet = &mut cfg.wallets[wallet_index]; + wallet.rotation_history.push(config::WalletRotationRecord { + rotated_at: Utc::now().to_rfc3339(), + previous_public_key: original_public_key.clone(), + previous_network: wallet.network.clone(), + previous_funded: wallet.funded, + }); + wallet.public_key = public_key.clone(); + wallet.secret_key = Some(secret_to_store); + wallet.network = network.clone(); + wallet.funded = false; + } + + if fund { + if network == "mainnet" { + p::warn("Friendbot is not available on Mainnet. Skipping fund step."); + } else { + p::step(3, steps, "Funding the replacement wallet via Friendbot..."); + match horizon::fund_account(&public_key) { + Ok(_) => { + if let Some(wallet) = cfg.wallets.iter_mut().find(|wallet| wallet.name == name) + { + wallet.funded = true; + } + p::success("Replacement wallet funded on testnet"); + } + Err(e) => p::warn(&format!("Funding failed: {}", e)), + } + } + } + + config::save(&cfg)?; + + println!(); + p::success(&format!("Wallet '{}' rotated", name)); + p::kv_accent("New Public Key", &public_key); + p::warn( + "The wallet name stayed the same, but the on-chain account changed. Update any funding, signer, or deploy flows that referenced the old public key.", + ); + if original_funded { + p::info("The previous key remains an on-chain account; rotation only updates the local wallet mapping."); + } + Ok(()) +} + fn export_wallet(name: String, output: PathBuf) -> Result<()> { config::validate_wallet_name(&name)?; let cfg = config::load()?; @@ -645,6 +757,7 @@ fn import_wallets(file: PathBuf) -> Result<()> { network: wallet.network, created_at: wallet.created_at, funded: wallet.funded, + rotation_history: Vec::new(), }); } @@ -702,7 +815,8 @@ fn handle_multisig(cmd: MultisigCommands) -> Result<()> { threshold, signers, network, - } => multisig_create(name, threshold, signers, network), + xdr_output, + } => multisig_create(name, threshold, signers, network, xdr_output), MultisigCommands::Sign { name, transaction, @@ -723,6 +837,7 @@ fn multisig_create( threshold: u8, signers: String, network: Option, + xdr_output: Option, ) -> Result<()> { config::validate_wallet_name(&name)?; multisig::validate_threshold(threshold)?; @@ -783,6 +898,7 @@ fn multisig_create( }; multisig::save_account(&account)?; + let setup_steps = multisig::build_stellar_cli_steps(&account, &network); println!(); p::header(&format!("Multi-sig: {}", name)); @@ -791,7 +907,27 @@ fn multisig_create( p::kv("Network", &network); p::kv("Threshold", &threshold.to_string()); p::kv("Signers", &account.signers.len().to_string()); - p::info("Sign with: starforge wallet multisig sign --transaction tx.json"); + if let Some(path) = xdr_output { + let setup_tx = multisig::build_account_setup_transaction(&account, &network)?; + multisig::save_transaction(&path, &setup_tx)?; + p::kv("Setup XDR JSON", &path.display().to_string()); + } + println!(); + p::info("Next steps to configure the account on-chain:"); + for (index, step) in setup_steps.iter().enumerate() { + println!(" {}. {}", index + 1, step.title); + println!(" {}", step.command.cyan()); + } + println!(); + p::info("After your account is updated on-chain, collect signatures with:"); + println!( + " {}", + format!( + "starforge wallet multisig sign {} --transaction tx.json", + account.name + ) + .cyan() + ); Ok(()) } @@ -942,10 +1078,10 @@ fn multisig_submit(name: String, transaction: PathBuf, network: Option) ); } - p::step(1, 2, "Combining signatures into final envelope…"); + p::step(1, 2, "Combining signatures into final envelope…"); let signed_xdr = multisig::combine_signatures(&tx.transaction_xdr, &tx.signatures)?; - p::step(2, 2, &format!("Submitting to Horizon ({})…", network)); + p::step(2, 2, &format!("Submitting to Horizon ({})…", network)); let result = horizon::submit_multisig_transaction(&signed_xdr, &network)?; println!(); @@ -958,3 +1094,4 @@ fn multisig_submit(name: String, transaction: PathBuf, network: Option) )); Ok(()) } + From e4fb8e123603c789c155d4ec7eccf545c3568b90 Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Wed, 27 May 2026 07:49:27 +0100 Subject: [PATCH 2/3] feat(multisig): build setup transaction guidance (#102) --- src/utils/multisig.rs | 78 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/utils/multisig.rs b/src/utils/multisig.rs index d2075368..6465e39a 100644 --- a/src/utils/multisig.rs +++ b/src/utils/multisig.rs @@ -64,6 +64,12 @@ pub enum TransactionStatus { Failed, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MultisigSetupStep { + pub title: String, + pub command: String, +} + fn multisig_dir() -> Result { let dir = crate::utils::config::get_data_dir()?.join("multisig"); if !dir.exists() { @@ -275,6 +281,77 @@ pub fn combine_signatures( Ok(general_purpose::STANDARD.encode(combined)) } +pub fn build_account_setup_transaction( + account: &MultiSigAccount, + network: &str, +) -> Result { + let operations = account + .signers + .iter() + .map(|signer| { + format!( + "set_options signer={} weight={}", + signer.public_key, signer.weight + ) + }) + .chain(std::iter::once(format!( + "set_options thresholds={}/{}/{}", + account.thresholds.low, account.thresholds.medium, account.thresholds.high + ))) + .collect::>(); + + let transaction_xdr = build_multisig_transaction_xdr(&account.account_id, &operations, 0, network)?; + + Ok(MultiSigTransaction { + id: format!("setup-{}", account.name), + account_id: account.account_id.clone(), + transaction_xdr, + signatures: Vec::new(), + threshold_required: account.thresholds.high, + current_weight: 0, + status: TransactionStatus::Pending, + created_at: chrono::Utc::now().to_rfc3339(), + }) +} + +pub fn build_stellar_cli_steps(account: &MultiSigAccount, network: &str) -> Vec { + let signer_args = account + .signers + .iter() + .map(|signer| format!("--signer {} --signer-weight {}", signer.public_key, signer.weight)) + .collect::>() + .join(" "); + + vec![ + MultisigSetupStep { + title: "Inspect the current account state".to_string(), + command: format!( + "stellar account show --account {} --network {}", + account.account_id, network + ), + }, + MultisigSetupStep { + title: "Apply signer weights and thresholds on-chain".to_string(), + command: format!( + "stellar tx new set-options --source-account {} {} --low-threshold {} --med-threshold {} --high-threshold {} --network {}", + account.account_id, + signer_args, + account.thresholds.low, + account.thresholds.medium, + account.thresholds.high, + network + ), + }, + MultisigSetupStep { + title: "Verify the account now reflects the multi-sig settings".to_string(), + command: format!( + "stellar account show --account {} --network {}", + account.account_id, network + ), + }, + ] +} + #[cfg(test)] mod tests { use super::*; @@ -355,3 +432,4 @@ mod tests { assert!(!check_transaction_ready(&tx2)); } } + From 99a05724f315e706f4775fbca81e1b5666a65495 Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Wed, 27 May 2026 07:49:30 +0100 Subject: [PATCH 3/3] docs: add multisig setup workflow example (#102) --- README.md | 76 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index c6068ebd..a491c716 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ⚡ starforge +# ? starforge -> A developer productivity CLI for Stellar and Soroban workflows — built in Rust. +> A developer productivity CLI for Stellar and Soroban workflows — built in Rust. ![License: MIT](https://img.shields.io/badge/License-MIT-cyan.svg) ![Language: Rust](https://img.shields.io/badge/Language-Rust-orange.svg) @@ -12,20 +12,20 @@ ## Overview -**starforge** is a free, open-source command-line toolkit for developers building on the Stellar network. It brings together the most common Stellar and Soroban developer workflows — wallet management, project scaffolding, and contract deployment — into a single fast, ergonomic CLI. +**starforge** is a free, open-source command-line toolkit for developers building on the Stellar network. It brings together the most common Stellar and Soroban developer workflows — wallet management, project scaffolding, and contract deployment — into a single fast, ergonomic CLI. Think of it as the "Hardhat or Foundry" experience for the Stellar ecosystem, built in Rust for speed and reliability. -This project is actively maintained and participates in the [Stellar Wave Program](https://www.drips.network/wave/stellar) on Drips — a monthly open-source contribution sprint where contributors earn rewards for merged pull requests. +This project is actively maintained and participates in the [Stellar Wave Program](https://www.drips.network/wave/stellar) on Drips — a monthly open-source contribution sprint where contributors earn rewards for merged pull requests. --- ## Features -### 🔑 Wallet Management +### ?? Wallet Management Create and manage Stellar ed25519 keypairs locally. Generate cryptographically secure keys using proper Stellar strkey encoding (G... for public, S... for secret). Optionally encrypt keys at rest with AES-256-GCM. Fund testnet accounts via Friendbot, list all saved wallets, inspect live on-chain balances, and securely store keys in `~/.starforge/config.toml`. -### ◻ Project Scaffolding +### ? Project Scaffolding Scaffold new Soroban smart contract projects from battle-tested templates with one command. Choose from: `hello-world`, `token`, `nft`, and `voting`. Use interactive mode (`--interactive`) to customize contract options like author, license, storage type, and test inclusion. Also scaffolds full Stellar dApp frontends (Vite + React). **NEW: Template Marketplace** - Discover and use community-contributed templates: @@ -40,7 +40,7 @@ starforge new contract my-dex --template uniswap-v2 --from marketplace starforge template publish ./my-template ``` -### 🚀 Contract Deployment +### ?? 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. --- @@ -49,7 +49,7 @@ Validate, size-check, and deploy compiled Soroban `.wasm` files to Testnet or Ma ### Prerequisites -- Rust ≥ 1.80 ([install via rustup](https://rustup.rs)) +- Rust = 1.80 ([install via rustup](https://rustup.rs)) ### Build from source @@ -103,8 +103,19 @@ starforge wallet fund alice # Remove a wallet starforge wallet remove alice + +# Rotate a wallet but keep the same local name +starforge wallet rotate alice --fund + +# Create a multisig config and also write a setup transaction payload +starforge wallet multisig create treasury \ + --threshold 2 \ + --signers alice,bob,charlie \ + --xdr-output treasury-setup.json ``` +Wallet rotation keeps the same local wallet name in `~/.starforge/config.toml`, but it creates a brand-new on-chain Stellar account keypair. Any scripts, signer sets, or deployment flows that referenced the previous public key still need to be updated separately. + ### Network commands ```bash @@ -214,13 +225,13 @@ starforge info ### Shell completions ```bash -# Bash — add to ~/.bashrc +# Bash — add to ~/.bashrc source <(starforge completions bash) -# Zsh — add to ~/.zshrc +# Zsh — add to ~/.zshrc source <(starforge completions zsh) -# Fish — save to fish completions directory +# Fish — save to fish completions directory starforge completions fish > ~/.config/fish/completions/starforge.fish ``` @@ -232,22 +243,22 @@ After adding the line to your shell config, restart your shell or run `source ~/ ``` starforge/ -├── Cargo.toml -└── src/ - ├── main.rs # CLI entry point + banner - ├── commands/ - │ ├── mod.rs - │ ├── wallet.rs # wallet create/list/show/fund/remove - │ ├── new.rs # project scaffolding + templates - │ ├── contract.rs # contract inspect + invoke - │ ├── deploy.rs # contract deployment - │ └── info.rs # environment info - └── utils/ - ├── mod.rs - ├── config.rs # ~/.starforge/config.toml read/write - ├── horizon.rs # Horizon API + Friendbot HTTP calls - ├── soroban.rs # Soroban RPC helpers - └── print.rs # Consistent CLI output helpers ++-- Cargo.toml ++-- src/ + +-- main.rs # CLI entry point + banner + +-- commands/ + ¦ +-- mod.rs + ¦ +-- wallet.rs # wallet create/list/show/fund/remove + ¦ +-- new.rs # project scaffolding + templates + ¦ +-- contract.rs # contract inspect + invoke + ¦ +-- deploy.rs # contract deployment + ¦ +-- info.rs # environment info + +-- utils/ + +-- mod.rs + +-- config.rs # ~/.starforge/config.toml read/write + +-- horizon.rs # Horizon API + Friendbot HTTP calls + +-- soroban.rs # Soroban RPC helpers + +-- print.rs # Consistent CLI output helpers ``` --- @@ -328,7 +339,7 @@ Please keep PRs scoped to a single issue and include a clear description of what --- ## License -MIT © 2025 — See [LICENSE](./LICENSE) for details. +MIT © 2025 — See [LICENSE](./LICENSE) for details. --- @@ -344,22 +355,22 @@ Powered by the [Stellar Horizon API](https://developers.stellar.org/api/horizon) StarForge has comprehensive documentation covering all aspects of the project: -### 📚 Core Documentation +### ?? Core Documentation - **[README.md](README.md)** - This file, quick start and overview - **[Documentation.md](Documentation.md)** - Extended documentation with architecture overview - **[ARCHITECTURE.md](ARCHITECTURE.md)** - Complete system architecture and design - **[DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md)** - Contributing and development guide - **[API_REFERENCE.md](API_REFERENCE.md)** - Complete command reference -### 🎯 Feature Documentation +### ?? Feature Documentation - **[TEMPLATE_MARKETPLACE.md](TEMPLATE_MARKETPLACE.md)** - Template marketplace feature - **[QUICK_START_TEMPLATES.md](QUICK_START_TEMPLATES.md)** - Template quick start guide -### 📖 Navigation +### ?? Navigation - **[DOCUMENTATION_INDEX.md](DOCUMENTATION_INDEX.md)** - Complete documentation index - **[DOCUMENTATION_SUMMARY.md](DOCUMENTATION_SUMMARY.md)** - Documentation overview -### 📁 Examples +### ?? Examples - **[examples/template_marketplace_usage.md](examples/template_marketplace_usage.md)** - Practical examples - **[tutorials/hello-world/](tutorials/hello-world/)** - Beginner tutorial @@ -367,3 +378,4 @@ StarForge has comprehensive documentation covering all aspects of the project: For a complete overview, see [DOCUMENTATION_INDEX.md](DOCUMENTATION_INDEX.md). +