diff --git a/.gitignore b/.gitignore index 2d010ab2..efba2372 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ **/target/ .claude bugs/ +.ows-dev/ gtm diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a433076a..f8330814 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,33 @@ cd ows && cargo clippy --workspace -- -D warnings # Lint 3. **Test.** Ensure `cargo test --workspace` passes and `cargo clippy` is clean. 4. **Open a PR** against `main` with a clear description of what changed and why. +### Chain Contributor Kit + +If you are adding support for a new chain, start from the repository root with: + +```bash +cd ows +cargo run -p ows-cli -- dev scaffold-chain --slug my-chain --family evm +``` + +That command performs a dry run and shows the files it would create under: + +```text +.ows-dev/chain-plugin-kit// +``` + +To create the scaffold on disk, re-run with `--write`. + +Choose `--family` as the closest existing OWS family baseline for derivation +and signing defaults. + +If you use `--output`, it must stay under `.ows-dev/chain-plugin-kit/`. This +keeps `--force` restricted to a dedicated safe scaffold area. + +The first scaffold PR is intentionally conservative: it produces a self-contained +contributor kit and checklist without modifying runtime chain integration files +for you. + ## Pull Request Guidelines - Keep PRs small and focused — one logical change per PR. diff --git a/docs/chain-plugin-kit.md b/docs/chain-plugin-kit.md new file mode 100644 index 00000000..efc90759 --- /dev/null +++ b/docs/chain-plugin-kit.md @@ -0,0 +1,183 @@ +# Chain Plugin Kit + +> Non-normative implementation design note for a contributor-focused scaffold command. + +## 1. Problem + +Adding a new supported chain currently requires contributors to understand and +update several manual sync points across the Rust workspace. Chain metadata, +CAIP mappings, derivation behavior, signer registration, tests, and docs are +spread across multiple files and crates. + +That makes the first contribution harder than it needs to be and increases the +risk of partial or inconsistent changes. + +## 2. Why This Matters For OWS's Supported-Chains / Multi-Chain Model + +OWS is explicitly multi-chain. It uses CAIP identifiers, chain-family-aware +derivation rules, and chain-specific signing and serialization behavior. + +Because supported chains are a core part of the OWS model, contributor +ergonomics matter. A clear scaffold reduces the time needed to add support for a +new chain and makes it easier to keep supported-chain changes consistent across +`ows-core`, `ows-signer`, `ows-lib`, CLI behavior, tests, and docs. + +## 3. Goals + +- Add a small contributor-oriented scaffold command to `ows-cli` +- Make the first step of adding a supported chain easier and more repeatable +- Generate a self-contained "Chain Plugin Kit" work area with the minimum files + a contributor needs to start +- Keep the first PR dry-run-first and safe by default +- Reuse existing OWS terminology such as chain family, supported chain, CAIP, + derivation path, and signer +- Keep the implementation merge-friendly for upstream review + +## 4. Non-Goals + +- No full runtime plugin loading +- No dynamic chain discovery at runtime +- No automatic edits to live integration files in `ows-core`, `ows-signer`, or + `ows-lib` +- No support for introducing brand-new chain families in this PR +- No automatic RPC wiring, broadcast wiring, or bindings updates +- No cleanup of all existing manual-sync hazards in this PR + +## 5. Recommended CLI Command Shape + +```bash +ows dev scaffold-chain \ + --slug \ + --family \ + [--display-name ] \ + [--curve ] \ + [--address-format ] \ + [--coin-type ] \ + [--derivation-path ] \ + [--caip-namespace ] \ + [--caip-reference ] \ + [--output ] \ + [--write] \ + [--force] +``` + +Behavior: + +- Dry run is the default +- `--write` creates the scaffold on disk +- `--family` must be an existing `ows_core::ChainType` and acts as the closest + existing OWS family baseline for defaults +- optional placeholder flags override the generated defaults +- `--output` is optional and must stay under `.ows-dev/chain-plugin-kit/` +- the command fails if the target exists unless `--force` is passed +- `--force` only operates on validated paths inside the dedicated scaffold area + +Recommended default output: + +```text +.ows-dev/chain-plugin-kit// +``` + +## 6. Generated File/Folder Structure + +```text +.ows-dev/ + chain-plugin-kit/ + / + README.md + CONTRIBUTOR_GUIDE.md + chain-profile.toml + caip-mapping.toml + derivation-rules.toml + sign.stub.rs + serialize.stub.rs + docs/ + supported-chain-entry.md + implementation-checklist.md + security-checklist.md + test-vectors/ + README.md + derivation.json + sign-message.json + tx-serialization.json +``` + +Purpose of generated files: + +- `README.md`: short contributor-facing overview of the generated kit +- `CONTRIBUTOR_GUIDE.md`: step-by-step guide and likely OWS follow-up touchpoints +- `chain-profile.toml`: chain profile and address-format metadata +- `caip-mapping.toml`: canonical CAIP mapping and alias placeholders +- `derivation-rules.toml`: curve, coin type, and derivation placeholders +- `sign.stub.rs`: signing starter with TODOs +- `serialize.stub.rs`: signable-byte and serialization starter with TODOs +- `docs/supported-chain-entry.md`: supported-chain write-up skeleton +- `docs/implementation-checklist.md`: checklist for filling in the scaffold +- `docs/security-checklist.md`: security review checklist +- `test-vectors/README.md`: starter guidance for machine-readable vectors +- `test-vectors/*.json`: sample cases for derivation, message signing, and tx serialization + +## 7. Validation Rules + +- `slug` must be lowercase ASCII letters, numbers, and hyphens only +- `slug` must not be empty +- `slug` must not start or end with a hyphen +- `slug` must not contain repeated hyphens +- `display-name`, when provided, must be printable text without leading or + trailing whitespace +- `family` must parse as an existing `ChainType` +- optional placeholder tokens such as `--caip-namespace` and + `--caip-reference` must not contain whitespace or path separators +- `output` must resolve inside the repository root +- The command must fail if the target exists unless `--force` is passed +- The command must remain safe in dry-run mode and avoid filesystem writes + +## 8. Testing Strategy + +Keep tests focused in `ows-cli`. + +Minimum test set: + +- dry-run plan generation uses the expected default output path +- `--write` creates the expected scaffold file set +- template rendering includes `slug`, `display-name`, family, namespace, and + derivation values +- output path validation rejects paths that escape the repository +- invalid `slug` values are rejected +- invalid display names are rejected +- an existing target requires `--force` +- `--force` replaces an existing scaffold deterministically + +This PR does not need end-to-end runtime integration tests because it does not +change live chain support behavior. + +## 9. Documentation Impact + +This PR should update contributor-facing docs only. + +Recommended documentation changes: + +- add a short usage note to `CONTRIBUTING.md` +- rely on CLI help text for command discovery +- keep the note in `docs/chain-plugin-kit.md` as a non-normative implementation + reference + +This PR should not add a new numbered specification document. + +## 10. Future Follow-Ups That Should NOT Be Included In This PR + +- Automatically wiring generated data into `ows/crates/ows-core/src/chain.rs` +- Automatically generating signer files under `ows-signer/src/chains/` +- Automatically registering signers in `ows-signer/src/chains/mod.rs` +- Automatically updating `ALL_CHAIN_TYPES`, `KNOWN_CHAINS`, or default RPC maps +- Runtime plugin loading or external plugin discovery +- Scaffolding new chain families beyond existing `ChainType` variants +- Full conformance-vector ingestion or validation +- Spark / `ALL_CHAIN_TYPES` consistency cleanup +- Website docs integration for this design note + +## Recommended First Code Change + +Add a new `Dev` subcommand to `ows/crates/ows-cli/src/main.rs` with a +`ScaffoldChain` variant, then route it to a dedicated +`ows/crates/ows-cli/src/commands/dev.rs` module. diff --git a/ows/crates/ows-cli/src/commands/dev.rs b/ows/crates/ows-cli/src/commands/dev.rs new file mode 100644 index 00000000..3e05e1a4 --- /dev/null +++ b/ows/crates/ows-cli/src/commands/dev.rs @@ -0,0 +1,1328 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use ows_core::ChainType; +use ows_signer::{signer_for_chain, Curve}; + +use crate::CliError; + +const README_TEMPLATE: &str = include_str!("../../templates/chain-plugin-kit/README.md.tmpl"); +const CONTRIBUTOR_GUIDE_TEMPLATE: &str = + include_str!("../../templates/chain-plugin-kit/CONTRIBUTOR_GUIDE.md.tmpl"); +const CHAIN_PROFILE_TEMPLATE: &str = + include_str!("../../templates/chain-plugin-kit/chain-profile.toml.tmpl"); +const CAIP_MAPPING_TEMPLATE: &str = + include_str!("../../templates/chain-plugin-kit/caip-mapping.toml.tmpl"); +const DERIVATION_RULES_TEMPLATE: &str = + include_str!("../../templates/chain-plugin-kit/derivation-rules.toml.tmpl"); +const SIGN_STUB_TEMPLATE: &str = include_str!("../../templates/chain-plugin-kit/sign.stub.rs.tmpl"); +const SERIALIZE_STUB_TEMPLATE: &str = + include_str!("../../templates/chain-plugin-kit/serialize.stub.rs.tmpl"); +const DOCS_SUPPORTED_CHAIN_TEMPLATE: &str = + include_str!("../../templates/chain-plugin-kit/docs/supported-chain-entry.md.tmpl"); +const DOCS_IMPLEMENTATION_CHECKLIST_TEMPLATE: &str = + include_str!("../../templates/chain-plugin-kit/docs/implementation-checklist.md.tmpl"); +const DOCS_SECURITY_CHECKLIST_TEMPLATE: &str = + include_str!("../../templates/chain-plugin-kit/docs/security-checklist.md.tmpl"); +const TEST_VECTORS_TEMPLATE: &str = + include_str!("../../templates/chain-plugin-kit/test-vectors/README.md.tmpl"); +const TEST_VECTORS_DERIVATION_TEMPLATE: &str = + include_str!("../../templates/chain-plugin-kit/test-vectors/derivation.json.tmpl"); +const TEST_VECTORS_SIGN_MESSAGE_TEMPLATE: &str = + include_str!("../../templates/chain-plugin-kit/test-vectors/sign-message.json.tmpl"); +const TEST_VECTORS_TX_SERIALIZATION_TEMPLATE: &str = + include_str!("../../templates/chain-plugin-kit/test-vectors/tx-serialization.json.tmpl"); + +pub struct ScaffoldChainOptions<'a> { + pub slug: &'a str, + pub family: ChainType, + pub display_name: Option<&'a str>, + pub curve: Option<&'a str>, + pub address_format: Option<&'a str>, + pub coin_type: Option, + pub derivation_path: Option<&'a str>, + pub caip_namespace: Option<&'a str>, + pub caip_reference: Option<&'a str>, + pub output: Option<&'a Path>, + pub write: bool, + pub force: bool, +} + +pub fn scaffold_chain(options: ScaffoldChainOptions<'_>) -> Result<(), CliError> { + let current_dir = std::env::current_dir()?; + let repo_root = find_repo_root(¤t_dir)?; + let plan = build_plan(&repo_root, &options)?; + + if options.write { + write_plan(&plan, options.force)?; + println!("Chain Plugin Kit"); + println!( + " Generated scaffold for {} ({})", + plan.context.display_name, plan.context.slug + ); + println!(" Family baseline: {}", plan.context.family_display); + println!(" Output: {}", plan.target_dir.display()); + if plan.target_exists { + println!(" Existing target was replaced because --force was passed."); + } + println!( + " Next step: open {}", + plan.target_dir.join("README.md").display() + ); + } else { + println!("Chain Plugin Kit"); + println!( + " Dry run for {} ({})", + plan.context.display_name, plan.context.slug + ); + println!(" Family baseline: {}", plan.context.family_display); + println!(" Output: {}", plan.target_dir.display()); + if plan.target_exists { + println!(" Existing target would be replaced because --force was passed."); + } + } + + println!(); + println!("Files:"); + for file in &plan.files { + println!(" {}", file.relative_path.display()); + } + + if !options.write { + println!(); + println!("Re-run with --write to create these files."); + } + + Ok(()) +} + +#[derive(Debug)] +struct ScaffoldPlan { + safe_output_root: PathBuf, + target_dir: PathBuf, + target_exists: bool, + context: ScaffoldContext, + files: Vec, +} + +#[derive(Debug)] +struct PlannedFile { + relative_path: PathBuf, + contents: String, +} + +#[derive(Debug)] +struct ScaffoldContext { + slug: String, + slug_ident: String, + display_name: String, + family_display: String, + family_variant: &'static str, + namespace: String, + reference_hint: String, + curve_display: String, + curve_variant: &'static str, + coin_type: u32, + default_derivation_path: String, + address_format: String, +} + +fn build_plan( + repo_root: &Path, + options: &ScaffoldChainOptions<'_>, +) -> Result { + validate_slug(options.slug)?; + if let Some(display_name) = options.display_name { + validate_display_name(display_name)?; + } + validate_optional_token("--curve", options.curve)?; + validate_optional_text("--address-format", options.address_format)?; + validate_optional_text("--derivation-path", options.derivation_path)?; + validate_optional_token("--caip-namespace", options.caip_namespace)?; + validate_optional_token("--caip-reference", options.caip_reference)?; + + let safe_output_root = resolve_safe_output_root(repo_root)?; + let target_dir = resolve_output_dir(&safe_output_root, options.slug, options.output)?; + let target_exists = target_dir.exists(); + if target_exists && !options.force { + return Err(CliError::InvalidArgs(format!( + "target '{}' already exists; re-run with --force to replace it", + target_dir.display() + ))); + } + + let context = build_context(options); + let files = vec![ + PlannedFile { + relative_path: PathBuf::from("README.md"), + contents: render_template(README_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("CONTRIBUTOR_GUIDE.md"), + contents: render_template(CONTRIBUTOR_GUIDE_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("chain-profile.toml"), + contents: render_template(CHAIN_PROFILE_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("caip-mapping.toml"), + contents: render_template(CAIP_MAPPING_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("derivation-rules.toml"), + contents: render_template(DERIVATION_RULES_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("sign.stub.rs"), + contents: render_template(SIGN_STUB_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("serialize.stub.rs"), + contents: render_template(SERIALIZE_STUB_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("docs").join("supported-chain-entry.md"), + contents: render_template(DOCS_SUPPORTED_CHAIN_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("docs").join("implementation-checklist.md"), + contents: render_template(DOCS_IMPLEMENTATION_CHECKLIST_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("docs").join("security-checklist.md"), + contents: render_template(DOCS_SECURITY_CHECKLIST_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("test-vectors").join("README.md"), + contents: render_template(TEST_VECTORS_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("test-vectors").join("derivation.json"), + contents: render_template(TEST_VECTORS_DERIVATION_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("test-vectors").join("sign-message.json"), + contents: render_template(TEST_VECTORS_SIGN_MESSAGE_TEMPLATE, &context), + }, + PlannedFile { + relative_path: PathBuf::from("test-vectors").join("tx-serialization.json"), + contents: render_template(TEST_VECTORS_TX_SERIALIZATION_TEMPLATE, &context), + }, + ]; + + Ok(ScaffoldPlan { + safe_output_root, + target_dir, + target_exists, + context, + files, + }) +} + +fn write_plan(plan: &ScaffoldPlan, force: bool) -> Result<(), CliError> { + validate_safe_scaffold_target(&plan.safe_output_root, &plan.target_dir)?; + + if plan.target_dir.exists() { + if !force { + return Err(CliError::InvalidArgs(format!( + "target '{}' already exists; re-run with --force to replace it", + plan.target_dir.display() + ))); + } + + validate_safe_force_delete_target(&plan.safe_output_root, &plan.target_dir)?; + + if plan.target_dir.is_dir() { + fs::remove_dir_all(&plan.target_dir)?; + } else { + fs::remove_file(&plan.target_dir)?; + } + } + + fs::create_dir_all(&plan.target_dir)?; + for file in &plan.files { + let path = plan.target_dir.join(&file.relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, &file.contents)?; + } + Ok(()) +} + +fn build_context(options: &ScaffoldChainOptions<'_>) -> ScaffoldContext { + let signer = signer_for_chain(options.family); + let curve = signer.curve(); + let curve_display = options + .curve + .map(str::to_string) + .unwrap_or_else(|| curve_display(curve).to_string()); + let curve_variant = if curve_display == "ed25519" { + "Ed25519" + } else { + "Secp256k1" + }; + + ScaffoldContext { + slug: options.slug.to_string(), + slug_ident: to_ident_name(options.slug), + display_name: options + .display_name + .map(str::to_string) + .unwrap_or_else(|| to_display_name(options.slug)), + family_display: options.family.to_string(), + family_variant: chain_type_variant(options.family), + namespace: options + .caip_namespace + .map(str::to_string) + .unwrap_or_else(|| options.family.namespace().to_string()), + reference_hint: options + .caip_reference + .map(str::to_string) + .unwrap_or_else(|| default_reference_hint(options.family).to_string()), + curve_display, + curve_variant, + coin_type: options + .coin_type + .unwrap_or(options.family.default_coin_type()), + default_derivation_path: options + .derivation_path + .map(str::to_string) + .unwrap_or_else(|| signer.default_derivation_path(0)), + address_format: options + .address_format + .map(str::to_string) + .unwrap_or_else(|| default_address_format(options.family).to_string()), + } +} + +fn render_template(template: &str, context: &ScaffoldContext) -> String { + template + .replace("{{slug}}", &context.slug) + .replace("{{slug_string}}", &escape_basic_string(&context.slug)) + .replace("{{slug_ident}}", &context.slug_ident) + .replace("{{display_name}}", &context.display_name) + .replace( + "{{display_name_string}}", + &escape_basic_string(&context.display_name), + ) + .replace("{{family}}", &context.family_display) + .replace( + "{{family_string}}", + &escape_basic_string(&context.family_display), + ) + .replace("{{family_variant}}", context.family_variant) + .replace("{{namespace}}", &context.namespace) + .replace( + "{{namespace_string}}", + &escape_basic_string(&context.namespace), + ) + .replace("{{reference_hint}}", &context.reference_hint) + .replace( + "{{reference_hint_string}}", + &escape_basic_string(&context.reference_hint), + ) + .replace("{{curve}}", &context.curve_display) + .replace( + "{{curve_string}}", + &escape_basic_string(&context.curve_display), + ) + .replace("{{curve_variant}}", context.curve_variant) + .replace("{{coin_type}}", &context.coin_type.to_string()) + .replace( + "{{default_derivation_path}}", + &context.default_derivation_path, + ) + .replace( + "{{default_derivation_path_string}}", + &escape_basic_string(&context.default_derivation_path), + ) + .replace("{{address_format}}", &context.address_format) + .replace( + "{{address_format_string}}", + &escape_basic_string(&context.address_format), + ) +} + +fn escape_basic_string(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '\\' => escaped.push_str("\\\\"), + '"' => escaped.push_str("\\\""), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + c if c.is_control() => escaped.push_str(&format!("\\u{:04X}", c as u32)), + c => escaped.push(c), + } + } + escaped +} + +fn find_repo_root(start: &Path) -> Result { + let mut current = start; + loop { + if is_repo_root(current) { + return Ok(current.to_path_buf()); + } + match current.parent() { + Some(parent) => current = parent, + None => break, + } + } + + Err(CliError::InvalidArgs( + "ows dev scaffold-chain must be run from inside the open-wallet-standard/core repository" + .into(), + )) +} + +fn is_repo_root(path: &Path) -> bool { + path.join(".git").exists() + && path.join("CONTRIBUTING.md").exists() + && path.join("ows").join("Cargo.toml").exists() + && path + .join("ows") + .join("crates") + .join("ows-cli") + .join("Cargo.toml") + .exists() +} + +fn validate_slug(slug: &str) -> Result<(), CliError> { + if slug.is_empty() { + return Err(CliError::InvalidArgs("--slug must not be empty".into())); + } + + if !slug + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(CliError::InvalidArgs( + "--slug must contain only lowercase letters, numbers, and hyphens".into(), + )); + } + + if slug.starts_with('-') || slug.ends_with('-') || slug.contains("--") { + return Err(CliError::InvalidArgs( + "--slug must not start/end with a hyphen or contain repeated hyphens".into(), + )); + } + + Ok(()) +} + +fn validate_display_name(display_name: &str) -> Result<(), CliError> { + if display_name.trim().is_empty() { + return Err(CliError::InvalidArgs( + "--display-name must not be empty".into(), + )); + } + + if display_name.trim() != display_name { + return Err(CliError::InvalidArgs( + "--display-name must not start or end with whitespace".into(), + )); + } + + if display_name.len() > 80 || display_name.chars().any(char::is_control) { + return Err(CliError::InvalidArgs( + "--display-name must be printable text up to 80 characters".into(), + )); + } + + Ok(()) +} + +fn validate_optional_token(flag: &str, value: Option<&str>) -> Result<(), CliError> { + let Some(value) = value else { + return Ok(()); + }; + if value.is_empty() { + return Err(CliError::InvalidArgs(format!("{flag} must not be empty"))); + } + if value.chars().any(char::is_whitespace) + || value.contains('/') + || value.contains('\\') + || value.chars().any(char::is_control) + { + return Err(CliError::InvalidArgs(format!( + "{flag} must not contain whitespace, path separators, or control characters" + ))); + } + Ok(()) +} + +fn validate_optional_text(flag: &str, value: Option<&str>) -> Result<(), CliError> { + let Some(value) = value else { + return Ok(()); + }; + if value.trim().is_empty() { + return Err(CliError::InvalidArgs(format!("{flag} must not be empty"))); + } + if value.chars().any(char::is_control) { + return Err(CliError::InvalidArgs(format!( + "{flag} must be printable text" + ))); + } + Ok(()) +} + +fn resolve_safe_output_root(repo_root: &Path) -> Result { + resolve_path_for_creation(&repo_root.join(".ows-dev").join("chain-plugin-kit")) +} + +fn resolve_output_dir( + safe_output_root: &Path, + slug: &str, + output: Option<&Path>, +) -> Result { + let default_dir = safe_output_root.join(slug); + let requested = output.unwrap_or(default_dir.as_path()); + let candidate = if requested.is_absolute() { + requested.to_path_buf() + } else { + safe_output_root + .parent() + .and_then(Path::parent) + .unwrap_or(safe_output_root) + .join(requested) + }; + + let resolved_candidate = resolve_path_for_creation(&candidate)?; + ensure_path_in_safe_scaffold_area(requested, safe_output_root, &resolved_candidate)?; + Ok(resolved_candidate) +} + +// Resolve as many components as currently exist so symlinked parents cannot +// lexically smuggle scaffold output outside the dedicated safe area. +fn resolve_path_for_creation(path: &Path) -> Result { + let mut missing_components = Vec::new(); + let mut current = path.to_path_buf(); + + loop { + if current.exists() { + let mut resolved = clean_canonical_path(fs::canonicalize(¤t)?); + for component in missing_components.iter().rev() { + resolved.push(component); + } + return Ok(resolved); + } + + let name = current.file_name().ok_or_else(|| { + CliError::InvalidArgs(format!("output path '{}' is invalid", path.display())) + })?; + missing_components.push(PathBuf::from(name)); + current = current + .parent() + .ok_or_else(|| { + CliError::InvalidArgs(format!("output path '{}' is invalid", path.display())) + })? + .to_path_buf(); + } +} + +#[cfg(windows)] +fn clean_canonical_path(path: PathBuf) -> PathBuf { + let text = path.to_string_lossy(); + if let Some(stripped) = text.strip_prefix(r"\\?\UNC\") { + PathBuf::from(format!(r"\\{stripped}")) + } else if let Some(stripped) = text.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else { + path + } +} + +#[cfg(not(windows))] +fn clean_canonical_path(path: PathBuf) -> PathBuf { + path +} + +fn ensure_path_in_safe_scaffold_area( + requested: &Path, + safe_output_root: &Path, + resolved_candidate: &Path, +) -> Result<(), CliError> { + if resolved_candidate == safe_output_root || !resolved_candidate.starts_with(safe_output_root) { + return Err(CliError::InvalidArgs(format!( + "output path '{}' is unsafe; scaffold output must live under '{}' and may not target the safe scaffold area root", + requested.display(), + safe_output_root.display() + ))); + } + + Ok(()) +} + +fn validate_safe_scaffold_target( + safe_output_root: &Path, + target_dir: &Path, +) -> Result<(), CliError> { + let resolved_target = resolve_path_for_creation(target_dir)?; + ensure_path_in_safe_scaffold_area(target_dir, safe_output_root, &resolved_target) +} + +fn validate_safe_force_delete_target( + safe_output_root: &Path, + target_dir: &Path, +) -> Result<(), CliError> { + let metadata = fs::symlink_metadata(target_dir)?; + if metadata.file_type().is_symlink() { + return Err(CliError::InvalidArgs(format!( + "output path '{}' is unsafe; scaffold targets must not be symlinks", + target_dir.display() + ))); + } + + validate_safe_scaffold_target(safe_output_root, target_dir) +} + +fn default_reference_hint(family: ChainType) -> &'static str { + match family { + ChainType::Evm => "TODO_CHAIN_ID", + ChainType::Solana => "TODO_CLUSTER_OR_GENESIS_HASH", + ChainType::Cosmos => "TODO_CHAIN_ID", + ChainType::Bitcoin => "TODO_GENESIS_HASH", + ChainType::Tron => "mainnet", + ChainType::Ton => "mainnet", + ChainType::Spark => "mainnet", + ChainType::Filecoin => "mainnet", + ChainType::Sui => "mainnet", + } +} + +fn default_address_format(family: ChainType) -> &'static str { + match family { + ChainType::Evm => "EIP-55 checksummed hex (0x...)", + ChainType::Solana => "base58-encoded public key", + ChainType::Cosmos => "bech32 account address", + ChainType::Bitcoin => "bech32 native segwit address", + ChainType::Tron => "base58check with 0x41 prefix", + ChainType::Ton => "base64url wallet address", + ChainType::Spark => "spark: prefixed compressed pubkey", + ChainType::Filecoin => "f1 + base32(blake2b-160)", + ChainType::Sui => "0x + BLAKE2b-256 hex", + } +} + +fn chain_type_variant(chain_type: ChainType) -> &'static str { + match chain_type { + ChainType::Evm => "Evm", + ChainType::Solana => "Solana", + ChainType::Cosmos => "Cosmos", + ChainType::Bitcoin => "Bitcoin", + ChainType::Tron => "Tron", + ChainType::Ton => "Ton", + ChainType::Spark => "Spark", + ChainType::Filecoin => "Filecoin", + ChainType::Sui => "Sui", + } +} + +fn curve_display(curve: Curve) -> &'static str { + match curve { + Curve::Secp256k1 => "secp256k1", + Curve::Ed25519 => "ed25519", + } +} + +fn to_display_name(slug: &str) -> String { + slug.split('-') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => { + let mut rendered = String::new(); + rendered.push(first.to_ascii_uppercase()); + rendered.push_str(chars.as_str()); + rendered + } + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +fn to_ident_name(slug: &str) -> String { + let ident = slug.replace('-', "_"); + if ident + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + { + format!("chain_{ident}") + } else { + ident + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[cfg(unix)] + fn create_dir_symlink(link: &Path, target: &Path) { + std::os::unix::fs::symlink(target, link).unwrap(); + } + + #[cfg(windows)] + fn create_dir_symlink(link: &Path, target: &Path) { + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .status() + .unwrap(); + assert!(status.success()); + } + + fn expected_tree_entries() -> Vec { + vec![ + PathBuf::from("CONTRIBUTOR_GUIDE.md"), + PathBuf::from("README.md"), + PathBuf::from("caip-mapping.toml"), + PathBuf::from("chain-profile.toml"), + PathBuf::from("derivation-rules.toml"), + PathBuf::from("docs"), + PathBuf::from("docs").join("implementation-checklist.md"), + PathBuf::from("docs").join("security-checklist.md"), + PathBuf::from("docs").join("supported-chain-entry.md"), + PathBuf::from("serialize.stub.rs"), + PathBuf::from("sign.stub.rs"), + PathBuf::from("test-vectors"), + PathBuf::from("test-vectors").join("README.md"), + PathBuf::from("test-vectors").join("derivation.json"), + PathBuf::from("test-vectors").join("sign-message.json"), + PathBuf::from("test-vectors").join("tx-serialization.json"), + ] + } + + fn collect_tree_entries(root: &Path) -> Vec { + let mut entries = Vec::new(); + collect_tree_entries_inner(root, root, &mut entries); + entries.sort(); + entries + } + + fn collect_tree_entries_inner(root: &Path, current: &Path, entries: &mut Vec) { + let mut children = fs::read_dir(current) + .unwrap() + .map(|entry| entry.unwrap()) + .collect::>(); + children.sort_by_key(|entry| entry.path()); + + for child in children { + let path = child.path(); + let relative = path.strip_prefix(root).unwrap().to_path_buf(); + entries.push(relative.clone()); + if child.file_type().unwrap().is_dir() { + collect_tree_entries_inner(root, &path, entries); + } + } + } + + fn make_repo_root() -> TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".git")).unwrap(); + fs::create_dir_all(dir.path().join("ows").join("crates").join("ows-cli")).unwrap(); + fs::write(dir.path().join("CONTRIBUTING.md"), "contrib").unwrap(); + fs::write(dir.path().join("ows").join("Cargo.toml"), "[workspace]").unwrap(); + fs::write( + dir.path() + .join("ows") + .join("crates") + .join("ows-cli") + .join("Cargo.toml"), + "[package]\nname = \"ows-cli\"\n", + ) + .unwrap(); + dir + } + + fn test_options<'a>() -> ScaffoldChainOptions<'a> { + ScaffoldChainOptions { + slug: "example-chain", + family: ChainType::Evm, + display_name: None, + curve: None, + address_format: None, + coin_type: None, + derivation_path: None, + caip_namespace: None, + caip_reference: None, + output: None, + write: false, + force: false, + } + } + + #[test] + fn build_plan_uses_default_output_without_writing() { + let repo = make_repo_root(); + let options = test_options(); + let plan = build_plan(repo.path(), &options).unwrap(); + + assert_eq!( + plan.target_dir, + repo.path() + .join(".ows-dev") + .join("chain-plugin-kit") + .join("example-chain") + ); + assert!(!plan.target_dir.exists()); + assert_eq!(plan.files.len(), 14); + } + + #[test] + fn write_plan_creates_expected_files() { + let repo = make_repo_root(); + let mut options = test_options(); + options.family = ChainType::Solana; + let plan = build_plan(repo.path(), &options).unwrap(); + + write_plan(&plan, false).unwrap(); + + assert!(plan.target_dir.join("README.md").exists()); + assert!(plan.target_dir.join("chain-profile.toml").exists()); + assert!(plan.target_dir.join("CONTRIBUTOR_GUIDE.md").exists()); + assert!(plan.target_dir.join("caip-mapping.toml").exists()); + assert!(plan.target_dir.join("derivation-rules.toml").exists()); + assert!(plan.target_dir.join("sign.stub.rs").exists()); + assert!(plan.target_dir.join("serialize.stub.rs").exists()); + assert!(plan + .target_dir + .join("docs") + .join("supported-chain-entry.md") + .exists()); + assert!(plan + .target_dir + .join("docs") + .join("implementation-checklist.md") + .exists()); + assert!(plan + .target_dir + .join("docs") + .join("security-checklist.md") + .exists()); + assert!(plan + .target_dir + .join("test-vectors") + .join("README.md") + .exists()); + assert!(plan + .target_dir + .join("test-vectors") + .join("derivation.json") + .exists()); + assert!(plan + .target_dir + .join("test-vectors") + .join("sign-message.json") + .exists()); + assert!(plan + .target_dir + .join("test-vectors") + .join("tx-serialization.json") + .exists()); + } + + #[test] + fn generated_tree_matches_expected_structure() { + let repo = make_repo_root(); + let plan = build_plan(repo.path(), &test_options()).unwrap(); + + write_plan(&plan, false).unwrap(); + + assert_eq!( + collect_tree_entries(&plan.target_dir), + expected_tree_entries() + ); + } + + #[test] + fn rendered_templates_include_slug_and_override_metadata() { + let repo = make_repo_root(); + let options = ScaffoldChainOptions { + slug: "example-chain", + family: ChainType::Bitcoin, + display_name: Some("Example Chain"), + curve: Some("ed25519"), + address_format: Some("hex-with-custom-checksum"), + coin_type: Some(777), + derivation_path: Some("m/44'/777'/0'/0/0"), + caip_namespace: Some("example"), + caip_reference: Some("alpha"), + output: None, + write: false, + force: false, + }; + let plan = build_plan(repo.path(), &options).unwrap(); + + let profile = plan + .files + .iter() + .find(|file| file.relative_path == PathBuf::from("chain-profile.toml")) + .unwrap(); + assert!(profile.contents.contains("slug = \"example-chain\"")); + assert!(profile + .contents + .contains("display_name = \"Example Chain\"")); + assert!(profile.contents.contains("curve = \"ed25519\"")); + assert!(profile + .contents + .contains("address_format = \"hex-with-custom-checksum\"")); + + let derivation = plan + .files + .iter() + .find(|file| file.relative_path == PathBuf::from("derivation-rules.toml")) + .unwrap(); + assert!(derivation.contents.contains("coin_type = 777")); + assert!(derivation.contents.contains("m/44'/777'/0'/0/0")); + + let mapping = plan + .files + .iter() + .find(|file| file.relative_path == PathBuf::from("caip-mapping.toml")) + .unwrap(); + assert!(mapping.contents.contains("namespace = \"example\"")); + assert!(mapping.contents.contains("reference = \"alpha\"")); + + let sign_stub = plan + .files + .iter() + .find(|file| file.relative_path == PathBuf::from("sign.stub.rs")) + .unwrap(); + assert!(sign_stub.contents.contains("sign_message_example_chain")); + + let contributor_guide = plan + .files + .iter() + .find(|file| file.relative_path == PathBuf::from("CONTRIBUTOR_GUIDE.md")) + .unwrap(); + assert!(contributor_guide.contents.contains("example:alpha")); + + let tx_vectors = plan + .files + .iter() + .find(|file| { + file.relative_path == PathBuf::from("test-vectors").join("tx-serialization.json") + }) + .unwrap(); + assert!(tx_vectors.contents.contains("\"canonical_encoding\"")); + } + + #[test] + fn templates_include_practical_contributor_guidance() { + let repo = make_repo_root(); + let plan = build_plan(repo.path(), &test_options()).unwrap(); + + let readme = plan + .files + .iter() + .find(|file| file.relative_path == PathBuf::from("README.md")) + .unwrap(); + assert!(readme.contents.contains("docs/supported-chain-entry.md")); + assert!(readme.contents.contains("test-vectors/sign-message.json")); + assert!(readme + .contents + .contains("Closest existing OWS family baseline")); + + let contributor_guide = plan + .files + .iter() + .find(|file| file.relative_path == PathBuf::from("CONTRIBUTOR_GUIDE.md")) + .unwrap(); + assert!(contributor_guide + .contents + .contains("ows/crates/ows-core/src/chain.rs")); + assert!(contributor_guide + .contents + .contains("ows/crates/ows-signer/src/chains/mod.rs")); + assert!(contributor_guide + .contents + .contains("Closest existing OWS family baseline")); + + let caip_mapping = plan + .files + .iter() + .find(|file| file.relative_path == PathBuf::from("caip-mapping.toml")) + .unwrap(); + assert!(caip_mapping.contents.contains("[account_format_notes]")); + } + + #[test] + fn output_dot_is_rejected() { + let repo = make_repo_root(); + let mut options = test_options(); + options.output = Some(Path::new(".")); + let error = build_plan(repo.path(), &options).unwrap_err(); + + match error { + CliError::InvalidArgs(message) => { + assert!( + message.contains(".ows-dev\\chain-plugin-kit") + || message.contains(".ows-dev/chain-plugin-kit") + ); + } + other => panic!("expected InvalidArgs, got {other}"), + } + } + + #[test] + fn protected_top_level_outputs_are_rejected() { + let repo = make_repo_root(); + + for output in [ + PathBuf::from("ows"), + PathBuf::from("docs"), + PathBuf::from(".git"), + PathBuf::from(".ows-dev"), + PathBuf::from(".ows-dev").join("chain-plugin-kit"), + ] { + let mut options = test_options(); + options.output = Some(output.as_path()); + let error = build_plan(repo.path(), &options).unwrap_err(); + + match error { + CliError::InvalidArgs(message) => { + assert!( + message.contains(".ows-dev\\chain-plugin-kit") + || message.contains(".ows-dev/chain-plugin-kit") + ); + } + other => panic!("expected InvalidArgs, got {other}"), + } + } + } + + #[test] + fn output_path_cannot_escape_repo_root() { + let repo = make_repo_root(); + let output = Path::new("..").join("outside"); + let mut options = test_options(); + options.output = Some(&output); + let error = build_plan(repo.path(), &options).unwrap_err(); + + match error { + CliError::InvalidArgs(message) => { + assert!( + message.contains(".ows-dev\\chain-plugin-kit") + || message.contains(".ows-dev/chain-plugin-kit") + ); + } + other => panic!("expected InvalidArgs, got {other}"), + } + } + + #[test] + fn symlink_escape_outside_safe_output_area_is_rejected() { + let repo = make_repo_root(); + let external = tempfile::tempdir().unwrap(); + let safe_base = repo.path().join(".ows-dev").join("chain-plugin-kit"); + fs::create_dir_all(&safe_base).unwrap(); + let link = safe_base.join("escape-link"); + create_dir_symlink(&link, external.path()); + + let mut options = test_options(); + let output = PathBuf::from(".ows-dev") + .join("chain-plugin-kit") + .join("escape-link") + .join("nested"); + options.output = Some(output.as_path()); + let error = build_plan(repo.path(), &options).unwrap_err(); + + match error { + CliError::InvalidArgs(message) => { + assert!(message.contains("safe scaffold area") || message.contains("symlink")); + } + other => panic!("expected InvalidArgs, got {other}"), + } + } + + #[test] + fn invalid_slug_is_rejected() { + let repo = make_repo_root(); + let mut options = test_options(); + options.slug = "Bad_Slug"; + let error = build_plan(repo.path(), &options).unwrap_err(); + + match error { + CliError::InvalidArgs(message) => { + assert!(message.contains("--slug")); + } + other => panic!("expected InvalidArgs, got {other}"), + } + } + + #[test] + fn invalid_display_name_is_rejected() { + let repo = make_repo_root(); + let mut options = test_options(); + options.display_name = Some(" "); + let error = build_plan(repo.path(), &options).unwrap_err(); + + match error { + CliError::InvalidArgs(message) => { + assert!(message.contains("--display-name")); + } + other => panic!("expected InvalidArgs, got {other}"), + } + } + + #[test] + fn existing_target_requires_force() { + let repo = make_repo_root(); + let target = repo + .path() + .join(".ows-dev") + .join("chain-plugin-kit") + .join("example-chain"); + fs::create_dir_all(&target).unwrap(); + + let options = test_options(); + let error = build_plan(repo.path(), &options).unwrap_err(); + + match error { + CliError::InvalidArgs(message) => { + assert!(message.contains("--force")); + } + other => panic!("expected InvalidArgs, got {other}"), + } + } + + #[test] + fn existing_target_is_left_untouched_without_force() { + let repo = make_repo_root(); + let target = repo + .path() + .join(".ows-dev") + .join("chain-plugin-kit") + .join("example-chain"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("stale.txt"), "keep-me").unwrap(); + + let error = build_plan(repo.path(), &test_options()).unwrap_err(); + + match error { + CliError::InvalidArgs(message) => { + assert!(message.contains("--force")); + } + other => panic!("expected InvalidArgs, got {other}"), + } + assert_eq!( + fs::read_to_string(target.join("stale.txt")).unwrap(), + "keep-me" + ); + } + + #[test] + fn force_replaces_existing_target_deterministically() { + let repo = make_repo_root(); + let target = repo + .path() + .join(".ows-dev") + .join("chain-plugin-kit") + .join("example-chain"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("stale.txt"), "old").unwrap(); + + let mut options = test_options(); + options.force = true; + let plan = build_plan(repo.path(), &options).unwrap(); + write_plan(&plan, true).unwrap(); + + assert!(!target.join("stale.txt").exists()); + assert!(target.join("README.md").exists()); + assert!(plan.target_exists); + assert_eq!( + collect_tree_entries(&plan.target_dir), + expected_tree_entries() + ); + } + + #[test] + fn force_rejects_deleting_safe_base_root() { + let repo = make_repo_root(); + let safe_base = repo.path().join(".ows-dev").join("chain-plugin-kit"); + fs::create_dir_all(&safe_base).unwrap(); + fs::write(safe_base.join("marker.txt"), "keep-me").unwrap(); + + let mut options = test_options(); + let output = PathBuf::from(".ows-dev").join("chain-plugin-kit"); + options.output = Some(output.as_path()); + options.force = true; + let error = build_plan(repo.path(), &options).unwrap_err(); + + match error { + CliError::InvalidArgs(message) => { + assert!( + message.contains(".ows-dev\\chain-plugin-kit") + || message.contains(".ows-dev/chain-plugin-kit") + ); + } + other => panic!("expected InvalidArgs, got {other}"), + } + + assert_eq!( + fs::read_to_string(safe_base.join("marker.txt")).unwrap(), + "keep-me" + ); + } + + #[test] + fn golden_path_aptos_scaffold_writes_expected_tree_and_contents() { + let repo = make_repo_root(); + let options = ScaffoldChainOptions { + slug: "aptos", + family: ChainType::Sui, + display_name: Some("Aptos"), + curve: Some("ed25519"), + address_format: Some("0x-prefixed hex account address"), + coin_type: Some(637), + derivation_path: Some("m/44'/637'/0'/0'/0'"), + caip_namespace: Some("aptos"), + caip_reference: Some("mainnet"), + output: None, + write: false, + force: false, + }; + let plan = build_plan(repo.path(), &options).unwrap(); + + write_plan(&plan, false).unwrap(); + + assert_eq!( + collect_tree_entries(&plan.target_dir), + expected_tree_entries() + ); + + let readme = fs::read_to_string(plan.target_dir.join("README.md")).unwrap(); + assert!(readme.contains("# Aptos Chain Plugin Kit")); + assert!(readme.contains("docs/supported-chain-entry.md")); + assert!(readme.contains("Closest existing OWS family baseline: `sui`")); + + let profile = fs::read_to_string(plan.target_dir.join("chain-profile.toml")).unwrap(); + assert!(profile.contains("slug = \"aptos\"")); + assert!(profile.contains("display_name = \"Aptos\"")); + assert!(profile.contains("family = \"sui\"")); + assert!(profile.contains("namespace = \"aptos\"")); + assert!(profile.contains("curve = \"ed25519\"")); + assert!(profile.contains("default_derivation_path = \"m/44'/637'/0'/0'/0'\"")); + + let caip = fs::read_to_string(plan.target_dir.join("caip-mapping.toml")).unwrap(); + assert!(caip.contains("chain_id = \"aptos:mainnet\"")); + assert!(caip.contains("caip10_account_format = \"aptos:mainnet:TODO_ACCOUNT_ADDRESS\"")); + + let derivation = fs::read_to_string(plan.target_dir.join("derivation-rules.toml")).unwrap(); + assert!(derivation.contains("coin_type = 637")); + assert!(derivation.contains("account_0_change_0_index_0 = \"m/44'/637'/0'/0'/0'\"")); + + let sign_stub = fs::read_to_string(plan.target_dir.join("sign.stub.rs")).unwrap(); + assert!(sign_stub.contains("pub fn sign_message_aptos")); + + let serialize_stub = fs::read_to_string(plan.target_dir.join("serialize.stub.rs")).unwrap(); + assert!(serialize_stub.contains("TODO(canonical-encoding)")); + + let docs = fs::read_to_string( + plan.target_dir + .join("docs") + .join("supported-chain-entry.md"), + ) + .unwrap(); + assert!(docs.contains("Canonical chain id: `aptos:mainnet`")); + assert!(docs.contains("Scaffold family baseline: `sui`")); + + let vectors = fs::read_to_string( + plan.target_dir + .join("test-vectors") + .join("sign-message.json"), + ) + .unwrap(); + assert!(vectors.contains("\"chain_id\": \"aptos:mainnet\"")); + assert!(vectors.contains("\"domain_separation\": \"TODO\"")); + } + + #[test] + fn toml_templates_escape_quotes_and_backslashes() { + let repo = make_repo_root(); + let options = ScaffoldChainOptions { + slug: "example-chain", + family: ChainType::Evm, + display_name: Some("Foo \"Bar\" \\\\ Name"), + curve: None, + address_format: Some("Backslash \\\\ Format"), + coin_type: None, + derivation_path: Some("m/44'/60'/0'/0/0"), + caip_namespace: Some("example"), + caip_reference: Some("alpha\"beta"), + output: None, + write: false, + force: false, + }; + let plan = build_plan(repo.path(), &options).unwrap(); + + let profile = plan + .files + .iter() + .find(|file| file.relative_path == PathBuf::from("chain-profile.toml")) + .unwrap(); + assert!(profile + .contents + .contains("display_name = \"Foo \\\"Bar\\\" \\\\\\\\ Name\"")); + assert!(profile + .contents + .contains("address_format = \"Backslash \\\\\\\\ Format\"")); + + let caip = plan + .files + .iter() + .find(|file| file.relative_path == PathBuf::from("caip-mapping.toml")) + .unwrap(); + assert!(caip.contents.contains("reference = \"alpha\\\"beta\"")); + } + + #[test] + fn rust_string_literals_escape_quotes_and_backslashes() { + let repo = make_repo_root(); + let options = ScaffoldChainOptions { + slug: "example-chain", + family: ChainType::Evm, + display_name: Some("Foo \"Bar\" \\\\ Name"), + curve: None, + address_format: None, + coin_type: None, + derivation_path: None, + caip_namespace: None, + caip_reference: None, + output: None, + write: false, + force: false, + }; + let plan = build_plan(repo.path(), &options).unwrap(); + + let sign_stub = plan + .files + .iter() + .find(|file| file.relative_path == PathBuf::from("sign.stub.rs")) + .unwrap(); + assert!(sign_stub.contents.contains( + "\"TODO(hash): define raw signing behavior for Foo \\\"Bar\\\" \\\\\\\\ Name\"" + )); + + let serialize_stub = plan + .files + .iter() + .find(|file| file.relative_path == PathBuf::from("serialize.stub.rs")) + .unwrap(); + assert!(serialize_stub.contents.contains( + "\"TODO(canonical-encoding): decide how Foo \\\"Bar\\\" \\\\\\\\ Name derives signable bytes\"" + )); + } +} diff --git a/ows/crates/ows-cli/src/commands/mod.rs b/ows/crates/ows-cli/src/commands/mod.rs index a6fee800..f029f277 100644 --- a/ows/crates/ows-cli/src/commands/mod.rs +++ b/ows/crates/ows-cli/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod config; pub mod derive; +pub mod dev; pub mod fund; pub mod generate; pub mod info; diff --git a/ows/crates/ows-cli/src/main.rs b/ows/crates/ows-cli/src/main.rs index d106319e..1345599c 100644 --- a/ows/crates/ows-cli/src/main.rs +++ b/ows/crates/ows-cli/src/main.rs @@ -7,6 +7,7 @@ use ows_core::OwsError; use ows_signer::hd::HdError; use ows_signer::mnemonic::MnemonicError; use ows_signer::{CryptoError, SignerError}; +use std::path::PathBuf; /// Open Wallet Standard CLI #[derive(Parser)] @@ -58,6 +59,11 @@ enum Commands { #[command(subcommand)] subcommand: ConfigCommands, }, + /// Contributor development helpers + Dev { + #[command(subcommand)] + subcommand: DevCommands, + }, /// Update ows to the latest release Update { /// Re-download even if already on the latest version @@ -341,6 +347,49 @@ enum ConfigCommands { Show, } +#[derive(Subcommand)] +enum DevCommands { + /// Scaffold a contributor kit for adding a supported chain + ScaffoldChain { + /// Contributor-facing chain slug (lowercase letters, numbers, hyphens) + #[arg(long)] + slug: String, + /// Closest existing OWS chain family to borrow derivation and signing defaults from + #[arg(long)] + family: ows_core::ChainType, + /// Optional human-friendly display name used inside the generated files + #[arg(long)] + display_name: Option, + /// Optional curve placeholder override for the generated templates + #[arg(long, value_parser = ["secp256k1", "ed25519"])] + curve: Option, + /// Optional address format placeholder override + #[arg(long)] + address_format: Option, + /// Optional coin type placeholder override + #[arg(long)] + coin_type: Option, + /// Optional default derivation path placeholder override + #[arg(long)] + derivation_path: Option, + /// Optional CAIP namespace placeholder override + #[arg(long)] + caip_namespace: Option, + /// Optional CAIP reference placeholder override + #[arg(long)] + caip_reference: Option, + /// Optional output directory under .ows-dev/chain-plugin-kit + #[arg(long)] + output: Option, + /// Create files on disk instead of printing a dry run + #[arg(long)] + write: bool, + /// Overwrite the target directory if it already exists + #[arg(long)] + force: bool, + }, +} + #[derive(Debug, thiserror::Error)] enum CliError { #[error("{0}")] @@ -510,7 +559,155 @@ fn run(cli: Cli) -> Result<(), CliError> { Commands::Config { subcommand } => match subcommand { ConfigCommands::Show => commands::config::show(), }, + Commands::Dev { subcommand } => match subcommand { + DevCommands::ScaffoldChain { + slug, + family, + display_name, + curve, + address_format, + coin_type, + derivation_path, + caip_namespace, + caip_reference, + output, + write, + force, + } => commands::dev::scaffold_chain(commands::dev::ScaffoldChainOptions { + slug: &slug, + family, + display_name: display_name.as_deref(), + curve: curve.as_deref(), + address_format: address_format.as_deref(), + coin_type, + derivation_path: derivation_path.as_deref(), + caip_namespace: caip_namespace.as_deref(), + caip_reference: caip_reference.as_deref(), + output: output.as_deref(), + write, + force, + }), + }, Commands::Update { force } => commands::update::run(force), Commands::Uninstall { purge } => commands::uninstall::run(purge), } } + +#[cfg(test)] +mod tests { + use super::*; + use clap::{CommandFactory, Parser}; + + fn render_scaffold_chain_help() -> String { + let mut command = Cli::command(); + let dev = command.find_subcommand_mut("dev").unwrap(); + let scaffold = dev.find_subcommand_mut("scaffold-chain").unwrap(); + let mut help = Vec::new(); + scaffold.write_long_help(&mut help).unwrap(); + String::from_utf8(help).unwrap() + } + + #[test] + fn cli_parses_dev_scaffold_chain_arguments() { + let cli = Cli::try_parse_from([ + "ows", + "dev", + "scaffold-chain", + "--slug", + "aptos", + "--family", + "sui", + "--display-name", + "Aptos", + "--curve", + "ed25519", + "--address-format", + "0x-prefixed hex account address", + "--coin-type", + "637", + "--derivation-path", + "m/44'/637'/0'/0'/0'", + "--caip-namespace", + "aptos", + "--caip-reference", + "mainnet", + "--write", + "--force", + ]) + .unwrap(); + + match cli.command { + Commands::Dev { subcommand } => match subcommand { + DevCommands::ScaffoldChain { + slug, + family, + display_name, + curve, + address_format, + coin_type, + derivation_path, + caip_namespace, + caip_reference, + output, + write, + force, + } => { + assert_eq!(slug, "aptos"); + assert_eq!(family, ows_core::ChainType::Sui); + assert_eq!(display_name.as_deref(), Some("Aptos")); + assert_eq!(curve.as_deref(), Some("ed25519")); + assert_eq!( + address_format.as_deref(), + Some("0x-prefixed hex account address") + ); + assert_eq!(coin_type, Some(637)); + assert_eq!(derivation_path.as_deref(), Some("m/44'/637'/0'/0'/0'")); + assert_eq!(caip_namespace.as_deref(), Some("aptos")); + assert_eq!(caip_reference.as_deref(), Some("mainnet")); + assert!(output.is_none()); + assert!(write); + assert!(force); + } + }, + _ => panic!("expected dev scaffold-chain command"), + } + } + + #[test] + fn cli_rejects_unknown_scaffold_chain_family() { + let error = Cli::try_parse_from([ + "ows", + "dev", + "scaffold-chain", + "--slug", + "aptos", + "--family", + "aptos", + ]) + .err() + .unwrap(); + + let rendered = error.to_string(); + assert!(rendered.contains("--family")); + assert!(rendered.contains("aptos")); + } + + #[test] + fn scaffold_chain_help_output_lists_expected_flags() { + let help = render_scaffold_chain_help(); + + assert!(help.contains("Scaffold a contributor kit for adding a supported chain")); + assert!(help.contains("--slug ")); + assert!(help.contains("--family ")); + assert!(help.contains("Closest existing OWS chain family")); + assert!(help.contains("--display-name ")); + assert!(help.contains("--curve ")); + assert!(help.contains("--address-format ")); + assert!(help.contains("--coin-type ")); + assert!(help.contains("--caip-namespace ")); + assert!(help.contains("--caip-reference ")); + assert!(help.contains(".ows-dev/chain-plugin-kit")); + assert!(help.contains("--write")); + assert!(help.contains("--force")); + } +} diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/CONTRIBUTOR_GUIDE.md.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/CONTRIBUTOR_GUIDE.md.tmpl new file mode 100644 index 00000000..80e7f6a5 --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/CONTRIBUTOR_GUIDE.md.tmpl @@ -0,0 +1,58 @@ +# {{display_name}} Contributor Guide + +Use this scaffold as a working area before touching OWS runtime files. + +## What To Fill In First + +1. `chain-profile.toml` + Capture the chain's identity, family, curve, address format, and high-level + implementation notes. +2. `caip-mapping.toml` + Confirm the CAIP-2 namespace/reference and write down CAIP-10 account format + expectations. +3. `derivation-rules.toml` + Confirm BIP purpose, coin type, hardened vs non-hardened levels, and index + examples. +4. `sign.stub.rs` and `serialize.stub.rs` + Decide what bytes are signed, how domain separation works, and what wire + format should be emitted. +5. `test-vectors/` + Replace the sample JSON placeholders with real machine-readable cases. + +## Repo Terminology + +- Closest existing OWS family baseline: `{{family}}` +- CAIP-2 chain id: `{{namespace}}:{{reference_hint}}` +- CAIP-10 account id example: `{{namespace}}:{{reference_hint}}:TODO_ACCOUNT_ADDRESS` + +If your chain does not cleanly match an existing OWS family, use the closest +existing OWS family as a scaffold baseline only and document the mismatch in +`chain-profile.toml`. + +## Suggested Contributor Workflow + +1. Fill in every `TODO` marker in this scaffold. +2. Confirm the supported-chain entry in `docs/supported-chain-entry.md`. +3. Add real vectors for derivation, message signing, and transaction + serialization. +4. Only then prepare the follow-up integration PR against OWS runtime files. + +## Likely OWS Follow-Up Touchpoints + +- `ows/crates/ows-core/src/chain.rs` + Add or update the supported-chain metadata, CAIP mapping, and parse/display hooks. +- `ows/crates/ows-signer/src/chains/` + Turn the signing and serialization stubs into a real signer implementation. +- `ows/crates/ows-signer/src/chains/mod.rs` + Register the chain-specific signer so OWS can dispatch to it. +- `ows/crates/ows-lib/src/ops.rs` + Review any chain-specific signing, derivation, or broadcast flow assumptions. +- `docs/07-supported-chains.md` + Convert the docs skeleton into a supported-chain entry for the main docs set. + +## Common Questions To Resolve + +- Is the default derivation path actually BIP-44 style, or a variant? +- Does message signing require domain separation or prefixing? +- Does transaction signing use raw bytes, signable bytes, or a canonical hash? +- Does serialization require a canonical byte encoding before signing? diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/README.md.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/README.md.tmpl new file mode 100644 index 00000000..1377d854 --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/README.md.tmpl @@ -0,0 +1,48 @@ +# {{display_name}} Chain Plugin Kit + +This contributor kit is a dry-run scaffold for adding support for +`{{display_name}}` (`{{slug}}`) using `{{family}}` as the closest existing OWS +family baseline. + +It does not modify OWS runtime integration files automatically. Instead, it +gives you a focused working area with the minimum artifacts needed to prepare a +small upstream PR. + +Start here: read `CONTRIBUTOR_GUIDE.md`, then fill in the TOML files and vector +placeholders before touching runtime code. + +## Included Files + +- `chain-profile.toml` - chain identity, family, curve, address format, and implementation notes +- `caip-mapping.toml` - CAIP-2 and CAIP-10 placeholders with examples +- `derivation-rules.toml` - BIP purpose, coin type, and path examples +- `sign.stub.rs` - `sign_message` and `sign_transaction` TODOs +- `serialize.stub.rs` - signable-byte extraction and canonical encoding TODOs +- `CONTRIBUTOR_GUIDE.md` - step-by-step guide plus follow-up OWS touchpoints +- `docs/supported-chain-entry.md` - supported-chain write-up skeleton +- `docs/implementation-checklist.md` - implementation checklist +- `docs/security-checklist.md` - security checklist +- `test-vectors/README.md` - vector structure notes +- `test-vectors/derivation.json` - derivation sample case +- `test-vectors/sign-message.json` - message-signing sample case +- `test-vectors/tx-serialization.json` - transaction serialization sample case + +## Generated Defaults + +- Display name: `{{display_name}}` +- Closest existing OWS family baseline: `{{family}}` +- Namespace: `{{namespace}}` +- Curve: `{{curve}}` +- Coin type: `{{coin_type}}` +- Default derivation path example: `{{default_derivation_path}}` +- Address format placeholder: `{{address_format}}` + +If `{{family}}` is only the closest current OWS fit, record the mismatch in +`chain-profile.toml` so the follow-up PR makes that tradeoff explicit. + +## Suggested Follow-Up Steps + +1. Fill in the profile, CAIP mapping, derivation rules, and stubs. +2. Read `CONTRIBUTOR_GUIDE.md` and answer every open `TODO`. +3. Add machine-readable vectors in `test-vectors/`. +4. Convert the placeholders into real OWS integration changes in a follow-up PR. diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/caip-mapping.toml.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/caip-mapping.toml.tmpl new file mode 100644 index 00000000..0e1942aa --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/caip-mapping.toml.tmpl @@ -0,0 +1,38 @@ +# Use this file to pin down CAIP identifiers before implementing code. + +slug = "{{slug_string}}" +display_name = "{{display_name_string}}" +family = "{{family_string}}" +namespace = "{{namespace_string}}" +reference = "{{reference_hint_string}}" + +[canonical] +# CAIP-2 format: namespace:reference +name = "{{slug_string}}" +chain_id = "{{namespace_string}}:{{reference_hint_string}}" +caip10_account_format = "{{namespace_string}}:{{reference_hint_string}}:TODO_ACCOUNT_ADDRESS" + +[account_format_notes] +# Document how the account component is encoded in CAIP-10 examples. +address_component = "TODO_ACCOUNT_ADDRESS" +encoding = "TODO" +notes = "TODO: note checksum, casing, HRP, or namespace-specific CAIP-10 rules." + +[aliases] +primary = "{{slug_string}}" +additional = [] + +[[examples]] +kind = "caip2" +value = "{{namespace_string}}:{{reference_hint_string}}" +notes = "Replace the reference placeholder with the real CAIP-2 reference." + +[[examples]] +kind = "caip10" +value = "{{namespace_string}}:{{reference_hint_string}}:TODO_ACCOUNT_ADDRESS" +notes = "Replace TODO_ACCOUNT_ADDRESS with the canonical CAIP-10 account component." + +[[networks]] +label = "mainnet" +chain_id = "{{namespace_string}}:{{reference_hint_string}}" +notes = "TODO: replace with the real CAIP-2 reference for {{display_name_string}}" diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/chain-profile.toml.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/chain-profile.toml.tmpl new file mode 100644 index 00000000..c355ac8c --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/chain-profile.toml.tmpl @@ -0,0 +1,48 @@ +# Fill in this file before touching OWS runtime files. +# Keep the profile concise: identity, family, curve, address format, and the +# chain-specific notes that contributors should confirm before coding. + +# Internal OWS slug used throughout the scaffold and future integration work. +slug = "{{slug_string}}" +# Human-readable display name for docs and examples. +display_name = "{{display_name_string}}" +# Closest existing OWS chain family used as the scaffold baseline. +# If the chain may need a new family later, record that gap below. +family = "{{family_string}}" +# CAIP-2 namespace used for canonical chain identifiers. +namespace = "{{namespace_string}}" +# Signing curve used by the chain family or chain-specific implementation. +curve = "{{curve_string}}" +# Canonical account-address encoding or presentation format. +address_format = "{{address_format_string}}" +# Default contributor placeholder for the first account derivation path. +default_derivation_path = "{{default_derivation_path_string}}" + +[caip] +# CAIP-2 chain identifier = namespace:reference +reference = "{{reference_hint_string}}" +canonical_chain_id = "{{namespace_string}}:{{reference_hint_string}}" +friendly_aliases = ["{{slug_string}}"] + +[signing] +# Keep these short and concrete. Describe what is signed, not the full implementation. +message_strategy = "TODO" +transaction_strategy = "TODO" +sign_stub = "sign.stub.rs" +serialize_stub = "serialize.stub.rs" + +[implementation_notes] +# TODO: note any address checksum, HRP, or encoding quirks here. +address_notes = "TODO" +# TODO: note whether derivation follows a standard path or a chain-specific variant. +derivation_notes = "TODO" +# TODO: note any signing prefixes, intent bytes, or domain separation rules here. +signing_notes = "TODO" +# TODO: capture any serializer or canonical wire-format constraints here. +serialization_notes = "TODO" +# TODO: if {{family}} is only an approximate family match, explain why here. +family_gap_notes = "TODO" + +[status] +profile_only = true +integration_complete = false diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/derivation-rules.toml.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/derivation-rules.toml.tmpl new file mode 100644 index 00000000..1d11b30c --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/derivation-rules.toml.tmpl @@ -0,0 +1,30 @@ +# Capture the derivation contract that contributors will implement later. + +slug = "{{slug_string}}" +display_name = "{{display_name_string}}" +family = "{{family_string}}" +curve = "{{curve_string}}" +bip_purpose = "TODO" +coin_type = {{coin_type}} +default_path = "{{default_derivation_path_string}}" + +[path_structure] +# Example: purpose'/coin_type'/account'/change/index +hardened_levels = ["TODO"] +non_hardened_levels = ["TODO"] +hardened_note = "TODO: state which segments must be hardened." +non_hardened_note = "TODO: state which segments stay unhardened." + +[address] +format = "{{address_format_string}}" +indexing_notes = "TODO: confirm account/change/index structure for {{display_name_string}}" + +[examples] +account_0_change_0_index_0 = "{{default_derivation_path_string}}" +account_1_change_0_index_0 = "TODO_SECOND_ACCOUNT_PATH" +account_0_change_1_index_0 = "TODO_CHANGE_PATH_OR_NA" +account_0_change_0_index_1 = "TODO_INDEX_1_PATH" + +[keys] +private_key_length = "TODO" +public_key_format = "TODO" diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/docs/implementation-checklist.md.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/docs/implementation-checklist.md.tmpl new file mode 100644 index 00000000..b1c3f9cc --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/docs/implementation-checklist.md.tmpl @@ -0,0 +1,14 @@ +# Implementation Checklist: {{display_name}} + +- [ ] Confirm canonical chain id: `{{namespace}}:{{reference_hint}}` +- [ ] Confirm chain family: `{{family}}` +- [ ] Confirm curve: `{{curve}}` +- [ ] Confirm address format and checksum behavior +- [ ] Confirm default derivation path: `{{default_derivation_path}}` +- [ ] Confirm BIP purpose and coin type +- [ ] Confirm hardened vs non-hardened path segments +- [ ] Define `sign_message` behavior +- [ ] Define `sign_transaction` behavior +- [ ] Define signable bytes and serialization rules +- [ ] Add derivation, message signing, and tx serialization vectors +- [ ] Write the supported-chain docs entry diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/docs/security-checklist.md.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/docs/security-checklist.md.tmpl new file mode 100644 index 00000000..aa4f3db5 --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/docs/security-checklist.md.tmpl @@ -0,0 +1,10 @@ +# Security Checklist: {{display_name}} + +- [ ] Address encoding is canonical and unambiguous +- [ ] Derivation path is documented with hardened vs non-hardened boundaries +- [ ] Signing inputs are clearly defined +- [ ] Hashing step is specified, if any +- [ ] Domain separation or message prefixing is specified, if any +- [ ] Canonical serialization is specified before signing +- [ ] Test vectors cover both expected and malformed inputs +- [ ] Chain-specific edge cases are listed in docs diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/docs/supported-chain-entry.md.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/docs/supported-chain-entry.md.tmpl new file mode 100644 index 00000000..45db55d2 --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/docs/supported-chain-entry.md.tmpl @@ -0,0 +1,41 @@ +# Supported-Chain Entry: {{display_name}} + +Use this skeleton when updating OWS supported-chain documentation. + +## Overview + +- Display name: `{{display_name}}` +- Internal slug: `{{slug}}` +- Scaffold family baseline: `{{family}}` +- Curve: `{{curve}}` + +## CAIP Identifiers + +- CAIP-2 namespace: `{{namespace}}` +- CAIP-2 reference: `{{reference_hint}}` +- Canonical chain id: `{{namespace}}:{{reference_hint}}` +- CAIP-10 account id example: `{{namespace}}:{{reference_hint}}:TODO_ACCOUNT_ADDRESS` + +## Address Format + +- Canonical address format: `{{address_format}}` +- Checksum or validation notes: `TODO` +- Example address: `TODO_ADDRESS` + +## Derivation + +- Default derivation path: `{{default_derivation_path}}` +- Coin type: `{{coin_type}}` +- Purpose/account/change/index notes: `TODO` + +## Signing + +- `sign_message` rules: `TODO` +- `sign_transaction` rules: `TODO` +- Hashing or domain separation notes: `TODO` + +## Serialization + +- Signable bytes description: `TODO` +- Canonical encoding description: `TODO` +- Broadcast payload description: `TODO` diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/serialize.stub.rs.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/serialize.stub.rs.tmpl new file mode 100644 index 00000000..fe7d8f60 --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/serialize.stub.rs.tmpl @@ -0,0 +1,27 @@ +// {{display_name}} serialization stub +// +// Use this file to reason about the bytes that should be signed and the final +// wire format that should be emitted for broadcast. +// TODO(canonical-encoding): document the unsigned transaction byte format. +// TODO(hash): document whether serialization happens before or after hashing. + +use ows_signer::traits::{SignOutput, SignerError}; + +pub fn extract_signable_bytes_{{slug_ident}}(tx_bytes: &[u8]) -> Result<&[u8], SignerError> { + let _ = tx_bytes; + Err(SignerError::InvalidTransaction( + "TODO(canonical-encoding): decide how {{display_name_string}} derives signable bytes" + .into(), + )) +} + +pub fn encode_signed_transaction_{{slug_ident}}( + tx_bytes: &[u8], + signature: &SignOutput, +) -> Result, SignerError> { + let _ = (tx_bytes, signature); + Err(SignerError::InvalidTransaction( + "TODO(canonical-encoding): implement signed transaction serialization for {{display_name_string}}" + .into(), + )) +} diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/sign.stub.rs.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/sign.stub.rs.tmpl new file mode 100644 index 00000000..4ea6b6a0 --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/sign.stub.rs.tmpl @@ -0,0 +1,38 @@ +// {{display_name}} signing stub +// +// This file is intentionally a contributor-facing starting point rather than a +// drop-in implementation. +// TODO(hash): identify whether the chain signs raw bytes or a prehash. +// TODO(domain-separation): document any prefix, intent bytes, or signing domain. + +use ows_signer::traits::{SignOutput, SignerError}; + +pub fn sign_raw_{{slug_ident}}( + private_key: &[u8], + payload: &[u8], +) -> Result { + let _ = (private_key, payload); + Err(SignerError::SigningFailed( + "TODO(hash): define raw signing behavior for {{display_name_string}}".into(), + )) +} + +pub fn sign_message_{{slug_ident}}( + private_key: &[u8], + message: &[u8], +) -> Result { + let _ = (private_key, message); + Err(SignerError::SigningFailed( + "TODO(domain-separation): define message signing for {{display_name_string}}".into(), + )) +} + +pub fn sign_transaction_{{slug_ident}}( + private_key: &[u8], + tx_bytes: &[u8], +) -> Result { + let _ = (private_key, tx_bytes); + Err(SignerError::SigningFailed( + "TODO(hash): define transaction signing rules for {{display_name_string}}".into(), + )) +} diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/test-vectors/README.md.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/test-vectors/README.md.tmpl new file mode 100644 index 00000000..4bde432f --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/test-vectors/README.md.tmpl @@ -0,0 +1,29 @@ +# {{slug}} Test Vectors + +Add machine-readable vectors here before wiring real integration files. + +Included starter files: + +- `derivation.json` - mnemonic/path to address expectations +- `sign-message.json` - message signing inputs and expected signature shape +- `tx-serialization.json` - signable payload, hashing, and final wire bytes + +Keep the JSON deterministic and chain-specific. Prefer real bytes over prose once +the actual implementation is known. + +Example JSON shape: + +```json +{ + "chain": "{{namespace_string}}:{{reference_hint_string}}", + "family": "{{family_string}}", + "display_name": "{{display_name_string}}", + "cases": [ + { + "name": "todo", + "input": {}, + "expected": {} + } + ] +} +``` diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/test-vectors/derivation.json.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/test-vectors/derivation.json.tmpl new file mode 100644 index 00000000..011f2706 --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/test-vectors/derivation.json.tmpl @@ -0,0 +1,17 @@ +{ + "chain_id": "{{namespace_string}}:{{reference_hint_string}}", + "family": "{{family_string}}", + "kind": "derivation", + "notes": "Replace placeholders with a real mnemonic, path, and expected address.", + "cases": [ + { + "name": "account-0", + "mnemonic": "TODO_TEST_MNEMONIC", + "path": "{{default_derivation_path_string}}", + "expected": { + "address": "TODO_ADDRESS", + "caip10_account_id": "{{namespace_string}}:{{reference_hint_string}}:TODO_ADDRESS" + } + } + ] +} diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/test-vectors/sign-message.json.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/test-vectors/sign-message.json.tmpl new file mode 100644 index 00000000..df2cf291 --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/test-vectors/sign-message.json.tmpl @@ -0,0 +1,20 @@ +{ + "chain_id": "{{namespace_string}}:{{reference_hint_string}}", + "family": "{{family_string}}", + "kind": "sign_message", + "notes": "Document message bytes, domain separation, and expected signature shape.", + "cases": [ + { + "name": "personal-message", + "private_key": "TODO_PRIVATE_KEY_HEX", + "message": "TODO_MESSAGE", + "encoding": "utf8", + "domain_separation": "TODO", + "expected": { + "hashing": "TODO", + "signature_hex": "TODO_SIGNATURE", + "recovery_id": "TODO_OR_NULL" + } + } + ] +} diff --git a/ows/crates/ows-cli/templates/chain-plugin-kit/test-vectors/tx-serialization.json.tmpl b/ows/crates/ows-cli/templates/chain-plugin-kit/test-vectors/tx-serialization.json.tmpl new file mode 100644 index 00000000..bd2f14b4 --- /dev/null +++ b/ows/crates/ows-cli/templates/chain-plugin-kit/test-vectors/tx-serialization.json.tmpl @@ -0,0 +1,20 @@ +{ + "chain_id": "{{namespace_string}}:{{reference_hint_string}}", + "family": "{{family_string}}", + "kind": "tx_serialization", + "notes": "Capture signable bytes, hashing rules, canonical encoding, and final wire bytes.", + "cases": [ + { + "name": "unsigned-to-signed", + "private_key": "TODO_PRIVATE_KEY_HEX", + "unsigned_tx_hex": "TODO_UNSIGNED_TX", + "signable_bytes_hex": "TODO_SIGNABLE_BYTES", + "canonical_encoding": "TODO", + "hashing": "TODO", + "expected": { + "signature_hex": "TODO_SIGNATURE", + "signed_tx_hex": "TODO_SIGNED_TX" + } + } + ] +} diff --git a/ows/crates/ows-lib/src/policy_engine.rs b/ows/crates/ows-lib/src/policy_engine.rs index b2daa63a..74fd6866 100644 --- a/ows/crates/ows-lib/src/policy_engine.rs +++ b/ows/crates/ows-lib/src/policy_engine.rs @@ -369,19 +369,28 @@ mod tests { fn executable_with_script() { // Create a temp script that outputs {"allow": true} let dir = tempfile::tempdir().unwrap(); + #[cfg(unix)] let script = dir.path().join("allow.sh"); - std::fs::write( - &script, - "#!/bin/sh\ncat > /dev/null\necho '{\"allow\": true}'\n", - ) - .unwrap(); + #[cfg(windows)] + let script = dir.path().join("allow.cmd"); #[cfg(unix)] { + std::fs::write( + &script, + "#!/bin/sh\ncat > /dev/null\necho '{\"allow\": true}'\n", + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); } + #[cfg(windows)] + { + std::fs::write(&script, "@echo off\r\necho {\"allow\": true}\r\n").unwrap(); + } + let ctx = base_context(); let result = evaluate_executable(script.to_str().unwrap(), None, "script-allow", &ctx); assert!(result.allow); @@ -390,19 +399,32 @@ mod tests { #[test] fn executable_deny_script() { let dir = tempfile::tempdir().unwrap(); + #[cfg(unix)] let script = dir.path().join("deny.sh"); - std::fs::write( - &script, - "#!/bin/sh\ncat > /dev/null\necho '{\"allow\": false, \"reason\": \"nope\"}'\n", - ) - .unwrap(); + #[cfg(windows)] + let script = dir.path().join("deny.cmd"); #[cfg(unix)] { + std::fs::write( + &script, + "#!/bin/sh\ncat > /dev/null\necho '{\"allow\": false, \"reason\": \"nope\"}'\n", + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); } + #[cfg(windows)] + { + std::fs::write( + &script, + "@echo off\r\necho {\"allow\": false, \"reason\": \"nope\"}\r\n", + ) + .unwrap(); + } + let ctx = base_context(); let result = evaluate_executable(script.to_str().unwrap(), None, "script-deny", &ctx); assert!(!result.allow); diff --git a/ows/crates/ows-signer/src/process_hardening.rs b/ows/crates/ows-signer/src/process_hardening.rs index eb93ffb5..a180ee0e 100644 --- a/ows/crates/ows-signer/src/process_hardening.rs +++ b/ows/crates/ows-signer/src/process_hardening.rs @@ -37,6 +37,7 @@ pub fn register_cleanup(f: impl Fn() + Send + 'static) { } /// Run all registered cleanup hooks. Called by the signal handler thread. +#[cfg(unix)] fn run_cleanup_hooks() { if let Some(hooks) = CLEANUP_HOOKS.get() { if let Ok(hooks) = hooks.lock() {