Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions docs/decisions/0002-hooks-capability.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 8 additions & 2 deletions docs/format-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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

Expand Down
10 changes: 8 additions & 2 deletions docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion examples/lending_v2/txmanifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion examples/lending_v3/txmanifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion schema/txmanifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@
{
"description": "A capability defined by this format.",
"enum": [
"simplicity"
"simplicity",
"hooks"
]
},
{
Expand Down
40 changes: 30 additions & 10 deletions txmanifest_lib/src/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//!
Expand All @@ -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
//!
Expand Down Expand Up @@ -251,22 +253,30 @@ 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 {
/// Canonical bare name.
pub fn as_str(self) -> &'static str {
match self {
CoreCapability::Simplicity => "simplicity",
CoreCapability::Hooks => "hooks",
}
}
}
Expand All @@ -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(
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down
21 changes: 20 additions & 1 deletion txmanifest_lib/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<HookBlock>| h.as_ref().is_some_and(|h| !h.set.is_empty());
let mut actions = self.actions.values().collect::<Vec<_>>();
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.
///
Expand Down
43 changes: 43 additions & 0 deletions txmanifest_lib/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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(),
Expand Down
Loading