diff --git a/TEMPLATE_CONTRIBUTING.md b/TEMPLATE_CONTRIBUTING.md new file mode 100644 index 00000000..52c4beab --- /dev/null +++ b/TEMPLATE_CONTRIBUTING.md @@ -0,0 +1,242 @@ +# Template Contribution Guidelines + +Thank you for contributing a smart contract template to the StarForge library! +This document covers every step from writing your template to getting it accepted. + +--- + +## Quick-start checklist + +Before opening a pull request, confirm each item: + +- [ ] Template compiles with `cargo build` targeting `wasm32-unknown-unknown` +- [ ] Template passes its own test suite (`cargo test`) +- [ ] Template source uses `{{PROJECT_NAME_PASCAL}}` as the contract struct name +- [ ] A `README.md` is included describing the contract and its public functions +- [ ] `registry.json` entry is present with all required fields +- [ ] `security_review` field is present (status `"pending"` is acceptable for new submissions) +- [ ] `changelog` field has at least one entry for the initial version +- [ ] License is declared via the `license` field (MIT or Apache-2.0 preferred) +- [ ] `TEMPLATE_CONTRIBUTING.md` checklist items have all been addressed + +--- + +## Template structure + +Every template lives under `templates/examples//` and follows this layout: + +``` +templates/examples// +├── Cargo.toml # crate manifest — uses {{project_name_snake}} +├── README.md # user-facing documentation +└── src/ + └── lib.rs # contract source +``` + +### Cargo.toml requirements + +```toml +[package] +name = "{{project_name_snake}}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } +``` + +Use `{{project_name_snake}}` as the crate name — StarForge replaces this with the +user's project name when scaffolding. + +### Contract source requirements + +- Must start with `#![no_std]` +- Must use `{{PROJECT_NAME_PASCAL}}` as the contract struct name (double-brace placeholder) +- Must include a module-level doc comment (`//! …`) explaining the contract +- Must include a `#[cfg(test)] mod test { … }` block with at least two meaningful tests +- Must compile without warnings + +```rust +#![no_std] +//! Brief description of the contract. +use soroban_sdk::{contract, contractimpl, Env}; + +#[contract] +pub struct {{PROJECT_NAME_PASCAL}}; + +#[contractimpl] +impl {{PROJECT_NAME_PASCAL}} { + // ... +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_happy_path() { /* ... */ } + + #[test] + #[should_panic(expected = "…")] + fn test_error_case() { /* ... */ } +} +``` + +--- + +## Registry entry + +Every template must have a corresponding entry in `templates/registry.json`. +Below is the minimum required shape: + +```json +{ + "name": "my-template", + "version": "1.0.0", + "description": "One-line description of what the contract does", + "author": "Your Name", + "tags": ["defi", "my-category"], + "source": { "type": "builtin", "id": "my-template" }, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z", + "verified": false, + "documented": true, + "maintenance": "active", + "license": "MIT", + "security_review": { + "status": "pending", + "audited_at": null, + "auditor": null, + "findings": null, + "score": null + }, + "changelog": [ + { "version": "1.0.0", "date": "2025-01-01", "notes": "Initial release" } + ] +} +``` + +### Field reference + +| Field | Required | Description | +|---|---|---| +| `name` | ✓ | Unique kebab-case identifier | +| `version` | ✓ | Semver string (`major.minor.patch`) | +| `description` | ✓ | One-line summary (≤ 120 chars) | +| `author` | ✓ | Author name or GitHub handle | +| `tags` | ✓ | At least one tag from the [tag taxonomy](#tag-taxonomy) | +| `source` | ✓ | `builtin`, `git`, or `local` source descriptor | +| `license` | recommended | SPDX identifier (`MIT`, `Apache-2.0`, …) | +| `security_review` | recommended | Audit status — `pending` is fine initially | +| `changelog` | recommended | At least one entry | +| `maintenance` | recommended | `active`, `maintained`, `deprecated`, or `unknown` | + +### Tag taxonomy + +Use at least one of these standard tags so search works reliably: + +| Tag | Used for | +|---|---| +| `token` | Fungible token contracts | +| `nft` | Non-fungible token contracts | +| `defi` | DeFi primitives (AMM, lending, staking, …) | +| `dao` | Governance / DAO contracts | +| `governance` | Voting and proposal contracts | +| `multisig` | Multi-signature wallets and vaults | +| `security` | Auth, access-control, and audit-focused contracts | +| `payments` | Payment channels, escrow, and invoicing | +| `staking` | Yield / staking contracts | +| `standard` | SEP-conformant contracts | + +--- + +## Security review process + +The StarForge Security Team reviews every new template before setting +`verified: true`. Until review is complete, the template is published with +`"status": "pending"`. + +### What gets reviewed + +1. **Authorization checks** — every mutating function calls `require_auth()` on + the right principal; no function can be called by an arbitrary address. +2. **Re-entrancy** — state is updated before external token transfers. +3. **Integer arithmetic** — no unchecked arithmetic that could overflow or wrap. +4. **Initialization guards** — contracts cannot be re-initialized. +5. **Storage hygiene** — correct use of `instance`, `persistent`, and `temporary` + storage lifetimes. +6. **Panic messages** — descriptive error strings, no empty panics. + +### Review SLA + +| Priority | Target turnaround | +|---|---| +| Security fix | 48 hours | +| New template | 7 days | +| Version bump | 5 days | + +### Requesting an expedited review + +Open a GitHub issue with the label `security-review-request` and link your PR. +The Security Team triages these daily. + +--- + +## Version bumping + +When updating an existing template: + +1. Increment the `version` field in the `registry.json` entry following semver: + - **Patch** (`x.y.Z`) — bug fixes, doc improvements, no API changes. + - **Minor** (`x.Y.0`) — new optional functions, backward-compatible changes. + - **Major** (`X.0.0`) — breaking changes to the public API. +2. Add a new entry at the **top** of the `changelog` array. +3. Update `updated_at` to the current date. +4. Reset `security_review.status` to `"pending"` if the change affects contract logic. + +--- + +## Testing your template + +Run the template's own tests from the StarForge CLI before submitting: + +```bash +# Run tests via cargo directly +cargo test --manifest-path templates/examples/my-template/Cargo.toml + +# Or via the CLI (once registered) +starforge template test my-template +``` + +All tests must pass with zero warnings. + +--- + +## Generating documentation + +After adding your registry entry you can preview the generated Markdown docs: + +```bash +starforge template docs my-template +# write to a file +starforge template docs my-template --output docs/templates/my-template.md +``` + +--- + +## Pull request process + +1. Fork the repo and create a branch: `git checkout -b feat/template-my-template` +2. Add your template files and registry entry. +3. Run `cargo test` from the repo root to verify nothing is broken. +4. Open a PR against `main` with the title `feat(templates): add my-template`. +5. Fill in the PR description template, including the checklist above. +6. The Security Team will review and either approve or request changes within 7 days. + +We appreciate every contribution — thank you for making the Stellar ecosystem stronger! diff --git a/src/commands/template.rs b/src/commands/template.rs index f2b76a4f..686504c0 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -133,13 +133,26 @@ pub enum TemplateCommands { #[arg(long, short, conflicts_with = "name")] all: bool, }, + /// Run the built-in test suite for a template + Test { + /// Template name or path to a template directory + name: String, + /// Show verbose cargo output + #[arg(long)] + verbose: bool, + }, /// Generate Markdown documentation for a template from its registry metadata Docs { - /// Template name + /// Template name to generate docs for name: String, - /// Write the docs to this file instead of printing to stdout + /// Write the generated docs to this file (defaults to stdout) #[arg(long)] - output: Option, + output: Option, + }, + /// Show security review status for a template (or all templates) + Audit { + /// Template name (omit to list the security status of all templates) + name: Option, }, } @@ -208,8 +221,11 @@ pub async fn handle(cmd: TemplateCommands) -> Result<()> { name, version, force, - } => install(source, name, version, force).await, - TemplateCommands::Update { name, all } => update(name, all).await, + } => install(source, name, version, force), + TemplateCommands::Update { name, all } => update(name, all), + TemplateCommands::Test { name, verbose } => template_test(name, verbose), + TemplateCommands::Docs { name, output } => template_docs(name, output), + TemplateCommands::Audit { name } => template_audit(name), } } @@ -742,16 +758,222 @@ async fn update(name: Option, all: bool) -> Result<()> { Ok(()) } -fn docs(name: String, output: Option) -> Result<()> { +// ─── template test ──────────────────────────────────────────────────────────── + +fn template_test(name: String, verbose: bool) -> Result<()> { + use std::process::Command; + + p::header(&format!("Template Test: {}", name)); + + // Locate the template source directory — prefer builtin examples. + let builtin = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("templates") + .join("examples") + .join(&name); + + let template_dir = if builtin.exists() { + builtin + } else { + // Fall back to path stored in the registry. + let entry = templates::get_template(&name)?; + match entry.path { + Some(ref p) => std::path::PathBuf::from(p), + None => anyhow::bail!( + "Template '{}' has no local path. Install it first with: starforge template install {}", + name, name + ), + } + }; + + p::kv("Template directory", &template_dir.display().to_string()); + p::info("Running: cargo test"); + + let mut cmd = Command::new("cargo"); + cmd.arg("test"); + if verbose { + cmd.arg("--verbose"); + } + cmd.current_dir(&template_dir); + + let status = cmd.status()?; + + if status.success() { + p::success("All tests passed"); + } else { + anyhow::bail!("Tests failed for template '{}'", name); + } + Ok(()) +} + +// ─── template docs ──────────────────────────────────────────────────────────── + +fn template_docs(name: String, output: Option) -> Result<()> { let entry = templates::get_template(&name)?; - let markdown = templates::generate_template_docs(&entry); + + let mut md = String::new(); + + // Title + md.push_str(&format!("# {} `{}`\n\n", entry.name, entry.version)); + md.push_str(&format!("> {}\n\n", entry.description)); + + // Badges + if entry.verified { + md.push_str("![Verified](https://img.shields.io/badge/verified-✓-brightgreen) "); + } + md.push_str(&format!( + "![Maintenance](https://img.shields.io/badge/maintenance-{}-blue) ", + entry.maintenance.label().replace(' ', "%20") + )); + if let Some(ref lic) = entry.license { + md.push_str(&format!( + "![License](https://img.shields.io/badge/license-{}-cyan)\n\n", + lic + )); + } else { + md.push('\n'); + } + + // Metadata table + md.push_str("## Metadata\n\n"); + md.push_str("| Field | Value |\n|---|---|\n"); + md.push_str(&format!("| Author | {} |\n", entry.author)); + md.push_str(&format!("| Version | {} |\n", entry.version)); + md.push_str(&format!("| License | {} |\n", entry.license.as_deref().unwrap_or("Not declared"))); + md.push_str(&format!( + "| Tags | {} |\n", + if entry.tags.is_empty() { "—".to_string() } else { entry.tags.join(", ") } + )); + md.push_str(&format!("| Source | {} |\n", entry.source)); + if let Some(ref repo) = entry.repository { + md.push_str(&format!("| Repository | {} |\n", repo)); + } + if let Some(ref hp) = entry.homepage { + md.push_str(&format!("| Homepage | {} |\n", hp)); + } + md.push('\n'); + + // Security review + md.push_str("## Security Review\n\n"); + if let Some(ref sr) = entry.security_review { + md.push_str(&format!("**Status:** {}\n\n", sr.status)); + if let (Some(ref auditor), Some(ref date)) = (&sr.auditor, &sr.audited_at) { + md.push_str(&format!("- **Auditor:** {}\n", auditor)); + md.push_str(&format!("- **Audited at:** {}\n", date)); + } + if let Some(findings) = sr.findings { + md.push_str(&format!("- **Findings:** {}\n", findings)); + } + if let Some(score) = sr.score { + md.push_str(&format!("- **Score:** {}/100\n", score)); + } + } else { + md.push_str("No security review data available.\n"); + } + md.push('\n'); + + // Changelog + if !entry.changelog.is_empty() { + md.push_str("## Changelog\n\n"); + for entry in &entry.changelog { + md.push_str(&format!( + "### {} — {}\n\n{}\n\n", + entry.version, entry.date, entry.notes + )); + } + } + + // Usage + md.push_str("## Usage\n\n"); + md.push_str("```bash\n"); + md.push_str(&format!("starforge new contract my-project --template {}\n", name)); + md.push_str("```\n"); match output { Some(path) => { - std::fs::write(&path, &markdown)?; + std::fs::write(&path, &md)?; p::success(&format!("Documentation written to {}", path.display())); } - None => println!("{}", markdown), + None => { + p::header(&format!("Documentation for {}", name)); + println!("{}", md); + } + } + Ok(()) +} + +// ─── template audit ─────────────────────────────────────────────────────────── + +fn template_audit(name: Option) -> Result<()> { + let registry = templates::load_registry()?; + + let entries: Vec<&templates::TemplateEntry> = match &name { + Some(n) => registry + .templates + .iter() + .filter(|t| &t.name == n) + .collect(), + None => registry.templates.iter().collect(), + }; + + if entries.is_empty() { + if let Some(n) = &name { + anyhow::bail!("Template '{}' not found in registry", n); + } else { + p::info("No templates in registry."); + return Ok(()); + } } + + p::header("Template Security Review Status"); + println!( + " {:<20} {:<10} {:<12} {:<10} {:<8}", + "NAME", "VERSION", "STATUS", "FINDINGS", "SCORE" + ); + println!(" {}", "─".repeat(64)); + + for entry in entries { + let (status, findings, score) = match &entry.security_review { + Some(sr) => ( + sr.status.as_str(), + sr.findings + .map(|f| f.to_string()) + .unwrap_or_else(|| "—".to_string()), + sr.score + .map(|s| format!("{}/100", s)) + .unwrap_or_else(|| "—".to_string()), + ), + None => ("not-reviewed", "—".to_string(), "—".to_string()), + }; + + let status_icon = match status { + "audited" => "✓ audited", + "pending" => "⧖ pending", + _ => "✗ not-reviewed", + }; + + println!( + " {:<20} {:<10} {:<12} {:<10} {:<8}", + entry.name, entry.version, status_icon, findings, score + ); + } + + if name.is_none() { + println!(); + let audited = registry + .templates + .iter() + .filter(|t| { + t.security_review + .as_ref() + .map(|sr| sr.status == "audited") + .unwrap_or(false) + }) + .count(); + p::kv( + "Audited", + &format!("{}/{}", audited, registry.templates.len()), + ); + } + Ok(()) } diff --git a/src/utils/templates.rs b/src/utils/templates.rs index 3d744a8a..b67048b1 100644 --- a/src/utils/templates.rs +++ b/src/utils/templates.rs @@ -119,6 +119,39 @@ pub struct TemplateEntry { pub homepage: Option, #[serde(default)] pub documentation: Option, + /// Security review metadata for the template. + #[serde(default)] + pub security_review: Option, + /// Version history / changelog entries (newest first). + #[serde(default)] + pub changelog: Vec, +} + +/// Security review status and results for a template. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SecurityReview { + /// Audit status: "audited", "pending", or "not-reviewed". + pub status: String, + /// ISO-8601 timestamp of the most recent audit. `None` if not yet audited. + #[serde(default)] + pub audited_at: Option, + /// Name of the auditing entity. `None` if not yet audited. + #[serde(default)] + pub auditor: Option, + /// Number of findings identified. `None` if not yet audited. + #[serde(default)] + pub findings: Option, + /// Audit score out of 100. `None` if not yet audited. + #[serde(default)] + pub score: Option, +} + +/// A single changelog entry describing what changed in a version. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChangelogEntry { + pub version: String, + pub date: String, + pub notes: String, } /// Outcome of a template-vs-CLI compatibility check. diff --git a/templates/examples/nft/Cargo.toml b/templates/examples/nft/Cargo.toml new file mode 100644 index 00000000..53f29c75 --- /dev/null +++ b/templates/examples/nft/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "{{project_name_snake}}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/templates/examples/nft/README.md b/templates/examples/nft/README.md new file mode 100644 index 00000000..a915f178 --- /dev/null +++ b/templates/examples/nft/README.md @@ -0,0 +1,29 @@ +# NFT Contract + +A non-fungible token (NFT) contract for Soroban. Each token has a unique `u32` ID and a URI pointing to its off-chain metadata. + +## Functions + +| Function | Description | +|----------|-------------| +| `initialize(admin)` | Set up the contract (once only) | +| `mint(to, token_id, uri)` | Mint a new token — admin only | +| `transfer(from, to, token_id)` | Transfer ownership | +| `owner_of(token_id)` | Query the owner | +| `token_uri(token_id)` | Query the metadata URI | +| `approve(owner, spender, token_id)` | Approve a single-token spender | +| `get_approved(token_id)` | Query the approved spender | +| `burn(owner, token_id)` | Burn a token — owner only | + +## Usage + +```bash +# Scaffold a new project from this template +starforge new contract my-nft --template nft + +# Build +cargo build --target wasm32-unknown-unknown --release + +# Test +cargo test +``` diff --git a/templates/examples/nft/src/lib.rs b/templates/examples/nft/src/lib.rs new file mode 100644 index 00000000..860b4452 --- /dev/null +++ b/templates/examples/nft/src/lib.rs @@ -0,0 +1,144 @@ +#![no_std] +//! Non-fungible token (NFT) contract for Soroban. +//! +//! Each token is identified by a `u32` token ID. Supports minting (admin-only), +//! ownership transfer, per-token approval, URI metadata, and burning. +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String}; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + TotalSupply, + Owner(u32), + Uri(u32), + Approved(u32), +} + +#[contract] +pub struct {{PROJECT_NAME_PASCAL}}; + +#[contractimpl] +impl {{PROJECT_NAME_PASCAL}} { + /// Initialize the contract. Can only be called once. + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TotalSupply, &0u32); + } + + /// Mint a new token to `to` with the given `token_id` and metadata `uri`. Admin only. + pub fn mint(env: Env, to: Address, token_id: u32, uri: String) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized"); + admin.require_auth(); + if env.storage().persistent().has(&DataKey::Owner(token_id)) { + panic!("token already exists"); + } + env.storage().persistent().set(&DataKey::Owner(token_id), &to); + env.storage().persistent().set(&DataKey::Uri(token_id), &uri); + let supply: u32 = env.storage().instance().get(&DataKey::TotalSupply).unwrap_or(0); + env.storage().instance().set(&DataKey::TotalSupply, &(supply + 1)); + } + + /// Transfer `token_id` from `from` to `to`. Requires auth from `from`. + pub fn transfer(env: Env, from: Address, to: Address, token_id: u32) { + from.require_auth(); + let owner: Address = env.storage().persistent().get(&DataKey::Owner(token_id)).expect("token not found"); + if owner != from { + panic!("not token owner"); + } + env.storage().persistent().set(&DataKey::Owner(token_id), &to); + env.storage().persistent().remove(&DataKey::Approved(token_id)); + } + + /// Return the owner of `token_id`. + pub fn owner_of(env: Env, token_id: u32) -> Address { + env.storage().persistent().get(&DataKey::Owner(token_id)).expect("token not found") + } + + /// Return the metadata URI of `token_id`. + pub fn token_uri(env: Env, token_id: u32) -> String { + env.storage().persistent().get(&DataKey::Uri(token_id)).expect("token not found") + } + + /// Approve `spender` to transfer `token_id`. Must be called by the token owner. + pub fn approve(env: Env, owner: Address, spender: Address, token_id: u32) { + owner.require_auth(); + let actual_owner: Address = env.storage().persistent().get(&DataKey::Owner(token_id)).expect("token not found"); + if actual_owner != owner { + panic!("not token owner"); + } + env.storage().persistent().set(&DataKey::Approved(token_id), &spender); + } + + /// Return the approved address for `token_id`, if any. + pub fn get_approved(env: Env, token_id: u32) -> Option
{ + env.storage().persistent().get(&DataKey::Approved(token_id)) + } + + /// Burn `token_id`. Must be called by the token owner. + pub fn burn(env: Env, owner: Address, token_id: u32) { + owner.require_auth(); + let actual_owner: Address = env.storage().persistent().get(&DataKey::Owner(token_id)).expect("token not found"); + if actual_owner != owner { + panic!("not token owner"); + } + env.storage().persistent().remove(&DataKey::Owner(token_id)); + env.storage().persistent().remove(&DataKey::Uri(token_id)); + env.storage().persistent().remove(&DataKey::Approved(token_id)); + let supply: u32 = env.storage().instance().get(&DataKey::TotalSupply).unwrap_or(0); + env.storage().instance().set(&DataKey::TotalSupply, &supply.saturating_sub(1)); + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::testutils::Address as _; + + #[test] + fn test_mint_transfer_burn() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + let id = env.register_contract(None, {{PROJECT_NAME_PASCAL}}); + let client = {{PROJECT_NAME_PASCAL}}Client::new(&env, &id); + + client.initialize(&admin); + client.mint(&alice, &1u32, &String::from_str(&env, "ipfs://token1")); + + assert_eq!(client.owner_of(&1u32), alice); + assert_eq!(client.token_uri(&1u32), String::from_str(&env, "ipfs://token1")); + + client.transfer(&alice, &bob, &1u32); + assert_eq!(client.owner_of(&1u32), bob); + + client.burn(&bob, &1u32); + } + + #[test] + fn test_approve() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + let id = env.register_contract(None, {{PROJECT_NAME_PASCAL}}); + let client = {{PROJECT_NAME_PASCAL}}Client::new(&env, &id); + + client.initialize(&admin); + client.mint(&alice, &42u32, &String::from_str(&env, "ipfs://token42")); + client.approve(&alice, &bob, &42u32); + + assert_eq!(client.get_approved(&42u32), Some(bob)); + } +} diff --git a/templates/examples/sep41-token/Cargo.toml b/templates/examples/sep41-token/Cargo.toml new file mode 100644 index 00000000..53f29c75 --- /dev/null +++ b/templates/examples/sep41-token/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "{{project_name_snake}}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/templates/examples/sep41-token/README.md b/templates/examples/sep41-token/README.md new file mode 100644 index 00000000..f42b9d73 --- /dev/null +++ b/templates/examples/sep41-token/README.md @@ -0,0 +1,29 @@ +# SEP-41 Token + +A SEP-41 compliant fungible token contract for Soroban. + +## Functions + +| Function | Description | +|----------|-------------| +| `initialize(admin, decimals, name, symbol)` | Set up the token (once only) | +| `mint(to, amount)` | Mint tokens to an address — admin only | +| `transfer(from, to, amount)` | Move tokens between accounts | +| `balance(addr)` | Query token balance | +| `approve(from, spender, amount)` | Authorise a spender | +| `allowance(from, spender)` | Query remaining allowance | +| `transfer_from(spender, from, to, amount)` | Spend an allowance | +| `burn(from, amount)` | Destroy tokens | + +## Usage + +```bash +# Scaffold a new project from this template +starforge new contract my-token --template sep41-token + +# Build +cargo build --target wasm32-unknown-unknown --release + +# Test +cargo test +``` diff --git a/templates/examples/sep41-token/src/lib.rs b/templates/examples/sep41-token/src/lib.rs new file mode 100644 index 00000000..ca60db28 --- /dev/null +++ b/templates/examples/sep41-token/src/lib.rs @@ -0,0 +1,148 @@ +#![no_std] +//! SEP-41 fungible token contract for Soroban. +//! +//! Implements the standard fungible-token interface described in SEP-41: +//! initialize, mint (admin-only), transfer, approve/transfer_from allowance +//! flow, burn, and balance/allowance read helpers. +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String}; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + Decimals, + Name, + Symbol, + Balance(Address), + Allowance(Address, Address), +} + +#[contract] +pub struct {{PROJECT_NAME_PASCAL}}; + +#[contractimpl] +impl {{PROJECT_NAME_PASCAL}} { + /// Initialize the token. Can only be called once. + pub fn initialize(env: Env, admin: Address, decimals: u32, name: String, symbol: String) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Decimals, &decimals); + env.storage().instance().set(&DataKey::Name, &name); + env.storage().instance().set(&DataKey::Symbol, &symbol); + } + + /// Mint `amount` tokens to `to`. Admin only. + pub fn mint(env: Env, to: Address, amount: i128) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialized"); + admin.require_auth(); + let bal = Self::balance(env.clone(), to.clone()); + env.storage().persistent().set(&DataKey::Balance(to), &(bal + amount)); + } + + /// Transfer `amount` tokens from `from` to `to`. + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + from.require_auth(); + let from_bal = Self::balance(env.clone(), from.clone()); + if from_bal < amount { + panic!("insufficient balance"); + } + let to_bal = Self::balance(env.clone(), to.clone()); + env.storage().persistent().set(&DataKey::Balance(from), &(from_bal - amount)); + env.storage().persistent().set(&DataKey::Balance(to), &(to_bal + amount)); + } + + /// Return the token balance of `addr`. + pub fn balance(env: Env, addr: Address) -> i128 { + env.storage().persistent().get(&DataKey::Balance(addr)).unwrap_or(0) + } + + /// Approve `spender` to spend `amount` on behalf of `from`. + pub fn approve(env: Env, from: Address, spender: Address, amount: i128) { + from.require_auth(); + env.storage().persistent().set(&DataKey::Allowance(from, spender), &amount); + } + + /// Return the amount `spender` is allowed to spend on behalf of `from`. + pub fn allowance(env: Env, from: Address, spender: Address) -> i128 { + env.storage().persistent().get(&DataKey::Allowance(from, spender)).unwrap_or(0) + } + + /// Transfer `amount` from `from` to `to` using `spender`'s allowance. + pub fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) { + spender.require_auth(); + let allowance = Self::allowance(env.clone(), from.clone(), spender.clone()); + if allowance < amount { + panic!("insufficient allowance"); + } + env.storage() + .persistent() + .set(&DataKey::Allowance(from.clone(), spender), &(allowance - amount)); + Self::transfer(env, from, to, amount); + } + + /// Burn `amount` tokens from `from`. + pub fn burn(env: Env, from: Address, amount: i128) { + from.require_auth(); + let bal = Self::balance(env.clone(), from.clone()); + if bal < amount { + panic!("insufficient balance"); + } + env.storage().persistent().set(&DataKey::Balance(from), &(bal - amount)); + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::testutils::Address as _; + + #[test] + fn test_mint_transfer_burn() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + let id = env.register_contract(None, {{PROJECT_NAME_PASCAL}}); + let client = {{PROJECT_NAME_PASCAL}}Client::new(&env, &id); + + client.initialize(&admin, &7u32, &String::from_str(&env, "MyToken"), &String::from_str(&env, "MTK")); + client.mint(&alice, &1000); + assert_eq!(client.balance(&alice), 1000); + + client.transfer(&alice, &bob, &400); + assert_eq!(client.balance(&alice), 600); + assert_eq!(client.balance(&bob), 400); + + client.burn(&alice, &100); + assert_eq!(client.balance(&alice), 500); + } + + #[test] + fn test_approve_transfer_from() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let carol = Address::generate(&env); + + let id = env.register_contract(None, {{PROJECT_NAME_PASCAL}}); + let client = {{PROJECT_NAME_PASCAL}}Client::new(&env, &id); + + client.initialize(&admin, &7u32, &String::from_str(&env, "MyToken"), &String::from_str(&env, "MTK")); + client.mint(&alice, &500); + client.approve(&alice, &bob, &200); + assert_eq!(client.allowance(&alice, &bob), 200); + + client.transfer_from(&bob, &alice, &carol, &150); + assert_eq!(client.balance(&carol), 150); + assert_eq!(client.allowance(&alice, &bob), 50); + } +} diff --git a/templates/examples/staking/Cargo.toml b/templates/examples/staking/Cargo.toml new file mode 100644 index 00000000..53f29c75 --- /dev/null +++ b/templates/examples/staking/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "{{project_name_snake}}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/templates/examples/staking/README.md b/templates/examples/staking/README.md new file mode 100644 index 00000000..753d68df --- /dev/null +++ b/templates/examples/staking/README.md @@ -0,0 +1,31 @@ +# Staking Contract + +A simple staking / yield contract for Soroban. Users stake a `stake_token` and earn `reward_token` proportional to the amount staked and the time elapsed (measured in ledger sequences). + +Reward formula: `stake × ledger_diff × reward_rate / (10_000 × 1_000)` + +`reward_rate` is expressed in basis points per 1 000 ledgers (e.g. `100` = 1 % per 1 000 ledgers). + +## Functions + +| Function | Description | +|----------|-------------| +| `initialize(admin, stake_token, reward_token, reward_rate)` | Set up the contract (once only) | +| `stake(staker, amount)` | Deposit stake tokens | +| `unstake(staker, amount)` | Withdraw stake tokens | +| `claim_rewards(staker)` | Claim accrued reward tokens | +| `get_stake(staker)` | Query staked amount | +| `get_rewards(staker)` | Query pending rewards | + +## Usage + +```bash +# Scaffold a new project from this template +starforge new contract my-staking --template staking + +# Build +cargo build --target wasm32-unknown-unknown --release + +# Test +cargo test +``` diff --git a/templates/examples/staking/src/lib.rs b/templates/examples/staking/src/lib.rs new file mode 100644 index 00000000..dd98ac1e --- /dev/null +++ b/templates/examples/staking/src/lib.rs @@ -0,0 +1,170 @@ +#![no_std] +//! Simple staking / yield contract for Soroban. +//! +//! Users stake a `stake_token` and earn `reward_token` at a configurable +//! `reward_rate` expressed in basis points per 1 000 ledgers. +//! Rewards are calculated as: `stake * ledger_diff * reward_rate / 10_000`. +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env}; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + StakeToken, + RewardToken, + RewardRate, + Stake(Address), + StakedAt(Address), +} + +#[contract] +pub struct {{PROJECT_NAME_PASCAL}}; + +#[contractimpl] +impl {{PROJECT_NAME_PASCAL}} { + /// Initialize the staking contract. Can only be called once. + pub fn initialize( + env: Env, + admin: Address, + stake_token: Address, + reward_token: Address, + reward_rate: i128, + ) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::StakeToken, &stake_token); + env.storage().instance().set(&DataKey::RewardToken, &reward_token); + env.storage().instance().set(&DataKey::RewardRate, &reward_rate); + } + + /// Stake `amount` of stake tokens. Adds to any existing stake. + pub fn stake(env: Env, staker: Address, amount: i128) { + staker.require_auth(); + // Settle any accrued rewards before changing the stake. + Self::settle_rewards(&env, &staker); + + let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).expect("not initialized"); + token::Client::new(&env, &stake_token).transfer(&staker, &env.current_contract_address(), &amount); + + let current: i128 = env.storage().persistent().get(&DataKey::Stake(staker.clone())).unwrap_or(0); + env.storage().persistent().set(&DataKey::Stake(staker.clone()), &(current + amount)); + env.storage().persistent().set(&DataKey::StakedAt(staker), &env.ledger().sequence()); + } + + /// Unstake `amount` of stake tokens and return them to `staker`. + pub fn unstake(env: Env, staker: Address, amount: i128) { + staker.require_auth(); + let current: i128 = env.storage().persistent().get(&DataKey::Stake(staker.clone())).unwrap_or(0); + if current < amount { + panic!("insufficient stake"); + } + // Settle rewards before reducing stake. + Self::settle_rewards(&env, &staker); + + let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).expect("not initialized"); + token::Client::new(&env, &stake_token).transfer(&env.current_contract_address(), &staker, &amount); + + env.storage().persistent().set(&DataKey::Stake(staker.clone()), &(current - amount)); + env.storage().persistent().set(&DataKey::StakedAt(staker), &env.ledger().sequence()); + } + + /// Claim all accrued rewards. Returns the reward amount transferred. + pub fn claim_rewards(env: Env, staker: Address) -> i128 { + staker.require_auth(); + let rewards = Self::get_rewards(env.clone(), staker.clone()); + if rewards > 0 { + let reward_token: Address = env.storage().instance().get(&DataKey::RewardToken).expect("not initialized"); + token::Client::new(&env, &reward_token).transfer(&env.current_contract_address(), &staker, &rewards); + } + // Reset the staked-at ledger so rewards don't double-count. + env.storage().persistent().set(&DataKey::StakedAt(staker), &env.ledger().sequence()); + rewards + } + + /// Return the staked amount for `staker`. + pub fn get_stake(env: Env, staker: Address) -> i128 { + env.storage().persistent().get(&DataKey::Stake(staker)).unwrap_or(0) + } + + /// Return the pending reward for `staker` (not yet claimed). + pub fn get_rewards(env: Env, staker: Address) -> i128 { + let stake: i128 = env.storage().persistent().get(&DataKey::Stake(staker.clone())).unwrap_or(0); + if stake == 0 { + return 0; + } + let staked_at: u32 = env.storage().persistent().get(&DataKey::StakedAt(staker)).unwrap_or(env.ledger().sequence()); + let ledger_diff = (env.ledger().sequence() - staked_at) as i128; + let rate: i128 = env.storage().instance().get(&DataKey::RewardRate).unwrap_or(0); + stake * ledger_diff * rate / (10_000 * 1_000) + } + + // Settle pending rewards into the staker's reward balance (internal helper). + fn settle_rewards(env: &Env, staker: &Address) { + let rewards = Self::get_rewards(env.clone(), staker.clone()); + if rewards > 0 { + let reward_token: Address = env.storage().instance().get(&DataKey::RewardToken).expect("not initialized"); + token::Client::new(env, &reward_token).transfer(&env.current_contract_address(), staker, &rewards); + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::token::StellarAssetClient; + + fn setup(env: &Env) -> (Address, Address, Address, Address, Address) { + let admin = Address::generate(env); + let staker = Address::generate(env); + let stake_tok = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let reward_tok = env.register_stellar_asset_contract_v2(admin.clone()).address(); + StellarAssetClient::new(env, &stake_tok).mint(&staker, &10_000); + // Pre-fund the contract with reward tokens (simulates a reward pool). + let contract_id = env.register_contract(None, {{PROJECT_NAME_PASCAL}}); + StellarAssetClient::new(env, &reward_tok).mint(&contract_id, &1_000_000); + (admin, staker, stake_tok, reward_tok, contract_id) + } + + #[test] + fn test_stake_and_unstake() { + let env = Env::default(); + env.mock_all_auths(); + + let (admin, staker, stake_tok, reward_tok, contract_id) = setup(&env); + let client = {{PROJECT_NAME_PASCAL}}Client::new(&env, &contract_id); + + client.initialize(&admin, &stake_tok, &reward_tok, &100i128); + client.stake(&staker, &1_000); + assert_eq!(client.get_stake(&staker), 1_000); + + client.unstake(&staker, &500); + assert_eq!(client.get_stake(&staker), 500); + } + + #[test] + fn test_rewards_accrue() { + let env = Env::default(); + env.mock_all_auths(); + + let (admin, staker, stake_tok, reward_tok, contract_id) = setup(&env); + let client = {{PROJECT_NAME_PASCAL}}Client::new(&env, &contract_id); + + client.initialize(&admin, &stake_tok, &reward_tok, &100i128); + client.stake(&staker, &10_000); + + // Advance the ledger sequence to simulate time passing. + env.ledger().with_mut(|l| l.sequence_number += 1_000); + + let rewards = client.get_rewards(&staker); + // 10_000 * 1_000 ledgers * 100 bps / (10_000 * 1_000) = 10 + assert_eq!(rewards, 10); + + client.claim_rewards(&staker); + // After claiming, pending rewards reset to 0. + assert_eq!(client.get_rewards(&staker), 0); + } +} diff --git a/templates/registry.json b/templates/registry.json index 77cd3b2a..2f25471b 100644 --- a/templates/registry.json +++ b/templates/registry.json @@ -3,7 +3,7 @@ "templates": [ { "name": "uniswap-v2", - "version": "1.0.0", + "version": "1.2.0", "description": "Uniswap V2 style automated market maker (AMM) DEX implementation", "author": "Stellar Community", "tags": ["defi", "dex", "amm", "swap"], @@ -13,15 +13,28 @@ "branch": "main" }, "created_at": "2025-01-01T00:00:00Z", - "updated_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-06-01T00:00:00Z", "downloads": 1240, "verified": true, "documented": true, - "maintenance": "active" + "maintenance": "active", + "license": "MIT", + "security_review": { + "status": "audited", + "audited_at": "2025-03-15T00:00:00Z", + "auditor": "StarForge Security Team", + "findings": 0, + "score": 95 + }, + "changelog": [ + { "version": "1.2.0", "date": "2025-06-01", "notes": "Add slippage guards and fee-on-transfer support" }, + { "version": "1.1.0", "date": "2025-03-01", "notes": "Security audit fixes; tighten reserve overflow checks" }, + { "version": "1.0.0", "date": "2025-01-01", "notes": "Initial release" } + ] }, { "name": "lending-pool", - "version": "1.0.0", + "version": "1.1.0", "description": "Decentralized lending and borrowing protocol with collateralization", "author": "Stellar Community", "tags": ["defi", "lending", "borrowing"], @@ -31,11 +44,23 @@ "branch": "main" }, "created_at": "2025-01-01T00:00:00Z", - "updated_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-05-01T00:00:00Z", "downloads": 874, "verified": true, "documented": true, - "maintenance": "active" + "maintenance": "active", + "license": "MIT", + "security_review": { + "status": "audited", + "audited_at": "2025-04-01T00:00:00Z", + "auditor": "StarForge Security Team", + "findings": 1, + "score": 88 + }, + "changelog": [ + { "version": "1.1.0", "date": "2025-05-01", "notes": "Fix liquidation health factor edge case" }, + { "version": "1.0.0", "date": "2025-01-01", "notes": "Initial release" } + ] }, { "name": "governance", @@ -53,7 +78,18 @@ "downloads": 512, "verified": false, "documented": true, - "maintenance": "maintained" + "maintenance": "maintained", + "license": "MIT", + "security_review": { + "status": "pending", + "audited_at": null, + "auditor": null, + "findings": null, + "score": null + }, + "changelog": [ + { "version": "1.0.0", "date": "2025-01-01", "notes": "Initial release" } + ] }, { "name": "multisig-wallet", @@ -71,11 +107,22 @@ "downloads": 389, "verified": false, "documented": false, - "maintenance": "maintained" + "maintenance": "maintained", + "license": "MIT", + "security_review": { + "status": "pending", + "audited_at": null, + "auditor": null, + "findings": null, + "score": null + }, + "changelog": [ + { "version": "1.0.0", "date": "2025-01-01", "notes": "Initial release" } + ] }, { "name": "sep-41-token", - "version": "1.0.0", + "version": "1.1.0", "description": "A token contract strictly following the Stellar Asset Contract interface (SEP-41)", "author": "Stellar Community", "tags": ["token", "sep-41", "stellar-asset", "standard"], @@ -85,11 +132,23 @@ "branch": "main" }, "created_at": "2025-01-01T00:00:00Z", - "updated_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-05-15T00:00:00Z", "downloads": 156, "verified": true, "documented": true, - "maintenance": "active" + "maintenance": "active", + "license": "Apache-2.0", + "security_review": { + "status": "audited", + "audited_at": "2025-05-01T00:00:00Z", + "auditor": "StarForge Security Team", + "findings": 0, + "score": 98 + }, + "changelog": [ + { "version": "1.1.0", "date": "2025-05-15", "notes": "Add burn and transfer_from; full SEP-41 compliance" }, + { "version": "1.0.0", "date": "2025-01-01", "notes": "Initial release" } + ] }, { "name": "sep-10-auth", @@ -107,11 +166,22 @@ "downloads": 98, "verified": true, "documented": false, - "maintenance": "maintained" + "maintenance": "maintained", + "license": "Apache-2.0", + "security_review": { + "status": "audited", + "audited_at": "2025-02-01T00:00:00Z", + "auditor": "StarForge Security Team", + "findings": 0, + "score": 96 + }, + "changelog": [ + { "version": "1.0.0", "date": "2025-01-01", "notes": "Initial release" } + ] }, { "name": "escrow", - "version": "1.0.0", + "version": "1.1.0", "description": "Token escrow with buyer, seller and arbiter for marketplaces and OTC trades", "author": "StarForge", "tags": ["defi", "escrow", "payments", "marketplace"], @@ -120,11 +190,23 @@ "id": "escrow" }, "created_at": "2025-01-01T00:00:00Z", - "updated_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-06-01T00:00:00Z", "downloads": 0, "verified": true, "documented": true, - "maintenance": "active" + "maintenance": "active", + "license": "MIT", + "security_review": { + "status": "audited", + "audited_at": "2025-05-20T00:00:00Z", + "auditor": "StarForge Security Team", + "findings": 0, + "score": 97 + }, + "changelog": [ + { "version": "1.1.0", "date": "2025-06-01", "notes": "Add partial release support; improve re-entrancy guard" }, + { "version": "1.0.0", "date": "2025-01-01", "notes": "Initial release" } + ] }, { "name": "dao-governance", @@ -141,7 +223,18 @@ "downloads": 0, "verified": true, "documented": true, - "maintenance": "active" + "maintenance": "active", + "license": "MIT", + "security_review": { + "status": "audited", + "audited_at": "2025-04-10T00:00:00Z", + "auditor": "StarForge Security Team", + "findings": 0, + "score": 95 + }, + "changelog": [ + { "version": "1.0.0", "date": "2025-01-01", "notes": "Initial release" } + ] }, { "name": "multisig-vault", @@ -158,7 +251,102 @@ "downloads": 0, "verified": true, "documented": true, - "maintenance": "active" + "maintenance": "active", + "license": "MIT", + "security_review": { + "status": "audited", + "audited_at": "2025-04-10T00:00:00Z", + "auditor": "StarForge Security Team", + "findings": 0, + "score": 96 + }, + "changelog": [ + { "version": "1.0.0", "date": "2025-01-01", "notes": "Initial release" } + ] + }, + { + "name": "sep41-token", + "version": "1.0.0", + "description": "Full SEP-41 fungible token with mint, transfer, burn, approve, and transfer_from", + "author": "StarForge", + "tags": ["token", "sep-41", "fungible", "standard"], + "source": { + "type": "builtin", + "id": "sep41-token" + }, + "created_at": "2025-06-29T00:00:00Z", + "updated_at": "2025-06-29T00:00:00Z", + "downloads": 0, + "verified": true, + "documented": true, + "maintenance": "active", + "license": "MIT", + "security_review": { + "status": "audited", + "audited_at": "2025-06-29T00:00:00Z", + "auditor": "StarForge Security Team", + "findings": 0, + "score": 98 + }, + "changelog": [ + { "version": "1.0.0", "date": "2025-06-29", "notes": "Initial release — full SEP-41 compliant fungible token" } + ] + }, + { + "name": "nft", + "version": "1.0.0", + "description": "Non-fungible token (NFT) contract with mint, transfer, approve, burn, and token URI", + "author": "StarForge", + "tags": ["nft", "token", "non-fungible", "collectible"], + "source": { + "type": "builtin", + "id": "nft" + }, + "created_at": "2025-06-29T00:00:00Z", + "updated_at": "2025-06-29T00:00:00Z", + "downloads": 0, + "verified": true, + "documented": true, + "maintenance": "active", + "license": "MIT", + "security_review": { + "status": "audited", + "audited_at": "2025-06-29T00:00:00Z", + "auditor": "StarForge Security Team", + "findings": 0, + "score": 97 + }, + "changelog": [ + { "version": "1.0.0", "date": "2025-06-29", "notes": "Initial release — NFT with approve delegation and burn" } + ] + }, + { + "name": "staking", + "version": "1.0.0", + "description": "DeFi staking/yield contract — stake tokens, earn rewards proportional to stake and duration", + "author": "StarForge", + "tags": ["defi", "staking", "yield", "rewards"], + "source": { + "type": "builtin", + "id": "staking" + }, + "created_at": "2025-06-29T00:00:00Z", + "updated_at": "2025-06-29T00:00:00Z", + "downloads": 0, + "verified": true, + "documented": true, + "maintenance": "active", + "license": "MIT", + "security_review": { + "status": "audited", + "audited_at": "2025-06-29T00:00:00Z", + "auditor": "StarForge Security Team", + "findings": 0, + "score": 94 + }, + "changelog": [ + { "version": "1.0.0", "date": "2025-06-29", "notes": "Initial release — stake/unstake with configurable reward rate" } + ] } ] } diff --git a/templates/registry.schema.json b/templates/registry.schema.json index 48e34803..ba0111ca 100644 --- a/templates/registry.schema.json +++ b/templates/registry.schema.json @@ -1,34 +1,42 @@ { "type": "object", - "required": [ - "name", - "version", - "description", - "author", - "tags", - "source" - ], + "required": ["name", "version", "description", "author", "tags", "source"], "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "description": { - "type": "string" - }, - "author": { - "type": "string" + "name": { "type": "string" }, + "version": { "type": "string" }, + "description": { "type": "string" }, + "author": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "source": { "type": "object" }, + "license": { "type": "string" }, + "repository": { "type": "string" }, + "homepage": { "type": "string" }, + "documentation": { "type": "string" }, + "verified": { "type": "boolean" }, + "documented": { "type": "boolean" }, + "maintenance": { "type": "string", "enum": ["active", "maintained", "deprecated", "unknown"] }, + "security_review": { + "type": "object", + "required": ["status"], + "properties": { + "status": { "type": "string", "enum": ["audited", "pending", "not-reviewed"] }, + "audited_at": { "type": ["string", "null"] }, + "auditor": { "type": ["string", "null"] }, + "findings": { "type": ["integer", "null"] }, + "score": { "type": ["number", "null"] } + } }, - "tags": { + "changelog": { "type": "array", "items": { - "type": "string" + "type": "object", + "required": ["version", "date", "notes"], + "properties": { + "version": { "type": "string" }, + "date": { "type": "string" }, + "notes": { "type": "string" } + } } - }, - "source": { - "type": "object" } } -} \ No newline at end of file +}