diff --git a/CHANGELOG.md b/CHANGELOG.md index 83858d1..1e7a4be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,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` errors when a manifest sets values in hooks (`on_resolved`, `on_pre_broadcast`, `on_post_broadcast`) without declaring `"requires": ["hooks"]`. Add `"hooks"` to `requires` if `validate` now reports it. `capabilities --supports` accepts `hooks`. - `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/0002-hooks-capability.md b/docs/decisions/0002-hooks-capability.md new file mode 100644 index 0000000..d8aa1c5 --- /dev/null +++ b/docs/decisions/0002-hooks-capability.md @@ -0,0 +1,43 @@ +# 0002: Hooks are an optional capability + +- **Status:** accepted +- **Date:** 2026-10-07 + +## Context + +Hooks let a manifest set values while a transaction is being built: `on_resolved` on an +input (typically to record an asset id an issuance input just created), and +`on_pre_broadcast` / `on_post_broadcast` on an action. + +Neither of the existing browser-extension wallets (humid, apogee) runs hooks. Both refuse +manifests that contain them. A wallet that doesn't implement hooks had no way to know in +advance that a manifest needs them; it found out by parsing the manifest or partway +through a run. + +`requires` already exists for this kind of question: it lists what a wallet must support, +and a wallet without Simplicity refuses any manifest that requires `simplicity`. + +## Decision + +1. **`hooks` is a core capability**, alongside `simplicity`. A manifest that sets a value in + any hook must declare `"requires": ["hooks"]`. A hook block with an empty `set` does + nothing and needs nothing. +2. **It is inferred and checked like `simplicity`.** `validate` reports a manifest that uses + hooks without declaring them, and a wallet that doesn't run hooks refuses a manifest + that requires them, before doing anything else. +3. **`create_instance` is not a hook** and stays in the core format. +4. **The two lending examples keep their hooks** and declare `hooks`, so they show the + capability in use. They are wire-compatible with the live simplicity-lending protocol, + and rewriting them without hooks would need re-checking that on a test network. + +## Consequences + +- `tx-manifest-wallet` runs hooks on every network, so its targets always provide `hooks`. + Another wallet reports what it supports with `capabilities --supports`, e.g. + `--supports simplicity` for one that compiles Simplicity but runs no hooks; the lending + examples then come back unsupported. +- A manifest that used hooks without declaring them now fails `validate`. It was already + unrunnable by a wallet without hooks; now that's visible up front. +- Unlike `simplicity`, `hooks` is a property of the wallet, not of the chain or the node. + The capability list is the residue the `chain` field can't settle, which includes + optional parts of the format as well as soft forks. diff --git a/docs/format-changelog.md b/docs/format-changelog.md index d079cb0..b41ae57 100644 --- a/docs/format-changelog.md +++ b/docs/format-changelog.md @@ -17,8 +17,9 @@ The JSON Schema for the current version is ## [0.3.1] - unreleased -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`. +Additive, with two tightened rules (below): a `0.3.0` manifest is a valid `0.3.1` manifest +unless it uses a Simplicity program without declaring `simplicity`, or a hook without +declaring `hooks`. ### Added @@ -36,6 +37,9 @@ unless it uses a Simplicity program without declaring `simplicity`. SimplicityHL's `simc` directive (`"0.7.1"` means `^0.7.1`). Priority: the reference or entry, then the manifest-wide value, then the file's own `simc` directive. The manifest can narrow the directive but not widen it. +- **`hooks` capability** ([decision 0002](decisions/0002-hooks-capability.md)): hooks + (`on_resolved`, `on_pre_broadcast`, `on_post_broadcast`) are an optional part of the + format. A wallet may not implement them, and refuses manifests that require them. - **Pinned and unpinned manifests.** A program is pinned when it has a hash and a compiler-version requirement; a manifest is pinned when all its programs are. Wallets must refuse unpinned manifests and programs that don't match their hash. Tools may @@ -56,6 +60,8 @@ unless it uses a Simplicity program without declaring `simplicity`. 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. +- A manifest that sets a value in any hook must declare `"requires": ["hooks"]`. A hook + block with an empty `set` needs nothing. ## [0.3.0] - 2026-10-06 diff --git a/docs/glossary.md b/docs/glossary.md index cefa127..d3ae8cf 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -6,8 +6,10 @@ Terms as this repo uses them. Where a term is defined in more detail, the entry outputs it creates. Declared under `actions`, or under a contract template. **Capability.** Something a wallet must support to run a manifest, listed in the -manifest's `requires` (e.g. `"simplicity"`). Third parties may define their own under a -namespace (`ns::name`). +manifest's `requires`. The format defines `simplicity` (the manifest uses a Simplicity +program) and `hooks` (it sets values in hooks; see +[decision 0002](decisions/0002-hooks-capability.md)). Third parties may define their own +under a namespace (`ns::name`). **Compiler-version requirement.** The SimplicityHL versions a program is written for, as a semver requirement in the syntax of SimplicityHL's `simc` directive: `"0.7.1"` means @@ -19,6 +21,10 @@ hex>"`. See [decision 0001](decisions/0001-program-pinning.md). **Contract template.** A reusable contract definition under `contract_templates`: typed fields plus the actions that operate on one instance of it. +**Hook.** A block that sets values while a transaction is being built: `on_resolved` on an +input, `on_pre_broadcast` / `on_post_broadcast` on an action. Optional for a wallet to +support; a manifest using one requires `hooks`. + **Instance.** One deployed contract made from a template: the values its fields were given when it was created. diff --git a/examples/lending_v2/txmanifest.json b/examples/lending_v2/txmanifest.json index ca07149..4a4d997 100644 --- a/examples/lending_v2/txmanifest.json +++ b/examples/lending_v2/txmanifest.json @@ -2,7 +2,7 @@ "$schema": "../../schema/txmanifest.schema.json", "manifest_version": "0.3.1", "protocol": "simplicity-lending", - "requires": ["simplicity"], + "requires": ["simplicity", "hooks"], "simplicity_hl": { "version": "0.7.1", "debug_symbols": true }, "description": "P2P collateralised lending on Liquid, wire-compatible with the simplicity-lending reference implementation (github BlockstreamResearch/simplicity-lending, `smplx-sdk` covenants). This 'v2' manifest reproduces that protocol's exact on-chain transaction layout so offers created with tx-manifest-wallet are discoverable and settleable by simplicity-lending's own CLI / indexer / web app, and vice-versa. Key differences from the standalone 'lending' example: (1) the principal payout AND the borrower NFT both go to the borrower's plain wallet address (an explicit v0 P2WPKH), committed into the covenant as sha256(scriptPubKey) — there is no p2pk covenant and no separate claim step; (2) the borrower NFT is held in the borrower's wallet during the active loan and spent with an ordinary signature on repayment; (3) NFT ordering is first-params, second-params, borrower, lender; (4) the creation OP_RETURN carries borrower_pubkey || principal_asset_id (64 bytes, internal asset order) for indexer discovery; (5) a single AMOUNTS_DECIMALS drives the bit-packed parameter NFTs, matching the reference wallet.", "utxo_types": { diff --git a/examples/lending_v3/txmanifest.json b/examples/lending_v3/txmanifest.json index cdc72ec..6adb93b 100644 --- a/examples/lending_v3/txmanifest.json +++ b/examples/lending_v3/txmanifest.json @@ -3,7 +3,7 @@ "$comment": "lending_v3 targets the DEPLOYED simplicity-lending indexer (odev). Interop constants are fixed: factory params (2,0), NUMS key, protocol-fee keeper 38fca2d9…, and the program-id tags below. Change a covenant .simf and you must recompute the matching *_PROGRAM_ID + covenant address.", "manifest_version": "0.3.1", "protocol": "simplicity-lending", - "requires": ["simplicity"], + "requires": ["simplicity", "hooks"], "simplicity_hl": { "version": "0.7.1", "debug_symbols": true }, "description": "lending_v3 — the redesigned 'issuance factory' lending protocol, wire-compatible with the deployed simplicity-lending indexer/site (odev branch). Phase 3a models factory creation: a persistent issuance_factory covenant plus the wallet-held auth NFT, from which many lending offers are later minted.", "utxo_types": { diff --git a/schema/txmanifest.schema.json b/schema/txmanifest.schema.json index ddba907..02a7ad1 100644 --- a/schema/txmanifest.schema.json +++ b/schema/txmanifest.schema.json @@ -143,7 +143,8 @@ { "description": "A capability defined by this format.", "enum": [ - "simplicity" + "simplicity", + "hooks" ] }, { diff --git a/txmanifest_lib/src/chain.rs b/txmanifest_lib/src/chain.rs index a6bce03..f3ac632 100644 --- a/txmanifest_lib/src/chain.rs +++ b/txmanifest_lib/src/chain.rs @@ -14,9 +14,10 @@ //! says there are no native assets, and a manifest repeating that in a feature list //! would only create a second place to disagree. //! - **[`Capability`]** — what a *manifest* must state because the chain alone does not -//! settle it. Today that is exactly one thing, [`Capability::SIMPLICITY`], because -//! Simplicity is a soft fork on Bitcoin rather than a property of it. Plus whatever -//! third parties define under their own namespace. +//! settle it. Today that is two things: [`Capability::SIMPLICITY`], because Simplicity +//! is a soft fork on Bitcoin rather than a property of it, and [`Capability::HOOKS`], +//! because running hooks is a property of the wallet rather than of any chain. Plus +//! whatever third parties define under their own namespace. //! - **[`Activation`]** — what the specific node being talked to actually provides. This //! is configuration, not discovery. //! @@ -29,8 +30,9 @@ //! using issuance on Bitcoin is still an error — they just read the chain instead of a //! restatement of it. See [`ChainFamily::has_native_assets`] and its neighbours. //! -//! What is left in [`Capability`] is the residue that genuinely cannot be inferred: a soft -//! fork's activation state, and features this crate has never heard of. +//! What is left in [`Capability`] is the residue the chain cannot settle: a soft fork's +//! activation state, an optional part of the format a wallet may not implement (hooks), +//! and features this crate has never heard of. //! //! # Namespaces //! @@ -251,15 +253,22 @@ pub enum CoreCapability { /// Covenant `utxo_types` backed by SimplicityHL programs, spent through a Simplicity /// tapleaf the validator executes. /// - /// The one core capability, because it is the one thing the `chain` field does not - /// settle. On Elements it is live. On Bitcoin it is a proposed soft fork (BINANA + /// Not settled by the `chain` field. On Elements it is live. On Bitcoin it is a proposed soft fork (BINANA /// 2026-0003) — implemented in the C library, but not activated on mainnet, testnet, /// or the default signet — so whether a given Bitcoin node honours it is a property of /// that node, not of Bitcoin. /// - /// A manifest that declares no `utxo_types` with a `script` does not need this, and - /// can target a stock Bitcoin node. + /// A manifest that uses no Simplicity program does not need this, and can target a + /// stock Bitcoin node. Simplicity, + /// Hooks: `on_resolved` on an input, `on_pre_broadcast` / `on_post_broadcast` on an + /// action, which set values while a transaction is being built. + /// + /// An optional part of the format rather than a property of any chain: the existing + /// browser-extension wallets run none of them, and a wallet that doesn't must be able to + /// refuse a manifest that relies on them before it starts, not partway through. + /// `create_instance` is not a hook and needs no capability. + Hooks, } impl CoreCapability { @@ -267,6 +276,7 @@ impl CoreCapability { pub fn as_str(self) -> &'static str { match self { CoreCapability::Simplicity => "simplicity", + CoreCapability::Hooks => "hooks", } } } @@ -275,8 +285,11 @@ impl Capability { /// The Simplicity capability, spelled out for call sites. pub const SIMPLICITY: Capability = Capability::Core(CoreCapability::Simplicity); + /// The hooks capability, spelled out for call sites. + pub const HOOKS: Capability = Capability::Core(CoreCapability::Hooks); + /// Every core capability this build defines. - pub const CORE: [CoreCapability; 1] = [CoreCapability::Simplicity]; + pub const CORE: [CoreCapability; 2] = [CoreCapability::Simplicity, CoreCapability::Hooks]; /// Build a namespaced capability, validating both halves. pub fn namespaced( @@ -315,6 +328,11 @@ impl Capability { activated" .to_string() } + Capability::Core(CoreCapability::Hooks) => { + "the manifest sets values in on_resolved / on_pre_broadcast / \ + on_post_broadcast hooks, which this wallet does not run" + .to_string() + } Capability::Namespaced { namespace, .. } => format!( "defined by '{namespace}', not by this format; a target provides it only by \ listing it as an extension" @@ -553,6 +571,8 @@ impl Network { if simplicity_live { caps.insert(Capability::SIMPLICITY); } + // Hooks are run by this wallet, not by the node, so every network has them. + caps.insert(Capability::HOOKS); caps } diff --git a/txmanifest_lib/src/manifest.rs b/txmanifest_lib/src/manifest.rs index fd85472..4eef8a7 100644 --- a/txmanifest_lib/src/manifest.rs +++ b/txmanifest_lib/src/manifest.rs @@ -1669,7 +1669,8 @@ impl Manifest { /// `requires` claims. /// /// Only the residue that [`Manifest::chain`] does not settle, which today means: does - /// anything need a Simplicity compiler. That is any `utxo_type` with a `script`, and + /// anything set a value in a hook (`hooks`), and does 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 @@ -1687,9 +1688,27 @@ impl Manifest { if uses_covenants || !program_refs.is_empty() { caps.insert(Capability::SIMPLICITY); } + if self.uses_hooks() { + caps.insert(Capability::HOOKS); + } caps } + /// Whether any action or input sets a value in a hook. A hook block with an empty + /// `set` does nothing, so it does not count. + fn uses_hooks(&self) -> bool { + let used = |h: &Option| h.as_ref().is_some_and(|h| !h.set.is_empty()); + let mut actions = self.actions.values().collect::>(); + for t in self.contract_templates.iter().flat_map(|t| t.values()) { + actions.extend(t.actions.values()); + } + actions.into_iter().any(|a| { + used(&a.on_pre_broadcast) + || used(&a.on_post_broadcast) + || a.inputs.iter().flatten().any(|i| used(&i.on_resolved)) + }) + } + /// Places where this manifest uses something its declared [`Manifest::chain`] does not /// have. /// diff --git a/txmanifest_lib/src/validate.rs b/txmanifest_lib/src/validate.rs index 9966f25..0cbde89 100644 --- a/txmanifest_lib/src/validate.rs +++ b/txmanifest_lib/src/validate.rs @@ -1258,6 +1258,48 @@ mod tests { } } + /// Hooks are optional for a wallet to implement, so a manifest that sets a value in one + /// must say so: an undeclared hook is the same kind of error as undeclared Simplicity. + #[test] + fn a_manifest_using_hooks_must_declare_them() { + let manifest = |requires: &str, hook: &str| { + Manifest::from_json_str(&format!( + r#"{{ "manifest_version": "0.3.1", "protocol": "t", "chain": "elements", + "requires": {requires}, + "actions": {{ "A": {{ + "params": {{ "B": {{ "type": "u64", "compute": {{ "type": "hook" }} }} }}, + {hook} }} }} }}"# + )) + .expect("manifest should parse") + }; + for hook in [ + r#""on_pre_broadcast": { "set": { "params.B": "1" } }"#, + r#""on_post_broadcast": { "set": { "params.B": "1" } }"#, + r#""inputs": [ { "id": "i", "utxo_source": "wallet", + "on_resolved": { "set": { "params.B": "1" } } } ]"#, + ] { + let report = validate(&manifest("[]", hook)); + assert!( + messages(&report).contains("uses 'hooks' but does not declare it"), + "{hook}: {}", + messages(&report) + ); + let report = validate(&manifest(r#"["hooks"]"#, hook)); + assert!( + !messages(&report).contains("'hooks'"), + "{hook}: {}", + messages(&report) + ); + } + // An empty hook block sets nothing, so it needs nothing. + let report = validate(&manifest("[]", r#""on_pre_broadcast": { "set": {} }"#)); + assert!( + !messages(&report).contains("'hooks'"), + "{}", + 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] @@ -1805,6 +1847,7 @@ mod tests { &serde_json::json!({ "manifest_version": "0.3.0", "protocol": "test", + "requires": ["hooks"], "actions": { "A": { "params": params, "on_pre_broadcast": { "set": set } } } }) .to_string(),