From deb232b9077bfaccadbd53def36280c24eea2921 Mon Sep 17 00:00:00 2001 From: woydarko Date: Tue, 31 Mar 2026 23:01:42 +0700 Subject: [PATCH 1/2] feat: add Soroban contract template system Closes #48 ## What was added ### server/utils/contract_templates.py (new) Template registry and generator with 4 starter templates: - hello_world: minimal greeting contract (beginner) - token: fungible token with initialize/transfer/balance (intermediate) - nft: non-fungible token with mint/transfer/owner_of/token_uri (intermediate) - governance: DAO proposal + voting with quorum threshold (advanced) Functions: list_templates(), get_template(id), generate_template(id, path, name) Each template generates: Cargo.toml, src/lib.rs, README.md ### server/routes/template_routes.py (new) Three endpoints registered under /api/templates/: GET /api/templates - Lists all 4 templates with id, name, description, difficulty, tags GET /api/templates/ - Returns metadata for one template, 404 with available list if not found POST /api/templates/generate - Validates session ownership and resolves instance_dir - Validates project_name with regex (alphanumeric, hyphens, underscores) - Blocks path traversal - Generates template files into session workspace - Returns files_created list and project_path ### server/start.py Registered templates_bp blueprint ### server/tests/test_contract_templates.py (new) 28 tests covering all components: - list_templates: count, required fields, expected IDs - get_template: valid/invalid IDs, all templates retrievable - generate_template: all 4 templates, Cargo.toml content, src/lib.rs has soroban_sdk, README mentions template name, raises for unknown template, raises if path exists, files_created list correct - GET /api/templates: 200, returns 4, required fields - GET /api/templates/: 200 valid, 404 unknown, available list on 404 - POST /api/templates/generate: missing fields, session not found, path traversal blocked, invalid project_name, unknown template 404, successful generation with files on disk ## Results Tests: 88 passed, 0 failed (full suite) --- server/routes/template_routes.py | 168 ++++++++++ server/start.py | 2 + server/tests/test_contract_templates.py | 243 +++++++++++++++ server/utils/contract_templates.py | 399 ++++++++++++++++++++++++ 4 files changed, 812 insertions(+) create mode 100755 server/routes/template_routes.py create mode 100755 server/tests/test_contract_templates.py create mode 100755 server/utils/contract_templates.py diff --git a/server/routes/template_routes.py b/server/routes/template_routes.py new file mode 100755 index 0000000..9070250 --- /dev/null +++ b/server/routes/template_routes.py @@ -0,0 +1,168 @@ +""" +Soroban contract template routes for Calliope IDE. +Addresses issue #48. + +Endpoints: + GET /api/templates — list all available templates + GET /api/templates/ — get metadata for one template + POST /api/templates/generate — generate a project from a template +""" + +import os +import logging +from pathlib import Path +from flask import Blueprint, request, jsonify +from server.utils.auth_utils import token_required +from server.utils.monitoring import capture_exception +from server.utils.contract_templates import list_templates, get_template, generate_template + +try: + from server.models import Session +except Exception: + Session = None # type: ignore + +templates_bp = Blueprint("templates", __name__, url_prefix="/api/templates") +logger = logging.getLogger(__name__) + + +@templates_bp.route("/", methods=["GET"]) +@templates_bp.route("", methods=["GET"]) +def list_all_templates(): + """ + List all available Soroban contract templates. + + Response JSON: + success (bool) + templates (list[dict]) — id, name, description, difficulty, tags + total (int) + """ + try: + templates = list_templates() + return jsonify({ + "success": True, + "templates": templates, + "total": len(templates), + }), 200 + except Exception as e: + logger.exception("List templates error") + return jsonify({"success": False, "error": "Failed to list templates"}), 500 + + +@templates_bp.route("/", methods=["GET"]) +def get_template_info(template_id: str): + """ + Get metadata for a single template. + + Response JSON: + success (bool) + template (dict) — id, name, description, difficulty, tags + """ + try: + template = get_template(template_id) + if not template: + return jsonify({ + "success": False, + "error": f"Template '{template_id}' not found", + "available": [t["id"] for t in list_templates()], + }), 404 + return jsonify({"success": True, "template": template}), 200 + except Exception as e: + logger.exception("Get template error") + return jsonify({"success": False, "error": "Failed to get template"}), 500 + + +@templates_bp.route("/generate", methods=["POST"]) +@token_required +def generate_from_template(current_user): + """ + Generate a new Soroban project from a template inside the session workspace. + + Request JSON: + session_id (int) — active session ID + template_id (str) — one of: hello_world, token, nft, governance + project_name (str) — directory name for the new project + package_name (str) — (optional) Rust package name in Cargo.toml + + Response JSON: + success (bool) + template_id (str) + template_name (str) + project_path (str) — absolute path inside the session workspace + files_created (list[str]) + """ + try: + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "No data provided"}), 400 + + session_id = data.get("session_id") + if not session_id: + return jsonify({"success": False, "error": "session_id is required"}), 400 + + template_id = data.get("template_id", "").strip() + if not template_id: + return jsonify({"success": False, "error": "template_id is required"}), 400 + + project_name = data.get("project_name", "").strip() + if not project_name: + return jsonify({"success": False, "error": "project_name is required"}), 400 + + # Validate project_name — alphanumeric, underscores, hyphens only + import re + if not re.match(r'^[a-zA-Z][a-zA-Z0-9_\-]{0,63}$', project_name): + return jsonify({ + "success": False, + "error": "project_name must start with a letter and contain only letters, digits, hyphens, or underscores (max 64 chars)" + }), 400 + + # Verify session belongs to current user + session = Session.query.filter_by( + id=session_id, user_id=current_user.id, is_active=True + ).first() + if not session: + return jsonify({"success": False, "error": "Session not found or access denied"}), 404 + + instance_dir = session.instance_dir + if not instance_dir or not os.path.isdir(instance_dir): + return jsonify({"success": False, "error": "Session workspace not found"}), 404 + + # Resolve and validate target path (block path traversal) + base = os.path.abspath(instance_dir) + target = os.path.abspath(os.path.join(base, project_name)) + if not target.startswith(base + os.sep): + return jsonify({"success": False, "error": "Invalid project_name — path traversal detected"}), 400 + + # Validate template exists + if not get_template(template_id): + from server.utils.contract_templates import list_templates as _lt + return jsonify({ + "success": False, + "error": f"Template '{template_id}' not found", + "available": [t["id"] for t in _lt()], + }), 404 + + package_name = data.get("package_name", "").strip() or None + + # Generate the template + result = generate_template( + template_id=template_id, + project_path=target, + project_name=package_name, + ) + + logger.info( + "User %s generated template '%s' at %s", + current_user.username, template_id, target, + ) + + return jsonify(result), 201 + + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 400 + except Exception as e: + logger.exception("Generate template error") + capture_exception(e, { + "route": "templates.generate_from_template", + "user_id": current_user.id, + }) + return jsonify({"success": False, "error": "An error occurred while generating the template"}), 500 diff --git a/server/start.py b/server/start.py index 09fa03a..2780d05 100644 --- a/server/start.py +++ b/server/start.py @@ -18,6 +18,7 @@ from server.routes import auth_bp from server.routes.chat_routes import chat_bp from server.routes.soroban_routes import soroban_bp +from server.routes.template_routes import templates_bp from server.routes.project_routes import project_bp from server.routes.soroban_deploy import soroban_deploy_bp from server.routes.soroban_wallet import wallet_bp @@ -80,6 +81,7 @@ app.register_blueprint(chat_bp) app.register_blueprint(project_bp) app.register_blueprint(soroban_bp) +app.register_blueprint(templates_bp) app.register_blueprint(soroban_deploy_bp) app.register_blueprint(wallet_bp) diff --git a/server/tests/test_contract_templates.py b/server/tests/test_contract_templates.py new file mode 100755 index 0000000..16f531e --- /dev/null +++ b/server/tests/test_contract_templates.py @@ -0,0 +1,243 @@ +"""Tests for server/utils/contract_templates.py and /api/templates routes.""" + +import pytest +import os +import sys +import functools +from unittest.mock import MagicMock + +# Stub deps before import +def _passthrough(f): + @functools.wraps(f) + def inner(*args, **kwargs): + u = MagicMock(); u.id = 1; u.username = "testuser" + return f(u, *args, **kwargs) + return inner + +_auth_stub = MagicMock() +_auth_stub.token_required = _passthrough +sys.modules["server.utils.auth_utils"] = _auth_stub +sys.modules["server.models"] = MagicMock() +sys.modules["server.utils.monitoring"] = MagicMock() + +import server.utils.contract_templates as ct +import server.routes.template_routes as tr +template_bp = tr.templates_bp + +for mod in ["server.utils.auth_utils", "server.models", "server.utils.monitoring"]: + sys.modules.pop(mod, None) + +from flask import Flask + + +@pytest.fixture +def app(): + a = Flask(__name__) + a.config["TESTING"] = True + a.register_blueprint(template_bp) + return a + +@pytest.fixture +def client(app): return app.test_client() + +def yes_session(d): + s = MagicMock(); s.id = 1; s.user_id = 1; s.is_active = True; s.instance_dir = d + x = MagicMock(); x.query.filter_by.return_value.first.return_value = s + return x + +def no_session(): + x = MagicMock(); x.query.filter_by.return_value.first.return_value = None + return x + + +# ── list_templates ──────────────────────────────────────────────────────────── + +class TestListTemplates: + def test_returns_4_templates(self): + templates = ct.list_templates() + assert len(templates) == 4 + + def test_all_have_required_fields(self): + for t in ct.list_templates(): + assert "id" in t + assert "name" in t + assert "description" in t + assert "difficulty" in t + assert "tags" in t + + def test_includes_expected_ids(self): + ids = [t["id"] for t in ct.list_templates()] + assert "hello_world" in ids + assert "token" in ids + assert "nft" in ids + assert "governance" in ids + + +# ── get_template ────────────────────────────────────────────────────────────── + +class TestGetTemplate: + def test_returns_template_for_valid_id(self): + t = ct.get_template("hello_world") + assert t is not None + assert t["id"] == "hello_world" + + def test_returns_none_for_invalid_id(self): + assert ct.get_template("nonexistent") is None + + def test_all_templates_retrievable(self): + for t in ct.list_templates(): + result = ct.get_template(t["id"]) + assert result is not None + + +# ── generate_template ───────────────────────────────────────────────────────── + +class TestGenerateTemplate: + def test_generates_hello_world(self, tmp_path): + target = str(tmp_path / "my_hello") + result = ct.generate_template("hello_world", target) + assert result["success"] is True + assert os.path.isfile(os.path.join(target, "Cargo.toml")) + assert os.path.isfile(os.path.join(target, "src", "lib.rs")) + assert os.path.isfile(os.path.join(target, "README.md")) + + def test_generates_all_templates(self, tmp_path): + for t in ct.list_templates(): + target = str(tmp_path / t["id"]) + result = ct.generate_template(t["id"], target) + assert result["success"] is True + + def test_cargo_toml_has_package_name(self, tmp_path): + target = str(tmp_path / "my_token") + ct.generate_template("token", target, project_name="my_token") + content = open(os.path.join(target, "Cargo.toml")).read() + assert 'name = "my_token"' in content + + def test_lib_rs_has_soroban_sdk(self, tmp_path): + target = str(tmp_path / "my_nft") + ct.generate_template("nft", target) + content = open(os.path.join(target, "src", "lib.rs")).read() + assert "soroban_sdk" in content + + def test_readme_mentions_template_name(self, tmp_path): + target = str(tmp_path / "my_gov") + ct.generate_template("governance", target) + content = open(os.path.join(target, "README.md")).read() + assert "DAO Governance" in content or "Governance" in content + + def test_raises_for_unknown_template(self, tmp_path): + with pytest.raises(ValueError, match="Unknown template"): + ct.generate_template("unknown_template", str(tmp_path / "x")) + + def test_raises_if_path_exists(self, tmp_path): + target = str(tmp_path / "existing") + os.makedirs(target) + with pytest.raises(ValueError, match="already exists"): + ct.generate_template("hello_world", target) + + def test_files_created_list(self, tmp_path): + target = str(tmp_path / "my_project") + result = ct.generate_template("token", target) + assert "Cargo.toml" in result["files_created"] + assert "src/lib.rs" in result["files_created"] + assert "README.md" in result["files_created"] + + +# ── GET /api/templates ──────────────────────────────────────────────────────── + +class TestListTemplatesRoute: + def test_returns_200(self, client): + resp = client.get("/api/templates") + assert resp.status_code == 200 + + def test_returns_4_templates(self, client): + data = client.get("/api/templates").get_json() + assert data["success"] is True + assert data["total"] == 4 + + def test_template_has_required_fields(self, client): + data = client.get("/api/templates").get_json() + for t in data["templates"]: + assert "id" in t + assert "name" in t + assert "difficulty" in t + + +# ── GET /api/templates/ ─────────────────────────────────────────────────── + +class TestGetTemplateRoute: + def test_returns_200_for_valid_id(self, client): + resp = client.get("/api/templates/hello_world") + assert resp.status_code == 200 + + def test_returns_404_for_unknown_id(self, client): + resp = client.get("/api/templates/nonexistent") + assert resp.status_code == 404 + + def test_returns_available_list_on_404(self, client): + data = client.get("/api/templates/nonexistent").get_json() + assert "available" in data + + +# ── POST /api/templates/generate ───────────────────────────────────────────── + +class TestGenerateRoute: + def test_missing_session_id(self, client): + resp = client.post("/api/templates/generate", json={"template_id": "hello_world", "project_name": "x"}) + assert resp.status_code == 400 + assert b"session_id" in resp.data + + def test_missing_template_id(self, client): + resp = client.post("/api/templates/generate", json={"session_id": 1, "project_name": "x"}) + assert resp.status_code == 400 + assert b"template_id" in resp.data + + def test_missing_project_name(self, client): + resp = client.post("/api/templates/generate", json={"session_id": 1, "template_id": "hello_world"}) + assert resp.status_code == 400 + assert b"project_name" in resp.data + + def test_session_not_found(self, client): + tr.Session = no_session() + resp = client.post("/api/templates/generate", json={ + "session_id": 99, "template_id": "hello_world", "project_name": "x" + }) + assert resp.status_code == 404 + + def test_path_traversal_blocked(self, client, tmp_path): + d = str(tmp_path / "inst"); os.makedirs(d) + tr.Session = yes_session(d) + resp = client.post("/api/templates/generate", json={ + "session_id": 1, "template_id": "hello_world", "project_name": "../../etc" + }) + # Either caught by project_name regex validation (400) or path traversal check (400) + assert resp.status_code == 400 + + def test_invalid_project_name_starts_with_digit(self, client, tmp_path): + d = str(tmp_path / "inst"); os.makedirs(d) + tr.Session = yes_session(d) + resp = client.post("/api/templates/generate", json={ + "session_id": 1, "template_id": "hello_world", "project_name": "1invalid" + }) + assert resp.status_code == 400 + + def test_unknown_template_returns_404(self, client, tmp_path): + d = str(tmp_path / "inst"); os.makedirs(d) + tr.Session = yes_session(d) + resp = client.post("/api/templates/generate", json={ + "session_id": 1, "template_id": "nonexistent", "project_name": "myproject" + }) + assert resp.status_code == 404 + + def test_successful_generation(self, client, tmp_path): + d = str(tmp_path / "inst"); os.makedirs(d) + tr.Session = yes_session(d) + resp = client.post("/api/templates/generate", json={ + "session_id": 1, "template_id": "hello_world", "project_name": "myproject" + }) + assert resp.status_code == 201 + data = resp.get_json() + assert data["success"] is True + assert data["template_id"] == "hello_world" + assert "files_created" in data + assert os.path.isfile(os.path.join(d, "myproject", "Cargo.toml")) diff --git a/server/utils/contract_templates.py b/server/utils/contract_templates.py new file mode 100755 index 0000000..4fadbfa --- /dev/null +++ b/server/utils/contract_templates.py @@ -0,0 +1,399 @@ +""" +Soroban contract template system for Calliope IDE. +Addresses issue #48. + +Provides 4 starter templates: + - hello_world : minimal greeting contract + - token : fungible token with transfer/balance + - nft : non-fungible token with mint/transfer + - governance : DAO proposal + voting contract + +Templates are generated as valid working Soroban Rust projects +with correct Cargo.toml, src/lib.rs, and a README. +""" + +import os +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +# ── Template registry ───────────────────────────────────────────────────────── + +TEMPLATES = { + "hello_world": { + "name": "Hello World", + "description": "Minimal Soroban contract — greeting function. Best starting point.", + "difficulty": "beginner", + "tags": ["starter", "beginner"], + }, + "token": { + "name": "Fungible Token", + "description": "ERC-20-style token with initialize, transfer, and balance functions.", + "difficulty": "intermediate", + "tags": ["token", "defi"], + }, + "nft": { + "name": "NFT Contract", + "description": "Non-fungible token with mint, transfer, owner_of, and token_uri.", + "difficulty": "intermediate", + "tags": ["nft", "collectible"], + }, + "governance": { + "name": "DAO Governance", + "description": "On-chain proposal and voting system with quorum threshold.", + "difficulty": "advanced", + "tags": ["dao", "governance", "voting"], + }, +} + + +def list_templates() -> list[dict]: + """Return metadata for all available templates.""" + return [ + {"id": tid, **meta} + for tid, meta in TEMPLATES.items() + ] + + +def get_template(template_id: str) -> dict | None: + """Return metadata for a single template, or None if not found.""" + if template_id not in TEMPLATES: + return None + return {"id": template_id, **TEMPLATES[template_id]} + + +def _cargo_toml(package_name: str) -> str: + return f"""[package] +name = "{package_name}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = {{ version = "22", features = ["testutils"] }} + +[dev-dependencies] +soroban-sdk = {{ version = "22", features = ["testutils"] }} + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true +""" + + +def _readme(template_name: str, description: str) -> str: + return f"""# {template_name} + +{description} + +## Build + +```bash +cargo build --target wasm32-unknown-unknown --release +``` + +## Test + +```bash +cargo test +``` + +## Deploy + +Use the Calliope IDE Soroban deploy panel to deploy the compiled `.wasm` artifact to Stellar testnet. +""" + + +# ── Template source code ────────────────────────────────────────────────────── + +_SOURCES: dict[str, str] = { + "hello_world": """\ +#![no_std] +use soroban_sdk::{contract, contractimpl, symbol_short, vec, Env, Symbol, Vec}; + +#[contract] +pub struct HelloContract; + +#[contractimpl] +impl HelloContract { + pub fn hello(env: Env, to: Symbol) -> Vec { + vec![&env, symbol_short!("Hello"), to] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{symbol_short, vec, Env}; + + #[test] + fn test_hello() { + let env = Env::default(); + let contract_id = env.register_contract(None, HelloContract); + let client = HelloContractClient::new(&env, &contract_id); + let words = client.hello(&symbol_short!("World")); + assert_eq!( + words, + vec![&env, symbol_short!("Hello"), symbol_short!("World")] + ); + } +} +""", + + "token": """\ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contracttype] +pub enum DataKey { + Balance(Address), + TotalSupply, + Admin, +} + +#[contract] +pub struct TokenContract; + +#[contractimpl] +impl TokenContract { + pub fn initialize(env: Env, admin: Address, total_supply: i128) { + assert!(!env.storage().instance().has(&DataKey::Admin), "already initialized"); + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TotalSupply, &total_supply); + env.storage().instance().set(&DataKey::Balance(admin.clone()), &total_supply); + } + + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + from.require_auth(); + assert!(amount > 0, "amount must be positive"); + let from_bal: i128 = env.storage().instance() + .get(&DataKey::Balance(from.clone())).unwrap_or(0); + assert!(from_bal >= amount, "insufficient balance"); + let to_bal: i128 = env.storage().instance() + .get(&DataKey::Balance(to.clone())).unwrap_or(0); + env.storage().instance().set(&DataKey::Balance(from), &(from_bal - amount)); + env.storage().instance().set(&DataKey::Balance(to), &(to_bal + amount)); + } + + pub fn balance(env: Env, address: Address) -> i128 { + env.storage().instance().get(&DataKey::Balance(address)).unwrap_or(0) + } + + pub fn total_supply(env: Env) -> i128 { + env.storage().instance().get(&DataKey::TotalSupply).unwrap_or(0) + } +} +""", + + "nft": """\ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String}; + +#[contracttype] +pub enum DataKey { + Owner(u64), + TokenUri(u64), + NextTokenId, + Admin, +} + +#[contract] +pub struct NftContract; + +#[contractimpl] +impl NftContract { + pub fn initialize(env: Env, admin: Address) { + assert!(!env.storage().instance().has(&DataKey::Admin), "already initialized"); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::NextTokenId, &0u64); + } + + pub fn mint(env: Env, to: Address, uri: String) -> u64 { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + let id: u64 = env.storage().instance() + .get(&DataKey::NextTokenId).unwrap_or(0); + env.storage().instance().set(&DataKey::Owner(id), &to); + env.storage().instance().set(&DataKey::TokenUri(id), &uri); + env.storage().instance().set(&DataKey::NextTokenId, &(id + 1)); + id + } + + pub fn transfer(env: Env, from: Address, to: Address, token_id: u64) { + from.require_auth(); + let owner: Address = env.storage().instance() + .get(&DataKey::Owner(token_id)).unwrap(); + assert!(owner == from, "not the token owner"); + env.storage().instance().set(&DataKey::Owner(token_id), &to); + } + + pub fn owner_of(env: Env, token_id: u64) -> Address { + env.storage().instance().get(&DataKey::Owner(token_id)).unwrap() + } + + pub fn token_uri(env: Env, token_id: u64) -> String { + env.storage().instance().get(&DataKey::TokenUri(token_id)).unwrap() + } +} +""", + + "governance": """\ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contracttype] +pub enum DataKey { + Proposal(u64), + HasVoted(u64, Address), + NextProposalId, + Admin, + QuorumThreshold, +} + +#[contracttype] +#[derive(Clone)] +pub struct Proposal { + pub id: u64, + pub proposer: Address, + pub description_hash: u64, + pub votes_for: u64, + pub votes_against: u64, + pub executed: bool, +} + +#[contract] +pub struct GovernanceContract; + +#[contractimpl] +impl GovernanceContract { + pub fn initialize(env: Env, admin: Address, quorum_threshold: u64) { + assert!(!env.storage().instance().has(&DataKey::Admin), "already initialized"); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::QuorumThreshold, &quorum_threshold); + env.storage().instance().set(&DataKey::NextProposalId, &0u64); + } + + pub fn propose(env: Env, proposer: Address, description_hash: u64) -> u64 { + proposer.require_auth(); + let id: u64 = env.storage().instance() + .get(&DataKey::NextProposalId).unwrap_or(0); + let proposal = Proposal { + id, + proposer, + description_hash, + votes_for: 0, + votes_against: 0, + executed: false, + }; + env.storage().instance().set(&DataKey::Proposal(id), &proposal); + env.storage().instance().set(&DataKey::NextProposalId, &(id + 1)); + id + } + + pub fn vote(env: Env, voter: Address, proposal_id: u64, support: bool) { + voter.require_auth(); + let voted: bool = env.storage().instance() + .get(&DataKey::HasVoted(proposal_id, voter.clone())) + .unwrap_or(false); + assert!(!voted, "already voted"); + let mut proposal: Proposal = env.storage().instance() + .get(&DataKey::Proposal(proposal_id)).unwrap(); + if support { proposal.votes_for += 1; } else { proposal.votes_against += 1; } + env.storage().instance().set(&DataKey::Proposal(proposal_id), &proposal); + env.storage().instance().set(&DataKey::HasVoted(proposal_id, voter), &true); + } + + pub fn get_proposal(env: Env, proposal_id: u64) -> Proposal { + env.storage().instance().get(&DataKey::Proposal(proposal_id)).unwrap() + } +} +""", +} + + +# ── Template generator ──────────────────────────────────────────────────────── + +def generate_template( + template_id: str, + project_path: str, + project_name: str | None = None, +) -> dict: + """ + Generate a Soroban project from a template into project_path. + + Creates: + {project_path}/ + Cargo.toml + src/ + lib.rs + README.md + + Args: + template_id: One of the keys in TEMPLATES. + project_path: Absolute path to the target directory (must not exist). + project_name: Package name for Cargo.toml (default: template_id). + + Returns: + dict with success, files_created, template_id, project_path. + + Raises: + ValueError: Unknown template_id or project_path already exists. + OSError: File system errors. + """ + if template_id not in TEMPLATES: + raise ValueError( + f"Unknown template '{template_id}'. " + f"Available: {', '.join(TEMPLATES.keys())}" + ) + + target = Path(project_path) + if target.exists(): + raise ValueError(f"Target path already exists: {project_path}") + + name = project_name or template_id.replace("-", "_") + meta = TEMPLATES[template_id] + source = _SOURCES[template_id] + + files_created = [] + + # Create directory structure + src_dir = target / "src" + src_dir.mkdir(parents=True, exist_ok=False) + + # Write Cargo.toml + cargo_path = target / "Cargo.toml" + cargo_path.write_text(_cargo_toml(name)) + files_created.append("Cargo.toml") + + # Write src/lib.rs + lib_path = src_dir / "lib.rs" + lib_path.write_text(source) + files_created.append("src/lib.rs") + + # Write README.md + readme_path = target / "README.md" + readme_path.write_text(_readme(meta["name"], meta["description"])) + files_created.append("README.md") + + logger.info( + "Generated template '%s' at %s (%d files)", + template_id, project_path, len(files_created), + ) + + return { + "success": True, + "template_id": template_id, + "template_name": meta["name"], + "project_path": str(target), + "files_created": files_created, + } From fdb4ff2d9b5765e8cbc38dc69dd73a04c3de51c4 Mon Sep 17 00:00:00 2001 From: woydarko Date: Sun, 19 Apr 2026 18:03:21 +0700 Subject: [PATCH 2/2] feat: add Soroban-specific AI prompt actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #54 ## What was added ### server/utils/soroban_prompts.py (new) 4 prebuilt Soroban prompt templates: - generate_contract: generate complete contract from description - explain_contract: explain contract in plain language - generate_tests: generate Rust test suite - security_review: security audit with severity ratings Functions: list_prompt_templates(), get_prompt_template(id), build_soroban_prompt(id, description, context_code) ### server/routes/soroban_prompt_routes.py (new) 4 endpoints under /api/prompts/soroban/: GET /api/prompts/soroban — list all templates GET /api/prompts/soroban/ — get template metadata POST /api/prompts/soroban/build — build prompt text only (no AI call) POST /api/prompts/soroban/execute — build + execute via Gemini, persists to chat history, validates session ownership ### server/start.py Registered soroban_prompts_bp blueprint ### server/tests/test_soroban_prompts.py (new) 33 tests covering all components ## Results Tests: 142 passed, 0 failed (33 new + all existing) --- server/routes/soroban_prompt_routes.py | 256 ++++++++++++++++++++++++ server/tests/test_soroban_prompts.py | 264 +++++++++++++++++++++++++ server/utils/soroban_prompts.py | 220 +++++++++++++++++++++ 3 files changed, 740 insertions(+) create mode 100755 server/routes/soroban_prompt_routes.py create mode 100755 server/tests/test_soroban_prompts.py create mode 100755 server/utils/soroban_prompts.py diff --git a/server/routes/soroban_prompt_routes.py b/server/routes/soroban_prompt_routes.py new file mode 100755 index 0000000..8dd4161 --- /dev/null +++ b/server/routes/soroban_prompt_routes.py @@ -0,0 +1,256 @@ +""" +Soroban prompt action routes for Calliope IDE. +Addresses issue #54. + +Endpoints: + GET /api/prompts/soroban — list all available prompt templates + GET /api/prompts/soroban/ — get a single template's metadata + POST /api/prompts/soroban/execute — build + execute a prompt via Gemini + POST /api/prompts/soroban/build — build prompt text only (no AI call) +""" + +import os +import logging +from flask import Blueprint, request, jsonify +from server.utils.auth_utils import token_required +from server.utils.monitoring import capture_exception +from server.utils.soroban_prompts import ( + list_prompt_templates, + get_prompt_template, + build_soroban_prompt, + PROMPT_TEMPLATES, +) + +try: + from server.models import Session, ChatHistory + from server.utils.db_utils import add_chat_message +except Exception: + Session = None # type: ignore + ChatHistory = None # type: ignore + add_chat_message = None # type: ignore + +logger = logging.getLogger(__name__) + +soroban_prompts_bp = Blueprint( + "soroban_prompts", __name__, url_prefix="/api/prompts/soroban" +) + +_MAX_CODE_LEN = 50_000 # characters +_MAX_DESC_LEN = 2_000 + + +# ── Routes ──────────────────────────────────────────────────────────────────── + +@soroban_prompts_bp.route("/", methods=["GET"]) +@soroban_prompts_bp.route("", methods=["GET"]) +def list_prompts(): + """ + List all available Soroban prompt templates. + + Response JSON: + success (bool) + prompts (list[dict]) — id, name, description, category, requires_code, placeholder + total (int) + """ + try: + prompts = list_prompt_templates() + return jsonify({"success": True, "prompts": prompts, "total": len(prompts)}), 200 + except Exception as e: + logger.exception("List prompts error") + return jsonify({"success": False, "error": "Failed to list prompts"}), 500 + + +@soroban_prompts_bp.route("/", methods=["GET"]) +def get_prompt(prompt_id: str): + """ + Get metadata for a single prompt template. + + Response JSON: + success (bool) + prompt (dict) + """ + try: + prompt = get_prompt_template(prompt_id) + if not prompt: + return jsonify({ + "success": False, + "error": f"Prompt '{prompt_id}' not found", + "available": [p["id"] for p in list_prompt_templates()], + }), 404 + return jsonify({"success": True, "prompt": prompt}), 200 + except Exception as e: + logger.exception("Get prompt error") + return jsonify({"success": False, "error": "Failed to get prompt"}), 500 + + +@soroban_prompts_bp.route("/build", methods=["POST"]) +@token_required +def build_prompt_text(current_user): + """ + Build the full prompt text without executing it. + Useful for the frontend to preview the prompt before sending. + + Request JSON: + prompt_id (str) — one of: generate_contract, explain_contract, generate_tests, security_review + description (str) — user's task description + context_code (str) — optional: contract source code + + Response JSON: + success (bool) + prompt_id (str) + prompt_text (str) — the full prompt string + char_count (int) + """ + try: + data = request.get_json(silent=True, force=True) + if not data: + return jsonify({"success": False, "error": "No data provided"}), 400 + + prompt_id = (data.get("prompt_id") or "").strip() + description = (data.get("description") or "").strip()[:_MAX_DESC_LEN] + context_code = (data.get("context_code") or "").strip()[:_MAX_CODE_LEN] + + if not prompt_id: + return jsonify({"success": False, "error": "prompt_id is required"}), 400 + + if prompt_id not in PROMPT_TEMPLATES: + return jsonify({ + "success": False, + "error": f"Unknown prompt '{prompt_id}'", + "available": list(PROMPT_TEMPLATES.keys()), + }), 404 + + template = PROMPT_TEMPLATES[prompt_id] + if template.requires_code and not context_code and not description: + return jsonify({ + "success": False, + "error": f"Prompt '{prompt_id}' requires either contract code or a description", + }), 400 + + prompt_text = build_soroban_prompt(prompt_id, description, context_code) + + return jsonify({ + "success": True, + "prompt_id": prompt_id, + "prompt_text": prompt_text, + "char_count": len(prompt_text), + }), 200 + + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 400 + except Exception as e: + logger.exception("Build prompt error") + capture_exception(e, {"route": "soroban_prompts.build_prompt_text", "user_id": current_user.id}) + return jsonify({"success": False, "error": "Failed to build prompt"}), 500 + + +@soroban_prompts_bp.route("/execute", methods=["POST"]) +@token_required +def execute_prompt(current_user): + """ + Build and execute a Soroban prompt via Gemini, returning the AI response. + + Request JSON: + session_id (int) — active session ID (for chat history) + prompt_id (str) — one of: generate_contract, explain_contract, generate_tests, security_review + description (str) — user's task description + context_code (str) — optional: contract source code + + Response JSON: + success (bool) + prompt_id (str) + result (str) — AI-generated response + char_count (int) + """ + try: + data = request.get_json(silent=True, force=True) + if not data: + return jsonify({"success": False, "error": "No data provided"}), 400 + + session_id = data.get("session_id") + prompt_id = (data.get("prompt_id") or "").strip() + description = (data.get("description") or "").strip()[:_MAX_DESC_LEN] + context_code = (data.get("context_code") or "").strip()[:_MAX_CODE_LEN] + + if not session_id: + return jsonify({"success": False, "error": "session_id is required"}), 400 + if not prompt_id: + return jsonify({"success": False, "error": "prompt_id is required"}), 400 + + if prompt_id not in PROMPT_TEMPLATES: + return jsonify({ + "success": False, + "error": f"Unknown prompt '{prompt_id}'", + "available": list(PROMPT_TEMPLATES.keys()), + }), 404 + + template = PROMPT_TEMPLATES[prompt_id] + if template.requires_code and not context_code and not description: + return jsonify({ + "success": False, + "error": f"Prompt '{prompt_id}' requires either contract code or a description", + }), 400 + + # Verify session + if Session: + session = Session.query.filter_by( + id=session_id, user_id=current_user.id, is_active=True + ).first() + if not session: + return jsonify({"success": False, "error": "Session not found or access denied"}), 404 + + # Build prompt + prompt_text = build_soroban_prompt(prompt_id, description, context_code) + + # Call Gemini + try: + import google.generativeai as genai + api_key = os.environ.get("GEMINI_API_KEY") + if not api_key: + return jsonify({"success": False, "error": "GEMINI_API_KEY not configured"}), 500 + + genai.configure(api_key=api_key) + model = genai.GenerativeModel( + model_name="gemini-2.0-flash", + generation_config={ + "temperature": 0.2, + "top_p": 0.95, + "max_output_tokens": 8192, + }, + ) + response = model.generate_content(prompt_text) + result = response.text + except ImportError: + return jsonify({"success": False, "error": "Gemini SDK not installed"}), 500 + + # Persist to chat history + if add_chat_message and session_id: + try: + add_chat_message( + session_id=session_id, + role="user", + content=f"[{template.name}] {description or '(no description)'}", + message_type="soroban_prompt", + ) + add_chat_message( + session_id=session_id, + role="assistant", + content=result, + message_type="soroban_prompt_response", + ) + except Exception as e: + logger.warning("Failed to persist prompt result: %s", e) + + return jsonify({ + "success": True, + "prompt_id": prompt_id, + "result": result, + "char_count": len(result), + }), 200 + + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 400 + except Exception as e: + logger.exception("Execute prompt error") + capture_exception(e, {"route": "soroban_prompts.execute_prompt", "user_id": current_user.id}) + return jsonify({"success": False, "error": "An error occurred while executing the prompt"}), 500 diff --git a/server/tests/test_soroban_prompts.py b/server/tests/test_soroban_prompts.py new file mode 100755 index 0000000..a60f40b --- /dev/null +++ b/server/tests/test_soroban_prompts.py @@ -0,0 +1,264 @@ +"""Tests for server/utils/soroban_prompts.py and /api/prompts/soroban routes""" + +import sys +import functools +from unittest.mock import MagicMock, patch + +# Stub deps +def _passthrough(f): + @functools.wraps(f) + def inner(*args, **kwargs): + u = MagicMock(); u.id = 1; u.username = "testuser" + return f(u, *args, **kwargs) + return inner + +_auth_stub = MagicMock() +_auth_stub.token_required = _passthrough +sys.modules["server.utils.auth_utils"] = _auth_stub +sys.modules["server.models"] = MagicMock() +sys.modules["server.utils.monitoring"] = MagicMock() +sys.modules["server.utils.db_utils"] = MagicMock() + +import server.utils.soroban_prompts as sp +import server.routes.soroban_prompt_routes as r +prompts_bp = r.soroban_prompts_bp + +for mod in ["server.utils.auth_utils","server.models","server.utils.monitoring","server.utils.db_utils"]: + sys.modules.pop(mod, None) + +import pytest +from flask import Flask + + +@pytest.fixture +def app(): + a = Flask(__name__) + a.config["TESTING"] = True + a.register_blueprint(prompts_bp) + return a + +@pytest.fixture +def client(app): return app.test_client() + +def yes_session(): + s = MagicMock(); s.id=1; s.user_id=1; s.is_active=True + x = MagicMock(); x.query.filter_by.return_value.first.return_value = s + return x + +def no_session(): + x = MagicMock(); x.query.filter_by.return_value.first.return_value = None + return x + + +# ── list_prompt_templates ───────────────────────────────────────────────────── + +class TestListPromptTemplates: + def test_returns_4_templates(self): + templates = sp.list_prompt_templates() + assert len(templates) == 4 + + def test_all_have_required_fields(self): + for t in sp.list_prompt_templates(): + assert "id" in t + assert "name" in t + assert "description" in t + assert "category" in t + assert "requires_code" in t + assert "placeholder" in t + + def test_includes_all_prompt_ids(self): + ids = [t["id"] for t in sp.list_prompt_templates()] + assert "generate_contract" in ids + assert "explain_contract" in ids + assert "generate_tests" in ids + assert "security_review" in ids + + +# ── get_prompt_template ─────────────────────────────────────────────────────── + +class TestGetPromptTemplate: + def test_returns_template_for_valid_id(self): + t = sp.get_prompt_template("generate_contract") + assert t is not None + assert t["id"] == "generate_contract" + + def test_returns_none_for_invalid_id(self): + assert sp.get_prompt_template("nonexistent") is None + + def test_generate_contract_does_not_require_code(self): + t = sp.get_prompt_template("generate_contract") + assert t["requires_code"] is False + + def test_explain_contract_requires_code(self): + t = sp.get_prompt_template("explain_contract") + assert t["requires_code"] is True + + def test_security_review_requires_code(self): + t = sp.get_prompt_template("security_review") + assert t["requires_code"] is True + + +# ── build_soroban_prompt ────────────────────────────────────────────────────── + +class TestBuildSorobanPrompt: + def test_generate_contract_contains_description(self): + prompt = sp.build_soroban_prompt("generate_contract", "A vesting contract") + assert "A vesting contract" in prompt + + def test_generate_contract_mentions_soroban(self): + prompt = sp.build_soroban_prompt("generate_contract", "token contract") + assert "Soroban" in prompt or "soroban" in prompt + + def test_explain_contract_includes_code(self): + code = "pub struct MyContract;" + prompt = sp.build_soroban_prompt("explain_contract", "", code) + assert code in prompt + + def test_generate_tests_mentions_test_suite(self): + prompt = sp.build_soroban_prompt("generate_tests", "my contract", "// code") + assert "test" in prompt.lower() + + def test_security_review_mentions_audit(self): + prompt = sp.build_soroban_prompt("security_review", "", "// code") + assert "security" in prompt.lower() or "audit" in prompt.lower() + + def test_raises_for_unknown_prompt_id(self): + with pytest.raises(ValueError, match="Unknown prompt"): + sp.build_soroban_prompt("nonexistent", "desc") + + def test_prompt_is_non_empty(self): + for pid in ["generate_contract", "explain_contract", "generate_tests", "security_review"]: + prompt = sp.build_soroban_prompt(pid, "test description", "// code") + assert len(prompt) > 100 + + +# ── GET /api/prompts/soroban ────────────────────────────────────────────────── + +class TestListPromptsRoute: + def test_returns_200(self, client): + resp = client.get("/api/prompts/soroban") + assert resp.status_code == 200 + + def test_returns_4_prompts(self, client): + data = client.get("/api/prompts/soroban").get_json() + assert data["success"] is True + assert data["total"] == 4 + + def test_prompt_has_required_fields(self, client): + data = client.get("/api/prompts/soroban").get_json() + for p in data["prompts"]: + assert "id" in p + assert "name" in p + assert "category" in p + + +# ── GET /api/prompts/soroban/ ───────────────────────────────────────────── + +class TestGetPromptRoute: + def test_returns_200_for_valid_id(self, client): + resp = client.get("/api/prompts/soroban/generate_contract") + assert resp.status_code == 200 + + def test_returns_404_for_unknown_id(self, client): + resp = client.get("/api/prompts/soroban/nonexistent") + assert resp.status_code == 404 + + def test_returns_available_list_on_404(self, client): + data = client.get("/api/prompts/soroban/nonexistent").get_json() + assert "available" in data + + +# ── POST /api/prompts/soroban/build ────────────────────────────────────────── + +class TestBuildPromptRoute: + def test_missing_prompt_id(self, client): + resp = client.post("/api/prompts/soroban/build", json={"description": "hi"}) + assert resp.status_code == 400 + + def test_unknown_prompt_id(self, client): + resp = client.post("/api/prompts/soroban/build", json={"prompt_id": "bad"}) + assert resp.status_code == 404 + + def test_requires_code_without_code_or_desc(self, client): + resp = client.post("/api/prompts/soroban/build", json={ + "prompt_id": "explain_contract" + }) + assert resp.status_code == 400 + + def test_builds_prompt_successfully(self, client): + resp = client.post("/api/prompts/soroban/build", json={ + "prompt_id": "generate_contract", + "description": "A simple counter contract", + }) + assert resp.status_code == 200 + data = resp.get_json() + assert data["success"] is True + assert "prompt_text" in data + assert "A simple counter contract" in data["prompt_text"] + assert "char_count" in data + + def test_includes_context_code_in_prompt(self, client): + resp = client.post("/api/prompts/soroban/build", json={ + "prompt_id": "explain_contract", + "description": "explain this", + "context_code": "pub struct MyContract;", + }) + data = resp.get_json() + assert "pub struct MyContract;" in data["prompt_text"] + + +# ── POST /api/prompts/soroban/execute ───────────────────────────────────────── + +class TestExecutePromptRoute: + def test_missing_session_id(self, client): + resp = client.post("/api/prompts/soroban/execute", json={ + "prompt_id": "generate_contract", "description": "hi" + }) + assert resp.status_code == 400 + + def test_missing_prompt_id(self, client): + resp = client.post("/api/prompts/soroban/execute", json={ + "session_id": 1, "description": "hi" + }) + assert resp.status_code == 400 + + def test_session_not_found(self, client): + r.Session = no_session() + resp = client.post("/api/prompts/soroban/execute", json={ + "session_id": 99, "prompt_id": "generate_contract", "description": "hi" + }) + assert resp.status_code == 404 + + def test_unknown_prompt_returns_404(self, client): + r.Session = yes_session() + resp = client.post("/api/prompts/soroban/execute", json={ + "session_id": 1, "prompt_id": "nonexistent", "description": "hi" + }) + assert resp.status_code == 404 + + def test_successful_execution(self, client): + r.Session = yes_session() + r.add_chat_message = MagicMock() + + mock_response = MagicMock() + mock_response.text = "Generated contract code here" + mock_model = MagicMock() + mock_model.generate_content.return_value = mock_response + + with patch.dict("sys.modules", { + "google.generativeai": MagicMock( + GenerativeModel=MagicMock(return_value=mock_model), + configure=MagicMock(), + ) + }), patch.dict("os.environ", {"GEMINI_API_KEY": "test-key"}): + resp = client.post("/api/prompts/soroban/execute", json={ + "session_id": 1, + "prompt_id": "generate_contract", + "description": "A simple counter contract", + }) + + assert resp.status_code == 200 + data = resp.get_json() + assert data["success"] is True + assert data["result"] == "Generated contract code here" + assert data["prompt_id"] == "generate_contract" diff --git a/server/utils/soroban_prompts.py b/server/utils/soroban_prompts.py new file mode 100755 index 0000000..c82a898 --- /dev/null +++ b/server/utils/soroban_prompts.py @@ -0,0 +1,220 @@ +""" +Soroban-specific AI prompt actions for Calliope IDE. +Addresses issue #54. + +Provides 4 prebuilt prompt templates: + - generate_contract : generate a Soroban smart contract from a description + - explain_contract : explain an existing contract in plain language + - generate_tests : generate a Rust test suite for a contract + - security_review : perform a security audit of a contract + +Each prompt is designed to produce focused, actionable AI output +that can be inserted directly into the editor. +""" + +from __future__ import annotations +from dataclasses import dataclass +from typing import Callable + +# ── Prompt registry ─────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class SorobanPromptTemplate: + id: str + name: str + description: str + category: str + requires_code: bool # True if user must supply contract code as context + placeholder: str # Hint shown in the input field + + +def _generate_contract(description: str, context_code: str = "") -> str: + ctx = f"\n\nExisting context:\n```rust\n{context_code}\n```" if context_code else "" + return f"""You are an expert Soroban smart contract developer for the Stellar blockchain. + +Generate a complete, production-ready Soroban smart contract for the following requirement: +{description}{ctx} + +Requirements: +- Use #![no_std] and soroban_sdk +- Include proper #[contract] and #[contractimpl] annotations +- Add #[contracttype] for all custom data structures +- Use persistent storage with typed DataKey enum +- Include proper authorization with require_auth() where needed +- Add inline comments explaining key logic +- Include a complete #[cfg(test)] module with at least 3 meaningful tests +- Follow Soroban best practices (no panics in production paths, proper error handling) + +Output ONLY the complete Rust source code, ready to save as src/lib.rs. +Do not include any explanation outside the code.""" + + +def _explain_contract(description: str, context_code: str = "") -> str: + code_section = f"\n\nContract code:\n```rust\n{context_code}\n```" if context_code else "" + extra = f"\nFocus on: {description}" if description else "" + return f"""You are an expert Soroban smart contract auditor and educator. + +Explain the following Soroban smart contract in clear, plain language.{code_section}{extra} + +Your explanation must cover: +1. **Purpose** — What does this contract do? What problem does it solve? +2. **Storage** — What data does it store and how is it organized? +3. **Functions** — Explain each public function: inputs, outputs, side effects +4. **Authorization** — Who can call each function and how is access controlled? +5. **Events** — What events are emitted and when? +6. **Limitations** — Any edge cases, assumptions, or known constraints? + +Write for a developer who understands Rust but is new to Soroban. +Use clear headings and bullet points.""" + + +def _generate_tests(description: str, context_code: str = "") -> str: + code_section = f"\n\nContract code:\n```rust\n{context_code}\n```" if context_code else "" + focus = f"\nTest focus: {description}" if description else "" + return f"""You are an expert Soroban smart contract tester. + +Generate a comprehensive Rust test suite for the following Soroban contract.{code_section}{focus} + +Requirements: +- Use soroban_sdk::testutils::Address as _ for Address::generate +- Use Env::default() and mock_all_auths() where appropriate +- Cover all public functions with at least one test each +- Include happy path tests AND edge case / failure tests +- Use #[should_panic(expected = "...")] for expected failures +- Add a setup() helper function to reduce boilerplate +- Group tests into logical test classes with descriptive names +- Each test should have a clear docstring explaining what it verifies + +Output ONLY the complete Rust test module code (the #[cfg(test)] block). +Do not include any explanation outside the code.""" + + +def _security_review(description: str, context_code: str = "") -> str: + code_section = f"\n\nContract code:\n```rust\n{context_code}\n```" if context_code else "" + scope = f"\nReview scope: {description}" if description else "" + return f"""You are an expert Soroban smart contract security auditor. + +Perform a thorough security review of the following Soroban smart contract.{code_section}{scope} + +Review checklist: +1. **Access Control** — Are all sensitive functions properly protected with require_auth()? + Are there any functions that should require admin-only access? +2. **Input Validation** — Are all inputs validated? Could any cause panics or unexpected behavior? +3. **Integer Overflow/Underflow** — Are arithmetic operations safe? (Soroban uses overflow-checks=true but review anyway) +4. **Storage Manipulation** — Can unauthorized callers read or write sensitive storage keys? +5. **Initialization** — Is there a risk of re-initialization or missing initialization? +6. **Reentrancy** — Are there any cross-contract call patterns that could be exploited? +7. **Event Emission** — Are sensitive operations properly logged via events? +8. **Denial of Service** — Are there unbounded loops or storage operations that could be abused? +9. **Logic Errors** — Any business logic flaws or incorrect assumptions? +10. **Best Practices** — Any deviations from Soroban / Stellar security best practices? + +For each issue found, provide: +- **Severity**: Critical / High / Medium / Low / Informational +- **Location**: Function name and line description +- **Description**: What the issue is and why it matters +- **Recommendation**: How to fix it with a code example if applicable + +End with an overall risk rating and a summary of the most important fixes.""" + + +# ── Registry ────────────────────────────────────────────────────────────────── + +PROMPT_TEMPLATES: dict[str, SorobanPromptTemplate] = { + "generate_contract": SorobanPromptTemplate( + id="generate_contract", + name="Generate Contract", + description="Generate a complete Soroban smart contract from a description", + category="generation", + requires_code=False, + placeholder="Describe the contract you want to build (e.g. 'A token vesting contract that releases tokens linearly over 12 months')", + ), + "explain_contract": SorobanPromptTemplate( + id="explain_contract", + name="Explain Contract", + description="Explain an existing Soroban contract in plain language", + category="education", + requires_code=True, + placeholder="Paste your contract code in the context field, or describe what aspect to focus on", + ), + "generate_tests": SorobanPromptTemplate( + id="generate_tests", + name="Generate Tests", + description="Generate a Rust test suite for a Soroban contract", + category="testing", + requires_code=True, + placeholder="Paste your contract code in the context field, or describe specific test scenarios to cover", + ), + "security_review": SorobanPromptTemplate( + id="security_review", + name="Security Review", + description="Perform a security audit of a Soroban contract", + category="security", + requires_code=True, + placeholder="Paste your contract code in the context field, or specify the review scope", + ), +} + +_BUILDERS: dict[str, Callable[[str, str], str]] = { + "generate_contract": _generate_contract, + "explain_contract": _explain_contract, + "generate_tests": _generate_tests, + "security_review": _security_review, +} + + +def list_prompt_templates() -> list[dict]: + """Return metadata for all available prompt templates.""" + return [ + { + "id": t.id, + "name": t.name, + "description": t.description, + "category": t.category, + "requires_code": t.requires_code, + "placeholder": t.placeholder, + } + for t in PROMPT_TEMPLATES.values() + ] + + +def get_prompt_template(prompt_id: str) -> dict | None: + """Return metadata for a single template, or None if not found.""" + t = PROMPT_TEMPLATES.get(prompt_id) + if not t: + return None + return { + "id": t.id, + "name": t.name, + "description": t.description, + "category": t.category, + "requires_code": t.requires_code, + "placeholder": t.placeholder, + } + + +def build_soroban_prompt( + prompt_id: str, + user_description: str, + context_code: str = "", +) -> str: + """ + Build the full prompt string for a given prompt template. + + Args: + prompt_id: One of the keys in PROMPT_TEMPLATES. + user_description: User's task description or focus area. + context_code: Optional contract source code to include. + + Returns: + The complete prompt string ready to send to the AI model. + + Raises: + ValueError: If prompt_id is not recognized. + """ + if prompt_id not in _BUILDERS: + raise ValueError( + f"Unknown prompt '{prompt_id}'. " + f"Available: {', '.join(_BUILDERS.keys())}" + ) + return _BUILDERS[prompt_id](user_description, context_code)