From 271e684f63af2d19481ae1185af0ff8490c87996 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Wed, 7 Oct 2026 16:08:02 +0200 Subject: [PATCH 1/2] fix: infer the simplicity capability from every program reference requires must name simplicity whenever the manifest needs a Simplicity compiler, so a wallet without one can refuse it up front. Inference only looked at covenant utxo_types; a tapleaf or simf_fn compute, or a programs entry, also compiles a program but went unflagged, so validate passed manifests that such a wallet would accept and then fail partway through. Infer from every program reference via programs::references. --- CHANGELOG.md | 1 + docs/format-changelog.md | 11 ++++++++++- txmanifest_lib/src/manifest.rs | 12 +++++++++--- txmanifest_lib/src/validate.rs | 36 ++++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) 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/format-changelog.md b/docs/format-changelog.md index ae3139b..2f75dc5 100644 --- a/docs/format-changelog.md +++ b/docs/format-changelog.md @@ -15,7 +15,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 +47,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/manifest.rs b/txmanifest_lib/src/manifest.rs index 90e0c19..a0af8e9 100644 --- a/txmanifest_lib/src/manifest.rs +++ b/txmanifest_lib/src/manifest.rs @@ -1666,8 +1666,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 +1680,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/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] From 6640a1f6df258f07862e5593c541b4762bf2fd78 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Wed, 7 Oct 2026 16:15:49 +0200 Subject: [PATCH 2/2] fix: audit findings for program pinning - resolve(): apply simplicity_hl.version only to files no reference versions, after merging per-reference values; it was applied per reference first and reported a false conflict against a programs entry's own version. - A tapleaf/simf_fn compute with "simf": null is rejected; it parsed as naming no program, so the reference escaped pinning and requires inference. - for_script(None) errors that the utxo type has no script, and each lifecycle lookup names the type, instead of a confusing 'covenant.simf was not loaded'. - RefIssue carries a conflict flag; pin_text no longer matches on message text. - Docs: older readers reject newer-patch fields (format changelog); wallets must refuse unpinned manifests, Unpinned::Allow is for dev tooling (decision 0001). - Remove an unused scratch dir in a programs test. --- docs/decisions/0001-program-pinning.md | 5 +- docs/format-changelog.md | 6 +- txmanifest_lib/src/lifecycle.rs | 27 ++-- txmanifest_lib/src/manifest.rs | 5 +- txmanifest_lib/src/programs.rs | 186 ++++++++++++++++--------- 5 files changed, 153 insertions(+), 76 deletions(-) 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 2f75dc5..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 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 a0af8e9..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" 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]