diff --git a/CHANGELOG.md b/CHANGELOG.md index cdfe025..83858d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Changed +- `validate` errors when a manifest uses a Simplicity program in a `tapleaf` or `simf_fn` compute, or a `programs` entry, without declaring `"requires": ["simplicity"]`. Previously only covenant `utxo_types` triggered this. Add `"simplicity"` to `requires` if `validate` now reports it. - `validate` also checks each program file: that it exists, matches its hash, meets its compiler requirement, and declares the witnesses the manifest supplies. ## [0.3.0] - 2026-10-06 diff --git a/docs/decisions/0001-program-pinning.md b/docs/decisions/0001-program-pinning.md index 90dfd2f..6fff307 100644 --- a/docs/decisions/0001-program-pinning.md +++ b/docs/decisions/0001-program-pinning.md @@ -41,8 +41,9 @@ different compilers could in principle produce different programs from the same - allowed while developing (`validate` warns; `run --allow-unpinned` runs it). A program edited since it was pinned counts as unpinned here, so `run --allow-unpinned` warns about the stale hash instead of refusing; - - refused for publishing (`validate --strict` errors) and by wallets (`run` refuses by - default, and a wallet library has no way to opt out). + - refused for publishing (`validate --strict` errors) and by wallets, which must not + run an unpinned manifest. `run` refuses by default; the library's `Unpinned::Allow` + exists only for development tooling such as `--allow-unpinned`. Manifests written before this change load as unpinned. 6. **Introduced in manifest format `0.3.1`.** The change only adds fields, so it's a patch diff --git a/docs/format-changelog.md b/docs/format-changelog.md index ae3139b..d079cb0 100644 --- a/docs/format-changelog.md +++ b/docs/format-changelog.md @@ -6,8 +6,10 @@ anyone implementing the format in a wallet. Changes to the `tx-manifest-wallet` the [app changelog](../CHANGELOG.md). A manifest declares the format it's written against in `manifest_version`. While the major -version is `0`, a different minor version is a different, incompatible format; the patch -version only adds things, so a reader of `0.3.x` accepts any `0.3` manifest. The format's +version is `0`, a different minor version is a different, incompatible format. A patch +version only adds things, so a newer reader accepts an older patch's manifests (a `0.3.1` +reader reads `0.3.0`). Not the reverse: an older reader rejects fields it doesn't know, +rather than ignoring them. The format's history before `0.2.0` was not recorded. The JSON Schema for the current version is @@ -15,7 +17,8 @@ The JSON Schema for the current version is ## [0.3.1] - unreleased -Additive: every `0.3.0` manifest is a valid `0.3.1` manifest. +Additive, with one tightened rule (below): a `0.3.0` manifest is a valid `0.3.1` manifest +unless it uses a Simplicity program without declaring `simplicity`. ### Added @@ -46,6 +49,14 @@ Additive: every `0.3.0` manifest is a valid `0.3.1` manifest. reference that gives neither inherits them from the others. - A `program` must name an entry in `programs`. +### Changed + +- `"requires": ["simplicity"]` is required by any use of a Simplicity program, not only by + covenant `utxo_types`: also a `tapleaf` or `simf_fn` compute, or a `programs` entry. + Each of these needs a compiler, and `requires` is how a wallet without one knows to + refuse the manifest before trying it. A manifest that relied on the narrower rule was + already unrunnable by such a wallet; it now fails validation instead. + ## [0.3.0] - 2026-10-06 ### Breaking diff --git a/txmanifest_lib/src/lifecycle.rs b/txmanifest_lib/src/lifecycle.rs index 48ad981..a2cba50 100644 --- a/txmanifest_lib/src/lifecycle.rs +++ b/txmanifest_lib/src/lifecycle.rs @@ -1411,7 +1411,9 @@ pub fn run( }; let (leaf_payloads, inp_params, inp_hints) = (site.leaf_payloads, site.compile_params, site.type_hints); - let inp_simf_path = programs.for_script(inp_ut.script.as_ref())?; + let inp_simf_path = programs + .for_script(inp_ut.script.as_ref()) + .with_context(|| format!("utxo_type '{type_name}'"))?; let script_pubkey = match actx.covenant_script_pubkey( inp_simf_path, &inp_params, @@ -1811,7 +1813,9 @@ pub fn run( }; let (leaf_payloads, out_params, out_hints) = (site.leaf_payloads, site.compile_params, site.type_hints); - let out_simf_path = programs.for_script(ut.script.as_ref())?; + let out_simf_path = programs + .for_script(ut.script.as_ref()) + .with_context(|| format!("utxo_type '{type_name}'"))?; let script_pubkey = match actx.covenant_script_pubkey( out_simf_path, &out_params, @@ -2213,7 +2217,10 @@ pub fn run( action, &ctx, )?; - let program = programs.for_script(ut.script.as_ref())?.clone(); + let program = programs + .for_script(ut.script.as_ref()) + .with_context(|| format!("utxo_type '{type_name}'"))? + .clone(); Ok(CovenantSpendSpec { program, params: site.compile_params, @@ -2436,8 +2443,9 @@ pub fn run( for inp in &covenant_inputs { let type_name = inp.utxo_type_name().unwrap(); let check_ut = manifest.utxo_type(&type_name).ok(); - let check_simf_path = - programs.for_script(check_ut.as_ref().and_then(|ut| ut.script.as_ref()))?; + let check_simf_path = programs + .for_script(check_ut.as_ref().and_then(|ut| ut.script.as_ref())) + .with_context(|| format!("utxo_type '{type_name}'"))?; let check_site = check_ut.map(|ut| { resolve_utxo_site( ut, @@ -2558,8 +2566,9 @@ pub fn run( dry_site.compile_params, dry_site.type_hints, ); - let dry_simf_path = - programs.for_script(dry_ut.script.as_ref())?; + let dry_simf_path = programs + .for_script(dry_ut.script.as_ref()) + .with_context(|| format!("utxo_type '{type_name}'"))?; use std::io::Write; print!( @@ -2736,7 +2745,9 @@ pub fn run( fin_site.compile_params, fin_site.type_hints, ); - let fin_simf_path = programs.for_script(fin_ut.script.as_ref())?; + let fin_simf_path = programs + .for_script(fin_ut.script.as_ref()) + .with_context(|| format!("utxo_type '{type_name}'"))?; print!( " {} Input '{}' ({}) — finalizing… ", diff --git a/txmanifest_lib/src/manifest.rs b/txmanifest_lib/src/manifest.rs index 90e0c19..fd85472 100644 --- a/txmanifest_lib/src/manifest.rs +++ b/txmanifest_lib/src/manifest.rs @@ -526,7 +526,10 @@ fn normalize_compute_value( // parse error naming the field rather than a failure much later in a run. let kind = obj.get("type").and_then(|t| t.as_str()).unwrap_or_default(); if matches!(kind, "tapleaf" | "simf_fn") { - match (obj.contains_key("simf"), obj.contains_key("program")) { + // `null` counts as absent: serde reads it as `None`, so a `"simf": null` that + // passed here would leave the compute naming no program at all. + let given = |key: &str| obj.get(key).is_some_and(|v| !v.is_null()); + match (given("simf"), given("program")) { (true, true) => { return Err(E::custom(format!( "a {kind} compute gives both \"simf\" and \"program\"; give one" @@ -1666,8 +1669,13 @@ impl Manifest { /// `requires` claims. /// /// Only the residue that [`Manifest::chain`] does not settle, which today means: does - /// any `utxo_type` carry a `script`. Namespaced capabilities are never inferred — this - /// crate does not know what they mean, so only the author can say one is needed. + /// anything need a Simplicity compiler. That is any `utxo_type` with a `script`, and + /// any other program reference — a `tapleaf` or `simf_fn` compute, or a `programs` + /// entry — since computing a tapleaf hash compiles the program just as deriving a + /// covenant address does. A wallet without a compiler relies on this to refuse such a + /// manifest up front rather than fail partway through. Namespaced capabilities are + /// never inferred — this crate does not know what they mean, so only the author can + /// say one is needed. pub fn inferred_capabilities(&self) -> Capabilities { let mut caps = Capabilities::none(); let uses_covenants = self @@ -1675,7 +1683,8 @@ impl Manifest { .iter() .flatten() .any(|(_, t)| t.script.is_some()); - if uses_covenants { + let (program_refs, _) = crate::programs::references(self); + if uses_covenants || !program_refs.is_empty() { caps.insert(Capability::SIMPLICITY); } caps diff --git a/txmanifest_lib/src/programs.rs b/txmanifest_lib/src/programs.rs index f228d03..34aed7d 100644 --- a/txmanifest_lib/src/programs.rs +++ b/txmanifest_lib/src/programs.rs @@ -140,6 +140,26 @@ pub struct ProgramRef { pub struct RefIssue { pub location: String, pub message: String, + /// Two references to one file disagree on its hash or compiler requirement. `pin` + /// rewrites conflicting hashes, so it needs to tell these from other problems. + pub conflict: bool, +} + +impl RefIssue { + fn new(location: impl Into, message: impl Into) -> Self { + RefIssue { + location: location.into(), + message: message.into(), + conflict: false, + } + } + + fn conflict(location: impl Into, message: impl Into) -> Self { + RefIssue { + conflict: true, + ..RefIssue::new(location, message) + } + } } /// Every program reference in `manifest`, plus the reference problems found on the way. @@ -223,27 +243,26 @@ impl Refs<'_> { hash: def.hash.clone(), version: def.simplicity_hl_version.clone(), }), - None => self.issues.push(RefIssue { + None => self.issues.push(RefIssue::new( location, - message: format!("names program \"{name}\", which is not in \"programs\""), - }), + format!("names program \"{name}\", which is not in \"programs\""), + )), } } fn script(&mut self, script: &UtxoScript, location: String) { match (&script.program, &script.source) { - (Some(_), Some(_)) => self.issues.push(RefIssue { + (Some(_), Some(_)) => self.issues.push(RefIssue::new( location, - message: "gives both \"program\" and \"source\"; give one".into(), - }), + "gives both \"program\" and \"source\"; give one", + )), (Some(name), None) => { if script.source_hash.is_some() || script.simplicity_hl_version.is_some() { - self.issues.push(RefIssue { - location: location.clone(), - message: "\"source_hash\" and \"simplicity_hl_version\" belong on the \ - \"programs\" entry when the script names a \"program\"" - .into(), - }); + self.issues.push(RefIssue::new( + location.clone(), + "\"source_hash\" and \"simplicity_hl_version\" belong on the \ + \"programs\" entry when the script names a \"program\"", + )); } self.by_name(location, name); } @@ -326,19 +345,9 @@ pub struct Pin { /// found merging their references. Needs no filesystem. pub fn resolve(manifest: &Manifest) -> (BTreeMap, Vec) { let (refs, mut issues) = references(manifest); - let global = manifest - .simplicity_hl - .as_ref() - .and_then(|s| s.version.clone()); - if let Some(v) = &global { - if let Err(e) = VersionRequirement::parse(v) { - issues.push(RefIssue { - location: "simplicity_hl.version".into(), - message: format!("\"{v}\" is not a semver requirement: {e}"), - }); - } - } + // First what the references themselves say. A reference that gives no hash or version + // inherits the file's from the others, so only a stated value can conflict. let mut pins: BTreeMap = BTreeMap::new(); for r in refs { let pin = pins.entry(normalise(&r.source)).or_insert_with(|| Pin { @@ -350,46 +359,61 @@ pub fn resolve(manifest: &Manifest) -> (BTreeMap, Vec) { if let Some(h) = &r.hash { match ContentHash::parse(h) { Ok(hash) => match pin.hash { - Some(existing) if existing != hash => issues.push(RefIssue { - location: r.location.clone(), - message: format!( + Some(existing) if existing != hash => issues.push(RefIssue::conflict( + &r.location, + format!( "gives {} the hash {hash}, but another reference gives {existing}", r.source ), - }), + )), _ => pin.hash = Some(hash), }, - Err(e) => issues.push(RefIssue { - location: r.location.clone(), - message: format!("{e:#}"), - }), + Err(e) => issues.push(RefIssue::new(&r.location, format!("{e:#}"))), } } - let Some(v) = r.version.clone().or_else(|| global.clone()) else { - continue; - }; - match VersionRequirement::parse(&v) { - Ok(req) => match &pin.version_text { - Some(existing) if *existing != v => issues.push(RefIssue { - location: r.location.clone(), - message: format!( - "gives {} the compiler requirement \"{v}\", but another reference \ - gives \"{existing}\"", - r.source - ), - }), - _ => { - pin.version = Some(req); - pin.version_text = Some(v); + if let Some(v) = &r.version { + match VersionRequirement::parse(v) { + Ok(req) => match &pin.version_text { + Some(existing) if existing != v => issues.push(RefIssue::conflict( + &r.location, + format!( + "gives {} the compiler requirement \"{v}\", but another reference \ + gives \"{existing}\"", + r.source + ), + )), + _ => { + pin.version = Some(req); + pin.version_text = Some(v.clone()); + } + }, + Err(e) => issues.push(RefIssue::new( + &r.location, + format!("\"{v}\" is not a semver requirement: {e}"), + )), + } + } + } + + // Then the manifest-wide requirement, for files no reference gave one. It is the + // lowest priority, so it never conflicts with a per-program value. + if let Some(v) = manifest + .simplicity_hl + .as_ref() + .and_then(|s| s.version.as_ref()) + { + match VersionRequirement::parse(v) { + Ok(req) => { + for pin in pins.values_mut().filter(|p| p.version.is_none()) { + pin.version = Some(req.clone()); + pin.version_text = Some(v.clone()); } - }, - // The manifest-wide value is reported once, above. - Err(e) if r.version.is_some() => issues.push(RefIssue { - location: r.location.clone(), - message: format!("\"{v}\" is not a semver requirement: {e}"), - }), - Err(_) => {} + } + Err(e) => issues.push(RefIssue::new( + "simplicity_hl.version", + format!("\"{v}\" is not a semver requirement: {e}"), + )), } } (pins, issues) @@ -564,11 +588,11 @@ impl Programs { &self.unpinned } - /// The program a `utxo_type` script runs. + /// The program a `utxo_type` script runs. A type with no script has none. pub fn for_script(&self, script: Option<&UtxoScript>) -> Result<&ProgramSource> { match script { Some(s) => self.lookup(s.source.as_deref(), s.program.as_deref()), - None => self.lookup(None, None), + None => bail!("it has no script, so there is no covenant program to use"), } } @@ -608,10 +632,7 @@ pub fn pin_text(text: &str, base_dir: &Path) -> Result { let manifest = Manifest::from_json_str(text)?; let (pins, issues) = resolve(&manifest); // Conflicting hashes are about to be overwritten; anything else must be fixed first. - if let Some(i) = issues - .iter() - .find(|i| !i.message.contains("another reference gives")) - { + if let Some(i) = issues.iter().find(|i| !i.conflict) { bail!("cannot pin: {}: {}", i.location, i.message); } let mut hashes = BTreeMap::new(); @@ -973,9 +994,49 @@ mod tests { assert_eq!(pins["a.simf"].version_text.as_deref(), Some("0.7")); } + /// The manifest-wide requirement is the lowest priority: a reference with no version + /// inherits the file's from another reference before it falls back to the manifest's. + #[test] + fn the_manifest_wide_version_does_not_override_another_reference() { + let m = manifest( + r#"{ "manifest_version": "0.3.1", "protocol": "t", "requires": ["simplicity"], + "simplicity_hl": { "version": "0.7.0" }, + "programs": { "a": { "source": "a.simf", "simplicity_hl_version": "0.7.1" } }, + "utxo_types": { + "x": { "description": "x", "script": { "type": "simplicity", "program": "a" } }, + "y": { "description": "y", "script": { "type": "simplicity", "source": "a.simf" } }, + "z": { "description": "z", "script": { "type": "simplicity", "source": "b.simf" } } } }"#, + ); + let (pins, issues) = resolve(&m); + assert!(issues.is_empty(), "{issues:?}"); + assert_eq!(pins["a.simf"].version_text.as_deref(), Some("0.7.1")); + assert_eq!( + pins["b.simf"].version_text.as_deref(), + Some("0.7.0"), + "a file no reference versions gets the manifest-wide requirement" + ); + } + + #[test] + fn a_null_simf_is_not_a_program_reference() { + let json = r#"{ "manifest_version": "0.3.1", "protocol": "t", "actions": { "A": { + "params": { "H": { "type": "u256", + "compute": { "type": "tapleaf", "simf": null } } } } } }"#; + let err = Manifest::from_json_str(json).unwrap_err().to_string(); + assert!(err.contains("needs \"simf\""), "{err}"); + } + + #[test] + fn a_type_without_a_script_has_no_program() { + let err = Programs::default() + .for_script(None) + .unwrap_err() + .to_string(); + assert!(err.contains("no script"), "{err}"); + } + #[test] fn the_source_directive_pins_the_version_and_cannot_be_widened() { - let dir = scratch("directive"); let current = compiler_version(); let pin = Pin { source: "a.simf".into(), @@ -1000,7 +1061,6 @@ mod tests { .unwrap_err() .to_string(); assert!(err.contains("can only narrow"), "{err}"); - let _ = dir; } #[test] diff --git a/txmanifest_lib/src/validate.rs b/txmanifest_lib/src/validate.rs index ca1cf06..9966f25 100644 --- a/txmanifest_lib/src/validate.rs +++ b/txmanifest_lib/src/validate.rs @@ -1222,6 +1222,42 @@ mod tests { assert!(report.is_ok(), "{:?}", report.issues); } + /// The compiler is needed for a computed tapleaf hash as much as for a covenant output, + /// so a manifest with no covenant `utxo_types` still requires `simplicity` when it + /// computes one. Otherwise a wallet without a compiler would accept the manifest and + /// fail partway through a run. + #[test] + fn a_computed_program_reference_requires_simplicity() { + for compute in [ + r#"{ "type": "tapleaf", "simf": "./x.simf" }"#, + r#"{ "type": "simf_fn", "simf": "./x.simf" }"#, + r#"{ "type": "tapleaf", "program": "x" }"#, + ] { + let manifest = |requires: &str| { + Manifest::from_json_str(&format!( + r#"{{ "manifest_version": "0.3.1", "protocol": "t", "chain": "elements", + "requires": {requires}, + "programs": {{ "x": {{ "source": "./x.simf" }} }}, + "actions": {{ "A": {{ "params": {{ "H": {{ "type": "u256", + "compute": {compute} }} }} }} }} }}"# + )) + .expect("manifest should parse") + }; + let report = validate(&manifest("[]")); + assert!( + messages(&report).contains("uses 'simplicity' but does not declare it"), + "{compute}: {}", + messages(&report) + ); + let report = validate(&manifest(r#"["simplicity"]"#)); + assert!( + !messages(&report).contains("does not declare it"), + "{compute}: {}", + messages(&report) + ); + } + } + /// Simplicity is legal to *declare* on Bitcoin — it is a specified soft fork, and /// whether a given node honours it is settled by the target, not by `validate`. #[test]