diff --git a/README.md b/README.md index c6068ebd..cc03c10e 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 @@ -58,6 +58,9 @@ git clone https://github.com/YOUR_USERNAME/starforge.git cd starforge cargo build --release +# Build with hardware wallet support +cargo build --release --features hardware-wallet + # Move the binary to your PATH cp target/release/starforge ~/.local/bin/ # or on macOS: @@ -103,8 +106,25 @@ 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 +``` + +Hardware wallet support is behind the `hardware-wallet` feature flag. Build it with: + +```bash +cargo build --release --features hardware-wallet ``` +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 +234,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 +252,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 +348,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 +364,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 +387,4 @@ StarForge has comprehensive documentation covering all aspects of the project: For a complete overview, see [DOCUMENTATION_INDEX.md](DOCUMENTATION_INDEX.md). + diff --git a/src/utils/hardware_wallet.rs b/src/utils/hardware_wallet.rs index d7dca650..63e68a10 100644 --- a/src/utils/hardware_wallet.rs +++ b/src/utils/hardware_wallet.rs @@ -1,10 +1,20 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use clap::ValueEnum; /// Stellar SLIP-0010 / BIP-44 HD derivation path. /// Default: m/44'/148'/0' (account index 0). pub const STELLAR_HD_PATH: &str = "m/44'/148'/0'"; +const LEDGER_VENDOR_ID: u16 = 0x2c97; +const HID_PACKET_SIZE: usize = 64; +const HID_CHANNEL: u16 = 0x0101; +const HID_TAG_APDU: u8 = 0x05; +const SW_OK: [u8; 2] = [0x90, 0x00]; + +const CLA_STELLAR: u8 = 0xE0; +const INS_GET_PUBLIC_KEY: u8 = 0x02; +const INS_SIGN_TX: u8 = 0x04; + #[derive(Debug, Clone, Copy, ValueEnum)] pub enum HardwareWalletKind { Ledger, @@ -23,16 +33,12 @@ impl std::fmt::Display for HardwareWalletKind { /// Basic information returned by a connected hardware wallet. #[derive(Debug, Clone)] pub struct HardwareWalletInfo { - #[allow(dead_code)] pub kind: HardwareWalletKind, pub device_count: usize, - #[allow(dead_code)] pub stellar_address: Option, pub hd_path: String, } -// ── Feature-disabled stubs ──────────────────────────────────────────────────── - #[cfg(not(feature = "hardware-wallet"))] pub fn connect(kind: HardwareWalletKind) -> Result { anyhow::bail!( @@ -57,98 +63,270 @@ pub fn device_status(_kind: HardwareWalletKind) -> Result { anyhow::bail!("Hardware wallet support is disabled in this build.") } -// ── Feature-enabled implementations ────────────────────────────────────────── - #[cfg(feature = "hardware-wallet")] pub fn connect(kind: HardwareWalletKind) -> Result { - let api = hidapi::HidApi::new() - .map_err(|e| anyhow::anyhow!("Failed to initialize HID API: {}", e))?; - - let devices: Vec<_> = api.device_list().collect(); - if devices.is_empty() { - anyhow::bail!( - "No HID devices detected. Ensure your {} is connected, unlocked, and has the Stellar app open.", - kind - ); - } + let transport = LedgerTransport::connect(kind)?; + let stellar_address = transport.get_public_key(STELLAR_HD_PATH).ok(); Ok(HardwareWalletInfo { kind, - device_count: devices.len(), - stellar_address: None, // populated lazily via get_stellar_address() + device_count: transport.device_count, + stellar_address, hd_path: STELLAR_HD_PATH.to_string(), }) } -/// Derive the Stellar public key at the given HD path from the hardware wallet. -/// -/// The APDU exchange with the Ledger Stellar app (INS 0x02 — GET PUBLIC KEY) -/// is outlined in the Ledger Stellar app documentation: -/// -/// -/// This function returns a stub address for the initial wiring; a full APDU -/// implementation can replace the inner block without changing the signature. #[cfg(feature = "hardware-wallet")] pub fn get_stellar_address(kind: HardwareWalletKind, hd_path: &str) -> Result { - // Verify the HID subsystem is reachable before claiming success. - let api = hidapi::HidApi::new() - .map_err(|e| anyhow::anyhow!("Failed to initialize HID API: {}", e))?; - - let devices: Vec<_> = api.device_list().collect(); - if devices.is_empty() { - anyhow::bail!("No {} device found. Connect and unlock the device.", kind); - } - - // Stub: real implementation would open the device, send the GET_PUBLIC_KEY APDU, - // and decode the 32-byte ed25519 public key from the response. - // - // APDU for Ledger Stellar app (INS=0x02, display=false): - // CLA=0xE0 INS=0x02 P1=0x00 P2=0x00 Data= - // - // For now we return a deterministic placeholder so the CLI integration is - // testable end-to-end without a physical device. - let placeholder = format!( - "GHARDWARE{:0>47}", - hd_path.chars().filter(|c| c.is_ascii_alphanumeric()).count() - ); - eprintln!( - " [hardware-wallet] Note: returning stub address for {} at {}.\n \ - Replace get_stellar_address() with full APDU exchange for production use.", - kind, hd_path - ); - Ok(placeholder) -} - -/// Return a human-readable status string for the connected device. + LedgerTransport::connect(kind)?.get_public_key(hd_path) +} + #[cfg(feature = "hardware-wallet")] pub fn device_status(kind: HardwareWalletKind) -> Result { - let api = hidapi::HidApi::new() - .map_err(|e| anyhow::anyhow!("Failed to initialize HID API: {}", e))?; + let transport = LedgerTransport::connect(kind)?; + Ok(format!( + "{}: {} HID device(s) visible, Stellar app reachable", + kind, transport.device_count + )) +} + +#[cfg(feature = "hardware-wallet")] +pub fn sign(kind: HardwareWalletKind, message: &[u8]) -> Result> { + LedgerTransport::connect(kind)?.sign_message(STELLAR_HD_PATH, message) +} + +fn parse_hd_path(path: &str) -> Result> { + let cleaned = path.trim(); + let segments = cleaned + .strip_prefix("m/") + .or_else(|| cleaned.strip_prefix("M/")) + .unwrap_or(cleaned); + + if segments.is_empty() { + anyhow::bail!("HD path cannot be empty"); + } + + let mut values = Vec::new(); + for segment in segments.split('/') { + if segment.is_empty() { + anyhow::bail!("Invalid HD path '{}'", path); + } + let hardened = segment.ends_with('\''); + let digits = if hardened { + &segment[..segment.len() - 1] + } else { + segment + }; + let index: u32 = digits + .parse() + .with_context(|| format!("Invalid HD path segment '{}'", segment))?; + if index >= 0x8000_0000 { + anyhow::bail!("HD path segment '{}' is out of range", segment); + } + values.push(if hardened { index | 0x8000_0000 } else { index }); + } + + Ok(values) +} + +fn encode_hd_path(path: &str) -> Result> { + let indices = parse_hd_path(path)?; + let mut out = Vec::with_capacity(1 + indices.len() * 4); + out.push(indices.len() as u8); + for index in indices { + out.extend_from_slice(&index.to_be_bytes()); + } + Ok(out) +} - let count = api.device_list().count(); - if count == 0 { - return Ok(format!("{}: not connected", kind)); +fn build_apdu(cla: u8, ins: u8, p1: u8, p2: u8, data: &[u8]) -> Vec { + let mut apdu = Vec::with_capacity(5 + data.len()); + apdu.push(cla); + apdu.push(ins); + apdu.push(p1); + apdu.push(p2); + apdu.push(data.len() as u8); + apdu.extend_from_slice(data); + apdu +} + +fn frame_apdu_for_hid(apdu: &[u8]) -> Vec<[u8; HID_PACKET_SIZE]> { + let mut framed = Vec::new(); + let mut remaining = apdu; + let mut sequence: u16 = 0; + + while sequence == 0 || !remaining.is_empty() { + let mut packet = [0u8; HID_PACKET_SIZE]; + packet[0..2].copy_from_slice(&HID_CHANNEL.to_be_bytes()); + packet[2] = HID_TAG_APDU; + packet[3..5].copy_from_slice(&sequence.to_be_bytes()); + + let header_len = if sequence == 0 { + packet[5..7].copy_from_slice(&(apdu.len() as u16).to_be_bytes()); + 7 + } else { + 5 + }; + + let chunk_len = remaining.len().min(HID_PACKET_SIZE - header_len); + packet[header_len..header_len + chunk_len].copy_from_slice(&remaining[..chunk_len]); + remaining = &remaining[chunk_len..]; + framed.push(packet); + sequence += 1; } - Ok(format!("{}: {} HID device(s) visible — ensure Stellar app is open", kind, count)) + + framed } -/// Sign raw bytes via the hardware wallet (APDU INS 0x04 — SIGN TRANSACTION). -/// -/// Returns the raw 64-byte ed25519 signature. #[cfg(feature = "hardware-wallet")] -pub fn sign(kind: HardwareWalletKind, _message: &[u8]) -> Result> { - // Verify device is reachable first. - let api = hidapi::HidApi::new() - .map_err(|e| anyhow::anyhow!("Failed to initialize HID API: {}", e))?; - if api.device_list().count() == 0 { - anyhow::bail!("No {} device found.", kind); - } - // Stub: real implementation sends the SIGN APDU and reads back 64 bytes. - anyhow::bail!( - "Hardware wallet signing via APDU is not yet implemented for {}.\n\ - Connect your device and use the Stellar app to sign manually, or contribute the APDU flow.", - kind - ) +struct LedgerTransport { + device: hidapi::HidDevice, + device_count: usize, +} + +#[cfg(feature = "hardware-wallet")] +impl LedgerTransport { + fn connect(kind: HardwareWalletKind) -> Result { + match kind { + HardwareWalletKind::Ledger => Self::connect_ledger(), + HardwareWalletKind::Trezor => anyhow::bail!( + "Trezor transport is not implemented yet. Use Ledger with `--features hardware-wallet` for now." + ), + } + } + + fn connect_ledger() -> Result { + let api = hidapi::HidApi::new().context("Failed to initialize HID API")?; + let devices = api + .device_list() + .filter(|info| info.vendor_id() == LEDGER_VENDOR_ID) + .collect::>(); + + if devices.is_empty() { + anyhow::bail!( + "No Ledger device detected. Connect it, unlock it, and open the Stellar app." + ); + } + + let device = devices[0] + .open_device(&api) + .context("Failed to open Ledger HID device")?; + + Ok(Self { + device, + device_count: devices.len(), + }) + } + + fn exchange(&self, apdu: &[u8]) -> Result> { + for packet in frame_apdu_for_hid(apdu) { + self.device + .write(&packet) + .context("Failed to write APDU packet to Ledger")?; + } + + let mut response = Vec::new(); + let mut expected_len: Option = None; + let mut sequence: u16 = 0; + + loop { + let mut packet = [0u8; HID_PACKET_SIZE]; + let read = self + .device + .read_timeout(&mut packet, 15_000) + .context("Timed out waiting for Ledger response")?; + + if read < 5 { + anyhow::bail!("Received short HID response from Ledger"); + } + if packet[0..2] != HID_CHANNEL.to_be_bytes() || packet[2] != HID_TAG_APDU { + anyhow::bail!("Received invalid Ledger HID framing"); + } + + let packet_sequence = u16::from_be_bytes([packet[3], packet[4]]); + if packet_sequence != sequence { + anyhow::bail!("Ledger response sequence mismatch"); + } + + let start = if sequence == 0 { + let total_len = u16::from_be_bytes([packet[5], packet[6]]) as usize; + expected_len = Some(total_len); + 7 + } else { + 5 + }; + + response.extend_from_slice(&packet[start..read]); + + if let Some(total) = expected_len { + if response.len() >= total { + response.truncate(total); + break; + } + } + + sequence += 1; + } + + if response.len() < 2 { + anyhow::bail!("Ledger response did not include a status word"); + } + let status = &response[response.len() - 2..]; + if status != SW_OK { + anyhow::bail!("Ledger returned APDU status {:02x}{:02x}", status[0], status[1]); + } + + Ok(response[..response.len() - 2].to_vec()) + } + + fn get_public_key(&self, hd_path: &str) -> Result { + let path_bytes = encode_hd_path(hd_path)?; + let apdu = build_apdu(CLA_STELLAR, INS_GET_PUBLIC_KEY, 0x01, 0x00, &path_bytes); + let response = self.exchange(&apdu)?; + let public_key_bytes = extract_public_key_bytes(&response)?; + Ok(stellar_strkey::ed25519::PublicKey(public_key_bytes).to_string()) + } + + fn sign_message(&self, hd_path: &str, message: &[u8]) -> Result> { + let path_bytes = encode_hd_path(hd_path)?; + let total_chunks = message.chunks(255).count().max(1); + let mut signature = None; + + for (index, chunk) in message.chunks(255).enumerate() { + let mut payload = Vec::new(); + if index == 0 { + payload.extend_from_slice(&path_bytes); + } + payload.extend_from_slice(chunk); + + let p1 = if index == 0 { 0x00 } else { 0x80 }; + let p2 = if index + 1 == total_chunks { 0x00 } else { 0x80 }; + let apdu = build_apdu(CLA_STELLAR, INS_SIGN_TX, p1, p2, &payload); + let response = self.exchange(&apdu)?; + + if index + 1 == total_chunks { + signature = Some(extract_signature_bytes(&response)?); + } + } + + signature.ok_or_else(|| anyhow::anyhow!("Ledger did not return a signature")) + } +} + +fn extract_public_key_bytes(response: &[u8]) -> Result<[u8; 32]> { + if response.len() >= 32 { + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&response[..32]); + return Ok(bytes); + } + anyhow::bail!("Ledger public-key response was too short") +} + +fn extract_signature_bytes(response: &[u8]) -> Result> { + if response.len() >= 64 { + return Ok(response[..64].to_vec()); + } + anyhow::bail!("Ledger signature response was too short") } #[cfg(test)] @@ -165,4 +343,54 @@ mod tests { assert_eq!(HardwareWalletKind::Ledger.to_string(), "Ledger"); assert_eq!(HardwareWalletKind::Trezor.to_string(), "Trezor"); } + + #[test] + fn parses_hd_path_segments() { + let parsed = parse_hd_path("m/44'/148'/0'").unwrap(); + assert_eq!(parsed, vec![0x8000_002c, 0x8000_0094, 0x8000_0000]); + } + + #[test] + fn encodes_hd_path_prefix_and_bytes() { + let encoded = encode_hd_path("m/44'/148'/0'").unwrap(); + assert_eq!(encoded[0], 3); + assert_eq!(&encoded[1..5], &0x8000_002c_u32.to_be_bytes()); + } + + #[test] + fn builds_apdu_header() { + let apdu = build_apdu(0xE0, 0x02, 0x01, 0x00, &[1, 2, 3]); + assert_eq!(apdu, vec![0xE0, 0x02, 0x01, 0x00, 3, 1, 2, 3]); + } + + #[test] + fn frames_large_apdu_into_multiple_hid_packets() { + let apdu = vec![0xAB; 120]; + let packets = frame_apdu_for_hid(&apdu); + assert!(packets.len() >= 2); + assert_eq!(packets[0][0..2], HID_CHANNEL.to_be_bytes()); + assert_eq!(packets[0][2], HID_TAG_APDU); + } + + #[test] + fn extracts_public_key_from_recorded_vector() { + let response = [7u8; 32]; + let key = extract_public_key_bytes(&response).unwrap(); + assert_eq!(key, [7u8; 32]); + } + + #[test] + fn extracts_signature_from_recorded_vector() { + let response = vec![9u8; 64]; + let signature = extract_signature_bytes(&response).unwrap(); + assert_eq!(signature.len(), 64); + assert!(signature.iter().all(|byte| *byte == 9)); + } + + #[cfg(feature = "hardware-wallet")] + #[test] + #[ignore = "requires a connected Ledger with the Stellar app open"] + fn ledger_integration_requires_device() { + let _ = connect(HardwareWalletKind::Ledger); + } }