From ecfbce7b8df22f58f9325fd512de06db865c0630 Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Wed, 27 May 2026 07:47:20 +0100 Subject: [PATCH 1/3] feat(wallet): add wallet rotation metadata (#99) --- src/utils/config.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/utils/config.rs b/src/utils/config.rs index 26df6330..4a63b202 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -184,6 +184,16 @@ pub struct WalletEntry { pub network: String, pub created_at: String, pub funded: bool, + #[serde(default)] + pub rotation_history: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WalletRotationRecord { + pub rotated_at: String, + pub previous_public_key: String, + pub previous_network: String, + pub previous_funded: bool, } impl Default for Config { @@ -473,3 +483,4 @@ pub fn add_custom_network( ); Ok(()) } + From d25589483a8dd5bc844bd0e1a09afff565c9d3f0 Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Wed, 27 May 2026 07:47:25 +0100 Subject: [PATCH 2/3] docs: explain wallet rotation behavior (#99) --- README.md | 70 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index c6068ebd..b59e8aab 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,13 @@ 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 ``` +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 +219,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 +237,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 +333,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 +349,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 +372,4 @@ StarForge has comprehensive documentation covering all aspects of the project: For a complete overview, see [DOCUMENTATION_INDEX.md](DOCUMENTATION_INDEX.md). + From 14b49f794f932ecee944ce6366693435fe27548d Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Wed, 27 May 2026 07:48:03 +0100 Subject: [PATCH 3/3] feat(wallet): add wallet rotation workflow (#99) --- src/commands/wallet.rs | 137 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 124 insertions(+), 13 deletions(-) diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index ec235cdc..56214f0b 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 @@ -199,6 +213,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 +234,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 +252,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 +268,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 +353,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 +374,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 +389,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 +446,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 +491,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 +531,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 +577,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 +585,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 +754,7 @@ fn import_wallets(file: PathBuf) -> Result<()> { network: wallet.network, created_at: wallet.created_at, funded: wallet.funded, + rotation_history: Vec::new(), }); } @@ -942,10 +1052,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 +1068,4 @@ fn multisig_submit(name: String, transaction: PathBuf, network: Option) )); Ok(()) } +