diff --git a/Cargo.lock b/Cargo.lock index 08f8a660..5c6796bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -808,15 +808,14 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "2.2.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980" dependencies = [ "curve25519-dalek", "ed25519", "serde", "sha2", - "subtle", "zeroize", ] @@ -2324,6 +2323,7 @@ dependencies = [ "rand 0.8.6", "serde", "serde_json", + "sha2", "stellar-strkey", "stellar-xdr", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 648eed58..62f250d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,13 +33,11 @@ stellar-xdr = { version = "22.0.0", features = ["serde"] } base64 = "0.21" urlencoding = "2.1" sha2 = "0.10" -soroban-sdk = { version = "20.5.0" } indicatif = "0.17.7" libloading = "0.8.1" uuid = { version = "1.6.1", features = ["v4"] } hidapi = { version = "2.6.5", optional = true } zxcvbn = "=3.1.0" -sha2 = "0.10" hex = "0.4" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "fmt"] } diff --git a/TEMPLATE_MARKETPLACE.md b/TEMPLATE_MARKETPLACE.md index 30e97350..302dc891 100644 --- a/TEMPLATE_MARKETPLACE.md +++ b/TEMPLATE_MARKETPLACE.md @@ -23,6 +23,36 @@ starforge template list starforge template show uniswap-v2 ``` +#### Relevance ranking, filters and explanations + +Search ranks results by **text relevance** first (name matches outweigh tag +matches, which outweigh description matches), then by quality score, then by +downloads. Each result explains *why* it matched so the list is easy to scan. + +```bash +# Rank by relevance to the query +starforge template search token + +# Require specific tags (a template must have all of them) +starforge template search "" --tags defi,dex + +# Only verified templates +starforge template search wallet --verified + +# Only high-quality templates (score 0-100) +starforge template search defi --min-quality 70 + +# List everything ranked by quality (empty query) +starforge template search +``` + +Each result shows the matched fields and a relevance value, e.g.: + +``` + 1. uniswap-v2@1.0.0 [quality 92/100] βœ“ Verified πŸ“– Documented β˜… Popular (1240 downloads) + Matched: name, tag: defi (relevance 70) +``` + ### 2. Template Usage Scaffold new projects using marketplace templates: @@ -356,6 +386,80 @@ latest version. 4. `template_source_content` reads `src/lib.rs` from the cached directory and returns it to the scaffolding step. +## Quality Signals & Trust Indicators + +To help users identify dependable templates in a growing community catalog, +each template carries lightweight quality metadata that is surfaced across the +`list`, `search` and `show` commands. + +### Metadata fields + +| Field | Meaning | +|---------------|----------------------------------------------------------------| +| `verified` | Template has been vetted by maintainers | +| `documented` | Template ships user-facing documentation (e.g. a README) | +| `maintenance` | Maintenance state: `active`, `maintained`, `deprecated`, `unknown` | +| `downloads` | Usage metadata used as a proxy for community confidence | + +Example registry entry: + +```json +{ + "name": "uniswap-v2", + "version": "1.0.0", + "verified": true, + "documented": true, + "maintenance": "active", + "downloads": 1240 +} +``` + +### Quality score + +Each template is assigned a `0-100` quality score blending the signals above: + +- Verified: `+40` +- Documented: `+20` +- Usage: up to `+30` (scaled by downloads, capped) +- Maintenance: `active +10`, `maintained +5`, `deprecated -25` + +The score drives ranking in `starforge template search` (highest quality +first, with raw downloads breaking ties) and is shown alongside trust badges +such as `βœ“ Verified`, `πŸ“– Documented`, `🟒 Actively maintained` and +`β˜… Popular`. This makes trusted, well-documented and well-maintained templates +easier to discover. + +## Installation Progress & Recovery + +Installing a marketplace template runs through three visible steps, each shown +with a spinner that resolves to a check mark: + +``` +βœ“ Fetched template 'uniswap-v2' +βœ“ Template structure is valid +βœ“ Installed into 'my-dex' +``` + +### Safe rollback + +Installation is **atomic from the user's point of view**: if any step fails the +partially-written files are removed automatically, so you never end up with a +half-installed project directory. + +- The download is staged in a temporary directory that is always cleaned up. +- The target project directory is only kept once every step succeeds; on + failure it is rolled back. + +### Actionable errors + +When a step fails, the error explains what went wrong and how to recover, e.g.: + +``` +Failed to fetch template 'uniswap-v2' from git:https://github.com/... + β€’ Check your network connection and that `git` is installed. + β€’ The partial download was rolled back automatically. +``` + ## Support For issues or questions: diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index 62a74085..e138a720 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -1,4 +1,3 @@ -use crate::utils::{config, horizon, optimizer, print as p, info, soroban}; use crate::commands::info; use crate::utils::{config, horizon, optimizer, print as p, soroban}; use anyhow::Result; diff --git a/src/commands/network.rs b/src/commands/network.rs index e9fe43dc..f1430a3c 100644 --- a/src/commands/network.rs +++ b/src/commands/network.rs @@ -37,8 +37,6 @@ pub fn handle(cmd: NetworkCommands) -> Result<()> { match cmd { NetworkCommands::Show => show(), NetworkCommands::Switch { network } => switch(network), - NetworkCommands::Add { name, horizon_url, soroban_rpc_url, friendbot_url } => - add_network(name, horizon_url, soroban_rpc_url, friendbot_url), NetworkCommands::Add { name, horizon_url, diff --git a/src/commands/new.rs b/src/commands/new.rs index 28122f96..3c421232 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -81,7 +81,7 @@ fn search_templates(query: &str) -> Result<()> { for (i, entry) in results.iter().enumerate() { println!(" {:>2}. {}@{}", i + 1, entry.name, entry.version); p::kv("Description", &entry.description); - p::kv("Source", &entry.source); + p::kv("Source", &entry.source.to_string()); if !entry.tags.is_empty() { p::kv("Tags", &entry.tags.join(", ")); } @@ -191,12 +191,18 @@ fn scaffold_contract( ) -> Result<()> { let dir = Path::new(&name); if dir.exists() { - anyhow::bail!("Directory '{}' already exists", name); + anyhow::bail!( + "Directory '{}' already exists.\n β€’ Choose a different project name, or remove the existing directory first.", + name + ); } p::header(&format!("Scaffolding Soroban contract: {}", name)); println!(" Template: {}\n", template.cyan()); + // Roll back the partially-created directory if any step below fails. + let mut target_guard = PathCleanup::new(dir.to_path_buf()); + p::step(1, 4, "Creating directory structure…"); fs::create_dir_all(dir.join("src"))?; fs::create_dir_all(dir.join(".cargo"))?; @@ -229,6 +235,9 @@ fn scaffold_contract( p::step(4, 4, "Writing README.md…"); fs::write(dir.join("README.md"), readme(&name, &template, source))?; + // Scaffolding completed: keep the directory. + target_guard.commit(); + println!(); p::success(&format!("Contract '{}' scaffolded!", name)); println!(); @@ -260,15 +269,10 @@ fn scaffold_dapp(name: String) -> Result<()> { p::step(3, 3, "Writing app scaffold…"); fs::write(dir.join("index.html"), dapp_index(&name))?; - fs::write(dir.join("src/main.jsx"), dapp_main(typescript, wallet_kit))?; + fs::write(dir.join("src/main.jsx"), dapp_main())?; fs::write(dir.join("src/App.jsx"), dapp_app(&name))?; fs::write(dir.join(".gitignore"), "node_modules/\ndist/\n")?; fs::write(dir.join("README.md"), dapp_readme(&name))?; - fs::write(dir.join("index.html"), dapp_index(&name))?; - fs::write(dir.join("src/main.jsx"), dapp_main())?; - fs::write(dir.join("src/App.jsx"), dapp_app(&name))?; - fs::write(dir.join(".gitignore"), "node_modules/\ndist/\n")?; - fs::write(dir.join("README.md"), dapp_readme(&name))?; println!(); p::success(&format!("dApp '{}' scaffolded!", name)); @@ -751,24 +755,6 @@ mod test {{ // ── dApp scaffold files ─────────────────────────────────────────────────────── -fn dapp_package(name: &str, typescript: bool, wallet_kit: bool) -> String { - let env_block = if wallet_kit { - " \"VITE_NETWORK\": \"testnet\"\n" - } else { - " \"VITE_NETWORK\": \"testnet\"\n" - }; - let wallet_deps = if wallet_kit { - "\n \"@creit.tech/stellar-wallets-kit\": \"^0.1.0\"," - } else { - "" - }; - let ts_deps = if typescript { - ",\n \"typescript\": \"^5.0.0\"" - } else { - "" - }; - format!( - r#"{{ fn dapp_package(name: &str) -> String { format!(r#"{{ "name": "{name}", @@ -824,16 +810,6 @@ fn dapp_vite_env_types(wallet_kit: bool) -> String { } } -fn dapp_main(typescript: bool, wallet_kit: bool) -> String { - let app_import = if typescript { "./App.tsx" } else { "./App.jsx" }; - let root_el = if typescript { - "document.getElementById('root')!" - } else { - "document.getElementById('root')" - }; - - let mut out = format!( - r#"import React from 'react' fn dapp_main() -> &'static str { r#"import React from 'react' import ReactDOM from 'react-dom/client' @@ -962,52 +938,152 @@ fn handle_template_search(query: &str, tags: Option<&str>) -> Result<()> { Ok(()) } +/// RAII guard that removes a filesystem path when dropped, unless it has been +/// committed. This gives clean rollback for partial template installs: if any +/// step fails and the function returns early, the partially-written directory +/// is removed automatically while the error unwinds. +struct PathCleanup { + path: PathBuf, + committed: bool, +} + +impl PathCleanup { + fn new(path: PathBuf) -> Self { + Self { + path, + committed: false, + } + } + + /// Keep the directory instead of removing it on drop. + fn commit(&mut self) { + self.committed = true; + } +} + +impl Drop for PathCleanup { + fn drop(&mut self) { + if !self.committed && self.path.exists() { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +/// Run a single install step behind a spinner, finishing with a check mark on +/// success or clearing the spinner and attaching an actionable message on +/// failure. +fn install_step( + label: &str, + done: &str, + action: impl FnOnce() -> Result, + err_context: impl FnOnce() -> String, +) -> Result { + let pb = p::spinner(label); + match action() { + Ok(value) => { + pb.finish_with_message(format!("βœ“ {}", done)); + Ok(value) + } + Err(e) => { + pb.finish_and_clear(); + Err(e).with_context(err_context) + } + } +} + fn scaffold_from_marketplace(name: String, template_name: String) -> Result<()> { p::header(&format!("Scaffolding from Marketplace: {}", template_name)); - + // Get template from registry - let template = templates::get_template(&template_name) - .with_context(|| format!("Template '{}' not found. Try: starforge new contract --search {}", - template_name, template_name))?; - + let template = templates::get_template(&template_name).with_context(|| { + format!( + "Template '{}' not found in the registry.\n β€’ List templates with `starforge template list`.\n β€’ Search with `starforge new contract --search {}`.", + template_name, template_name + ) + })?; + let dir = Path::new(&name); if dir.exists() { - anyhow::bail!("Directory '{}' already exists", name); + anyhow::bail!( + "Directory '{}' already exists.\n β€’ Choose a different project name, or remove the existing directory first.", + name + ); } - + p::separator(); p::kv("Template", &template.name); p::kv("Version", &template.version); p::kv("Author", &template.author); p::kv("Description", &template.description); p::separator(); - println!(); - p::step(1, 3, "Fetching template..."); - - // Create temporary directory for template - let temp_dir = std::env::temp_dir().join(format!("starforge-template-{}", uuid::Uuid::new_v4())); - templates::fetch_template(&template, &temp_dir)?; - - p::step(2, 3, "Validating template structure..."); - templates::validate_template_structure(&temp_dir)?; - - p::step(3, 3, "Copying template to project directory..."); - - // Copy template to target directory - fs::create_dir_all(dir)?; - copy_template_contents(&temp_dir, dir, &name)?; - - // Clean up temp directory - fs::remove_dir_all(&temp_dir).ok(); - - // Update download count - let mut registry = templates::load_registry()?; - if let Some(entry) = registry.templates.iter_mut().find(|t| t.name == template.name) { - entry.downloads += 1; - templates::save_registry(®istry)?; + + // Stage the download in a temporary directory. The guard guarantees the + // temp dir is removed whether the install succeeds or fails. + let temp_dir = + std::env::temp_dir().join(format!("starforge-template-{}", uuid::Uuid::new_v4())); + let temp_guard = PathCleanup::new(temp_dir.clone()); + + // The target project directory is only kept if every step succeeds. + let mut target_guard = PathCleanup::new(dir.to_path_buf()); + + install_step( + &format!("[1/3] Fetching template '{}'…", template.name), + &format!("Fetched template '{}'", template.name), + || templates::fetch_template(&template, &temp_dir), + || { + format!( + "Failed to fetch template '{}' from {}.\n β€’ Check your network connection and that `git` is installed.\n β€’ The partial download was rolled back automatically.", + template.name, template.source + ) + }, + )?; + + install_step( + "[2/3] Validating template structure…", + "Template structure is valid", + || templates::validate_template_structure(&temp_dir), + || { + format!( + "Template '{}' is missing required files (expected Cargo.toml, src/ and src/lib.rs).\n β€’ The template may be malformed; contact its author or pick another.\n β€’ The partial install was rolled back automatically.", + template.name + ) + }, + )?; + + install_step( + "[3/3] Installing into project directory…", + &format!("Installed into '{}'", name), + || { + fs::create_dir_all(dir).with_context(|| { + format!("Failed to create project directory '{}'", dir.display()) + })?; + copy_template_contents(&temp_dir, dir, &name) + }, + || { + format!( + "Failed to install template into '{}'.\n β€’ Check that you have write permission for this location and enough disk space.\n β€’ The half-written project directory was rolled back automatically.", + name + ) + }, + )?; + + // Everything succeeded: keep the project directory; the temp dir is removed + // by its guard when this function returns. + target_guard.commit(); + drop(temp_guard); + + // Update download count (best-effort; failure here must not roll back a + // successfully installed project). + if let Ok(mut registry) = templates::load_registry() { + if let Some(entry) = registry.templates.iter_mut().find(|t| t.name == template.name) { + entry.downloads += 1; + if let Err(e) = templates::save_registry(®istry) { + p::warn(&format!("Installed, but could not update download count: {}", e)); + } + } } - + println!(); p::success(&format!("Contract '{}' scaffolded from marketplace!", name)); println!(); @@ -1019,7 +1095,7 @@ fn scaffold_from_marketplace(name: String, template_name: String) -> Result<()> name.replace('-', "_") )); println!(); - + Ok(()) } @@ -1047,10 +1123,46 @@ fn copy_template_contents(src: &Path, dst: &Path, project_name: &str) -> Result< content = content.replace("{{PROJECT_NAME}}", project_name); content = content.replace("{{PROJECT_NAME_SNAKE}}", &project_name.replace('-', "_")); content = content.replace("{{PROJECT_NAME_PASCAL}}", &to_pascal(project_name)); - + fs::write(&dest_path, content)?; } } - + Ok(()) } + +#[cfg(test)] +mod install_tests { + use super::PathCleanup; + use std::fs; + + #[test] + fn cleanup_removes_dir_when_not_committed() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("partial-install"); + fs::create_dir_all(dir.join("src")).unwrap(); + fs::write(dir.join("src/lib.rs"), "partial").unwrap(); + assert!(dir.exists()); + + { + let _guard = PathCleanup::new(dir.clone()); + // guard dropped here without commit -> directory should be removed + } + + assert!(!dir.exists(), "uncommitted install should be rolled back"); + } + + #[test] + fn cleanup_keeps_dir_when_committed() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("good-install"); + fs::create_dir_all(&dir).unwrap(); + + { + let mut guard = PathCleanup::new(dir.clone()); + guard.commit(); + } + + assert!(dir.exists(), "committed install should be kept"); + } +} diff --git a/src/commands/template.rs b/src/commands/template.rs index a8679c86..6f96aeae 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -10,11 +10,18 @@ use colored::Colorize; pub enum TemplateCommands { /// Search for templates in the marketplace Search { - /// Search query (matches name, description, or tags) + /// Search query (matches name, description, or tags). Use "" to list all. + #[arg(default_value = "")] query: String, - /// Filter by tags (comma-separated) + /// Filter by tags (comma-separated); a template must have all of them #[arg(long)] tags: Option, + /// Only show verified templates + #[arg(long)] + verified: bool, + /// Only show templates with at least this quality score (0-100) + #[arg(long, default_value_t = 0)] + min_quality: u8, /// Force refresh of remote registry, ignoring cached copy #[arg(long)] refresh: bool, @@ -61,7 +68,9 @@ pub fn handle(cmd: TemplateCommands) -> Result<()> { publish(path, name, description, author, tags, version) } TemplateCommands::List => list(), - TemplateCommands::Search { query, tags, refresh } => search(query, tags, refresh), + TemplateCommands::Search { query, tags, verified, min_quality, refresh } => { + search(query, tags, verified, min_quality, refresh) + } TemplateCommands::Show { name } => show(name), TemplateCommands::Remove { name } => remove(name), TemplateCommands::Init => init(), @@ -109,7 +118,7 @@ fn publish( p::success("Template registered successfully"); p::kv_accent("Name", &template.name); p::kv("Version", &template.version); - p::kv("Source", &template.source); + p::kv("Source", &template.source.to_string()); if !template.tags.is_empty() { p::kv("Tags", &template.tags.join(", ")); } @@ -129,9 +138,22 @@ fn list() -> Result<()> { } for (i, template) in registry.templates.iter().enumerate() { - println!(" {:>2}. {}@{}", i + 1, template.name, template.version); + let badges = template.trust_indicators(); + let badge_suffix = if badges.is_empty() { + String::new() + } else { + format!(" {}", badges.join(" ")) + }; + println!( + " {:>2}. {}@{} [quality {}/100]{}", + i + 1, + template.name, + template.version, + template.quality_score(), + badge_suffix + ); p::kv("Description", &template.description); - p::kv("Source", &template.source); + p::kv("Source", &template.source.to_string()); if !template.tags.is_empty() { p::kv("Tags", &template.tags.join(", ")); } @@ -146,41 +168,96 @@ fn list() -> Result<()> { Ok(()) } -fn search(query: String, tags: Option, refresh: bool) -> Result<()> { - // Determine tags filter if provided (comma-separated) - let tag_vec: Option> = tags.as_ref().map(|t| t.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()); +fn search( + query: String, + tags: Option, + verified: bool, + min_quality: u8, + refresh: bool, +) -> Result<()> { + let tag_list: Vec = tags + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + let filters = templates::SearchFilters { + tags: tag_list, + verified_only: verified, + min_quality, + }; - // Load registry, optionally forcing a refresh + // Load registry, optionally forcing a refresh of the remote copy. let results = if refresh { - // Temporarily set env var to force refresh std::env::set_var("STARFORGE_TEMPLATE_REGISTRY_FORCE_REFRESH", "1"); - let res = templates::search_templates(&query, tag_vec.as_ref().map(|v| &v[..])); + let res = templates::search_templates_ranked(&query, &filters); std::env::remove_var("STARFORGE_TEMPLATE_REGISTRY_FORCE_REFRESH"); res? } else { - templates::search_templates(&query, tag_vec.as_ref().map(|v| &v[..]))? + templates::search_templates_ranked(&query, &filters)? + }; + + let heading = if query.trim().is_empty() { + "Template search results".to_string() + } else { + format!("Template search results for '{}'", query) }; + p::header(&heading); + + // Summarize the active filters so users understand the result set. + let mut active_filters = Vec::new(); + if !filters.tags.is_empty() { + active_filters.push(format!("tags: {}", filters.tags.join(", "))); + } + if filters.verified_only { + active_filters.push("verified only".to_string()); + } + if filters.min_quality > 0 { + active_filters.push(format!("min quality: {}", filters.min_quality)); + } + if !active_filters.is_empty() { + p::kv("Filters", &active_filters.join(" | ")); + } -fn search(query: String, tags: Option) -> Result<()> { - let tag_list: Option> = 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 results.is_empty() { - p::info("No templates matched that query."); + p::info("No templates matched. Try a broader query or relaxing the filters."); return Ok(()); } - for (i, template) in results.iter().enumerate() { - println!(" {:>2}. {}@{}", i + 1, template.name, template.version); + p::kv("Matches", &results.len().to_string()); + println!(); + + for (i, result) in results.iter().enumerate() { + let template = &result.entry; + let badges = template.trust_indicators(); + let badge_suffix = if badges.is_empty() { + String::new() + } else { + format!(" {}", badges.join(" ")) + }; + println!( + " {:>2}. {}@{} [quality {}/100]{}", + i + 1, + template.name, + template.version, + template.quality_score(), + badge_suffix + ); p::kv("Description", &template.description); p::kv("Downloads", &template.downloads.to_string()); - p::kv("Source", &template.source.to_string()); - p::kv("Source", &template.source); + p::kv("Maintenance", template.maintenance.label()); if !template.tags.is_empty() { p::kv("Tags", &template.tags.join(", ")); } + // Explain why this result matched, helping users scan the list. + if !result.reasons.is_empty() { + p::kv( + "Matched", + &format!("{} (relevance {})", result.reasons.join(", "), result.relevance), + ); + } + p::kv("Source", &template.source.to_string()); if i + 1 < results.len() { println!(); } @@ -194,16 +271,37 @@ fn show(name: String) -> Result<()> { p::header(&format!("Template: {}", template.name)); p::kv("Version", &template.version); p::kv("Description", &template.description); - p::kv("Source", &template.source); + p::kv("Source", &template.source.to_string()); if !template.author.is_empty() { p::kv("Author", &template.author); } if !template.tags.is_empty() { p::kv("Tags", &template.tags.join(", ")); } + print_quality_signals(&template); Ok(()) } +/// Render the quality / trust signals for a template so users can quickly +/// gauge how dependable it is. +fn print_quality_signals(template: &templates::TemplateEntry) { + p::kv("Quality score", &format!("{}/100", template.quality_score())); + p::kv("Maintenance", template.maintenance.label()); + p::kv( + "Documentation", + if template.documented { + "Available" + } else { + "Not provided" + }, + ); + p::kv("Downloads", &template.downloads.to_string()); + let badges = template.trust_indicators(); + if !badges.is_empty() { + p::kv("Trust signals", &badges.join(" ")); + } +} + fn remove(name: String) -> Result<()> { templates::remove_template(&name)?; p::success(&format!("Template '{}' removed", name)); diff --git a/src/commands/tx.rs b/src/commands/tx.rs index 94c8298a..b3c11190 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -503,8 +503,6 @@ fn handle_history(args: HistoryArgs) -> Result<()> { let filter = horizon::TxFilter { limit, cursor: args.cursor, - order: None, - type_filter: None, after: args.after, before: args.before, order: None, diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index c50a47b5..83fc8cdc 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -304,13 +304,10 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { fund, network, encrypt, - } => rotate_wallet(name, fund, network, encrypt), - WalletCommands::Export { name, all, output } => export_wallet(name, all, output), - WalletCommands::Import { file } => import_wallets(file), mem, iterations, } => rotate_wallet(name, fund, network, encrypt, mem, iterations), - WalletCommands::Export { name, output } => export_wallet(name, output), + WalletCommands::Export { name, all, output } => export_wallet(name, all, output), WalletCommands::Import { name, file, @@ -1095,7 +1092,7 @@ fn export_wallet(name_opt: Option, all: bool, output: PathBuf) -> Result let json = serde_json::to_string_pretty(&backup) .with_context(|| "Failed to serialize wallet backup")?; let passphrase = crypto::prompt_passphrase("Enter passphrase to encrypt backup", false)?; - let encrypted = crypto::encrypt_secret(&passphrase, &json)?; + let encrypted = crypto::encrypt_secret(&passphrase, &json, None)?; fs::write(&output, encrypted) .with_context(|| format!("Failed to write {}", output.display()))?; diff --git a/src/main.rs b/src/main.rs index 47af7427..d125d916 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ mod commands; pub mod plugins; mod utils; -mod info; use clap::{Parser, Subcommand}; use colored::*; diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index f64b0a5b..715df412 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -87,6 +87,7 @@ impl PassphraseStrength { } /// Result of a passphrase strength evaluation. +#[derive(Debug)] pub struct StrengthReport { pub strength: PassphraseStrength, /// First suggestion from zxcvbn, if any. diff --git a/src/utils/templates.rs b/src/utils/templates.rs index fa67f9ee..12476891 100644 --- a/src/utils/templates.rs +++ b/src/utils/templates.rs @@ -41,12 +41,42 @@ impl std::fmt::Display for TemplateSource { } } +/// Maintenance state of a marketplace template. +/// +/// Surfaced to users as a lightweight trust signal so they can tell at a +/// glance whether a template is being kept up to date or has been abandoned. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MaintenanceStatus { + /// Updated recently and accepting changes. + Active, + /// Stable and still supported, but not under active development. + Maintained, + /// No longer maintained; use with caution. + Deprecated, + /// Maintenance state has not been declared. + #[default] + Unknown, +} + +impl MaintenanceStatus { + /// Short human-readable label used in trust indicators. + pub fn label(&self) -> &'static str { + match self { + MaintenanceStatus::Active => "Actively maintained", + MaintenanceStatus::Maintained => "Maintained", + MaintenanceStatus::Deprecated => "Deprecated", + MaintenanceStatus::Unknown => "Unknown maintenance", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TemplateEntry { pub name: String, pub description: String, pub version: String, - pub source: String, + pub source: TemplateSource, #[serde(default)] pub tags: Vec, #[serde(default)] @@ -61,9 +91,76 @@ pub struct TemplateEntry { pub created_at: String, #[serde(default)] pub updated_at: String, + /// Whether the template ships user-facing documentation (e.g. a README). + #[serde(default)] + pub documented: bool, + /// Declared maintenance state of the template. + #[serde(default)] + pub maintenance: MaintenanceStatus, +} + +impl TemplateEntry { + /// Compute a 0-100 quality/trust score from the available signals. + /// + /// The score blends verification status, documentation, usage (downloads) + /// and maintenance state so that dependable templates rank higher and are + /// easier to discover in a growing community catalog. + pub fn quality_score(&self) -> u8 { + let mut score: i32 = 0; + + // Verified templates have been vetted β€” the strongest trust signal. + if self.verified { + score += 40; + } + + // Documentation makes a template far easier to adopt. + if self.documented { + score += 20; + } + + // Usage is a proxy for community confidence (capped so a single + // wildly-popular template cannot dominate the ranking). + score += (self.downloads / 50).min(30) as i32; + + // Maintenance state rewards living projects and penalizes dead ones. + score += match self.maintenance { + MaintenanceStatus::Active => 10, + MaintenanceStatus::Maintained => 5, + MaintenanceStatus::Deprecated => -25, + MaintenanceStatus::Unknown => 0, + }; + + score.clamp(0, 100) as u8 + } + + /// Human-readable trust/quality badges suitable for display to users. + pub fn trust_indicators(&self) -> Vec { + let mut badges = Vec::new(); + + if self.verified { + badges.push("βœ“ Verified".to_string()); + } + if self.documented { + badges.push("πŸ“– Documented".to_string()); + } + + match self.maintenance { + MaintenanceStatus::Active => badges.push("🟒 Actively maintained".to_string()), + MaintenanceStatus::Maintained => badges.push("🟑 Maintained".to_string()), + MaintenanceStatus::Deprecated => badges.push("⚠ Deprecated".to_string()), + MaintenanceStatus::Unknown => {} + } + + if self.downloads >= 1000 { + badges.push(format!("β˜… Popular ({} downloads)", self.downloads)); + } + + badges + } } #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] struct TemplateManifest { name: Option, description: Option, @@ -123,7 +220,7 @@ pub fn fetch_template_cached(entry: &TemplateEntry, force_refresh: bool) -> Resu } } - fetch_git_template(&entry.source, None, &dest)?; + fetch_template(entry, &dest)?; Ok(dest) } @@ -185,7 +282,7 @@ pub fn load_registry() -> Result { // Either forced refresh or cache is missing/old – attempt to fetch remote. match fetch_and_cache_remote(&remote_url) { Ok(registry) => Ok(registry), - Err(fetch_err) => { + Err(_fetch_err) => { // If the remote fetch failed but a cached registry exists, fall back to it. if cache_path.exists() { let contents = fs::read_to_string(&cache_path).with_context(|| { @@ -195,15 +292,13 @@ pub fn load_registry() -> Result { .with_context(|| "Failed to parse cached template registry")?; return Ok(registry); } - // If no cache is available, propagate the fetch error. - Err(fetch_err) + // No cache available – fall back to the registry bundled with the binary + // so the marketplace still works offline on a fresh install. + let registry: TemplateRegistry = serde_json::from_str(DEFAULT_REGISTRY) + .with_context(|| "Failed to parse bundled default template registry")?; + Ok(registry) } } - let contents = fs::read_to_string(&path) - .with_context(|| format!("Failed to read registry at {}", path.display()))?; - let registry: TemplateRegistry = serde_json::from_str(&contents) - .with_context(|| "Failed to parse template registry")?; - Ok(registry) } pub fn save_registry(registry: &TemplateRegistry) -> Result<()> { @@ -252,43 +347,142 @@ fn fetch_and_cache_remote(url: &str) -> Result { Ok(registry) } -pub fn search_templates(query: &str, tags: Option<&[String]>) -> Result> { +/// Filters applied on top of a text query when searching the marketplace. +#[derive(Debug, Clone, Default)] +pub struct SearchFilters { + /// Templates must carry all of these tags (case-insensitive). + pub tags: Vec, + /// Only include templates flagged as verified. + pub verified_only: bool, + /// Only include templates whose quality score is at least this value. + pub min_quality: u8, +} + +/// A single ranked search result, carrying the matched template alongside the +/// information needed to explain *why* it matched and *how* it ranked. +#[derive(Debug, Clone)] +pub struct SearchResult { + pub entry: TemplateEntry, + /// Text-relevance score for the query (0 when the query is empty). + pub relevance: u32, + /// Human-readable reasons the template matched the query. + pub reasons: Vec, +} + +/// Compute the text-relevance of a template for a query, returning the score +/// and the reasons it matched. Field weighting (name > tags > description) +/// makes the most meaningful matches rank highest. +fn relevance_for(entry: &TemplateEntry, query_lower: &str) -> (u32, Vec) { + if query_lower.is_empty() { + return (0, Vec::new()); + } + + let mut score = 0u32; + let mut reasons = Vec::new(); + + let name_lower = entry.name.to_lowercase(); + if name_lower == query_lower { + score += 100; + reasons.push("exact name".to_string()); + } else if name_lower.starts_with(query_lower) { + score += 60; + reasons.push("name prefix".to_string()); + } else if name_lower.contains(query_lower) { + score += 40; + reasons.push("name".to_string()); + } + + for tag in &entry.tags { + let tag_lower = tag.to_lowercase(); + if tag_lower == query_lower { + score += 30; + reasons.push(format!("tag: {}", tag)); + } else if tag_lower.contains(query_lower) { + score += 15; + reasons.push(format!("tag ~ {}", tag)); + } + } + + if entry.description.to_lowercase().contains(query_lower) { + score += 10; + reasons.push("description".to_string()); + } + + (score, reasons) +} + +/// Search the marketplace with relevance ranking, filtering and per-result +/// match explanations. +/// +/// Results are ordered by text relevance first, then by overall quality score +/// (verification, documentation, usage, maintenance), then by raw downloads. +/// An empty query lists every template that satisfies the filters, ranked by +/// quality alone. +pub fn search_templates_ranked( + query: &str, + filters: &SearchFilters, +) -> Result> { let registry = load_registry()?; - let query_lower = query.to_lowercase(); - - let mut results: Vec = registry + let query_lower = query.trim().to_lowercase(); + + let mut results: Vec = registry .templates .into_iter() - .filter(|t| { - let name_match = t.name.to_lowercase().contains(&query_lower); - let desc_match = t.description.to_lowercase().contains(&query_lower); - let tag_match = t.tags.iter().any(|tag| tag.to_lowercase().contains(&query_lower)); - - let text_match = name_match || desc_match || tag_match; - - if let Some(filter_tags) = tags { - let has_all_tags = filter_tags.iter().all(|ft| { - t.tags.iter().any(|t| t.eq_ignore_ascii_case(ft)) - }); - text_match && has_all_tags - } else { - text_match + .filter_map(|entry| { + // Apply structured filters first β€” they are independent of the text query. + let has_all_tags = filters + .tags + .iter() + .all(|ft| entry.tags.iter().any(|t| t.eq_ignore_ascii_case(ft))); + if !has_all_tags { + return None; + } + if filters.verified_only && !entry.verified { + return None; + } + if entry.quality_score() < filters.min_quality { + return None; } + + let (relevance, reasons) = relevance_for(&entry, &query_lower); + // When a text query is supplied, drop templates that do not match it. + if !query_lower.is_empty() && relevance == 0 { + return None; + } + + Some(SearchResult { + entry, + relevance, + reasons, + }) }) .collect(); - - // Sort by downloads (popularity) and verified status + + // Rank by relevance, then quality, then downloads. This keeps the most + // pertinent matches at the top while still favouring trusted, well- + // documented and well-maintained templates. results.sort_by(|a, b| { - match (a.verified, b.verified) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => b.downloads.cmp(&a.downloads), - } + b.relevance + .cmp(&a.relevance) + .then_with(|| b.entry.quality_score().cmp(&a.entry.quality_score())) + .then_with(|| b.entry.downloads.cmp(&a.entry.downloads)) }); - + Ok(results) } +/// Backwards-compatible search returning just the ranked template entries. +pub fn search_templates(query: &str, tags: Option<&[String]>) -> Result> { + let filters = SearchFilters { + tags: tags.map(|t| t.to_vec()).unwrap_or_default(), + ..Default::default() + }; + Ok(search_templates_ranked(query, &filters)? + .into_iter() + .map(|r| r.entry) + .collect()) +} + pub fn get_template(name: &str) -> Result { let registry = load_registry()?; registry @@ -336,78 +530,9 @@ fn semver_cmp(a: &str, b: &str) -> std::cmp::Ordering { parse_version(a).cmp(&parse_version(b)) } -pub fn template_source_content(name: &str) -> Result> { - let entry = match get_template(name) { - Ok(entry) => entry, - Err(_) => return Ok(None), - }; - - let content = match &entry.source { - serde_json::Value::Object(obj) => { - if let Some(type_val) = obj.get("type").and_then(|v| v.as_str()) { - match type_val { - "builtin" => { - if let Some(id) = obj.get("id").and_then(|v| v.as_str()) { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("templates") - .join("examples") - .join(id) - .join("src") - .join("lib.rs"); - if path.exists() { - Some(fs::read_to_string(&path).with_context(|| { - format!( - "Failed to read built-in template at {}", - path.display() - ) - })?) - } else { - None - } - } else { - None - } - } - "local" => { - if let Some(path_val) = obj.get("path").and_then(|v| v.as_str()) { - let lib_rs = Path::new(path_val).join("src").join("lib.rs"); - if lib_rs.exists() { - Some(fs::read_to_string(&lib_rs).with_context(|| { - format!( - "Failed to read template source at {}", - lib_rs.display() - ) - })?) - } else { - None - } - } else { - None - } - } - _ => None, - } - } else { - None - } - } - _ => None, - }; - - Ok(content) -} - pub fn add_template(entry: TemplateEntry) -> Result<()> { let mut registry = load_registry()?; - if let Some(existing) = registry - .templates - .iter_mut() - .find(|t| t.name == entry.name && t.version == entry.version) - { -pub fn add_template(entry: TemplateEntry) -> Result<()> { - let mut registry = load_registry()?; - // Check if template already exists if let Some(existing) = registry.templates.iter_mut().find(|t| t.name == entry.name) { // Update existing template @@ -416,7 +541,7 @@ pub fn add_template(entry: TemplateEntry) -> Result<()> { // Add new template registry.templates.push(entry); } - + save_registry(®istry)?; Ok(()) } @@ -438,72 +563,42 @@ pub fn update_template(name: &str) -> Result<()> { let entry = get_template(name)?; match &entry.source { - serde_json::Value::Object(obj) => { - if let Some(type_val) = obj.get("type").and_then(|v| v.as_str()) { - match type_val { - "git" => { - if let Some(url) = obj.get("url").and_then(|v| v.as_str()) { - let branch = obj.get("branch").and_then(|v| v.as_str()); - let dest = std::env::temp_dir().join(&entry.name); - if dest.exists() { - fs::remove_dir_all(&dest).ok(); - } - fetch_git_template(url, branch, &dest)?; - } - } - _ => anyhow::bail!( - "Template source type '{}' does not support updates", - type_val - ), - } + TemplateSource::Git { url, branch } => { + let dest = std::env::temp_dir().join(&entry.name); + if dest.exists() { + fs::remove_dir_all(&dest).ok(); } + fetch_git_template(url, branch.as_deref(), &dest)?; + Ok(()) } - _ => {} + other => anyhow::bail!("Template source '{}' does not support updates", other), } - - Ok(()) } -#[allow(dead_code)] +/// Fetch a template's files into `dest` according to its source type. pub fn fetch_template(entry: &TemplateEntry, dest: &Path) -> Result<()> { match &entry.source { - serde_json::Value::Object(obj) => { - if let Some(type_val) = obj.get("type").and_then(|v| v.as_str()) { - match type_val { - "git" => { - let url = obj - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Git URL not found"))?; - let branch = obj.get("branch").and_then(|v| v.as_str()); - fetch_git_template(url, branch, dest) - } - "local" => { - let path = obj - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Local path not found"))?; - fetch_local_template(Path::new(path), dest) - } - "builtin" => { - anyhow::bail!("Built-in template should be handled separately") - } - _ => anyhow::bail!("Unknown template source type"), - } - } else { - anyhow::bail!("Template source type not specified") - } - } - _ => anyhow::bail!("Invalid template source"), -pub fn fetch_template(entry: &TemplateEntry, dest: &Path) -> Result<()> { - let source = &entry.source; - if source.starts_with("http://") || source.starts_with("https://") || source.starts_with("git@") { - fetch_git_template(source, None, dest) - } else if !source.is_empty() { - fetch_local_template(Path::new(source), dest) - } else { - anyhow::bail!("Template '{}' has no source configured", entry.name) + TemplateSource::Git { url, branch } => fetch_git_template(url, branch.as_deref(), dest), + TemplateSource::Local { path } => fetch_local_template(Path::new(path), dest), + TemplateSource::Builtin { id } => fetch_builtin_template(id, dest), + } +} + +/// Copy a built-in example template (shipped under `templates/examples/`) +/// into `dest`. +fn fetch_builtin_template(id: &str, dest: &Path) -> Result<()> { + let src = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("templates") + .join("examples") + .join(id); + if !src.exists() { + anyhow::bail!( + "Built-in template '{}' was not found at {}", + id, + src.display() + ); } + fetch_local_template(&src, dest) } fn fetch_git_template(url: &str, branch: Option<&str>, dest: &Path) -> Result<()> { @@ -601,12 +696,16 @@ pub fn publish_template( description, author, tags, - source: dest.to_string_lossy().to_string(), + source: TemplateSource::Local { + path: dest.to_string_lossy().to_string(), + }, path: Some(dest.to_string_lossy().to_string()), downloads: 0, verified: false, created_at: String::new(), updated_at: String::new(), + documented: template_path.join("README.md").exists(), + maintenance: MaintenanceStatus::Active, }; add_template(entry)?; @@ -647,12 +746,17 @@ mod tests { description: "Uniswap V2 DEX implementation".to_string(), author: "DeFi Team".to_string(), tags: vec!["defi".to_string(), "dex".to_string(), "amm".to_string()], - source: "https://github.com/example/uniswap-v2.git".to_string(), + source: TemplateSource::Git { + url: "https://github.com/example/uniswap-v2.git".to_string(), + branch: None, + }, path: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), downloads: 100, verified: true, + documented: true, + maintenance: MaintenanceStatus::Active, }); // Test name search @@ -678,7 +782,10 @@ mod tests { let entry = TemplateEntry { name: "my-template".to_string(), - source: "https://example.com/repo.git".to_string(), + source: TemplateSource::Git { + url: "https://example.com/repo.git".to_string(), + branch: None, + }, description: String::new(), version: "1.0.0".to_string(), tags: vec![], @@ -688,6 +795,8 @@ mod tests { verified: false, created_at: String::new(), updated_at: String::new(), + documented: false, + maintenance: MaintenanceStatus::Unknown, }; // When the dest already exists, fetch_template_cached returns it without re-cloning. @@ -714,6 +823,113 @@ mod tests { assert!(!cache_dir.exists(), "old cache dir should be gone after force_refresh"); } + fn sample_entry() -> TemplateEntry { + TemplateEntry { + name: "sample".to_string(), + version: "1.0.0".to_string(), + description: String::new(), + author: String::new(), + tags: vec![], + source: TemplateSource::Builtin { + id: "sample".to_string(), + }, + path: None, + created_at: String::new(), + updated_at: String::new(), + downloads: 0, + verified: false, + documented: false, + maintenance: MaintenanceStatus::Unknown, + } + } + + #[test] + fn quality_score_rewards_trust_signals() { + let bare = sample_entry(); + assert_eq!(bare.quality_score(), 0); + + let mut trusted = sample_entry(); + trusted.verified = true; + trusted.documented = true; + trusted.maintenance = MaintenanceStatus::Active; + trusted.downloads = 2000; + // 40 (verified) + 20 (documented) + 30 (downloads cap) + 10 (active) + assert_eq!(trusted.quality_score(), 100); + + let mut deprecated = sample_entry(); + deprecated.maintenance = MaintenanceStatus::Deprecated; + // Penalty is clamped at 0, never negative. + assert_eq!(deprecated.quality_score(), 0); + } + + #[test] + fn quality_score_ranks_verified_above_unverified() { + let mut verified = sample_entry(); + verified.verified = true; + + let mut popular = sample_entry(); + popular.downloads = 500; // capped contribution of 10 + + assert!(verified.quality_score() > popular.quality_score()); + } + + #[test] + fn trust_indicators_reflect_metadata() { + let mut entry = sample_entry(); + entry.verified = true; + entry.documented = true; + entry.maintenance = MaintenanceStatus::Deprecated; + entry.downloads = 1500; + + let badges = entry.trust_indicators(); + assert!(badges.iter().any(|b| b.contains("Verified"))); + assert!(badges.iter().any(|b| b.contains("Documented"))); + assert!(badges.iter().any(|b| b.contains("Deprecated"))); + assert!(badges.iter().any(|b| b.contains("Popular"))); + } + + #[test] + fn relevance_weights_name_above_description() { + let mut entry = sample_entry(); + entry.name = "uniswap-v2".to_string(); + entry.description = "an amm dex".to_string(); + entry.tags = vec!["defi".to_string()]; + + let (name_score, name_reasons) = relevance_for(&entry, "uniswap"); + let (desc_score, _) = relevance_for(&entry, "amm"); + assert!(name_score > desc_score); + assert!(name_reasons.iter().any(|r| r.contains("name"))); + } + + #[test] + fn relevance_exact_name_beats_prefix() { + let mut exact = sample_entry(); + exact.name = "token".to_string(); + let mut prefix = sample_entry(); + prefix.name = "token-allowlist".to_string(); + + let (exact_score, _) = relevance_for(&exact, "token"); + let (prefix_score, _) = relevance_for(&prefix, "token"); + assert!(exact_score > prefix_score); + } + + #[test] + fn relevance_empty_query_scores_zero() { + let entry = sample_entry(); + let (score, reasons) = relevance_for(&entry, ""); + assert_eq!(score, 0); + assert!(reasons.is_empty()); + } + + #[test] + fn relevance_tag_match_is_reported() { + let mut entry = sample_entry(); + entry.tags = vec!["defi".to_string(), "dex".to_string()]; + let (score, reasons) = relevance_for(&entry, "defi"); + assert!(score > 0); + assert!(reasons.iter().any(|r| r == "tag: defi")); + } + #[test] fn template_source_content_returns_none_for_unknown_template() { // An empty registry should return None for any template name. diff --git a/templates/README.md b/templates/README.md index b72643c3..9ab4e3c6 100644 --- a/templates/README.md +++ b/templates/README.md @@ -89,6 +89,9 @@ Built-in example templates are provided under `templates/examples/`: - `simple-counter`: A basic smart contract demonstrating storage usage by incrementing, getting, and resetting a counter. - `token-allowlist`: A smart contract for managing an allowlist of approved addresses, controlled by an administrator. +- `escrow`: A DeFi token escrow with buyer, seller, and arbiter roles for marketplaces, freelance payments, and OTC trades. +- `dao-governance`: A minimal DAO governance contract with member proposals and one-member-one-vote tallying. +- `multisig-vault`: A threshold (M-of-N) multi-signature vault for shared-custody token transfers and treasuries. ## Template Placeholders diff --git a/templates/examples/dao-governance/Cargo.toml b/templates/examples/dao-governance/Cargo.toml new file mode 100644 index 00000000..57324939 --- /dev/null +++ b/templates/examples/dao-governance/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "{{PROJECT_NAME}}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "21.0.0" + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/templates/examples/dao-governance/README.md b/templates/examples/dao-governance/README.md new file mode 100644 index 00000000..c047d1b6 --- /dev/null +++ b/templates/examples/dao-governance/README.md @@ -0,0 +1,59 @@ +# {{PROJECT_NAME}} + +A minimal DAO governance smart contract for Soroban. + +Members create proposals and cast one-member-one-vote ballots. A proposal passes +when it has more votes for than against. This demonstrates the core governance +loop (propose β†’ vote β†’ tally) that most on-chain DAOs build upon. + +## Features + +- Initialize the DAO with a set of founding members +- Members create titled proposals +- One-member-one-vote, enforced per proposal +- Close proposals to end voting +- Tally results and check whether a proposal has passed + +## Build + +```bash +stellar contract build +``` + +## Test + +```bash +cargo test +``` + +## Deploy + +```bash +starforge deploy \ + --wasm target/wasm32-unknown-unknown/release/{{PROJECT_NAME_SNAKE}}.wasm \ + --network testnet +``` + +## Usage + +```bash +# Initialize with members +stellar contract invoke \ + --id --network testnet \ + -- initialize --members '["",""]' + +# Create a proposal +stellar contract invoke \ + --id --network testnet \ + -- propose --proposer --title "Fund the treasury" + +# Vote on proposal 0 +stellar contract invoke \ + --id --network testnet \ + -- vote --voter --proposal_id 0 --support true + +# Check whether it passed +stellar contract invoke \ + --id --network testnet \ + -- has_passed --proposal_id 0 +``` diff --git a/templates/examples/dao-governance/src/lib.rs b/templates/examples/dao-governance/src/lib.rs new file mode 100644 index 00000000..246037c6 --- /dev/null +++ b/templates/examples/dao-governance/src/lib.rs @@ -0,0 +1,194 @@ +#![no_std] +//! A minimal DAO governance contract for Soroban. +//! +//! Members create proposals and cast one-member-one-vote ballots. Once the +//! voting window closes a proposal is considered passed if it has more votes +//! for than against. This demonstrates the core governance loop (propose β†’ +//! vote β†’ tally) that most on-chain DAOs build upon. +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// List of addresses allowed to create proposals and vote. + Members, + /// The next proposal id to assign. + NextId, + /// A stored proposal, keyed by id. + Proposal(u32), + /// Whether `(proposal_id, voter)` has already voted. + Voted(u32, Address), +} + +#[contracttype] +#[derive(Clone)] +pub struct Proposal { + pub id: u32, + pub proposer: Address, + pub title: String, + pub votes_for: u32, + pub votes_against: u32, + pub closed: bool, +} + +#[contract] +pub struct {{PROJECT_NAME_PASCAL}}; + +#[contractimpl] +impl {{PROJECT_NAME_PASCAL}} { + /// Initialize the DAO with its founding members. + pub fn initialize(env: Env, members: Vec
) { + if env.storage().instance().has(&DataKey::Members) { + panic!("already initialized"); + } + if members.is_empty() { + panic!("at least one member is required"); + } + env.storage().instance().set(&DataKey::Members, &members); + env.storage().instance().set(&DataKey::NextId, &0u32); + } + + /// Create a new proposal. Only members may propose. + pub fn propose(env: Env, proposer: Address, title: String) -> u32 { + proposer.require_auth(); + Self::require_member(&env, &proposer); + + let id: u32 = env.storage().instance().get(&DataKey::NextId).unwrap_or(0); + let proposal = Proposal { + id, + proposer, + title, + votes_for: 0, + votes_against: 0, + closed: false, + }; + env.storage() + .persistent() + .set(&DataKey::Proposal(id), &proposal); + env.storage().instance().set(&DataKey::NextId, &(id + 1)); + id + } + + /// Cast a vote on a proposal. Each member may vote once per proposal. + pub fn vote(env: Env, voter: Address, proposal_id: u32, support: bool) { + voter.require_auth(); + Self::require_member(&env, &voter); + + let mut proposal = Self::proposal(&env, proposal_id); + if proposal.closed { + panic!("proposal is closed"); + } + + let voted_key = DataKey::Voted(proposal_id, voter.clone()); + if env.storage().persistent().get(&voted_key).unwrap_or(false) { + panic!("already voted"); + } + + if support { + proposal.votes_for += 1; + } else { + proposal.votes_against += 1; + } + env.storage().persistent().set(&voted_key, &true); + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + } + + /// Close a proposal so no further votes can be cast. Only the proposer may close. + pub fn close(env: Env, caller: Address, proposal_id: u32) { + caller.require_auth(); + let mut proposal = Self::proposal(&env, proposal_id); + if caller != proposal.proposer { + panic!("only the proposer can close the proposal"); + } + proposal.closed = true; + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + } + + /// Return a proposal by id. + pub fn get_proposal(env: Env, proposal_id: u32) -> Proposal { + Self::proposal(&env, proposal_id) + } + + /// Return whether a proposal has passed (more votes for than against). + pub fn has_passed(env: Env, proposal_id: u32) -> bool { + let proposal = Self::proposal(&env, proposal_id); + proposal.votes_for > proposal.votes_against + } + + fn proposal(env: &Env, proposal_id: u32) -> Proposal { + env.storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .expect("proposal not found") + } + + fn require_member(env: &Env, address: &Address) { + let members: Vec
= env + .storage() + .instance() + .get(&DataKey::Members) + .expect("not initialized"); + if !members.contains(address) { + panic!("caller is not a member"); + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::testutils::Address as _; + + #[test] + fn test_proposal_passes() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let carol = Address::generate(&env); + + let contract_id = env.register_contract(None, {{PROJECT_NAME_PASCAL}}); + let client = {{PROJECT_NAME_PASCAL}}Client::new(&env, &contract_id); + + let mut members = Vec::new(&env); + members.push_back(alice.clone()); + members.push_back(bob.clone()); + members.push_back(carol.clone()); + client.initialize(&members); + + let id = client.propose(&alice, &String::from_str(&env, "Fund the treasury")); + + client.vote(&alice, &id, &true); + client.vote(&bob, &id, &true); + client.vote(&carol, &id, &false); + + let proposal = client.get_proposal(&id); + assert_eq!(proposal.votes_for, 2); + assert_eq!(proposal.votes_against, 1); + assert!(client.has_passed(&id)); + } + + #[test] + #[should_panic(expected = "already voted")] + fn test_double_vote_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + let contract_id = env.register_contract(None, {{PROJECT_NAME_PASCAL}}); + let client = {{PROJECT_NAME_PASCAL}}Client::new(&env, &contract_id); + + let mut members = Vec::new(&env); + members.push_back(alice.clone()); + client.initialize(&members); + + let id = client.propose(&alice, &String::from_str(&env, "Test")); + client.vote(&alice, &id, &true); + client.vote(&alice, &id, &true); + } +} diff --git a/templates/examples/escrow/Cargo.toml b/templates/examples/escrow/Cargo.toml new file mode 100644 index 00000000..57324939 --- /dev/null +++ b/templates/examples/escrow/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "{{PROJECT_NAME}}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "21.0.0" + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/templates/examples/escrow/README.md b/templates/examples/escrow/README.md new file mode 100644 index 00000000..e61c592d --- /dev/null +++ b/templates/examples/escrow/README.md @@ -0,0 +1,70 @@ +# {{PROJECT_NAME}} + +A token escrow smart contract for Soroban. + +A buyer locks tokens in the contract; a neutral arbiter then either releases the +funds to the seller (on successful delivery) or refunds the buyer (on a +dispute). This is a common building block for marketplaces, freelance payments +and over-the-counter trades. + +## Roles + +- **Buyer** β€” funds the escrow and can release the funds to the seller. +- **Seller** β€” receives the funds on release and can refund the buyer. +- **Arbiter** β€” neutral third party who can release or refund to resolve a dispute. + +## Features + +- Initialize an escrow with buyer, seller, arbiter, token and amount +- Fund the escrow from the buyer +- Release funds to the seller (buyer or arbiter) +- Refund funds to the buyer (seller or arbiter) +- Inspect funded / settled state + +## Build + +```bash +stellar contract build +``` + +## Test + +```bash +cargo test +``` + +## Deploy + +```bash +starforge deploy \ + --wasm target/wasm32-unknown-unknown/release/{{PROJECT_NAME_SNAKE}}.wasm \ + --network testnet +``` + +## Usage + +```bash +# Initialize the escrow +stellar contract invoke \ + --id \ + --network testnet \ + -- initialize \ + --buyer \ + --seller \ + --arbiter \ + --token \ + --amount 500 + +# Buyer deposits the funds +stellar contract invoke --id --network testnet -- deposit + +# Release funds to the seller (called by buyer or arbiter) +stellar contract invoke \ + --id --network testnet \ + -- release --caller + +# Refund funds to the buyer (called by seller or arbiter) +stellar contract invoke \ + --id --network testnet \ + -- refund --caller +``` diff --git a/templates/examples/escrow/src/lib.rs b/templates/examples/escrow/src/lib.rs new file mode 100644 index 00000000..d6767b88 --- /dev/null +++ b/templates/examples/escrow/src/lib.rs @@ -0,0 +1,205 @@ +#![no_std] +//! A token escrow contract for Soroban. +//! +//! A buyer locks tokens in the contract. A neutral arbiter then either releases +//! the funds to the seller (on successful delivery) or refunds the buyer (on a +//! dispute). This is a common building block for marketplaces, freelance +//! payments and over-the-counter trades. +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env}; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// The immutable escrow configuration set at initialization. + Config, + /// Whether the buyer has funded the escrow. + Funded, + /// Whether the escrow has been settled (released or refunded). + Settled, +} + +#[contracttype] +#[derive(Clone)] +pub struct EscrowConfig { + pub buyer: Address, + pub seller: Address, + pub arbiter: Address, + pub token: Address, + pub amount: i128, +} + +#[contract] +pub struct {{PROJECT_NAME_PASCAL}}; + +#[contractimpl] +impl {{PROJECT_NAME_PASCAL}} { + /// Initialize the escrow with its parties, token and amount. + pub fn initialize( + env: Env, + buyer: Address, + seller: Address, + arbiter: Address, + token: Address, + amount: i128, + ) { + if env.storage().instance().has(&DataKey::Config) { + panic!("already initialized"); + } + if amount <= 0 { + panic!("amount must be positive"); + } + let config = EscrowConfig { + buyer, + seller, + arbiter, + token, + amount, + }; + env.storage().instance().set(&DataKey::Config, &config); + env.storage().instance().set(&DataKey::Funded, &false); + env.storage().instance().set(&DataKey::Settled, &false); + } + + /// The buyer deposits the agreed amount into the escrow. + pub fn deposit(env: Env) { + let config = Self::config(&env); + config.buyer.require_auth(); + + if env.storage().instance().get(&DataKey::Funded).unwrap_or(false) { + panic!("already funded"); + } + + let client = token::Client::new(&env, &config.token); + client.transfer( + &config.buyer, + &env.current_contract_address(), + &config.amount, + ); + env.storage().instance().set(&DataKey::Funded, &true); + } + + /// Release the escrowed funds to the seller. + /// + /// Authorized by either the buyer (confirming delivery) or the arbiter + /// (resolving a dispute in the seller's favor). + pub fn release(env: Env, caller: Address) { + let config = Self::config(&env); + caller.require_auth(); + if caller != config.buyer && caller != config.arbiter { + panic!("only buyer or arbiter can release"); + } + Self::settle(&env, &config, &config.seller); + } + + /// Refund the escrowed funds to the buyer. + /// + /// Authorized by either the seller (cancelling the deal) or the arbiter + /// (resolving a dispute in the buyer's favor). + pub fn refund(env: Env, caller: Address) { + let config = Self::config(&env); + caller.require_auth(); + if caller != config.seller && caller != config.arbiter { + panic!("only seller or arbiter can refund"); + } + Self::settle(&env, &config, &config.buyer); + } + + /// Return the escrow configuration. + pub fn get_config(env: Env) -> EscrowConfig { + Self::config(&env) + } + + /// Return whether the escrow has been funded. + pub fn is_funded(env: Env) -> bool { + env.storage().instance().get(&DataKey::Funded).unwrap_or(false) + } + + /// Return whether the escrow has been settled. + pub fn is_settled(env: Env) -> bool { + env.storage().instance().get(&DataKey::Settled).unwrap_or(false) + } + + fn config(env: &Env) -> EscrowConfig { + env.storage() + .instance() + .get(&DataKey::Config) + .expect("not initialized") + } + + fn settle(env: &Env, config: &EscrowConfig, recipient: &Address) { + if !env.storage().instance().get(&DataKey::Funded).unwrap_or(false) { + panic!("escrow not funded"); + } + if env.storage().instance().get(&DataKey::Settled).unwrap_or(false) { + panic!("escrow already settled"); + } + let client = token::Client::new(env, &config.token); + client.transfer( + &env.current_contract_address(), + recipient, + &config.amount, + ); + env.storage().instance().set(&DataKey::Settled, &true); + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::token::{StellarAssetClient, TokenClient}; + + fn create_token(env: &Env, admin: &Address) -> (Address, TokenClient) { + let contract = env.register_stellar_asset_contract_v2(admin.clone()); + let address = contract.address(); + (address.clone(), TokenClient::new(env, &address)) + } + + #[test] + fn test_release_flow() { + let env = Env::default(); + env.mock_all_auths(); + + let buyer = Address::generate(&env); + let seller = Address::generate(&env); + let arbiter = Address::generate(&env); + + let (token_address, token) = create_token(&env, &buyer); + StellarAssetClient::new(&env, &token_address).mint(&buyer, &1000); + + let contract_id = env.register_contract(None, {{PROJECT_NAME_PASCAL}}); + let client = {{PROJECT_NAME_PASCAL}}Client::new(&env, &contract_id); + + client.initialize(&buyer, &seller, &arbiter, &token_address, &500); + client.deposit(); + assert!(client.is_funded()); + assert_eq!(token.balance(&contract_id), 500); + + client.release(&buyer); + assert!(client.is_settled()); + assert_eq!(token.balance(&seller), 500); + } + + #[test] + fn test_refund_flow() { + let env = Env::default(); + env.mock_all_auths(); + + let buyer = Address::generate(&env); + let seller = Address::generate(&env); + let arbiter = Address::generate(&env); + + let (token_address, token) = create_token(&env, &buyer); + StellarAssetClient::new(&env, &token_address).mint(&buyer, &1000); + + let contract_id = env.register_contract(None, {{PROJECT_NAME_PASCAL}}); + let client = {{PROJECT_NAME_PASCAL}}Client::new(&env, &contract_id); + + client.initialize(&buyer, &seller, &arbiter, &token_address, &500); + client.deposit(); + + client.refund(&arbiter); + assert!(client.is_settled()); + assert_eq!(token.balance(&buyer), 1000); + } +} diff --git a/templates/examples/multisig-vault/Cargo.toml b/templates/examples/multisig-vault/Cargo.toml new file mode 100644 index 00000000..57324939 --- /dev/null +++ b/templates/examples/multisig-vault/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "{{PROJECT_NAME}}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "21.0.0" + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/templates/examples/multisig-vault/README.md b/templates/examples/multisig-vault/README.md new file mode 100644 index 00000000..4e93bdba --- /dev/null +++ b/templates/examples/multisig-vault/README.md @@ -0,0 +1,60 @@ +# {{PROJECT_NAME}} + +A threshold multi-signature vault smart contract for Soroban. + +A set of owners controls a token balance held by the contract. Any owner can +propose a payment; once at least `threshold` distinct owners have approved it, +any owner can execute the transfer. This is the standard pattern behind treasury +and shared-custody wallets. + +## Features + +- Initialize with a set of owners and an approval threshold (M-of-N) +- Any owner can propose a token transfer (counts as their approval) +- One approval per owner per transaction +- Execute a transfer only once the threshold is met +- Inspect transactions and the configured threshold + +## Build + +```bash +stellar contract build +``` + +## Test + +```bash +cargo test +``` + +## Deploy + +```bash +starforge deploy \ + --wasm target/wasm32-unknown-unknown/release/{{PROJECT_NAME_SNAKE}}.wasm \ + --network testnet +``` + +## Usage + +```bash +# Initialize a 2-of-3 vault +stellar contract invoke \ + --id --network testnet \ + -- initialize --owners '["","",""]' --threshold 2 + +# Propose a transfer (counts as the proposer's approval) +stellar contract invoke \ + --id --network testnet \ + -- propose --proposer --token --to --amount 400 + +# Second owner approves transaction 0 +stellar contract invoke \ + --id --network testnet \ + -- approve --owner --tx_id 0 + +# Execute once the threshold is reached +stellar contract invoke \ + --id --network testnet \ + -- execute --owner --tx_id 0 +``` diff --git a/templates/examples/multisig-vault/src/lib.rs b/templates/examples/multisig-vault/src/lib.rs new file mode 100644 index 00000000..9b9e300c --- /dev/null +++ b/templates/examples/multisig-vault/src/lib.rs @@ -0,0 +1,227 @@ +#![no_std] +//! A threshold multi-signature vault for Soroban. +//! +//! A set of owners controls a token balance held by the contract. Any owner can +//! propose a payment; once at least `threshold` distinct owners have approved +//! it, any owner can execute the transfer. This is the standard pattern behind +//! treasury and shared-custody wallets. +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, Vec}; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// The owners authorized to propose, approve and execute. + Owners, + /// Number of approvals required to execute a transaction. + Threshold, + /// The next transaction id to assign. + NextId, + /// A stored transaction, keyed by id. + Tx(u32), + /// Whether `(tx_id, owner)` has already approved. + Approved(u32, Address), +} + +#[contracttype] +#[derive(Clone)] +pub struct Transaction { + pub id: u32, + pub token: Address, + pub to: Address, + pub amount: i128, + pub approvals: u32, + pub executed: bool, +} + +#[contract] +pub struct {{PROJECT_NAME_PASCAL}}; + +#[contractimpl] +impl {{PROJECT_NAME_PASCAL}} { + /// Initialize the vault with its owners and approval threshold. + pub fn initialize(env: Env, owners: Vec
, threshold: u32) { + if env.storage().instance().has(&DataKey::Owners) { + panic!("already initialized"); + } + if owners.is_empty() { + panic!("at least one owner is required"); + } + if threshold == 0 || threshold > owners.len() { + panic!("threshold must be between 1 and the number of owners"); + } + env.storage().instance().set(&DataKey::Owners, &owners); + env.storage().instance().set(&DataKey::Threshold, &threshold); + env.storage().instance().set(&DataKey::NextId, &0u32); + } + + /// Propose a token transfer from the vault. Counts as the proposer's approval. + pub fn propose(env: Env, proposer: Address, token: Address, to: Address, amount: i128) -> u32 { + proposer.require_auth(); + Self::require_owner(&env, &proposer); + if amount <= 0 { + panic!("amount must be positive"); + } + + let id: u32 = env.storage().instance().get(&DataKey::NextId).unwrap_or(0); + let tx = Transaction { + id, + token, + to, + amount, + approvals: 1, + executed: false, + }; + env.storage().persistent().set(&DataKey::Tx(id), &tx); + env.storage() + .persistent() + .set(&DataKey::Approved(id, proposer), &true); + env.storage().instance().set(&DataKey::NextId, &(id + 1)); + id + } + + /// Approve a pending transaction. Each owner may approve once. + pub fn approve(env: Env, owner: Address, tx_id: u32) { + owner.require_auth(); + Self::require_owner(&env, &owner); + + let mut tx = Self::transaction(&env, tx_id); + if tx.executed { + panic!("transaction already executed"); + } + + let approved_key = DataKey::Approved(tx_id, owner.clone()); + if env.storage().persistent().get(&approved_key).unwrap_or(false) { + panic!("already approved"); + } + + tx.approvals += 1; + env.storage().persistent().set(&approved_key, &true); + env.storage().persistent().set(&DataKey::Tx(tx_id), &tx); + } + + /// Execute a transaction once it has reached the approval threshold. + pub fn execute(env: Env, owner: Address, tx_id: u32) { + owner.require_auth(); + Self::require_owner(&env, &owner); + + let mut tx = Self::transaction(&env, tx_id); + if tx.executed { + panic!("transaction already executed"); + } + let threshold: u32 = env + .storage() + .instance() + .get(&DataKey::Threshold) + .expect("not initialized"); + if tx.approvals < threshold { + panic!("not enough approvals"); + } + + let client = token::Client::new(&env, &tx.token); + client.transfer(&env.current_contract_address(), &tx.to, &tx.amount); + + tx.executed = true; + env.storage().persistent().set(&DataKey::Tx(tx_id), &tx); + } + + /// Return a transaction by id. + pub fn get_transaction(env: Env, tx_id: u32) -> Transaction { + Self::transaction(&env, tx_id) + } + + /// Return the approval threshold. + pub fn get_threshold(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::Threshold) + .expect("not initialized") + } + + fn transaction(env: &Env, tx_id: u32) -> Transaction { + env.storage() + .persistent() + .get(&DataKey::Tx(tx_id)) + .expect("transaction not found") + } + + fn require_owner(env: &Env, address: &Address) { + let owners: Vec
= env + .storage() + .instance() + .get(&DataKey::Owners) + .expect("not initialized"); + if !owners.contains(address) { + panic!("caller is not an owner"); + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::token::{StellarAssetClient, TokenClient}; + + #[test] + fn test_multisig_execute() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let carol = Address::generate(&env); + let recipient = Address::generate(&env); + + let contract_id = env.register_contract(None, {{PROJECT_NAME_PASCAL}}); + let client = {{PROJECT_NAME_PASCAL}}Client::new(&env, &contract_id); + + let mut owners = Vec::new(&env); + owners.push_back(alice.clone()); + owners.push_back(bob.clone()); + owners.push_back(carol.clone()); + client.initialize(&owners, &2); + + // Fund the vault with a token. + let issuer = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(issuer.clone()); + let token_address = token_contract.address(); + StellarAssetClient::new(&env, &token_address).mint(&contract_id, &1000); + + // Propose (1 approval) then approve to reach the threshold of 2. + let id = client.propose(&alice, &token_address, &recipient, &400); + client.approve(&bob, &id); + + client.execute(&alice, &id); + + let token = TokenClient::new(&env, &token_address); + assert_eq!(token.balance(&recipient), 400); + assert_eq!(token.balance(&contract_id), 600); + assert!(client.get_transaction(&id).executed); + } + + #[test] + #[should_panic(expected = "not enough approvals")] + fn test_execute_below_threshold_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let recipient = Address::generate(&env); + + let contract_id = env.register_contract(None, {{PROJECT_NAME_PASCAL}}); + let client = {{PROJECT_NAME_PASCAL}}Client::new(&env, &contract_id); + + let mut owners = Vec::new(&env); + owners.push_back(alice.clone()); + owners.push_back(bob.clone()); + client.initialize(&owners, &2); + + let issuer = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(issuer.clone()); + let token_address = token_contract.address(); + + let id = client.propose(&alice, &token_address, &recipient, &100); + client.execute(&alice, &id); + } +} diff --git a/templates/registry.json b/templates/registry.json index 53f262a2..77cd3b2a 100644 --- a/templates/registry.json +++ b/templates/registry.json @@ -15,7 +15,9 @@ "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "downloads": 1240, - "verified": true + "verified": true, + "documented": true, + "maintenance": "active" }, { "name": "lending-pool", @@ -31,7 +33,9 @@ "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "downloads": 874, - "verified": true + "verified": true, + "documented": true, + "maintenance": "active" }, { "name": "governance", @@ -47,7 +51,9 @@ "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "downloads": 512, - "verified": false + "verified": false, + "documented": true, + "maintenance": "maintained" }, { "name": "multisig-wallet", @@ -63,7 +69,9 @@ "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "downloads": 389, - "verified": false + "verified": false, + "documented": false, + "maintenance": "maintained" }, { "name": "sep-41-token", @@ -79,7 +87,9 @@ "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "downloads": 156, - "verified": true + "verified": true, + "documented": true, + "maintenance": "active" }, { "name": "sep-10-auth", @@ -95,7 +105,60 @@ "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", "downloads": 98, - "verified": true + "verified": true, + "documented": false, + "maintenance": "maintained" + }, + { + "name": "escrow", + "version": "1.0.0", + "description": "Token escrow with buyer, seller and arbiter for marketplaces and OTC trades", + "author": "StarForge", + "tags": ["defi", "escrow", "payments", "marketplace"], + "source": { + "type": "builtin", + "id": "escrow" + }, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z", + "downloads": 0, + "verified": true, + "documented": true, + "maintenance": "active" + }, + { + "name": "dao-governance", + "version": "1.0.0", + "description": "Minimal DAO governance with member proposals and one-member-one-vote tallying", + "author": "StarForge", + "tags": ["dao", "governance", "voting"], + "source": { + "type": "builtin", + "id": "dao-governance" + }, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z", + "downloads": 0, + "verified": true, + "documented": true, + "maintenance": "active" + }, + { + "name": "multisig-vault", + "version": "1.0.0", + "description": "Threshold M-of-N multi-signature vault for shared-custody token transfers", + "author": "StarForge", + "tags": ["wallet", "multisig", "security", "treasury"], + "source": { + "type": "builtin", + "id": "multisig-vault" + }, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z", + "downloads": 0, + "verified": true, + "documented": true, + "maintenance": "active" } ] }