Complete guide for developers contributing to or extending StarForge.
- Plugin Version Compatibility
- Getting Started
- Development Setup
- Project Structure
- Code Style Guide
- Adding New Features
- Testing
- Documentation
- Common Tasks
- Debugging
- Release Process
- Database Migrations
StarForge enforces version compatibility when loading plugins to prevent subtle runtime failures caused by ABI or API mismatches.
Every plugin shared library must export a PLUGIN_DECLARATION symbol (provided
automatically by the export_plugin! macro). When starforge plugin load runs,
the loader checks two fields in that declaration:
| Field | What is checked | Failure behaviour |
|---|---|---|
rustc_version |
Must match the exact rustc version used to build StarForge | Hard error — load aborted |
core_version |
Major version must match StarForge's own CARGO_PKG_VERSION |
Hard error — load aborted |
The compatibility rule for core_version follows semantic versioning:
0.x.yplugins are only compatible with a0.x.yStarForge core (major0).1.x.yplugins are only compatible with a1.x.yStarForge core (major1).- Minor and patch bumps within the same major are considered backwards-compatible.
When a plugin fails the version check you will see a clear message, for example:
Error: Plugin version incompatibility in 'libmy_plugin.so':
Plugin was built for StarForge 0.1.0
Running StarForge 1.0.0
The major version must match. Rebuild the plugin against
StarForge 1.0.0 or install a compatible StarForge version.
See DEVELOPER_GUIDE.md § "Plugin Version Compatibility" for details.
-
Pin the StarForge version in your plugin's
Cargo.toml:[dependencies] # Use the same major version as the StarForge binary your users will run. starforge = "0.1"
-
Use the
export_plugin!macro — it embeds bothrustc_versionandcore_versionautomatically at compile time:use starforge::export_plugin; export_plugin!(register); fn register(registrar: &mut dyn starforge::plugins::PluginRegistrar) { registrar.register_plugin(Box::new(MyPlugin)); }
-
Rebuild when StarForge's major version changes. Check the running version with
starforge --versionand compare it to the version your plugin was built against (shown instarforge plugin loadoutput under "Built for StarForge"). -
Use the same Rust toolchain as the StarForge binary. The easiest way is to keep a
rust-toolchain.tomlin your plugin repo that mirrors the one in the StarForge repo.
# See which StarForge version is running
starforge --version
# See which version each installed plugin was built for
starforge plugin loadThe load command prints a "Built for StarForge" line for every successfully
loaded plugin, and a descriptive error for any that fail the check.
- Rust: 1.80 or higher (install via rustup)
- Git: For version control
- Stellar CLI: For contract operations (optional)
- Docker: For containerized development (optional)
# Clone repository
git clone https://github.com/YOUR_USERNAME/starforge.git
cd starforge
# Build in debug mode
cargo build
# Build in release mode
cargo build --release
# Run tests
cargo test
# Run with logging
RUST_LOG=debug cargo run -- wallet listRecommended extensions:
rust-analyzer- Rust language supportcrates- Cargo.toml dependency managementBetter TOML- TOML syntax highlightingError Lens- Inline error display
.vscode/settings.json:
{
"rust-analyzer.checkOnSave.command": "clippy",
"rust-analyzer.cargo.features": "all",
"editor.formatOnSave": true
}Install the Rust plugin and configure:
- Enable Clippy for code analysis
- Set rustfmt as formatter
- Enable external linter
# Enable debug logging
export RUST_LOG=debug
# Disable telemetry during development
export STARFORGE_TELEMETRY=false
# Use custom config directory
export STARFORGE_CONFIG_DIR=~/.starforge-dev# 1. Create feature branch
git checkout -b feature/my-feature
# 2. Make changes
# ... edit files ...
# 3. Run tests
cargo test
# 4. Check formatting
cargo fmt --check
# 5. Run clippy
cargo clippy -- -D warnings
# 6. Build
cargo build
# 7. Test manually
cargo run -- <command>
# 8. Run smoke tests (optional but recommended)
./scripts/e2e-smoke.sh
# 9. Commit
git add .
git commit -m "feat: add my feature"
# 10. Push and create PR
git push origin feature/my-featuresrc/
├── main.rs # Entry point
├── commands/ # User-facing commands
│ ├── mod.rs # Module exports
│ ├── wallet.rs # Wallet operations
│ ├── template.rs # Template marketplace
│ └── ...
├── utils/ # Shared utilities
│ ├── mod.rs # Module exports
│ ├── config.rs # Configuration
│ ├── templates.rs # Template system
│ └── ...
└── plugins/ # Plugin system
├── mod.rs # Module exports
├── interface.rs # Plugin traits
└── ...
- Commands:
<noun>.rs(e.g.,wallet.rs,network.rs) - Utilities:
<function>.rs(e.g.,config.rs,crypto.rs) - Tests:
<module>_test.rsor inline#[cfg(test)] mod tests
Each module should follow this structure:
// 1. Imports
use crate::utils::config;
use anyhow::Result;
// 2. Type definitions
pub struct MyStruct { /* ... */ }
pub enum MyEnum { /* ... */ }
// 3. Public API
pub fn public_function() -> Result<()> { /* ... */ }
// 4. Private helpers
fn private_helper() { /* ... */ }
// 5. Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_something() { /* ... */ }
}Follow the Rust Style Guide:
// ✅ Good
pub fn create_wallet(name: String, encrypt: bool) -> Result<()> {
validate_name(&name)?;
let keypair = generate_keypair();
save_wallet(name, keypair, encrypt)
}
// ❌ Bad
pub fn CreateWallet(Name: String, Encrypt: bool) -> Result<()> {
ValidateName(&Name)?;
let KeyPair = GenerateKeypair();
SaveWallet(Name, KeyPair, Encrypt)
}| Type | Convention | Example |
|---|---|---|
| Functions | snake_case |
fetch_account() |
| Types | PascalCase |
WalletEntry |
| Constants | SCREAMING_SNAKE_CASE |
MAX_RETRIES |
| Modules | snake_case |
hardware_wallet |
| Lifetimes | 'lowercase |
'a, 'static |
// ✅ Use Result and ? operator
pub fn operation() -> Result<()> {
let data = load_data()?;
process_data(data)?;
Ok(())
}
// ✅ Add context to errors
fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?
// ❌ Don't use unwrap() in production code
let data = load_data().unwrap(); // Bad!
// ✅ Use expect() with clear message for programmer errors
let data = load_data()
.expect("Config should be initialized in main()");/// Fetches account information from Horizon API.
///
/// # Arguments
///
/// * `public_key` - The Stellar public key (G...)
/// * `network` - Network name ("testnet" or "mainnet")
///
/// # Returns
///
/// Returns `AccountResponse` with balance and sequence information.
///
/// # Errors
///
/// Returns error if:
/// - Account doesn't exist on the network
/// - Network is unreachable
/// - Response parsing fails
///
/// # Example
///
/// ```
/// let account = fetch_account("GABC...", "testnet")?;
/// println!("Balance: {}", account.balances[0].balance);
/// ```
pub fn fetch_account(public_key: &str, network: &str) -> Result<AccountResponse> {
// Implementation
}// ✅ Explain WHY, not WHAT
// Use shallow clone to reduce bandwidth and disk usage
git_clone(&url, "--depth", "1");
// ❌ Don't state the obvious
// Clone the repository
git_clone(&url);
// ✅ TODO comments with context
// TODO(username): Add retry logic after implementing exponential backoff
// ❌ Vague TODOs
// TODO: fix thisStep 1: Create command file
touch src/commands/mycommand.rsStep 2: Define command structure
// src/commands/mycommand.rs
use anyhow::Result;
use clap::Subcommand;
use crate::utils::print as p;
#[derive(Subcommand)]
pub enum MyCommands {
/// Do something useful
Action {
/// Input parameter
#[arg(long)]
input: String,
},
}
pub fn handle(cmd: MyCommands) -> Result<()> {
match cmd {
MyCommands::Action { input } => action(input),
}
}
fn action(input: String) -> Result<()> {
p::header("My Command");
p::kv("Input", &input);
// Your logic here
p::success("Done!");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_action() {
// Test your command
}
}Step 3: Register in mod.rs
// src/commands/mod.rs
pub mod mycommand;Step 4: Add to main CLI
// src/main.rs
#[derive(Subcommand)]
enum Commands {
// ... existing commands
/// My new command
#[command(subcommand)]
MyCommand(commands::mycommand::MyCommands),
}
// In main():
let result = match cli.command {
// ... existing matches
Commands::MyCommand(cmd) => commands::mycommand::handle(cmd),
};Step 5: Update documentation
# Update README.md with new command
# Add examples to examples/ directory
# Update ARCHITECTURE.md if neededStep 1: Create utility file
touch src/utils/myutil.rsStep 2: Implement functionality
// src/utils/myutil.rs
use anyhow::Result;
/// Does something useful
pub fn do_something(input: &str) -> Result<String> {
// Implementation
Ok(format!("Processed: {}", input))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_do_something() {
let result = do_something("test").unwrap();
assert_eq!(result, "Processed: test");
}
}Step 3: Register in mod.rs
// src/utils/mod.rs
pub mod myutil;Step 4: Use in commands
use crate::utils::myutil;
fn my_command() -> Result<()> {
let result = myutil::do_something("input")?;
println!("{}", result);
Ok(())
}Step 1: Create template directory
mkdir -p templates/examples/my-template/srcStep 2: Add template files
# templates/examples/my-template/Cargo.toml
[package]
name = "{{PROJECT_NAME}}"
version = "0.1.0"
edition = "2021"
[dependencies]
soroban-sdk = "21.0.0"// templates/examples/my-template/src/lib.rs
#![no_std]
use soroban_sdk::{contract, contractimpl, Env};
#[contract]
pub struct {{PROJECT_NAME_PASCAL}};
#[contractimpl]
impl {{PROJECT_NAME_PASCAL}} {
pub fn hello(env: Env) -> String {
String::from_str(&env, "Hello from {{PROJECT_NAME}}")
}
}Step 3: Add to registry
// templates/registry.json
{
"templates": [
{
"name": "my-template",
"version": "1.0.0",
"description": "My awesome template",
"author": "Your Name",
"tags": ["example"],
"source": {
"type": "local",
"path": "templates/examples/my-template"
},
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z",
"downloads": 0,
"verified": false
}
]
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_functionality() {
let result = my_function("input");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "expected");
}
#[test]
fn test_error_case() {
let result = my_function("");
assert!(result.is_err());
}
#[test]
#[should_panic(expected = "Invalid input")]
fn test_panic() {
panic_function();
}
}// tests/integration_test.rs
use starforge::utils::config;
use tempfile::TempDir;
#[test]
fn test_config_lifecycle() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("config.toml");
// Test save
let config = config::Config::default();
config::save(&config).unwrap();
// Test load
let loaded = config::load().unwrap();
assert_eq!(loaded.version, config.version);
}Fast regression checks for core commands live in tests/cli_smoke.rs and
scripts/e2e-smoke.sh. CI runs both after every build:
cargo test --test cli_smoke
./scripts/e2e-smoke.sh
STARFORGE_E2E=1 ./scripts/e2e-smoke.sh # optional network checks# Run all tests
cargo test
# Run specific test
cargo test test_name
# Run tests with output
cargo test -- --nocapture
# Run tests in specific module
cargo test config::tests
# Run integration tests only
cargo test --test integration_test
# Run with coverage (requires tarpaulin)
cargo tarpaulin --out HtmlStarForge includes an end-to-end smoke test script that verifies basic functionality across all major commands.
Location: scripts/e2e-smoke.sh
Running Smoke Tests:
# Build the project first
cargo build --release
# Run smoke tests (without network tests)
./scripts/e2e-smoke.sh
# Run smoke tests with network tests (requires internet)
STARFORGE_E2E=1 ./scripts/e2e-smoke.shWhat the smoke test covers:
-
Basic Commands
starforge info- System informationstarforge --version- Version displaystarforge --help- Help text
-
Wallet Operations
wallet create- Create test walletwallet list- List walletswallet show- Display wallet details
-
Network Operations
network show- Display network configurationnetwork test- Test network connectivity (requiresSTARFORGE_E2E=1)wallet fund- Fund testnet wallet (requiresSTARFORGE_E2E=1)
-
Template Operations
template list- List available templatestemplate search- Search templates
-
Other Commands
completions- Generate shell completions
Network Test Gating:
Network tests are gated behind the STARFORGE_E2E=1 environment variable because they:
- Require internet connectivity
- Depend on external services (Stellar testnet, Friendbot)
- May be slow or flaky in CI environments
- Can hit rate limits
To skip network tests in CI:
# .github/workflows/ci.yml
- name: Run smoke tests
run: ./scripts/e2e-smoke.sh # Skips network tests by defaultTo run full tests locally:
STARFORGE_E2E=1 ./scripts/e2e-smoke.shExit Codes:
0- All tests passed1- One or more tests failed
Cleanup:
The smoke test automatically cleans up test wallets on exit. If cleanup fails, you may need to manually remove test wallets:
# List wallets to find test wallets
starforge wallet list
# Remove test wallet (when delete command is implemented)
# starforge wallet delete smoke-test-<timestamp>tests/
├── integration_test.rs # Integration tests
├── template_test.rs # Template-specific tests
└── common/
└── mod.rs # Shared test utilities
/// Module-level documentation
///
/// This module handles wallet operations including creation,
/// listing, and management of Stellar keypairs.
/// Function documentation
///
/// Creates a new wallet with the given name.
///
/// # Arguments
///
/// * `name` - Wallet identifier
/// * `encrypt` - Whether to encrypt the secret key
///
/// # Returns
///
/// Returns `Ok(())` on success, or an error if:
/// - Wallet name already exists
/// - Keypair generation fails
/// - Config save fails
pub fn create_wallet(name: String, encrypt: bool) -> Result<()> {
// Implementation
}Update these files when adding features:
- README.md - Main documentation, usage examples
- ARCHITECTURE.md - Architecture and design decisions
- DEVELOPER_GUIDE.md - This file
- Feature-specific docs - Detailed feature documentation
- Use clear, concise language
- Include code examples
- Explain WHY, not just WHAT
- Keep examples up-to-date
- Add diagrams for complex flows
- Update docs/COMMAND_REFERENCE.md when adding or renaming CLI subcommands
# Add to Cargo.toml
cargo add <crate-name>
# Add with specific version
cargo add <crate-name>@1.0.0
# Add with features
cargo add <crate-name> --features feature1,feature2
# Add as dev dependency
cargo add --dev <crate-name># Update all dependencies
cargo update
# Update specific dependency
cargo update <crate-name>
# Check for outdated dependencies
cargo outdated# Run clippy
cargo clippy
# Deny all warnings
cargo clippy -- -D warnings
# Fix automatically (when possible)
cargo clippy --fix# Format all code
cargo fmt
# Check formatting without changing
cargo fmt --check
# Format specific file
rustfmt src/main.rsShell completion scripts are generated by build.rs into the completions/ directory.
# Regenerate completions (bash/zsh/fish)
cargo build# Build docs
cargo doc
# Build and open in browser
cargo doc --open
# Include private items
cargo doc --document-private-items# Run benchmarks
cargo bench
# Run specific benchmark
cargo bench benchmark_name
# Save baseline
cargo bench -- --save-baseline my-baseline
# Compare to baseline
cargo bench -- --baseline my-baselineThe shell command supports connecting to a local Soroban sandbox via Docker:
# Start the interactive shell against a local Docker Soroban sandbox
starforge shell --contract ./target/wasm32-unknown-unknown/release/my_contract.wasm --network docker-testnetWhen --network docker-testnet is used, StarForge:
- Ensures the Docker containers defined in
docker-compose.ymlare running (includesstellar-testnetandsoroban-rpc) - Runs contract invocations inside the Docker network where the Soroban RPC is available at
http://soroban-rpc:8000 - Routes all RPC calls through the local sandbox instead of Stellar testnet
The docker-compose.yml at the project root defines:
- stellar-testnet: A full Stellar + Soroban RPC node on
localhost:8000 - soroban-rpc: Dedicated Soroban RPC endpoint on
localhost:8001
Prerequisites:
- Docker and docker-compose installed
- Docker daemon running
// Add to Cargo.toml
[dependencies]
log = "0.4"
env_logger = "0.10"
// In main.rs
env_logger::init();
// In code
use log::{debug, info, warn, error};
debug!("Debug message: {:?}", value);
info!("Info message");
warn!("Warning message");
error!("Error message");# Enable all debug logs
RUST_LOG=debug cargo run -- wallet list
# Enable specific module
RUST_LOG=starforge::commands::wallet=debug cargo run -- wallet list
# Multiple modules
RUST_LOG=starforge::commands=debug,starforge::utils=info cargo run# Build with debug symbols
cargo build
# Run with gdb
rust-gdb target/debug/starforge
# Set breakpoint
(gdb) break src/main.rs:42
# Run
(gdb) run wallet list
# Step through
(gdb) step
(gdb) next
# Print variable
(gdb) print variable_nameIssue: Compilation errors after updating dependencies
# Solution: Clean and rebuild
cargo clean
cargo buildIssue: Tests failing intermittently
# Solution: Run tests serially
cargo test -- --test-threads=1Issue: Slow compilation
# Solution: Use sccache
cargo install sccache
export RUSTC_WRAPPER=sccache- Update version in
Cargo.toml - Update version in
src/main.rs(if hardcoded) - Update CHANGELOG.md
- Commit:
git commit -m "chore: bump version to X.Y.Z"
# 1. Tag the release
git tag -a v0.2.0 -m "Release v0.2.0"
# 2. Push tag
git push origin v0.2.0
# 3. Build release binaries
cargo build --release
# 4. Create GitHub release
# - Go to GitHub releases
# - Create new release from tag
# - Upload binaries
# - Add release notes- All tests passing
- Documentation updated
- CHANGELOG.md updated
- Version bumped
- Release notes prepared
- Binaries built for all platforms
- GitHub release created
- Announcement posted
// ✅ Use Result for fallible operations
pub fn operation() -> Result<()> {
let data = load_data()?;
process(data)?;
Ok(())
}
// ✅ Add context to errors
load_data()
.with_context(|| "Failed to load configuration")?
// ✅ Create custom error types for complex cases
#[derive(Debug, thiserror::Error)]
pub enum MyError {
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Network error")]
Network(#[from] ureq::Error),
}// ✅ Load config once, pass as reference
let config = config::load()?;
process_with_config(&config)?;
// ❌ Don't reload config repeatedly
fn process() {
let config = config::load().unwrap(); // Bad!
// ...
}// ✅ Provide progress indicators
p::step(1, 3, "Loading configuration...");
p::step(2, 3, "Processing data...");
p::step(3, 3, "Saving results...");
// ✅ Show helpful error messages
anyhow::bail!(
"Wallet '{}' not found.\n\nTry: starforge wallet list",
name
);// ✅ Test edge cases
#[test]
fn test_empty_input() { /* ... */ }
#[test]
fn test_invalid_input() { /* ... */ }
#[test]
fn test_boundary_conditions() { /* ... */ }
// ✅ Use descriptive test names
#[test]
fn creates_wallet_with_encrypted_key_when_encrypt_flag_is_true() {
// ...
}StarForge uses a transactional database migration system to manage schema changes over time. This ensures that database upgrades are safe, reversible, and can be rolled back if needed.
The migration system is implemented in src/utils/database.rs and provides:
- Version tracking: Each schema change has a unique version number
- Transaction safety: Migrations run within SQLite transactions for atomicity
- Rollback capability: Failed migrations can be rolled back to the previous version
- Migration history: All applied migrations are recorded in the
schema_migrationstable - Checksum validation: Each migration has a checksum to detect changes
The current schema version is defined by CURRENT_SCHEMA_VERSION constant in src/utils/database.rs.
When you need to modify the database schema, follow these steps:
-
Increment the schema version
Update
CURRENT_SCHEMA_VERSIONinsrc/utils/database.rs:pub const CURRENT_SCHEMA_VERSION: i64 = 2; // Increment from 1 to 2
-
Implement the migration struct
Add a new migration struct that implements the
Migrationtrait:struct MigrationV2; impl Migration for MigrationV2 { fn version(&self) -> i64 { 2 } fn description(&self) -> &str { "add_wallet_encryption_index" } fn up(&self, conn: &mut Connection) -> Result<()> { // Apply schema changes conn.execute( "CREATE INDEX IF NOT EXISTS idx_wallets_encryption ON wallets(encryption_status)", [], )?; Ok(()) } fn down(&self, conn: &mut Connection) -> Result<()> { // Rollback schema changes conn.execute( "DROP INDEX IF EXISTS idx_wallets_encryption", [], )?; Ok(()) } }
-
Register the migration
Add the migration to the
get_migrationmethod:fn get_migration(&self, version: i64) -> Option<Box<dyn Migration>> { match version { 1 => Some(Box::new(MigrationV1 {})), 2 => Some(Box::new(MigrationV2 {})), // Add this line _ => None, } }
-
Write tests
Add tests for your migration in the
testsmodule:#[test] fn migration_v2_adds_index() { let db = in_memory_db(); let migration = MigrationV2 {}; let mut conn = db.conn; migration.up(&mut conn).unwrap(); // Verify the index exists let index_exists: bool = conn .query_row( "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name='idx_wallets_encryption'", [], |row| row.get(0), ) .unwrap(); assert!(index_exists); }
- Fresh Database: When a new database is created, it starts at the current schema version
- Existing Database: On startup, the system checks the current version and applies any pending migrations
- Migration Application: Each migration runs in a transaction. If it fails, the transaction is rolled back
- Migration Recording: Successful migrations are recorded in
schema_migrationstable - Rollback: The latest migration can be rolled back using
rollback_migration()
Key methods in the Database struct:
get_current_schema_version()- Returns the current schema versionget_applied_migrations()- Returns all applied migrationsrun_migrations()- Applies pending migrations to reach current versionrollback_migration(version)- Rolls back a specific migration
- Always implement
down(): Every migration must have a rollback implementation - Keep migrations idempotent: Use
IF NOT EXISTSand similar patterns - Test thoroughly: Test both
up()anddown()methods - Document changes: Update this guide when adding migrations
- Version monotonicity: Versions must always increase, never reuse old numbers
- Transaction safety: All schema changes should be within the migration transaction
Migration fails to apply:
- Check the error message for specific SQL or logic errors
- Verify the migration's
up()method is correct - Ensure the database is not locked by another process
Rollback fails:
- Ensure you're rolling back the latest migration only
- Check that the
down()method correctly reverses theup()changes - Verify no data constraints prevent rollback
Schema version mismatch:
- Check
CURRENT_SCHEMA_VERSIONmatches your expectations - Verify all migrations are properly registered in
get_migration() - Review the
schema_migrationstable for applied versions
Run migration-specific tests:
cargo test --lib database::tests::migrationTest with a real database:
# Backup your database first
cp ~/.starforge/starforge.db ~/.starforge/starforge.db.backup
# Run the application to trigger migrations
starforge wallet list
# Check the schema version
starforge db stats- Fork the repository
- Create a feature branch
- Make your changes
- Test thoroughly
- Document your changes
- Submit a pull request
- Code follows style guide
- Tests added/updated
- Documentation updated
- Commit messages follow convention
- No merge conflicts
- CI passes
<type>(<scope>): <subject>
<body>
<footer>
Types:
feat: New featurefix: Bug fixdocs: Documentationstyle: Formattingrefactor: Code restructuringtest: Adding testschore: Maintenance
Examples:
feat(wallet): add hardware wallet support
Implements Ledger and Trezor integration for secure key storage.
Closes #123
- rust-analyzer - IDE support
- clippy - Linter
- rustfmt - Formatter
- cargo-edit - Dependency management
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Discord: Join the Stellar Discord
- Email: maintainer@example.com
Happy Coding! 🚀