From 1ccf9fe939142d8e7feed14767e618bd9b5c88b0 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 4 Sep 2026 14:48:34 +0200 Subject: [PATCH 01/16] add chain abstraction and capability --- CHANGELOG.md | 88 +++ examples/deadcat/txmanifest.json | 3 +- examples/deadcat_v2/txmanifest.json | 3 +- examples/deadcat_v3/txmanifest.json | 3 +- examples/dex/txmanifest.json | 3 +- examples/last_will/txmanifest.json | 3 +- examples/lending/txmanifest.json | 3 +- examples/lending_v2/txmanifest.json | 3 +- examples/lending_v3/txmanifest.json | 3 +- examples/p2pk/txmanifest.json | 3 +- examples/zeroconf/txmanifest.json | 3 +- schema/txmanifest.schema.json | 48 +- txmanifest_lib/src/canonical.rs | 6 +- txmanifest_lib/src/chain.rs | 946 ++++++++++++++++++++++++++++ txmanifest_lib/src/covenant.rs | 74 ++- txmanifest_lib/src/describe.rs | 5 +- txmanifest_lib/src/lib.rs | 1 + txmanifest_lib/src/lifecycle.rs | 14 +- txmanifest_lib/src/manifest.rs | 311 +++++++-- txmanifest_lib/src/validate.rs | 232 ++++++- txmanifest_lib/tests/schema.rs | 6 +- 21 files changed, 1654 insertions(+), 107 deletions(-) create mode 100644 txmanifest_lib/src/chain.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ec24001..6c1beae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,94 @@ released together. No changelog was kept before 0.2.0; for 0.1.x see the git history. +## [Unreleased] + +**Breaking:** `manifest_version` must now read `"0.3.0"`. The changes below make +a `0.2.0` file read wrongly rather than fail, so the version moves. Every +example was updated. + +**Breaking:** a manifest with covenant `utxo_types` must now declare +`"requires": ["simplicity"]`, or `validate` fails. + +**Breaking:** `chain` is now a closed vocabulary — `elements`, `bitcoin`, and +the aliases `liquid` and `btc`. It was a free-form string that nothing read; +`cross-chain` was previously accepted with a warning and is now refused. + +### Added + +- **`chain` module — the seam between this engine and the ledger it targets.** + Everything here was written against Elements, where a great deal is assumed: + outputs carry an asset id, amounts may be blinded, the fee is its own `TxOut`, + taproot tags are domain-separated with `/elements`, and a Simplicity tapleaf + will be executed. None of that holds on Bitcoin. The module sorts those + assumptions by who settles them: `ChainFamily` properties follow from the + ledger, `Capability` is what a manifest must state because the chain does not + settle it, and `Activation` is what the specific node provides. + +- **`requires`: the features a manifest needs that `chain` does not already + imply.** Deliberately one core capability, `simplicity` — because it is the + only one the chain does not answer. It is live on Elements and a proposed soft + fork on Bitcoin (BINANA 2026-0003, leaf version `0xbe`, not activated on any + public network), so whether a Bitcoin node honours it is a property of that + node. + + `validate` checks it both ways: a covenant manifest omitting `simplicity` is + an error, since `requires` is what a target gets checked against before a + build; declaring what nothing uses is a warning only, because the inference + reads field presence rather than semantics and must not block a run on its own + guess. + + The empty list is the point of the field, not a degenerate case. A manifest + that declares nothing needs nothing a stock node lacks — which is exactly the + manifest that can target Bitcoin today. + +- **Namespaced capabilities.** A bare name is defined by this format and comes + from a closed set, so a typo is an error rather than a silently-ignored + request. A name containing `::` (`custom::my-feature`, `mosaik::tessera`) + belongs to whoever owns the namespace: this crate parses it, round-trips it + verbatim, and judges it in neither direction — never inferred, never reported + unused. A target satisfies one only by naming it in `Activation::extensions`. + Core names normalize `_` to `-`; namespaced ones do not, since rewriting them + would make two spellings this crate treats as equal and their owner may not. + +- **`Manifest::chain_mismatches`** reports, per field, where a manifest uses + something its chain lacks — an issuance input or a blinded output on Bitcoin. + This replaced the `multi-asset` / `asset-issuance` / `confidential-amounts` + capabilities: the check was worth keeping, but making an author *declare* them + was not, because `chain: "bitcoin"` already says there are no native assets. A + restatement is something that can disagree with itself. The rule now reads the + chain directly, and reports the dot-path of each site rather than one verdict — + an author porting a protocol needs the list, not the answer. + + An `asset` naming the policy asset outright (`"lbtc"`) is single-asset + behaviour and stays clean on Bitcoin. That is how the portable examples here + are written, and counting it as multi-asset marked `p2pk` and `last_will` + unportable when they are the two that port most cleanly. + +### Changed + +- **Taproot tag domains are derived from the chain rather than hardcoded.** + `build_tapbranch` took the `TapBranch/elements` tag as a constant; it now takes + a `ChainFamily`, as do `dry_run_covenant` and `finalize_covenant_input`. + `compute_covenant_address` derives it from the network it already receives, so + no caller changed. This is the one change here that cannot fail loudly: the + wrong tag yields a well-formed address that no script path can ever satisfy, + so it is now pinned by a test that cross-checks the Elements branch against + `rust-elements`' own tag. + +- **The Simplicity leaf version comes from one constant for both chains.** + `simplicity::leaf_version()` returns an `elements::taproot::LeafVersion`, + which is the wrong type the moment a Bitcoin tree is built. The byte is the + same either way (`0xbe`, matching `TAPROOT_LEAF_TAPSIMPLICITY` in the Bitcoin + proposal), so it is now `chain::SIMPLICITY_LEAF_VERSION`. + +- **The generated JSON Schema matches the parser exactly** for the two new + types. The `schemars` derive emits only canonical spellings, which would make + an editor flag `"chain": "liquid"` in files this engine reads happily — + including every example here. `Capability`'s schema is an `anyOf` of the + closed core list and a namespace pattern, since an `enum` cannot express a set + that is closed at one end and open at the other. + ## [0.2.0] - 2026-08-20 **Breaking:** a manifest that sets `utxo_type.confidential` no longer parses. diff --git a/examples/deadcat/txmanifest.json b/examples/deadcat/txmanifest.json index 13db536..36123ab 100644 --- a/examples/deadcat/txmanifest.json +++ b/examples/deadcat/txmanifest.json @@ -1,10 +1,11 @@ { "$schema": "../../schema/txmanifest.schema.json", "$comment": "Ported from Deadcat.Live (github.com/Resolvr-io/deadcat, src-tauri/crates/deadcat-sdk). prediction_market.simf is a verbatim copy of that crate's contract/prediction_market.simf — do not edit it, every byte feeds the CMR and therefore all four covenant addresses. The upstream SDK builds these transactions in Rust (src/pset/*.rs); this manifest is the same seven spending paths expressed declaratively. Deadcat's OTHER covenant, maker_order.simf, is deliberately NOT modelled: it tweaks the MAKER's key as the taproot internal key, while this engine hardcodes the NUMS internal key (covenant.rs::NUMS_KEY_BYTES), so no address it computed would be correct.", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "deadcat-prediction-market", "description": "Deadcat — a binary (YES/NO) prediction market on Liquid. Collateral is locked in a covenant that mints matched YES/NO token pairs at 2 x COLLATERAL_PER_TOKEN per pair; an off-chain oracle commits the outcome ON-CHAIN as a state transition, and winners then burn tokens to draw the whole pair's collateral. The market's state (0 dormant, 1 unresolved, 2 resolved-YES, 3 resolved-NO) is not stored in a variable — it is a tapdata leaf in the covenant's tap tree, so each state is a DIFFERENT address and the covenant proves its own state by comparing the address it is being spent from. Modelled as one template: one instance per market.", "chain": "liquid", + "requires": ["simplicity"], "simplicity_hl": { "$comment": "Deadcat compiles with debug symbols OFF (contract.rs: template.instantiate(args, false)). Flipping this changes every fail-node commitment, hence the CMR, hence all four addresses.", "debug_symbols": false diff --git a/examples/deadcat_v2/txmanifest.json b/examples/deadcat_v2/txmanifest.json index 5b8d3bc..dd1dc4d 100644 --- a/examples/deadcat_v2/txmanifest.json +++ b/examples/deadcat_v2/txmanifest.json @@ -1,10 +1,11 @@ { "$schema": "../../schema/txmanifest.schema.json", "$comment": "v2 of examples/deadcat — SAME protocol, one forked covenant. prediction_market.simf here treats the two reissuance tokens as EXPLICIT rather than as Pedersen commitments, because the engine emits every covenant output explicit and upstream's commitment check (unwrap_left on a confidential asset) therefore fails on the issuance, resolve and full-cancel paths. Dropping the EC operations changes the CMR, so all four covenant addresses differ from examples/deadcat and markets are NOT interoperable with Deadcat's. Everything else — states, paths, amounts, output layouts — is unchanged; examples/deadcat remains the faithful port and is what deadcat_recon checks against upstream. Original header follows. Ported from Deadcat.Live (github.com/Resolvr-io/deadcat, src-tauri/crates/deadcat-sdk). prediction_market.simf is a verbatim copy of that crate's contract/prediction_market.simf — do not edit it, every byte feeds the CMR and therefore all four covenant addresses. The upstream SDK builds these transactions in Rust (src/pset/*.rs); this manifest is the same seven spending paths expressed declaratively. Deadcat's OTHER covenant, maker_order.simf, is deliberately NOT modelled: it tweaks the MAKER's key as the taproot internal key, while this engine hardcodes the NUMS internal key (covenant.rs::NUMS_KEY_BYTES), so no address it computed would be correct.", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "deadcat-prediction-market-v2", "description": "Deadcat v2 — a binary (YES/NO) prediction market on Liquid, with explicit (unblinded) reissuance tokens so it can actually be executed by this engine. Collateral is locked in a covenant that mints matched YES/NO token pairs at 2 x COLLATERAL_PER_TOKEN per pair; an off-chain oracle commits the outcome ON-CHAIN as a state transition, and winners then burn tokens to draw the whole pair's collateral. The market's state (0 dormant, 1 unresolved, 2 resolved-YES, 3 resolved-NO) is not stored in a variable — it is a tapdata leaf in the covenant's tap tree, so each state is a DIFFERENT address and the covenant proves its own state by comparing the address it is being spent from. Modelled as one template: one instance per market.", "chain": "liquid", + "requires": ["simplicity"], "simplicity_hl": { "$comment": "Deadcat compiles with debug symbols OFF (contract.rs: template.instantiate(args, false)). Flipping this changes every fail-node commitment, hence the CMR, hence all four addresses.", "debug_symbols": false diff --git a/examples/deadcat_v3/txmanifest.json b/examples/deadcat_v3/txmanifest.json index 1dd0fbf..4c8b1e5 100644 --- a/examples/deadcat_v3/txmanifest.json +++ b/examples/deadcat_v3/txmanifest.json @@ -1,10 +1,11 @@ { "$schema": "../../schema/txmanifest.schema.json", "$comment": "v3 of examples/deadcat - the runnable fork. The reissuance tokens stay confidential (Elements requires it), but each recreated token advances both blinding factors by exactly one, so the covenant checks the outputs as a translation of the inputs and needs no output-side witnesses. The factors are therefore derivable from the previous spend's on-chain witness and nothing has to be persisted; the first pair is the constant abf = vbf = 1, set by CreateMarket. The fee is checked at num_outputs - 1 on every path, which lets these actions declare an L-BTC change output instead of paying the surplus to miners. Different CMR, different addresses, NOT interoperable with Deadcat. examples/deadcat remains the faithful port. Original header follows. Ported from Deadcat.Live (github.com/Resolvr-io/deadcat, src-tauri/crates/deadcat-sdk). prediction_market.simf is a verbatim copy of that crate's contract/prediction_market.simf — do not edit it, every byte feeds the CMR and therefore all four covenant addresses. The upstream SDK builds these transactions in Rust (src/pset/*.rs); this manifest is the same seven spending paths expressed declaratively. Deadcat's OTHER covenant, maker_order.simf, is deliberately NOT modelled: it tweaks the MAKER's key as the taproot internal key, while this engine hardcodes the NUMS internal key (covenant.rs::NUMS_KEY_BYTES), so no address it computed would be correct.", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "deadcat-prediction-market-v3", "description": "Deadcat v3 — a binary (YES/NO) prediction market on Liquid. Collateral is locked in a covenant that mints matched YES/NO token pairs at 2 x COLLATERAL_PER_TOKEN per pair; an off-chain oracle commits the outcome ON-CHAIN as a state transition, and winners then burn tokens to draw the whole pair's collateral. The market's state (0 dormant, 1 unresolved, 2 resolved-YES, 3 resolved-NO) is not stored in a variable — it is a tapdata leaf in the covenant's tap tree, so each state is a DIFFERENT address and the covenant proves its own state by comparing the address it is being spent from. Modelled as one template: one instance per market.", "chain": "liquid", + "requires": ["simplicity"], "simplicity_hl": { "$comment": "Deadcat compiles with debug symbols OFF (contract.rs: template.instantiate(args, false)). Flipping this changes every fail-node commitment, hence the CMR, hence all four addresses.", "debug_symbols": false diff --git a/examples/dex/txmanifest.json b/examples/dex/txmanifest.json index 9472c51..592cce3 100644 --- a/examples/dex/txmanifest.json +++ b/examples/dex/txmanifest.json @@ -1,10 +1,11 @@ { "$schema": "../../schema/txmanifest.schema.json", "$comment": "Ported from Mosaik's tessera.simf (github.com/kaleidoswap/mosaik, crates/tessera/contracts/tessera.simf). Upstream substitutes the four offer terms as inline TESSERA_PARAM literals; here they are ordinary compile params. Change tessera.simf and every offer address changes — the terms live in the tapleaf. NOTE: the Refund method is not executable until upnext/12 (absolute nLockTime) lands; see its description.", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "tessera-dex", "description": "Tessera — a keyless atomic swap offer on Liquid, the primitive a Mosaik DEX is built from. One UTXO is one all-or-nothing offer: it holds asset A and is spendable by ANYONE who pays AMOUNT_B of ASSET_B to the maker (Settle), or, after TIMEOUT, by anyone who returns asset A to the maker (Refund). No signature on either path — the covenant is pure transaction introspection, and the maker is identified only by a scriptPubKey hash. Modelled as a class: one instance per offer.", "chain": "liquid", + "requires": ["simplicity"], "utxo_types": { "tessera_offer": { "description": "The offer UTXO: holds OFFER_AMOUNT of the maker's asset A, spendable via the keyless Settle or Refund paths. Its address commits to the four offer terms (ASSET_B, AMOUNT_B, MAKER_SPK, TIMEOUT) plus MAX_FEE — so the terms cannot change once funded. It does NOT commit to asset A or its amount: the covenant never inspects them on the settle path (see the note in tessera.simf), which is why OFFER_ASSET_ID/OFFER_AMOUNT are instance fields but not compile params.", diff --git a/examples/last_will/txmanifest.json b/examples/last_will/txmanifest.json index 52249f4..e049547 100644 --- a/examples/last_will/txmanifest.json +++ b/examples/last_will/txmanifest.json @@ -1,9 +1,10 @@ { "$schema": "../../schema/txmanifest.schema.json", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "last-will", "description": "Last Will — a recursive covenant with three spending paths: inherit (after a 180-day timelock), cold-key break-out, and hot-key refresh. Modelled as a class: one instance per will.", "chain": "liquid", + "requires": ["simplicity"], "utxo_types": { "last_will": { "description": "Funds locked under the last-will covenant.", diff --git a/examples/lending/txmanifest.json b/examples/lending/txmanifest.json index 7fb45b2..a550e95 100644 --- a/examples/lending/txmanifest.json +++ b/examples/lending/txmanifest.json @@ -1,7 +1,8 @@ { "$schema": "../../schema/txmanifest.schema.json", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "simplicity-lending", + "requires": ["simplicity"], "description": "P2P collateralised lending protocol on Liquid using SimplicityHL covenants. Borrower locks collateral in a PreLockCovenant and advertises terms via bit-packed Parameter NFTs. A Lender accepts by providing the principal, activating the LendingCovenant. Settlement is either repayment (borrower returns principal+interest, reclaims collateral) or liquidation (lender claims collateral after loan expiry). All covenants are enforced on-chain via Simplicity programs; no trusted backend is required.", "utxo_types": { "p2pk": { diff --git a/examples/lending_v2/txmanifest.json b/examples/lending_v2/txmanifest.json index 535b0aa..f25f11d 100644 --- a/examples/lending_v2/txmanifest.json +++ b/examples/lending_v2/txmanifest.json @@ -1,7 +1,8 @@ { "$schema": "../../schema/txmanifest.schema.json", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "simplicity-lending", + "requires": ["simplicity"], "simplicity_hl": { "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 929c503..b77c7fb 100644 --- a/examples/lending_v3/txmanifest.json +++ b/examples/lending_v3/txmanifest.json @@ -1,8 +1,9 @@ { "$schema": "../../schema/txmanifest.schema.json", "$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.2.0", + "manifest_version": "0.3.0", "protocol": "simplicity-lending", + "requires": ["simplicity"], "simplicity_hl": { "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/examples/p2pk/txmanifest.json b/examples/p2pk/txmanifest.json index 4d6a16e..d33c9be 100644 --- a/examples/p2pk/txmanifest.json +++ b/examples/p2pk/txmanifest.json @@ -1,9 +1,10 @@ { "$schema": "../../schema/txmanifest.schema.json", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "p2pk-simplicity", "description": "Hello World — Pay-to-public-key using a Simplicity checksig program on Liquid.", "chain": "liquid", + "requires": ["simplicity"], "utxo_types": { "p2pk_output": { "description": "A Liquid UTXO locked to PUBKEY via the compiled p2pk.simf program.", diff --git a/examples/zeroconf/txmanifest.json b/examples/zeroconf/txmanifest.json index 9732baa..e44045f 100644 --- a/examples/zeroconf/txmanifest.json +++ b/examples/zeroconf/txmanifest.json @@ -1,9 +1,10 @@ { "$schema": "../../schema/txmanifest.schema.json", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "zeroconf", "description": "Example zeroconf", "chain": "liquid", + "requires": [], "utxo_types": {}, "actions": {} } \ No newline at end of file diff --git a/schema/txmanifest.schema.json b/schema/txmanifest.schema.json index 56ed66d..3ebd972 100644 --- a/schema/txmanifest.schema.json +++ b/schema/txmanifest.schema.json @@ -138,6 +138,33 @@ }, "type": "object" }, + "Capability": { + "anyOf": [ + { + "description": "A capability defined by this format.", + "enum": [ + "simplicity" + ] + }, + { + "description": "A third-party capability, 'namespace::name'. This format does not define its meaning; a target provides it by listing it as an extension.", + "pattern": "^[a-z0-9][a-z0-9_-]*::[a-z0-9][a-z0-9_-]*$", + "type": "string" + } + ], + "description": "A ledger feature this manifest depends on that the 'chain' field does not already settle. Bare names are defined by this format; anything containing '::' belongs to the named namespace.", + "type": "string" + }, + "ChainFamily": { + "description": "Ledger this manifest targets. 'liquid' is an accepted alias for 'elements', and 'btc' for 'bitcoin'. Defaults to 'elements' when absent.", + "enum": [ + "elements", + "liquid", + "bitcoin", + "btc" + ], + "type": "string" + }, "ComputeSpec": { "anyOf": [ { @@ -1113,10 +1140,15 @@ "type": "object" }, "chain": { - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/ChainFamily" + }, + { + "type": "null" + } + ], + "description": "Which ledger this protocol is written for: `\"elements\"` (or its alias `\"liquid\"`) or `\"bitcoin\"`. Defaults to [`ChainFamily::DEFAULT`] when absent.\n\nDeclares the *family*, not the network — a protocol that works on Liquid works on Liquid testnet, and pinning one here would be wrong. The wallet's config picks the concrete [`crate::chain::Network`]." }, "contract_templates": { "additionalProperties": { @@ -1141,6 +1173,14 @@ "protocol": { "type": "string" }, + "requires": { + "description": "Ledger features this manifest depends on that [`Manifest::chain`] does not already settle, e.g. `[\"simplicity\"]` or `[\"simplicity\", \"custom::my-feature\"]`.\n\nDeliberately narrow. Whether outputs carry an asset id, whether amounts can be blinded, whether issuance exists — all of that follows from `chain`, so listing it here would be a restatement that can disagree with itself. What is left is the residue the chain does not answer: `simplicity`, which is live on Elements but a soft fork on Bitcoin, and namespaced third-party features this crate cannot know about. See [`crate::chain`].\n\n`validate` checks it both ways — a covenant manifest that omits `simplicity` is an error, and declaring what nothing uses is a warning — because both mistakes are real: the first passes a target check and then fails at broadcast, and the second makes a manifest look less portable than it is.\n\nThe empty default is the honest one and the useful one. A manifest that declares nothing is claiming to need nothing a stock node lacks — which is exactly the manifest that can target Bitcoin today, with no Simplicity activation.", + "items": { + "$ref": "#/definitions/Capability" + }, + "type": "array", + "uniqueItems": true + }, "simplicity_hl": { "anyOf": [ { diff --git a/txmanifest_lib/src/canonical.rs b/txmanifest_lib/src/canonical.rs index 61fc380..42f9c45 100644 --- a/txmanifest_lib/src/canonical.rs +++ b/txmanifest_lib/src/canonical.rs @@ -108,7 +108,7 @@ mod tests { use super::*; const BASE: &str = r#"{ - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "test", "description": "the original prose", "actions": { "A": { @@ -157,9 +157,9 @@ mod tests { #[test] fn array_order_is_significant() { // Input/output ordering is consensus-relevant — covenants introspect by index. - let two = r#"{"manifest_version":"0.2.0","protocol":"t","actions":{"A":{"outputs":[ + let two = r#"{"manifest_version":"0.3.0","protocol":"t","actions":{"A":{"outputs":[ {"id":"a","destination":"change"},{"id":"b","destination":"change"}]}}}"#; - let swapped = r#"{"manifest_version":"0.2.0","protocol":"t","actions":{"A":{"outputs":[ + let swapped = r#"{"manifest_version":"0.3.0","protocol":"t","actions":{"A":{"outputs":[ {"id":"b","destination":"change"},{"id":"a","destination":"change"}]}}}"#; assert_ne!(manifest_id(two).unwrap(), manifest_id(swapped).unwrap()); } diff --git a/txmanifest_lib/src/chain.rs b/txmanifest_lib/src/chain.rs new file mode 100644 index 0000000..48f9977 --- /dev/null +++ b/txmanifest_lib/src/chain.rs @@ -0,0 +1,946 @@ +//! Which ledger a manifest targets, and what that ledger can do. +//! +//! Everything in this engine was written against Liquid/Elements, where a great deal is +//! simply assumed: outputs carry an asset id, amounts may be blinded, the fee is its own +//! `TxOut`, taproot tagged hashes are domain-separated with an `/elements` suffix, and a +//! Simplicity tapleaf will be executed by the validator. None of those hold on Bitcoin. +//! +//! This module is the seam. It splits the assumptions into three kinds, because they are +//! settled by different parties at different times: +//! +//! - **[`ChainFamily`] properties** — what follows from the ledger itself. Whether outputs +//! carry an asset id, whether amounts can be blinded, whether the fee is its own output, +//! which taproot tag domain applies. Nobody declares these: `chain: "bitcoin"` already +//! 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. +//! - **[`Activation`]** — what the specific node being talked to actually provides. This +//! is configuration, not discovery. +//! +//! # Why the core capability set is so small +//! +//! An earlier cut of this module also had `multi-asset`, `asset-issuance` and +//! `confidential-amounts` as declarable capabilities. They came out: every one of them is +//! implied by `chain`, so declaring them was redundant, and a redundant declaration is a +//! declaration that can be wrong. The checks they powered did not go away — a manifest +//! 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. +//! +//! # Namespaces +//! +//! A bare name (`simplicity`) is defined by this format and comes from a closed set — an +//! unrecognized bare name is an error, because it is almost always a typo, and silently +//! ignoring it would mean a manifest requesting nothing while appearing to request +//! something. A name containing `::` (`custom::my-feature`, `mosaik::tessera`) belongs to +//! whoever owns that namespace. This crate cannot check those, so it carries them through: +//! they parse, they round-trip, and a target satisfies them only by declaring them in +//! [`Activation::extensions`]. That is what lets a downstream tool extend the vocabulary +//! without patching this crate or colliding with a future core name. + +use std::collections::BTreeSet; +use std::fmt; +use std::str::FromStr; + +use schemars::gen::SchemaGenerator; +use schemars::schema::{InstanceType, Schema, SchemaObject}; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer}; + +// --------------------------------------------------------------------------- +// Chain family +// --------------------------------------------------------------------------- + +/// The ledger a manifest is written for, independent of which network of it is in use. +/// +/// This is the manifest-facing granularity: a protocol works on Liquid and Liquid testnet +/// alike, so pinning the network in the file would be wrong. The wallet's config picks the +/// [`Network`]; the manifest picks the family. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ChainFamily { + /// Liquid and any other Elements-based sidechain: confidential, multi-asset, + /// explicit fee outputs, Simplicity already live. + Elements, + /// Bitcoin: single asset, transparent amounts, implicit fee. + Bitcoin, +} + +impl ChainFamily { + /// The family assumed when a manifest declares no `chain`. + /// + /// Elements, because every manifest written before the field existed was an Elements + /// manifest. A new default would silently reinterpret them. + pub const DEFAULT: ChainFamily = ChainFamily::Elements; + + /// Whether outputs carry an asset id other than the chain's own unit of account. + /// + /// False on Bitcoin, which has exactly one asset. This is the fact that makes a + /// per-output `asset` field, an issuance input, or `allow_change: "any"` meaningless + /// there — see `Manifest::elements_only_uses`. + pub fn has_native_assets(self) -> bool { + matches!(self, ChainFamily::Elements) + } + + /// Whether an issuance or reissuance input can mint an asset. + /// + /// Separate from [`Self::has_native_assets`] even though the two agree today: a chain + /// could carry assets it cannot mint, and the error messages differ — a manifest that + /// merely *moves* a second asset has a smaller problem than one that creates it. + pub fn has_asset_issuance(self) -> bool { + matches!(self, ChainFamily::Elements) + } + + /// Whether amounts and assets can be blinded — rangeproofs, surjection proofs, + /// blinding factors. False on Bitcoin, where amounts are always explicit. + pub fn has_confidential_amounts(self) -> bool { + matches!(self, ChainFamily::Elements) + } + + /// Whether the fee is carried by an explicit `TxOut`. + /// + /// Elements makes the fee a real output with the policy asset and no scriptPubKey, so + /// a Simplicity program can introspect it (`jet::output_is_fee`). Bitcoin leaves it + /// implicit as inputs minus outputs, and a covenant reads it via `jet::fee`. + pub fn has_explicit_fee_output(self) -> bool { + matches!(self, ChainFamily::Elements) + } + + /// Tag suffix for taproot tagged hashes on this family. + /// + /// Elements domain-separates its taproot hashes (`TapLeaf/elements`) so that a tree + /// built for one chain cannot be replayed on the other. The consequence for this + /// engine is blunt: the same covenant program yields a **different address** on + /// Bitcoin than on Liquid. + pub fn taproot_tag_suffix(self) -> &'static str { + match self { + ChainFamily::Elements => "/elements", + ChainFamily::Bitcoin => "", + } + } + + /// Full tag string for a taproot tagged hash, e.g. `TapBranch/elements`. + pub fn taproot_tag(self, base: TaprootTag) -> String { + format!("{}{}", base.base_name(), self.taproot_tag_suffix()) + } + + /// Canonical lowercase name, as written in a manifest's `chain` field. + pub fn as_str(self) -> &'static str { + match self { + ChainFamily::Elements => "elements", + ChainFamily::Bitcoin => "bitcoin", + } + } +} + +impl Default for ChainFamily { + fn default() -> Self { + Self::DEFAULT + } +} + +impl fmt::Display for ChainFamily { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ChainFamily { + type Err = UnknownChain; + + /// Accepts the spellings already in the wild. `liquid` is the name every example in + /// this repo uses, and it names a specific Elements network rather than the family — + /// but rejecting it would break every existing manifest to no purpose. + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "elements" | "liquid" => Ok(ChainFamily::Elements), + "bitcoin" | "btc" => Ok(ChainFamily::Bitcoin), + other => Err(UnknownChain(other.to_string())), + } + } +} + +/// A `chain` value this build does not recognize. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnknownChain(pub String); + +impl fmt::Display for UnknownChain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "unknown chain '{}'; expected one of: elements, liquid, bitcoin", + self.0 + ) + } +} + +impl std::error::Error for UnknownChain {} + +impl<'de> Deserialize<'de> for ChainFamily { + fn deserialize>(d: D) -> Result { + let raw = String::deserialize(d)?; + ChainFamily::from_str(&raw).map_err(serde::de::Error::custom) + } +} + +/// Which of the three taproot tagged hashes a tag string is for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaprootTag { + Leaf, + Branch, + Tweak, +} + +impl TaprootTag { + /// The Bitcoin (unsuffixed) tag name. + pub fn base_name(self) -> &'static str { + match self { + TaprootTag::Leaf => "TapLeaf", + TaprootTag::Branch => "TapBranch", + TaprootTag::Tweak => "TapTweak", + } + } +} + +/// Taproot leaf version reserved for Simplicity. +/// +/// The same byte on both chains: Elements uses it for its live deployment, and the +/// Bitcoin proposal (BINANA 2026-0003, `TAPROOT_LEAF_TAPSIMPLICITY`) reuses it. The tag +/// *domain* differs, but the leaf version does not — so this is a constant, not a +/// [`ChainFamily`] method, and should stay one unless a chain actually diverges. +pub const SIMPLICITY_LEAF_VERSION: u8 = 0xbe; + +// --------------------------------------------------------------------------- +// Capabilities +// --------------------------------------------------------------------------- + +/// The separator between a capability's namespace and its name. +pub const NAMESPACE_SEP: &str = "::"; + +/// A feature a manifest declares in `requires`, because the chain alone does not settle it. +/// +/// Two shapes: +/// +/// - **Core**, written bare (`simplicity`). Defined by this format, drawn from +/// [`Capability::CORE`], and validated on parse. +/// - **Namespaced**, written `namespace::name` (`custom::my-feature`). Owned by whoever +/// owns the namespace. This crate does not know what they mean and does not pretend to; +/// it parses them, keeps them, and reports them unsatisfied unless a target names them +/// in [`Activation::extensions`]. +/// +/// Anything a chain settles on its own is deliberately *not* here. See the module docs. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Capability { + /// A capability defined by this format, written without a namespace. + Core(CoreCapability), + /// A third-party capability, written `namespace::name`. + /// + /// Both halves are lowercase, start with an alphanumeric, and otherwise contain only + /// alphanumerics, `_` and `-`. Held as strings because the set is open by design: the + /// point is that a downstream tool can define one without touching this crate. + Namespaced { namespace: String, name: String }, +} + +/// A capability this format defines itself. Bare names, closed set. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +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 + /// 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. + Simplicity, +} + +impl CoreCapability { + /// Canonical bare name. + pub fn as_str(self) -> &'static str { + match self { + CoreCapability::Simplicity => "simplicity", + } + } +} + +impl Capability { + /// The Simplicity capability, spelled out for call sites. + pub const SIMPLICITY: Capability = Capability::Core(CoreCapability::Simplicity); + + /// Every core capability this build defines. + pub const CORE: [CoreCapability; 1] = [CoreCapability::Simplicity]; + + /// Build a namespaced capability, validating both halves. + pub fn namespaced( + namespace: impl Into, + name: impl Into, + ) -> Result { + let namespace = namespace.into(); + let name = name.into(); + check_segment(&namespace)?; + check_segment(&name)?; + Ok(Capability::Namespaced { namespace, name }) + } + + /// The namespace, or `None` for a core capability. + pub fn namespace(&self) -> Option<&str> { + match self { + Capability::Core(_) => None, + Capability::Namespaced { namespace, .. } => Some(namespace), + } + } + + /// Whether this crate can reason about what the capability means. + /// + /// False for every namespaced capability. Callers use it to decide whether an + /// unsatisfied requirement is worth explaining or merely worth reporting. + pub fn is_core(&self) -> bool { + matches!(self, Capability::Core(_)) + } + + /// One line on why a target that lacks this capability cannot run the manifest. + pub fn unsupported_hint(&self) -> String { + match self { + Capability::Core(CoreCapability::Simplicity) => { + "covenant programs need a validator that executes Simplicity tapleaves; on \ + Bitcoin that is the BINANA 2026-0003 soft fork, which no public network has \ + activated" + .to_string() + } + Capability::Namespaced { namespace, .. } => format!( + "defined by '{namespace}', not by this format; a target provides it only by \ + listing it as an extension" + ), + } + } +} + +impl fmt::Display for Capability { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Capability::Core(c) => f.write_str(c.as_str()), + Capability::Namespaced { namespace, name } => { + write!(f, "{namespace}{NAMESPACE_SEP}{name}") + } + } + } +} + +/// A `requires` entry that is not a valid capability. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BadCapability { + /// A bare name that is not in [`Capability::CORE`]. + UnknownCore(String), + /// A namespace or name that breaks the charset rule. + MalformedSegment(String), + /// More than one `::`, or an empty half. + MalformedName(String), +} + +impl fmt::Display for BadCapability { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BadCapability::UnknownCore(s) => write!( + f, + "unknown capability '{s}'; this format defines {}. \ + A third-party feature must be namespaced, e.g. 'custom{NAMESPACE_SEP}{s}'", + Capability::CORE + .iter() + .map(|c| format!("'{}'", c.as_str())) + .collect::>() + .join(", ") + ), + BadCapability::MalformedSegment(s) => write!( + f, + "invalid capability segment '{s}'; each half must be lowercase, start with a \ + letter or digit, and contain only letters, digits, '_' and '-'" + ), + BadCapability::MalformedName(s) => write!( + f, + "malformed capability '{s}'; expected a bare name or exactly one \ + '{NAMESPACE_SEP}' separating a namespace from a name" + ), + } + } +} + +impl std::error::Error for BadCapability {} + +/// Validate one half of a namespaced capability. +fn check_segment(seg: &str) -> Result<(), BadCapability> { + let ok = !seg.is_empty() + && seg.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) + && seg + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'); + if ok { + Ok(()) + } else { + Err(BadCapability::MalformedSegment(seg.to_string())) + } +} + +impl FromStr for Capability { + type Err = BadCapability; + + fn from_str(s: &str) -> Result { + // Underscores as well as hyphens in a *core* name: the rest of the manifest format + // is snake_case, so an author reaching for `asset_issuance` has not made a + // meaningful mistake. Namespaced names are left exactly as written — they belong to + // someone else, and silently rewriting them would make two spellings of one + // third-party feature that this crate treats as equal and its owner may not. + let raw = s.trim(); + match raw.split_once(NAMESPACE_SEP) { + None => { + let norm = raw.to_ascii_lowercase().replace('_', "-"); + Capability::CORE + .iter() + .copied() + .find(|c| c.as_str() == norm) + .map(Capability::Core) + .ok_or_else(|| BadCapability::UnknownCore(raw.to_string())) + } + Some((ns, name)) => { + // A second separator would make the owner ambiguous. + if name.contains(NAMESPACE_SEP) || ns.is_empty() || name.is_empty() { + return Err(BadCapability::MalformedName(raw.to_string())); + } + Capability::namespaced(ns, name) + } + } + } +} + +impl<'de> Deserialize<'de> for Capability { + fn deserialize>(d: D) -> Result { + let raw = String::deserialize(d)?; + Capability::from_str(&raw).map_err(serde::de::Error::custom) + } +} + +/// A set of [`Capability`] values. +/// +/// A set rather than a bitflags integer both because the space is open — a namespaced +/// capability has no bit to assign — and because it is serialized into a human-edited file +/// and read back in error messages. Ordering is core-first, then namespaced +/// lexicographically, which keeps `validate` output stable. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct Capabilities(BTreeSet); + +impl Capabilities { + /// The empty set — a manifest that needs nothing beyond plain transactions. + pub fn none() -> Self { + Self(BTreeSet::new()) + } + + pub fn contains(&self, c: &Capability) -> bool { + self.0.contains(c) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn insert(&mut self, c: Capability) -> bool { + self.0.insert(c) + } + + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter() + } + + /// Members of `self` that `other` does not provide. Empty means `other` can run + /// whatever needs `self`. + pub fn missing_from(&self, other: &Capabilities) -> Vec { + self.0 + .iter() + .filter(|c| !other.contains(c)) + .cloned() + .collect() + } + + /// Comma-separated canonical names, or `"none"` when empty. + pub fn describe(&self) -> String { + if self.is_empty() { + return "none".to_string(); + } + self.iter().map(Capability::to_string).collect::>().join(", ") + } +} + +impl FromIterator for Capabilities { + fn from_iter>(iter: I) -> Self { + Self(iter.into_iter().collect()) + } +} + +impl fmt::Display for Capabilities { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.describe()) + } +} + +// --------------------------------------------------------------------------- +// Networks +// --------------------------------------------------------------------------- + +/// A concrete network the wallet can connect to. +/// +/// Chosen by the wallet's config, not by the manifest. Exists so that nothing outside +/// this module has to name `lwk_wollet::ElementsNetwork` — that type cannot describe a +/// Bitcoin network, and every call site that takes it today is a place the Bitcoin port +/// would otherwise have to fork. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Network { + Liquid, + LiquidTestnet, + ElementsRegtest, + Bitcoin, + BitcoinTestnet, + BitcoinSignet, + BitcoinRegtest, +} + +impl Network { + pub fn family(self) -> ChainFamily { + match self { + Network::Liquid | Network::LiquidTestnet | Network::ElementsRegtest => { + ChainFamily::Elements + } + Network::Bitcoin + | Network::BitcoinTestnet + | Network::BitcoinSignet + | Network::BitcoinRegtest => ChainFamily::Bitcoin, + } + } + + /// Whether this is a production network carrying real value. + /// + /// Drives confirmation prompts, so it errs toward `true`: a network this build does + /// not recognize as a testnet is treated as mainnet. + pub fn is_mainnet(self) -> bool { + matches!(self, Network::Liquid | Network::Bitcoin) + } + + /// What this network, running the node described by `activation`, actually provides. + /// + /// Simplicity is unconditional on Elements and configuration-dependent on Bitcoin. + /// Everything else comes from `activation.extensions` verbatim: this crate cannot + /// verify a namespaced capability, so it takes the operator's word and reports the + /// requirement as satisfied. + pub fn capabilities(self, activation: &Activation) -> Capabilities { + let mut caps = activation.extensions.clone(); + let simplicity_live = match self.family() { + ChainFamily::Elements => true, + ChainFamily::Bitcoin => activation.simplicity, + }; + if simplicity_live { + caps.insert(Capability::SIMPLICITY); + } + caps + } + + /// Canonical lowercase name, as written in the wallet config's `default_network`. + pub fn as_str(self) -> &'static str { + match self { + Network::Liquid => "liquid", + Network::LiquidTestnet => "liquid-testnet", + Network::ElementsRegtest => "elements-regtest", + Network::Bitcoin => "bitcoin", + Network::BitcoinTestnet => "bitcoin-testnet", + Network::BitcoinSignet => "bitcoin-signet", + Network::BitcoinRegtest => "bitcoin-regtest", + } + } +} + +impl fmt::Display for Network { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for Network { + type Err = UnknownNetwork; + + /// Accepts the config spellings this wallet has always written (`mainnet`, `testnet`, + /// meaning Liquid) alongside the explicit ones. The bare legacy names stay bound to + /// Elements: a config file written before Bitcoin support existed means Liquid by + /// `mainnet`, and re-reading it as Bitcoin would point a funded wallet at the wrong + /// chain. + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().replace('_', "-").as_str() { + "liquid" | "mainnet" => Ok(Network::Liquid), + "liquid-testnet" | "liquidtestnet" | "testnet" => Ok(Network::LiquidTestnet), + "elements-regtest" | "elementsregtest" | "regtest" => Ok(Network::ElementsRegtest), + "bitcoin" | "bitcoin-mainnet" => Ok(Network::Bitcoin), + "bitcoin-testnet" | "bitcoin-testnet4" => Ok(Network::BitcoinTestnet), + "bitcoin-signet" | "signet" => Ok(Network::BitcoinSignet), + "bitcoin-regtest" => Ok(Network::BitcoinRegtest), + other => Err(UnknownNetwork(other.to_string())), + } + } +} + +/// A `default_network` value this build does not recognize. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnknownNetwork(pub String); + +impl fmt::Display for UnknownNetwork { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "unknown network '{}'; expected one of: liquid, liquid-testnet, elements-regtest, \ + bitcoin, bitcoin-testnet, bitcoin-signet, bitcoin-regtest", + self.0 + ) + } +} + +impl std::error::Error for UnknownNetwork {} + +/// What the node the wallet is talking to provides, beyond its network's base rules. +/// +/// Configuration, not discovery: nothing here probes the node. A wallet pointed at a +/// patched signet sets `simplicity: true` and takes responsibility for that claim; the +/// failure mode if it is wrong is a rejected broadcast, not a lost coin. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Activation { + /// Whether the node executes Simplicity tapleaves. Ignored on Elements networks, + /// where it is live regardless. + pub simplicity: bool, + /// Namespaced capabilities the operator asserts this target provides. + /// + /// The escape hatch that makes third-party capabilities usable: this crate has no way + /// to verify `custom::my-feature`, so the only thing that can satisfy it is somebody + /// saying so here. + pub extensions: Capabilities, +} + +impl Activation { + /// Nothing beyond the network's base rules. + pub fn none() -> Activation { + Activation::default() + } + + /// Simplicity live and no extensions. + pub fn simplicity() -> Activation { + Activation { + simplicity: true, + extensions: Capabilities::none(), + } + } + + /// What to assume for `network` when the config says nothing. + /// + /// Elements networks have Simplicity live, so assuming it is correct there. Bitcoin + /// networks do not, on any public network, so the default is off and a user running a + /// patched node opts in explicitly. + pub fn default_for(network: Network) -> Activation { + match network.family() { + ChainFamily::Elements => Activation::simplicity(), + ChainFamily::Bitcoin => Activation::none(), + } + } +} + +// --------------------------------------------------------------------------- +// Interop with lwk / elements types +// --------------------------------------------------------------------------- + +impl Network { + /// The `lwk_wollet` network for an Elements network. + /// + /// `None` for a Bitcoin network — `ElementsNetwork` has no variant that could stand in + /// for one, and inventing a mapping would let Bitcoin flow into Elements-only code + /// paths and produce addresses on the wrong chain. Callers must handle the `None`. + pub fn elements_network(self) -> Option { + match self { + Network::Liquid => Some(lwk_wollet::ElementsNetwork::Liquid), + Network::LiquidTestnet => Some(lwk_wollet::ElementsNetwork::LiquidTestnet), + Network::ElementsRegtest => { + // The policy asset of a regtest chain is chosen by whoever started it, so + // there is no single right answer here. This is the value the wallet has + // always used; it stays until regtest support needs to be configurable. + Some(lwk_wollet::ElementsNetwork::default_regtest()) + } + Network::Bitcoin + | Network::BitcoinTestnet + | Network::BitcoinSignet + | Network::BitcoinRegtest => None, + } + } +} + +impl From for Network { + fn from(n: lwk_wollet::ElementsNetwork) -> Self { + match n { + lwk_wollet::ElementsNetwork::Liquid => Network::Liquid, + lwk_wollet::ElementsNetwork::LiquidTestnet => Network::LiquidTestnet, + lwk_wollet::ElementsNetwork::ElementsRegtest { .. } => Network::ElementsRegtest, + } + } +} + +// --------------------------------------------------------------------------- +// JSON Schema +// --------------------------------------------------------------------------- + +impl JsonSchema for ChainFamily { + fn schema_name() -> String { + "ChainFamily".to_string() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + // Written by hand rather than derived: the derive emits only the canonical + // spellings, while `FromStr` accepts aliases. A schema stricter than the parser is + // not a harmless conservatism here — it makes an editor red-underline + // `"chain": "liquid"` in files this engine reads happily, including every example + // in this repo. Whatever the parser accepts, the schema must list. + let mut schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + ..Default::default() + }; + schema.metadata().description = Some( + "Ledger this manifest targets. 'liquid' is an accepted alias for 'elements', and \ + 'btc' for 'bitcoin'. Defaults to 'elements' when absent." + .to_string(), + ); + schema.enum_values = Some( + ["elements", "liquid", "bitcoin", "btc"] + .iter() + .map(|v| serde_json::json!(v)) + .collect(), + ); + Schema::Object(schema) + } +} + +impl JsonSchema for Capability { + fn schema_name() -> String { + "Capability".to_string() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + // An `enum` cannot express this: core names are a closed list, but namespaced ones + // are open by design. So the schema is a union of the two — the closed list, so an + // editor can still complete and typo-check a bare name, and a pattern for anything + // namespaced. + let mut core_names: Vec = Vec::new(); + for c in Capability::CORE { + core_names.push(serde_json::json!(c.as_str())); + let snake = c.as_str().replace('-', "_"); + if snake != c.as_str() { + core_names.push(serde_json::json!(snake)); + } + } + + let core = serde_json::json!({ + "enum": core_names, + "description": "A capability defined by this format.", + }); + let seg = "[a-z0-9][a-z0-9_-]*"; + let namespaced = serde_json::json!({ + "type": "string", + "pattern": format!("^{seg}{NAMESPACE_SEP}{seg}$"), + "description": + "A third-party capability, 'namespace::name'. This format does not define \ + its meaning; a target provides it by listing it as an extension.", + }); + + let mut schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + ..Default::default() + }; + schema.metadata().description = Some( + "A ledger feature this manifest depends on that the 'chain' field does not \ + already settle. Bare names are defined by this format; anything containing \ + '::' belongs to the named namespace." + .to_string(), + ); + schema.subschemas().any_of = Some(vec![ + Schema::Object(serde_json::from_value(core).expect("core branch is a schema")), + Schema::Object( + serde_json::from_value(namespaced).expect("namespaced branch is a schema"), + ), + ]); + Schema::Object(schema) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chain_family_accepts_the_spellings_already_in_use() { + assert_eq!("liquid".parse::().unwrap(), ChainFamily::Elements); + assert_eq!("elements".parse::().unwrap(), ChainFamily::Elements); + assert_eq!("Bitcoin".parse::().unwrap(), ChainFamily::Bitcoin); + assert_eq!(" BTC ".parse::().unwrap(), ChainFamily::Bitcoin); + assert!("cross-chain".parse::().is_err()); + } + + #[test] + fn taproot_tags_are_domain_separated_on_elements_only() { + assert_eq!(ChainFamily::Elements.taproot_tag(TaprootTag::Branch), "TapBranch/elements"); + assert_eq!(ChainFamily::Bitcoin.taproot_tag(TaprootTag::Branch), "TapBranch"); + assert_eq!(ChainFamily::Elements.taproot_tag(TaprootTag::Leaf), "TapLeaf/elements"); + assert_eq!(ChainFamily::Bitcoin.taproot_tag(TaprootTag::Leaf), "TapLeaf"); + } + + /// The properties that replaced the old declarable capabilities. These are read off + /// `chain` rather than declared, which is the whole point of removing them. + #[test] + fn asset_and_confidentiality_follow_from_the_chain() { + assert!(ChainFamily::Elements.has_native_assets()); + assert!(ChainFamily::Elements.has_asset_issuance()); + assert!(ChainFamily::Elements.has_confidential_amounts()); + assert!(ChainFamily::Elements.has_explicit_fee_output()); + + assert!(!ChainFamily::Bitcoin.has_native_assets()); + assert!(!ChainFamily::Bitcoin.has_asset_issuance()); + assert!(!ChainFamily::Bitcoin.has_confidential_amounts()); + assert!(!ChainFamily::Bitcoin.has_explicit_fee_output()); + } + + #[test] + fn core_capability_names_round_trip() { + for c in Capability::CORE { + let parsed: Capability = c.as_str().parse().unwrap(); + assert_eq!(parsed, Capability::Core(c)); + assert_eq!(parsed.to_string(), c.as_str()); + assert!(parsed.is_core()); + } + } + + /// An unrecognized bare name is a typo far more often than a deliberate extension, and + /// silently accepting it would mean a manifest that requests nothing while appearing to + /// request something. The error points at the namespaced spelling. + #[test] + fn an_unknown_bare_name_is_refused_and_suggests_a_namespace() { + let err = "teleportation".parse::().unwrap_err(); + assert!(matches!(err, BadCapability::UnknownCore(_))); + assert!(err.to_string().contains("custom::teleportation"), "{err}"); + + // The capabilities removed in favour of reading `chain` are refused the same way. + for gone in ["multi-asset", "asset-issuance", "confidential-amounts"] { + assert!(gone.parse::().is_err(), "{gone} should no longer parse"); + } + } + + #[test] + fn namespaced_capabilities_round_trip_verbatim() { + let c: Capability = "custom::my-feature".parse().unwrap(); + assert_eq!( + c, + Capability::Namespaced { + namespace: "custom".into(), + name: "my-feature".into() + } + ); + assert_eq!(c.to_string(), "custom::my-feature"); + assert_eq!(c.namespace(), Some("custom")); + assert!(!c.is_core()); + + // Any namespace, not just `custom` — vendor namespaces are what avoid collisions. + assert!("mosaik::tessera".parse::().is_ok()); + } + + /// A core name normalizes `_` to `-`; a namespaced one must not, because the two + /// spellings would then be one capability here and possibly two to whoever defined it. + #[test] + fn only_core_names_are_normalized() { + assert_eq!("SIMPLICITY".parse::().unwrap(), Capability::SIMPLICITY); + let a: Capability = "custom::my_feature".parse().unwrap(); + let b: Capability = "custom::my-feature".parse().unwrap(); + assert_ne!(a, b, "namespaced names must be taken verbatim"); + } + + #[test] + fn malformed_capabilities_are_refused() { + for bad in [ + "custom::", + "::feature", + "a::b::c", + "Custom::Feature", + "custom::-leading-dash", + "custom::has space", + ] { + assert!(bad.parse::().is_err(), "{bad:?} should not parse"); + } + } + + #[test] + fn simplicity_on_bitcoin_depends_on_activation() { + assert!(!Network::BitcoinSignet + .capabilities(&Activation::none()) + .contains(&Capability::SIMPLICITY)); + assert!(Network::BitcoinSignet + .capabilities(&Activation::simplicity()) + .contains(&Capability::SIMPLICITY)); + + // Elements has it live regardless of what the config claims. + assert!(Network::Liquid + .capabilities(&Activation::none()) + .contains(&Capability::SIMPLICITY)); + } + + /// The only thing that can satisfy a namespaced capability is a target asserting it. + #[test] + fn a_namespaced_capability_is_satisfied_only_by_an_extension() { + let want = Capabilities::from_iter(["custom::my-feature".parse().unwrap()]); + + let plain = Network::Liquid.capabilities(&Activation::simplicity()); + assert_eq!(want.missing_from(&plain).len(), 1); + + let extended = Network::Liquid.capabilities(&Activation { + simplicity: true, + extensions: Capabilities::from_iter(["custom::my-feature".parse().unwrap()]), + }); + assert!(want.missing_from(&extended).is_empty()); + } + + #[test] + fn a_manifest_needing_nothing_runs_on_stock_bitcoin() { + let plain = Capabilities::none(); + let stock = Network::Bitcoin.capabilities(&Activation::default_for(Network::Bitcoin)); + assert!(plain.missing_from(&stock).is_empty()); + assert_eq!(plain.describe(), "none"); + + // ...and a covenant manifest does not. + let covenant = Capabilities::from_iter([Capability::SIMPLICITY]); + assert_eq!(covenant.missing_from(&stock), vec![Capability::SIMPLICITY]); + } + + #[test] + fn legacy_network_names_stay_bound_to_elements() { + // A config written before Bitcoin support existed must not be reinterpreted. + assert_eq!("mainnet".parse::().unwrap(), Network::Liquid); + assert_eq!("testnet".parse::().unwrap(), Network::LiquidTestnet); + assert_eq!("signet".parse::().unwrap(), Network::BitcoinSignet); + assert!("liquid-signet".parse::().is_err()); + } + + #[test] + fn elements_networks_round_trip_through_lwk() { + for n in [Network::Liquid, Network::LiquidTestnet, Network::ElementsRegtest] { + let lwk = n.elements_network().expect("elements network maps"); + assert_eq!(Network::from(lwk), n); + } + assert!(Network::BitcoinSignet.elements_network().is_none()); + } +} diff --git a/txmanifest_lib/src/covenant.rs b/txmanifest_lib/src/covenant.rs index aa49375..02cedf3 100644 --- a/txmanifest_lib/src/covenant.rs +++ b/txmanifest_lib/src/covenant.rs @@ -9,6 +9,7 @@ use lwk_wollet::elements::{ taproot::{ControlBlock, LeafVersion, TaprootMerkleBranch, TaprootSpendInfo}, Address, AddressParams, BlockHash, Script, Transaction, TxOut, }; +use crate::chain::{ChainFamily, Network, TaprootTag, SIMPLICITY_LEAF_VERSION}; use simplicityhl::ast::ElementsJetHinter; use simplicityhl::simplicity::bit_machine::{ExecTracker, FrameIter, NodeOutput}; use simplicityhl::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; @@ -20,9 +21,14 @@ use simplicityhl::{ /// Signs `(key_label, kind, sighash)` and returns a 64-byte Schnorr signature. type SigSigner = dyn Fn(&str, &str, &[u8; 32]) -> Result<[u8; 64]>; -/// Simplicity leaf version for Elements/Liquid. +/// Taproot leaf version for a Simplicity tapleaf. +/// +/// Sourced from [`SIMPLICITY_LEAF_VERSION`] rather than `simplicity::leaf_version()` so +/// that one constant governs both chains. The upstream helper returns an +/// `elements::taproot::LeafVersion` specifically, which is the wrong return type the +/// moment a Bitcoin tree is being built — the byte itself is the same on both. fn simplicity_leaf_version() -> LeafVersion { - simplicity::leaf_version() + LeafVersion::from_u8(SIMPLICITY_LEAF_VERSION).expect("constant leaf version") } /// The NUMS (Nothing-Up-My-Sleeve) internal key for covenant Taproot outputs. @@ -374,6 +380,8 @@ pub fn dry_run_covenant( witness_utxos: &[TxOut], input_index: u32, genesis_hash: BlockHash, + // Chain whose taproot tag domain the tree is built under; see `build_tapbranch`. + family: ChainFamily, debug_jets: bool, opts: impl Into, ) -> Result<()> { @@ -486,7 +494,7 @@ pub fn dry_run_covenant( for payload in extra_leaf_payloads { let extra = tapdata_hash(payload); sibling_hashes.push(sha256::Hash::from_byte_array(extra)); - merkle_root_bytes = build_tapbranch(merkle_root_bytes, extra); + merkle_root_bytes = build_tapbranch(family, merkle_root_bytes, extra); } let tap_node = tap_node_hash_from_bytes(merkle_root_bytes); @@ -679,6 +687,8 @@ pub fn finalize_covenant_input( witness_utxos: &[TxOut], input_index: u32, genesis_hash: BlockHash, + // Chain whose taproot tag domain the tree is built under; see `build_tapbranch`. + family: ChainFamily, pset_input: &mut lwk_wollet::elements::pset::Input, opts: impl Into, ) -> Result<()> { @@ -713,7 +723,7 @@ pub fn finalize_covenant_input( for payload in extra_leaf_payloads { let extra = tapdata_hash(payload); sibling_hashes.push(sha256::Hash::from_byte_array(extra)); - merkle_root_bytes = build_tapbranch(merkle_root_bytes, extra); + merkle_root_bytes = build_tapbranch(family, merkle_root_bytes, extra); } let tap_node = tap_node_hash_from_bytes(merkle_root_bytes); @@ -790,6 +800,7 @@ pub fn compute_covenant_address( opts: impl Into, ) -> Result
{ let opts = opts.into(); + let family = Network::from(network).family(); eprintln!( "[covenant] compute_covenant_address: {} extra leaf(s), simf={}", extra_leaf_payloads.len(), @@ -850,7 +861,7 @@ pub fn compute_covenant_address( hex_bytes(payload), hex_bytes(&extra) ); - root = build_tapbranch(root, extra); + root = build_tapbranch(family, root, extra); } root }; @@ -1198,9 +1209,14 @@ fn tapdata_hash(data: &[u8]) -> [u8; 32] { sha256::Hash::from_engine(engine).to_byte_array() } -/// TapBranch hash (Elements variant): SHA256(SHA256(tag) || SHA256(tag) || min(a,b) || max(a,b)). -fn build_tapbranch(a: [u8; 32], b: [u8; 32]) -> [u8; 32] { - let tag_hash = sha256::Hash::hash(b"TapBranch/elements"); +/// TapBranch hash: SHA256(SHA256(tag) || SHA256(tag) || min(a,b) || max(a,b)). +/// +/// The tag is domain-separated per chain (`TapBranch/elements` vs `TapBranch`), which is +/// why `family` is a parameter and not a constant: the same covenant tree yields a +/// different merkle root, and therefore a different address, on each chain. Getting this +/// wrong produces a valid-looking address that nothing can ever spend. +fn build_tapbranch(family: ChainFamily, a: [u8; 32], b: [u8; 32]) -> [u8; 32] { + let tag_hash = sha256::Hash::hash(family.taproot_tag(TaprootTag::Branch).as_bytes()); let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; let mut engine = sha256::HashEngine::default(); engine.input(&tag_hash[..]); @@ -1228,6 +1244,46 @@ fn network_to_params(network: lwk_wollet::ElementsNetwork) -> &'static AddressPa mod tests { use super::*; + /// The tag domain must actually change the tree, and must match Elements' published + /// tag on the chain this engine already ships against. + /// + /// A wrong tag here is the worst class of bug this module can have: it yields a + /// perfectly well-formed address that no script path can ever satisfy, and the funds + /// sent to it are unrecoverable. Nothing downstream would catch it — an address is a + /// hash, and a wrong hash looks exactly like a right one. + #[test] + fn tapbranch_is_domain_separated_per_chain() { + let a = [0x11u8; 32]; + let b = [0x22u8; 32]; + + let elements = build_tapbranch(ChainFamily::Elements, a, b); + let bitcoin = build_tapbranch(ChainFamily::Bitcoin, a, b); + assert_ne!( + elements, bitcoin, + "the two chains must not produce the same merkle root" + ); + + // Cross-check the Elements branch against rust-elements' own TapBranch tag, so a + // typo in our tag string cannot pass by agreeing with itself. + let expected = { + use lwk_wollet::elements::taproot::TapNodeHash; + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + let mut engine = TapNodeHash::engine(); + engine.input(&lo); + engine.input(&hi); + TapNodeHash::from_engine(engine).to_byte_array() + }; + assert_eq!(elements, expected, "Elements TapBranch tag disagrees with rust-elements"); + } + + /// Both chains reserve the same leaf version for Simplicity, so this is a constant + /// rather than a per-chain value. Pinned because the whole tapleaf hash depends on it. + #[test] + fn simplicity_leaf_version_is_0xbe() { + assert_eq!(simplicity_leaf_version().as_u8(), 0xbe); + assert_eq!(simplicity_leaf_version(), simplicity::leaf_version()); + } + /// Build a `WitnessTypes` the way a compiled program hands one over. fn witness_types(entries: &[(&str, simplicityhl::ResolvedType)]) -> WitnessTypes { use simplicityhl::parse::ParseFromStr as _; @@ -1361,7 +1417,7 @@ mod tests { #[test] fn unstable_features_gate_a_program_that_uses_enums() { let manifest = crate::manifest::Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", + r#"{ "manifest_version": "0.3.0", "protocol": "t", "simplicity_hl": { "unstable_features": ["enums"] } }"#, ) .expect("manifest should parse"); diff --git a/txmanifest_lib/src/describe.rs b/txmanifest_lib/src/describe.rs index 16ac68b..e10feb8 100644 --- a/txmanifest_lib/src/describe.rs +++ b/txmanifest_lib/src/describe.rs @@ -164,7 +164,10 @@ fn print_overview(manifest: &Manifest) { if let Some(d) = &manifest.description { println!(" {}", style(d).italic()); } - println!(" chain : {}", manifest.chain.as_deref().unwrap_or("elements (default)")); + let family = manifest.chain_family(); + let defaulted = if manifest.chain.is_none() { " (default)" } else { "" }; + println!(" chain : {family}{defaulted}"); + println!(" requires : {}", manifest.requires.describe()); println!(" version : {}", manifest.manifest_version); if let Some(utxo_types) = &manifest.utxo_types { diff --git a/txmanifest_lib/src/lib.rs b/txmanifest_lib/src/lib.rs index 9e1dbd7..9104edb 100644 --- a/txmanifest_lib/src/lib.rs +++ b/txmanifest_lib/src/lib.rs @@ -1,5 +1,6 @@ pub mod manifest; pub mod backend; +pub mod chain; pub mod canonical; pub mod config; pub mod describe; diff --git a/txmanifest_lib/src/lifecycle.rs b/txmanifest_lib/src/lifecycle.rs index eab7173..a700d1b 100644 --- a/txmanifest_lib/src/lifecycle.rs +++ b/txmanifest_lib/src/lifecycle.rs @@ -2004,6 +2004,7 @@ pub fn run( let utxos: Vec = witness_utxos.into_iter().flatten().collect(); let genesis_hash = network_genesis_hash(net_for_hash); + let chain_family = crate::chain::Network::from(net_for_hash).family(); let action_inputs = action.inputs.as_deref().unwrap_or_default(); let mut exec_all_ok = true; @@ -2082,6 +2083,7 @@ pub fn run( &utxos, pset_idx as u32, genesis_hash, + chain_family, debug_jets, &compile_opts, ) { @@ -2164,6 +2166,7 @@ pub fn run( let tx = Arc::new(tx); let genesis_hash = network_genesis_hash(net_for_hash); + let chain_family = crate::chain::Network::from(net_for_hash).family(); let action_inputs = action.inputs.as_deref().unwrap_or_default(); let mut all_finalized = true; @@ -2248,6 +2251,7 @@ pub fn run( &utxos, pset_idx as u32, genesis_hash, + chain_family, &mut pset.inputs_mut()[pset_idx], &compile_opts, ) { @@ -3878,7 +3882,7 @@ mod tests { None => String::new(), }; Manifest::from_json_str(&format!( - r#"{{ "manifest_version": "0.2.0", "protocol": "t", "actions": {{ "A": {{ "outputs": [ + r#"{{ "manifest_version": "0.3.0", "protocol": "t", "actions": {{ "A": {{ "outputs": [ {{ "id": "o0", "amount_sat": "1", "destination": "params.a"{extra} }} ] }} }} }}"# )) .expect("manifest should parse") @@ -3918,7 +3922,7 @@ mod tests { #[test] fn closed_utxo_type_resolves_each_site_independently() { let manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", + r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "params": { "claim": { "type": "bytes32" } } } }, "utxo_types": { "prize": { "description": "d", @@ -3966,7 +3970,7 @@ mod tests { #[test] fn closed_utxo_type_cannot_read_action_scope() { let manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", + r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "params": { "claim": { "type": "bytes32" } } } }, "utxo_types": { "leaky_leaf": { @@ -4035,7 +4039,7 @@ mod tests { #[test] fn declared_inputs_that_never_reach_the_pset_are_detected() { let manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", "actions": { "A": { "inputs": [ + r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "inputs": [ { "id": "contest_in", "utxo_source": "prize_covenant" }, { "id": "fees_in", "utxo_source": "wallet" } ] } } }"#, ) @@ -4087,7 +4091,7 @@ mod tests { #[test] fn change_outputs_are_never_skipped_for_a_missing_amount() { let manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", "actions": { "A": { "outputs": [ + r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "outputs": [ { "id": "change_out", "asset": "lbtc", "destination": "change" }, { "id": "opt_change", "asset": "lbtc", "destination": "change", "optional": true }, { "id": "opt_wallet", "asset": "lbtc", "destination": "wallet", "optional": true }, diff --git a/txmanifest_lib/src/manifest.rs b/txmanifest_lib/src/manifest.rs index fd5b537..36323c4 100644 --- a/txmanifest_lib/src/manifest.rs +++ b/txmanifest_lib/src/manifest.rs @@ -8,6 +8,8 @@ use schemars::JsonSchema; use serde::Deserialize; use simplicityhl::{UnstableFeature, UnstableFeatures}; +use crate::chain::{Capabilities, Capability, ChainFamily}; + // --------------------------------------------------------------------------- // Top-level file // --------------------------------------------------------------------------- @@ -17,7 +19,7 @@ use simplicityhl::{UnstableFeature, UnstableFeatures}; /// This is the version of the *file format*, not of this crate. The two move /// independently: a release that changes no format field leaves this alone, and a /// format change lands here whether or not the crate version moved with it. -pub const FORMAT_VERSION: &str = "0.2.0"; +pub const FORMAT_VERSION: &str = "0.3.0"; /// Split a version string into `(major, minor)`, ignoring the patch and any /// pre-release or build metadata. @@ -75,7 +77,33 @@ pub struct Manifest { pub manifest_version: String, pub protocol: String, pub description: Option, - pub chain: Option, + /// Which ledger this protocol is written for: `"elements"` (or its alias `"liquid"`) + /// or `"bitcoin"`. Defaults to [`ChainFamily::DEFAULT`] when absent. + /// + /// Declares the *family*, not the network — a protocol that works on Liquid works on + /// Liquid testnet, and pinning one here would be wrong. The wallet's config picks the + /// concrete [`crate::chain::Network`]. + pub chain: Option, + /// Ledger features this manifest depends on that [`Manifest::chain`] does not already + /// settle, e.g. `["simplicity"]` or `["simplicity", "custom::my-feature"]`. + /// + /// Deliberately narrow. Whether outputs carry an asset id, whether amounts can be + /// blinded, whether issuance exists — all of that follows from `chain`, so listing it + /// here would be a restatement that can disagree with itself. What is left is the + /// residue the chain does not answer: `simplicity`, which is live on Elements but a + /// soft fork on Bitcoin, and namespaced third-party features this crate cannot know + /// about. See [`crate::chain`]. + /// + /// `validate` checks it both ways — a covenant manifest that omits `simplicity` is an + /// error, and declaring what nothing uses is a warning — because both mistakes are + /// real: the first passes a target check and then fails at broadcast, and the second + /// makes a manifest look less portable than it is. + /// + /// The empty default is the honest one and the useful one. A manifest that declares + /// nothing is claiming to need nothing a stock node lacks — which is exactly the + /// manifest that can target Bitcoin today, with no Simplicity activation. + #[serde(default)] + pub requires: Capabilities, /// SimplicityHL toolchain settings for this manifest's `.simf` programs. pub simplicity_hl: Option, pub utxo_types: Option>, @@ -1521,6 +1549,127 @@ impl UtxoType { } impl Manifest { + /// The ledger family this manifest targets, defaulting when `chain` is absent. + pub fn chain_family(&self) -> ChainFamily { + self.chain.unwrap_or(ChainFamily::DEFAULT) + } + + /// Every action in the file, top-level and contract-template alike, paired with a + /// dot-path location. + /// + /// `validate` keeps its own walk because it needs each action's param types and + /// whether it sits in a template; this one exists for callers that just want the + /// actions. Any check that only needs the set should use this rather than open-coding + /// the `actions` + `contract_templates` union a third time — a walk that forgets the + /// template arm silently skips most of a real manifest. + pub fn all_actions(&self) -> Vec<(String, &Action)> { + let mut out: Vec<(String, &Action)> = self + .actions + .iter() + .map(|(n, a)| (format!("actions.{n}"), a)) + .collect(); + for (cname, cdef) in self.contract_templates.iter().flatten() { + for (aname, method) in &cdef.actions { + out.push(( + format!("contract_templates.{cname}.actions.{aname}"), + method, + )); + } + } + out + } + + /// The capabilities this manifest's *contents* actually demand, ignoring what + /// `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. + pub fn inferred_capabilities(&self) -> Capabilities { + let mut caps = Capabilities::none(); + let uses_covenants = self + .utxo_types + .iter() + .flatten() + .any(|(_, t)| t.script.is_some()); + if uses_covenants { + caps.insert(Capability::SIMPLICITY); + } + caps + } + + /// Places where this manifest uses something its declared [`Manifest::chain`] does not + /// have. + /// + /// This is what replaced the `multi-asset` / `asset-issuance` / `confidential-amounts` + /// capabilities. The check they powered was worth keeping; making an author *declare* + /// them was not, because `chain: "bitcoin"` already says there are no native assets. + /// So the rule now reads the chain directly, and there is nothing to keep in sync. + /// + /// Conservative in the same direction as before: presence of a field is taken as use + /// of the feature, because whether an expression resolves to the policy asset is not + /// knowable here. The exception is an `asset` naming the policy asset outright — see + /// [`names_policy_asset_str`] — which is single-asset behaviour and how this repo's + /// own portable examples are written. + pub fn chain_mismatches(&self) -> Vec { + let family = self.chain_family(); + let mut out = Vec::new(); + + let mut flag = |location: String, uses: &'static str, missing: &'static str| { + out.push(ChainMismatch { location, uses, missing }); + }; + + if !family.has_native_assets() { + for (name, t) in self.utxo_types.iter().flatten() { + if t.asset.as_deref().is_some_and(|a| !names_policy_asset_str(a)) { + flag(format!("utxo_types.{name}.asset"), "a non-policy asset", "native assets"); + } + } + } + + for (loc, action) in self.all_actions() { + if !family.has_native_assets() && matches!(action.allow_change, AllowChange::Any) { + flag( + format!("{loc}.allow_change"), + "change in any asset", + "native assets", + ); + } + for input in action.inputs.iter().flatten() { + let at = |f: &str| format!("{loc}.inputs.{}.{f}", input.id); + if !family.has_native_assets() + && input.asset.as_ref().is_some_and(names_non_policy_asset) + { + flag(at("asset"), "a non-policy asset", "native assets"); + } + if !family.has_asset_issuance() && input.issuance.is_some() { + flag(at("issuance"), "an asset issuance", "asset issuance"); + } + if !family.has_confidential_amounts() && input.blinding.is_some() { + flag(at("blinding"), "blinding factors", "confidential amounts"); + } + } + for output in action.outputs.iter().flatten() { + let at = |f: &str| format!("{loc}.outputs.{}.{f}", output.id); + if !family.has_native_assets() + && output.asset.as_ref().is_some_and(names_non_policy_asset) + { + flag(at("asset"), "a non-policy asset", "native assets"); + } + if !family.has_confidential_amounts() { + if output.blinding.is_some() { + flag(at("blinding"), "blinding factors", "confidential amounts"); + } + if output.confidential == Some(true) { + flag(at("confidential"), "a blinded output", "confidential amounts"); + } + } + } + } + + out + } + /// Whether covenants should be compiled with SimplicityHL debug symbols included. /// Defaults to `false`; see [`SimplicityHl::debug_symbols`]. pub fn include_debug_symbols(&self) -> bool { @@ -1566,11 +1715,13 @@ mod tests { /// minor is where a breaking change lands, so 0.1 and 0.2 are separate formats. #[test] fn format_version_gate_is_minor_exact_at_zero_x() { - assert!(check_format_version("0.2.0").is_ok()); - assert!(check_format_version("0.2.7").is_ok(), "patch must not gate"); - assert!(check_format_version("0.2.0-rc1").is_ok(), "pre-release must not gate"); + assert!(check_format_version("0.3.0").is_ok()); + assert!(check_format_version("0.3.7").is_ok(), "patch must not gate"); + assert!(check_format_version("0.3.0-rc1").is_ok(), "pre-release must not gate"); - for rejected in ["0.1.0", "0.3.0", "1.0.0", "1.2.0"] { + // Both neighbours are refused, not just the older one: at 0.x the minor is where + // breaking changes live, so a newer minor is as unreadable as an older one. + for rejected in ["0.1.0", "0.2.0", "0.4.0", "1.0.0", "1.3.0"] { assert!( check_format_version(rejected).is_err(), "{rejected} is a different format from {FORMAT_VERSION} and must be refused" @@ -1597,7 +1748,7 @@ mod tests { "the error should name the version that was refused, got: {err}" ); - Manifest::from_json_str(r#"{ "manifest_version": "0.2.0", "protocol": "t" }"#) + Manifest::from_json_str(r#"{ "manifest_version": "0.3.0", "protocol": "t" }"#) .expect("the current format version must parse"); } @@ -1609,7 +1760,7 @@ mod tests { fn manifest_json(extra: &str) -> String { format!( r#"{{ - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "test", "actions": {{ "A": {{ "inputs": [ {{ "id": "in0", "utxo_source": "wallet"{extra} }} @@ -1642,46 +1793,46 @@ mod tests { fn removed_legacy_fields_are_rejected() { // `deploy` — superseded by `create_instance`. let deploy = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "deploy": true } } }"#; // Top-level `compile_params` — superseded by the flat `params` map. let compile_params = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "compile_params": { "user_provided": {}, "derived": {} } }"#; // `attestation_version` — never read by anything. let attestation = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "attestation_version": "1" }"#; // `confidential_outputs` — a file-level default no manifest ever set, so it // only ever passed through to the chain default. Set it per output instead. let confidential = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "confidential_outputs": true }"#; // `lifecycle` — a free-form state/transition block nothing enforced; removed // for now, so it must not silently reappear as an ignored key. let lifecycle = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "lifecycle": { "states": ["a"], "transitions": {} } }"#; // Both folded into the `simplicity_hl` object. let hl_version = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "simplicity_hl_version": "0.6.0" }"#; let debug_symbols = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "compile_debug_symbols": true }"#; // `errors` — a code→description lookup table nothing ever read. let errors = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "errors": { "1": "something went wrong" } }"#; // `validations` — deferred to a future addition. Of the 11 entries the @@ -1698,7 +1849,7 @@ mod tests { // starting point for that work, and the thing to delete if the decision is // that covenant-level enforcement is sufficient. let validations = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "validations": [ { "id": "v", "rule": { "type": "arithmetic", "expr": "1 != 2" } } ] } } @@ -1707,20 +1858,20 @@ mod tests { // Top-level `params` — no example ever used it; template `fields` is the live // path. Action-level `params` is a different field and still exists. let params = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "params": { "P": { "type": "u64" } } }"#; // Top-level `source` — never set by any manifest; the engine now always // falls back to "covenant.simf". Per-utxo_type `script.source` is unaffected. let source = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "source": "./covenant.simf" }"#; // `classes` — renamed to `contract_templates` to match tx_manifest_spec // (2026-07-06). `create_instance.class` became `template` in the same pass. let classes = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "classes": { "C": { "fields": {}, "methods": {} } } }"#; @@ -1729,7 +1880,7 @@ mod tests { // executed hooks in alphabetical rather than declaration order), and // `on_validate` was never executed at all. let hooks = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "hooks": { "on_validate": "assert!(true)" } } } }"#; @@ -1740,14 +1891,14 @@ mod tests { // NOTE: this puts the repo *ahead* of tx_manifest_spec, whose Hooks extension // still lists `args.NAME` as an assignment target. let args = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "args": { "SIG": { "type": "bytes32" } } } } }"#; // Action-level `ui` — flattened to a bare `intent` string (the wrapper held // exactly one field). Per-leg `ui` is a different field and still exists. let action_ui = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "ui": { "action": "do the thing" } } } }"#; @@ -1755,21 +1906,21 @@ mod tests { // the same type (`MethodDef` was a type alias for `Action`), and "methods" is // the OOP jargon `contract_templates` was chosen to avoid. let methods = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "contract_templates": { "T": { "fields": {}, "methods": {} } } }"#; // `is_constructor` — an action carrying `create_instance` *is* a constructor; // the flag was a second way of saying the same thing, and could disagree. let is_constructor = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "contract_templates": { "T": { "fields": {}, "actions": { "A": { "is_constructor": true } } } } }"#; // `create_instance.template` — the instance is always of the enclosing // template, so naming it invited creating an instance of a different one. let ci_template = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "contract_templates": { "T": { "fields": {}, "actions": { "A": { "create_instance": { "template": "T", "fields": {} } } } } } }"#; @@ -1778,19 +1929,19 @@ mod tests { // they belong on the input. No manifest ever set the action-level map, and // Spec.md §8 places witnesses on an input descriptor only. let action_witnesses = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "witnesses": { "SIG": { "type": "Signature" } } } } }"#; // `derived` — a boolean saying "this is computed", alongside `compute`, which // says the same thing and also says how. Only the second is load-bearing. let derived = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "params": { "P": { "type": "u64", "derived": true } } } } }"#; // `formula` — merged into `compute`, whose bare-string form it now is. let formula = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "params": { "P": { "type": "u64", "formula": "1 + 1" } } } } }"#; @@ -1800,7 +1951,7 @@ mod tests { // tokens beside an explicit collateral UTXO. Every example set it `false`, and // the builder only ever consulted it to warn. `output.confidential` says it now. let utxo_type_confidential = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "utxo_types": { "t": { "description": "d", "confidential": true } } }"#; @@ -1808,7 +1959,7 @@ mod tests { // same question ("where does this value come from, if not the user?") and its // name collided with `script.source`, which is a file path. let source = r#"{ - "manifest_version": "0.2.0", "protocol": "test", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "params": { "P": { "type": "pubkey", "source": { "type": "wallet_key" } } } } } }"#; @@ -1852,7 +2003,7 @@ mod tests { // `debug_symbols` changes every covenant address, so pin both the plumbing // and the default rather than trusting the field is wired up. let with_debug = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", + r#"{ "manifest_version": "0.3.0", "protocol": "t", "simplicity_hl": { "debug_symbols": true } }"#, ) .expect("simplicity_hl should parse"); @@ -1860,7 +2011,7 @@ mod tests { // An empty block defaults to false... let empty = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", "simplicity_hl": {} }"#, + r#"{ "manifest_version": "0.3.0", "protocol": "t", "simplicity_hl": {} }"#, ) .expect("empty simplicity_hl should parse"); assert!(!empty.include_debug_symbols()); @@ -1869,7 +2020,7 @@ mod tests { // `simc "";` directive's job, so the key must be rejected. for key in ["version", "min_version", "simc"] { let json = format!( - r#"{{ "manifest_version": "0.2.0", "protocol": "t", + r#"{{ "manifest_version": "0.3.0", "protocol": "t", "simplicity_hl": {{ "{key}": "0.6.0" }} }}"# ); assert!( @@ -1880,7 +2031,7 @@ mod tests { // ...as does an absent block entirely. let absent = - Manifest::from_json_str(r#"{ "manifest_version": "0.2.0", "protocol": "t" }"#).unwrap(); + Manifest::from_json_str(r#"{ "manifest_version": "0.3.0", "protocol": "t" }"#).unwrap(); assert!(!absent.include_debug_symbols()); } @@ -1892,7 +2043,7 @@ mod tests { fn destination_accepts_exactly_the_documented_forms() { let parse = |dest: &str| { Manifest::from_json_str(&format!( - r#"{{ "manifest_version": "0.2.0", "protocol": "t", "actions": {{ "A": {{ "outputs": [ + r#"{{ "manifest_version": "0.3.0", "protocol": "t", "actions": {{ "A": {{ "outputs": [ {{ "id": "o0", "amount_sat": "1", "destination": {dest} }} ] }} }} }}"# )) }; @@ -1935,7 +2086,7 @@ mod tests { #[test] fn closed_utxo_type_binds_params_from_the_site_not_the_action() { let manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", "utxo_types": { "vault": { + r#"{ "manifest_version": "0.3.0", "protocol": "t", "utxo_types": { "vault": { "description": "d", "params": { "STATE": { "type": "bytes32", "default": "0xff" }, @@ -1973,7 +2124,7 @@ mod tests { #[test] fn unbound_param_without_a_default_is_an_error_naming_it() { let manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", "utxo_types": { "vault": { + r#"{ "manifest_version": "0.3.0", "protocol": "t", "utxo_types": { "vault": { "description": "d", "params": { "DEBT": { "type": "u64" } }, "script": { "type": "simplicity", "source": "./x.simf" } } } }"#, @@ -1996,7 +2147,7 @@ mod tests { fn leaf_payload_items_accept_exactly_the_documented_forms() { let parse = |item: &str| { Manifest::from_json_str(&format!( - r#"{{ "manifest_version": "0.2.0", "protocol": "t", "utxo_types": {{ "u": {{ + r#"{{ "manifest_version": "0.3.0", "protocol": "t", "utxo_types": {{ "u": {{ "description": "d", "script": {{ "type": "simplicity", "source": "./x.simf", "extra_leaves": [ {{ "type": "tapdata", "payload": [{item}] }} ] }} }} }} }}"# @@ -2018,7 +2169,7 @@ mod tests { // `tapdata` is the only hashing scheme implemented; anything else was silently // hashed as tapdata anyway. let err = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", "utxo_types": { "u": { + r#"{ "manifest_version": "0.3.0", "protocol": "t", "utxo_types": { "u": { "description": "d", "script": { "type": "simplicity", "source": "./x.simf", "extra_leaves": [ { "type": "tapscript", "payload": ["0x01"] } ] } } } }"#, @@ -2033,7 +2184,7 @@ mod tests { // the manifest listed — an entry that silently doesn't arrive shows up much later // as an "unstable feature not enabled" compile error. let enabled = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", + r#"{ "manifest_version": "0.3.0", "protocol": "t", "simplicity_hl": { "unstable_features": ["enums"] } }"#, ) .expect("unstable_features should parse"); @@ -2046,9 +2197,9 @@ mod tests { // Absent block, empty block and empty list all mean "nothing unstable". for json in [ - r#"{ "manifest_version": "0.2.0", "protocol": "t" }"#, - r#"{ "manifest_version": "0.2.0", "protocol": "t", "simplicity_hl": {} }"#, - r#"{ "manifest_version": "0.2.0", "protocol": "t", + r#"{ "manifest_version": "0.3.0", "protocol": "t" }"#, + r#"{ "manifest_version": "0.3.0", "protocol": "t", "simplicity_hl": {} }"#, + r#"{ "manifest_version": "0.3.0", "protocol": "t", "simplicity_hl": { "unstable_features": [] } }"#, ] { let m = Manifest::from_json_str(json).expect("should parse"); @@ -2058,7 +2209,7 @@ mod tests { // A name the compiler doesn't know is a load-time error, not a mystery compile // failure later — and the message says which names exist. let err = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", + r#"{ "manifest_version": "0.3.0", "protocol": "t", "simplicity_hl": { "unstable_features": ["enum"] } }"#, ) .expect_err("a misspelled feature must not parse"); @@ -2068,7 +2219,7 @@ mod tests { // Both settings travel together to the compile sites. let both = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", + r#"{ "manifest_version": "0.3.0", "protocol": "t", "simplicity_hl": { "debug_symbols": true, "unstable_features": ["enums", "enums"] } }"#, ) .expect("both settings should parse"); @@ -2084,7 +2235,7 @@ mod tests { #[test] fn wallet_computes_are_recognised_and_are_not_expressions() { let m = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", "actions": { "A": { "params": { + r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "params": { "K": { "type": "pubkey", "compute": { "type": "wallet", "wallet": "key" } }, "H": { "type": "bytes32", "compute": { "type": "wallet", "wallet": "script_hash" } }, "A": { "type": "string", "compute": { "type": "wallet", "wallet": "address" } }, @@ -2112,12 +2263,12 @@ mod tests { // `"compute": "a + b"` is shorthand for `{"type":"expr","expr":"a + b"}`. // Callers read through `as_expr()`, so neither spelling is privileged. let bare = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", "actions": { "A": { + r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "params": { "P": { "type": "u64", "compute": "1 + 1" } } } } }"#, ) .expect("bare expression should parse"); let spelled = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", "actions": { "A": { + r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "params": { "P": { "type": "u64", "compute": { "type": "expr", "expr": "1 + 1" } } } } } }"#, ) @@ -2136,7 +2287,7 @@ mod tests { // A tapleaf spec is not an expression, and must not masquerade as one. let tapleaf = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", "actions": { "A": { + r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "params": { "P": { "type": "u64", "compute": { "type": "tapleaf", "simf": "./a.simf" } } } } } }"#, ) @@ -2149,7 +2300,7 @@ mod tests { // Two *other* fields share the name and must be unaffected by the removal of // the top-level one: the per-utxo-type simf wiring, and the simf_fn list. let json = r#"{ - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { @@ -2194,7 +2345,7 @@ mod tests { fn comment_key_is_allowed_at_top_level() { let json = r#"{ "$comment": "file-level note", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "test" }"#; Manifest::from_json_str(json).expect("top-level $comment should be stripped"); @@ -2209,7 +2360,7 @@ mod tests { fn a_bad_compute_spec_names_the_problem() { let manifest_with = |compute: &str| { format!( - r#"{{ "manifest_version": "0.2.0", "protocol": "t", "actions": {{ "A": {{ + r#"{{ "manifest_version": "0.3.0", "protocol": "t", "actions": {{ "A": {{ "params": {{ "P": {{ "type": "u64", "compute": {compute} }} }} }} }} }}"# ) }; @@ -2242,7 +2393,7 @@ mod tests { fn a_bad_ui_spec_names_the_problem() { let manifest_with = |ui: &str| { format!( - r#"{{ "manifest_version": "0.2.0", "protocol": "t", "actions": {{ "A": {{ + r#"{{ "manifest_version": "0.3.0", "protocol": "t", "actions": {{ "A": {{ "outputs": [ {{ "id": "o0", "destination": "change", "ui": {ui} }} ] }} }} }}"# ) }; @@ -2266,7 +2417,7 @@ mod tests { #[test] fn both_ui_spellings_still_parse() { // The manual impl must not have narrowed what is accepted. - let json = r#"{ "manifest_version": "0.2.0", "protocol": "t", "actions": { "A": { + let json = r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "outputs": [ { "id": "o0", "destination": "change", "ui": "bare label" }, { "id": "o1", "destination": "change", @@ -2305,3 +2456,53 @@ mod tests { assert!(matches!(fv, ComputeSpec::Compute(ParamCompute::Expr { .. }))); } } + +/// One place a manifest uses something its declared chain does not have. +/// +/// Carries the dot-path so `validate` can point at the offending field rather than at the +/// manifest as a whole — an author porting a protocol needs the list of sites, not a +/// verdict. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChainMismatch { + /// Dot-path to the offending field, e.g. `actions.Mint.inputs.i0.issuance`. + pub location: String, + /// What the manifest does there, as a noun phrase: "an asset issuance". + pub uses: &'static str, + /// What the chain would need to provide, as a noun phrase: "asset issuance". + pub missing: &'static str, +} + +/// Aliases and ids that denote the chain's own policy asset (L-BTC on Liquid, BTC on +/// Bitcoin) rather than a second asset. +/// +/// Kept in sync with `preview::lookup_asset`, which resolves the same names for display. +/// The mainnet Liquid id is absent for the same reason it is absent there: this engine has +/// only ever hardcoded the testnet assets, and adding one chain's id but not the other's +/// would be worse than adding neither. +const POLICY_ASSET_ALIASES: [&str; 4] = [ + "lbtc", + "l-btc", + "bitcoin", + // Liquid testnet L-BTC. + "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", +]; + +/// Whether an asset label names the policy asset. +/// +/// Only a literal counts. A reference (`instance.COLLATERAL_ASSET`) names a value this +/// module cannot resolve, so it is treated as a second asset — the conservative direction, +/// since a manifest wrongly marked as needing `multi-asset` costs one line in `requires` +/// while one wrongly marked portable fails at build time. +pub fn names_policy_asset_str(label: &str) -> bool { + let l = label.trim().to_ascii_lowercase(); + POLICY_ASSET_ALIASES.contains(&l.as_str()) +} + +/// [`names_policy_asset_str`] for the JSON-valued `asset` fields on inputs and outputs. +/// A non-string value (an object, a computed expression) is not a policy-asset literal. +fn names_non_policy_asset(value: &serde_json::Value) -> bool { + match value.as_str() { + Some(s) => !names_policy_asset_str(s), + None => true, + } +} diff --git a/txmanifest_lib/src/validate.rs b/txmanifest_lib/src/validate.rs index db86c2a..3990d2e 100644 --- a/txmanifest_lib/src/validate.rs +++ b/txmanifest_lib/src/validate.rs @@ -195,15 +195,7 @@ pub fn validate(manifest: &Manifest) -> Report { if manifest.protocol.trim().is_empty() { report.warn("protocol", "protocol identifier is empty"); } - if let Some(chain) = &manifest.chain { - let c = chain.to_lowercase(); - if !matches!(c.as_str(), "bitcoin" | "elements" | "liquid" | "cross-chain") { - report.warn( - "chain", - format!("unrecognized chain '{chain}' (expected bitcoin, liquid/elements, or cross-chain)"), - ); - } - } + check_capabilities(&mut report, manifest); if actions.is_empty() { report.warn("actions", "no actions or class methods are defined"); } @@ -897,13 +889,164 @@ mod tests { use super::*; use crate::manifest::Manifest; + /// Build a minimal manifest with the given `chain`/`requires` and one covenant type. + fn caps_manifest(chain: &str, requires: &str, extra_out: &str) -> Manifest { + Manifest::from_json_str(&format!( + r#"{{ "manifest_version": "0.3.0", "protocol": "t", + "chain": "{chain}", "requires": {requires}, + "utxo_types": {{ "v": {{ "description": "d", + "script": {{ "type": "simplicity", "source": "./x.simf" }} }} }}, + "actions": {{ "A": {{ "outputs": [ {{ "id": "o0", "amount_sat": "1", + "destination": {{ "utxo_type": "v" }}{extra_out} }} ] }} }} }}"# + )) + .expect("manifest should parse") + } + + fn messages(report: &Report) -> String { + report.issues.iter().map(|i| i.message.clone()).collect::>().join("\n") + } + + #[test] + fn a_covenant_manifest_must_declare_simplicity() { + let report = validate(&caps_manifest("elements", r#"[]"#, "")); + assert!(!report.is_ok(), "undeclared capability must be an error"); + assert!( + messages(&report).contains("uses 'simplicity' but does not declare it"), + "{}", + messages(&report) + ); + + let report = validate(&caps_manifest("elements", r#"["simplicity"]"#, "")); + assert!(report.is_ok(), "{:?}", report.issues); + } + + /// 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] + fn declaring_simplicity_on_bitcoin_is_not_a_static_error() { + let report = validate(&caps_manifest("bitcoin", r#"["simplicity"]"#, "")); + assert!(report.is_ok(), "{:?}", report.issues); + } + + /// The check that replaced the `multi-asset` capability. It reads `chain` directly, so + /// there is nothing the author could add to `requires` to satisfy it — and the finding + /// points at the field, not at the manifest. + #[test] + fn a_non_policy_asset_on_bitcoin_is_flagged_at_the_field() { + let report = validate(&caps_manifest( + "bitcoin", + r#"["simplicity"]"#, + r#", "asset": "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5""#, + )); + assert!(!report.is_ok()); + let issue = report + .issues + .iter() + .find(|i| i.message.contains("native assets")) + .expect("asset mismatch must be reported"); + assert_eq!(issue.location, "actions.A.outputs.o0.asset"); + assert!(issue.message.contains("chain 'bitcoin' has no native assets"), "{}", issue.message); + // Nothing suggests adding a capability, because no capability would help. + assert!(!messages(&report).contains("to `requires`"), "{}", messages(&report)); + } + + /// Naming the policy asset is single-asset behaviour and stays clean on Bitcoin — this + /// is how the portable examples in this repo are written. + #[test] + fn naming_the_policy_asset_is_fine_on_bitcoin() { + let report = validate(&caps_manifest("bitcoin", r#"["simplicity"]"#, r#", "asset": "lbtc""#)); + assert!(report.is_ok(), "{:?}", report.issues); + } + + #[test] + fn issuance_and_blinding_on_bitcoin_are_flagged() { + let m = Manifest::from_json_str( + r#"{ "manifest_version": "0.3.0", "protocol": "t", "chain": "bitcoin", + "requires": [], + "actions": { "A": { "inputs": [ { "id": "i0", "utxo_source": "wallet", + "issuance": { "kind": "new", "asset_amount_sat": "1", + "inflation_amount_sat": "0" } } ], + "outputs": [ { "id": "o0", "amount_sat": "1", "destination": "wallet", + "confidential": true } ] } } }"#, + ) + .expect("manifest should parse"); + let report = validate(&m); + let msg = messages(&report); + assert!(msg.contains("has no asset issuance"), "{msg}"); + assert!(msg.contains("has no confidential amounts"), "{msg}"); + // ...and all of it is clean on Elements, with no `requires` entries needed. + let elements = Manifest::from_json_str( + &std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../examples/deadcat_v3/txmanifest.json")) + .expect("read deadcat_v3"), + ) + .expect("deadcat_v3 parses"); + assert!(elements.chain_mismatches().is_empty(), "{:?}", elements.chain_mismatches()); + } + + #[test] + fn declaring_more_than_you_use_is_only_a_warning() { + // No covenant types, but `simplicity` declared. + let m = Manifest::from_json_str( + r#"{ "manifest_version": "0.3.0", "protocol": "t", "chain": "bitcoin", + "requires": ["simplicity"], + "actions": { "Pay": { "outputs": [ { "id": "o0", "amount_sat": "1000", + "destination": "wallet" } ] } } }"#, + ) + .expect("manifest should parse"); + let report = validate(&m); + assert!(report.is_ok(), "overdeclaring must not block a run: {:?}", report.issues); + assert!( + messages(&report).contains("declares 'simplicity' but nothing"), + "{}", + messages(&report) + ); + } + + /// A namespaced capability is judged in neither direction: never inferred, never + /// reported unused. This crate does not know what it means, and guessing either way + /// would be worse than silence. + #[test] + fn a_namespaced_capability_is_carried_without_judgement() { + let report = validate(&caps_manifest( + "elements", + r#"["simplicity", "custom::my-feature"]"#, + "", + )); + assert!(report.is_ok(), "{:?}", report.issues); + assert!( + !messages(&report).contains("custom::my-feature"), + "an unknown-but-valid namespace must not be second-guessed: {}", + messages(&report) + ); + } + + /// A manifest that declares nothing and uses nothing is portable to stock Bitcoin — + /// the case that motivates `requires` having a meaningful empty value. + #[test] + fn a_plain_manifest_validates_on_bitcoin() { + let m = Manifest::from_json_str( + r#"{ "manifest_version": "0.3.0", "protocol": "t", "chain": "bitcoin", + "requires": [], + "actions": { "Pay": { "outputs": [ { "id": "o0", "amount_sat": "1000", + "destination": "wallet", "asset": "lbtc" } ] } } }"#, + ) + .expect("manifest should parse"); + let report = validate(&m); + assert!( + report.is_ok() && !messages(&report).contains("requires"), + "a plain payment manifest must raise no capability findings: {}", + messages(&report) + ); + } + /// Every site that names a closed `utxo_type` must bind what that type requires — /// statically, before a run derives an address from a value nobody supplied. #[test] fn sites_must_bind_a_closed_utxo_types_required_params() { let validate_site = |dest: &str| { let manifest = Manifest::from_json_str(&format!( - r#"{{ "manifest_version": "0.2.0", "protocol": "t", + r#"{{ "manifest_version": "0.3.0", "protocol": "t", + "requires": ["simplicity"], "actions": {{ "A": {{ "params": {{ "claim": {{ "type": "bytes32" }} }}, "outputs": [ {{ "id": "o0", "amount_sat": "1", "destination": {dest} }} ] }} }}, @@ -936,7 +1079,7 @@ mod tests { // `args` against a type with no interface binds nothing — say so. let manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", + r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "outputs": [ { "id": "o0", "amount_sat": "1", "destination": { "utxo_type": "plain", "args": { "X": "1" } } } ] } }, "utxo_types": { "plain": { "description": "d", @@ -952,7 +1095,7 @@ mod tests { /// given `witnesses` JSON, then validate it. fn validate_with_input_witnesses(witnesses: Value) -> Report { let manifest: Manifest = serde_json::from_value(serde_json::json!({ - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { @@ -1134,7 +1277,7 @@ mod tests { /// with `PRINCIPAL_ASSET_ID` (an asset) and `AMOUNT` (a u64) declared as fields. fn validate_with_ui(intent: Option<&str>, legs: Value) -> Report { let manifest: Manifest = Manifest::from_json_str(&serde_json::json!({ - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "test", "contract_templates": { "T": { "fields": { @@ -1233,7 +1376,7 @@ mod tests { // A standalone action has no enclosing template, so there is nothing for it // to construct — and the old `create_instance.template` let it name any. let manifest: Manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", + r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "create_instance": { "fields": {} } } } }"#, ) .expect("test manifest should parse"); @@ -1250,7 +1393,7 @@ mod tests { #[test] fn create_instance_inside_a_template_is_accepted() { let manifest: Manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", + r#"{ "manifest_version": "0.3.0", "protocol": "t", "contract_templates": { "T": { "fields": {}, "actions": { "A": { "create_instance": { "fields": {} } } } } } }"#, ) @@ -1274,7 +1417,7 @@ mod tests { fn validate_with_hook_and_params(set: Value, params: Value) -> Report { let manifest: Manifest = Manifest::from_json_str( &serde_json::json!({ - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "params": params, "on_pre_broadcast": { "set": set } } } }) @@ -1389,7 +1532,7 @@ mod tests { fn on_resolved_hooks_are_checked_too() { // The input hook and the action hook are one type now, so one rule covers both. let manifest: Manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.2.0", "protocol": "t", "actions": { "A": { "inputs": [ + r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "inputs": [ { "id": "in0", "utxo_source": "wallet", "on_resolved": { "set": { "instance.K": { "type": "wallet", "wallet": "key" } } } } ] } } }"#, ) @@ -1402,3 +1545,58 @@ mod tests { ); } } + +/// Cross-check `requires` against what the manifest uses, and the manifest against its chain. +/// +/// Two independent checks, because they fail for different reasons and need different +/// fixes: +/// +/// 1. **`requires` vs. contents** — a covenant manifest that does not declare +/// `simplicity`. An error: `requires` is what a target gets checked against before a +/// build, so a gap here means the check passes and the broadcast fails. The reverse — +/// declaring what nothing uses — is a warning only, because inference reads field +/// presence rather than semantics and must not block a run on its own guess. +/// 2. **Contents vs. `chain`** — an issuance input on a Bitcoin manifest. An error, and +/// not expressible through `requires` at all: `chain: "bitcoin"` already says there is +/// no issuance, so there is nothing an author could add to `requires` to make it work. +/// The fix is to change the manifest or change the chain. +/// +/// Namespaced capabilities are checked in neither direction. This crate cannot know what +/// `custom::my-feature` means, so it will not claim the manifest needs it, and will not +/// claim it does not. +fn check_capabilities(report: &mut Report, manifest: &Manifest) { + let family = manifest.chain_family(); + let declared = &manifest.requires; + let inferred = manifest.inferred_capabilities(); + + for used in inferred.iter() { + if !declared.contains(used) { + report.error( + "requires", + format!("manifest uses '{used}' but does not declare it; add \"{used}\" to `requires`"), + ); + } + } + + for extra in declared.iter() { + // Only core capabilities can be judged unused — a namespaced one is satisfied by + // machinery this crate has never seen, so silence is the only honest answer. + if !extra.is_core() || inferred.contains(extra) { + continue; + } + report.warn( + "requires", + format!("declares '{extra}' but nothing in this manifest appears to use it"), + ); + } + + for m in manifest.chain_mismatches() { + report.error( + m.location.clone(), + format!( + "uses {} but chain '{family}' has no {}", + m.uses, m.missing + ), + ); + } +} diff --git a/txmanifest_lib/tests/schema.rs b/txmanifest_lib/tests/schema.rs index cfd20d0..bf6b09f 100644 --- a/txmanifest_lib/tests/schema.rs +++ b/txmanifest_lib/tests/schema.rs @@ -66,7 +66,7 @@ fn schema_rejects_an_unknown_field() { // If this ever passes, the schema has stopped catching typos in downstream repos // and is worse than useless — it would be actively reassuring about broken files. let bad = serde_json::json!({ - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "inputs": [ { "id": "in0", "utxo_source": "wallet", "from_addres": "typo" } @@ -85,7 +85,7 @@ fn schema_accepts_the_authoring_keys() { let ok = serde_json::json!({ "$schema": tx_manifest_lib::schema::SCHEMA_ID, "$comment": "file-level note", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "inputs": [ { "id": "in0", "utxo_source": "wallet", "$comment": "why this input exists" } @@ -163,7 +163,7 @@ fn both_spellings_of_ui_carry_the_label_cap() { serde_json::json!({ "label": long }), ] { let bad = serde_json::json!({ - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "test", "actions": { "A": { "outputs": [ { "id": "o0", "destination": "change", "ui": ui } From a0c9c98d462a1bccc2a25060762bf677c245f602 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 4 Sep 2026 16:04:34 +0200 Subject: [PATCH 02/16] add capabilities check --- CHANGELOG.md | 53 ++++++++++++++ txmanifest_lib/src/chain.rs | 24 ++++++- txmanifest_lib/src/config.rs | 119 ++++++++++++++++++++++++++++++++ txmanifest_lib/src/covenant.rs | 77 ++++++++++++++++++++- txmanifest_lib/src/lifecycle.rs | 67 ++++++++++++++++++ txmanifest_lib/src/manifest.rs | 116 +++++++++++++++++++++++++++++++ txmanifest_wallet/src/main.rs | 112 ++++++++++++++++++++++++++++++ 7 files changed, 565 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c1beae..8275f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,59 @@ the aliases `liquid` and `btc`. It was a free-form string that nothing read; are written, and counting it as multi-asset marked `p2pk` and `last_will` unportable when they are the two that port most cleanly. +- **`capabilities` command and `Manifest::supported_by` — the support check + `requires` exists for.** A third-party wallet answers "do I handle this file" + by passing what it implements and reading a verdict, instead of reimplementing + this crate's inference over the manifest body: + + ``` + $ tx-manifest-wallet capabilities m.json # the contract + chain : elements + requires : simplicity + + $ tx-manifest-wallet capabilities m.json --supports simplicity + ✓ supported # exit 0 + + $ tx-manifest-wallet capabilities m.json --supports "" --json + { "supported": false, "missing": ["simplicity"], ... } # exit 1 + ``` + + `--supports` sets the exit code so it can gate CI. Without it the command + reports the contract and stops, rather than answering a question about a + wallet nobody named — exiting 0 there would read as a passing check. The chain + is checked alongside the capabilities and reported differently, because the two + mean different things to an implementor: a capability gap is closable by + implementing something, a wrong chain is not. + + Scope worth stating: a `supported` verdict certifies the *ledger* requirements + only. OP_RETURN outputs, relative timelocks and similar transaction shapes are + not in the capability vocabulary, so an implementor still reads the manifest + body for those. + +- **The jet set is chosen from the chain, and `requires` is enforced at run time.** + `CompileOpts` gained a `family`, so the SimplicityHL jet hinter follows the + manifest's `chain` the way `debug_symbols` already did — it belongs there for + the same reason, since a jet's CMR depends on its position in its jet set and + therefore moves every covenant address. Verified against the Bitcoin-enabled + forks: `p2pk.simf` compiles unchanged under both jet sets and yields two + different CMRs, so a covenant address differs per chain for two independent + reasons (jet CMRs and the taproot tag domain). + + This build still pins upstream SimplicityHL, which ships no `BitcoinJetHinter`, + so a Bitcoin covenant is refused with an error naming the fork that works + rather than being silently compiled against the Elements jet set — which would + succeed for any program using only shared jets and produce an address on the + wrong chain. + +- **`config.json` gained `simplicity_activated` and `extra_capabilities`,** and + `lifecycle::run` now refuses before deriving, signing or broadcasting anything + if the target cannot provide what `requires` declares. `validate` cannot do + this: it is offline, and Simplicity on Bitcoin is a property of the node rather + than of the chain. Also refuses when the manifest's `chain` and the wallet's + network disagree — caught at the gate, where the message can be about the + mistake, rather than deep in address derivation, where it would be about + taproot tags. + ### Changed - **Taproot tag domains are derived from the chain rather than hardcoded.** diff --git a/txmanifest_lib/src/chain.rs b/txmanifest_lib/src/chain.rs index 48f9977..2e9554d 100644 --- a/txmanifest_lib/src/chain.rs +++ b/txmanifest_lib/src/chain.rs @@ -81,7 +81,7 @@ impl ChainFamily { /// /// False on Bitcoin, which has exactly one asset. This is the fact that makes a /// per-output `asset` field, an issuance input, or `allow_change: "any"` meaningless - /// there — see `Manifest::elements_only_uses`. + /// there — see `Manifest::chain_mismatches`. pub fn has_native_assets(self) -> bool { matches!(self, ChainFamily::Elements) } @@ -926,6 +926,28 @@ mod tests { assert_eq!(covenant.missing_from(&stock), vec![Capability::SIMPLICITY]); } + /// The gate `lifecycle::check_target_capabilities` enforces, at the level this module + /// owns: a covenant manifest is refused on a stock Bitcoin node and accepted on a + /// patched one, with nothing else changing. + #[test] + fn a_covenant_manifest_is_gated_on_bitcoin_activation() { + let requires = Capabilities::from_iter([Capability::SIMPLICITY]); + + let stock = Network::BitcoinSignet + .capabilities(&Activation::default_for(Network::BitcoinSignet)); + assert_eq!( + requires.missing_from(&stock), + vec![Capability::SIMPLICITY], + "the default for a Bitcoin network must be off — no public network has activated it" + ); + + let patched = Network::BitcoinSignet.capabilities(&Activation { + simplicity: true, + extensions: Capabilities::none(), + }); + assert!(requires.missing_from(&patched).is_empty()); + } + #[test] fn legacy_network_names_stay_bound_to_elements() { // A config written before Bitcoin support existed must not be reinterpreted. diff --git a/txmanifest_lib/src/config.rs b/txmanifest_lib/src/config.rs index 9e19c1a..ddf0ec4 100644 --- a/txmanifest_lib/src/config.rs +++ b/txmanifest_lib/src/config.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use crate::backend::BackendKind; +use crate::chain::{Activation, Capabilities, Capability, Network}; use crate::wallet::default_data_dir; #[derive(Debug, Serialize, Deserialize)] @@ -20,6 +21,22 @@ pub struct Config { /// is "electrum". If None, a network-appropriate Blockstream default is chosen. #[serde(default)] pub default_electrum: Option, + /// Whether the node this wallet talks to executes Simplicity tapleaves. + /// + /// Only consulted on Bitcoin networks; Elements has Simplicity live regardless. `None` + /// means "take the network's default", which is off for Bitcoin — no public Bitcoin + /// network has activated the BINANA 2026-0003 soft fork, so a user running a patched + /// node opts in rather than every other user opting out. + /// + /// Configuration, not discovery: nothing probes the node. Setting it wrongly costs a + /// rejected broadcast, not a coin. + #[serde(default)] + pub simplicity_activated: Option, + /// Namespaced capabilities (`custom::my-feature`) the operator asserts this target + /// provides. The only thing that can satisfy a third-party `requires` entry, since + /// this crate has no way to verify one. + #[serde(default)] + pub extra_capabilities: Vec, } impl Default for Config { @@ -29,6 +46,8 @@ impl Default for Config { default_esplora: None, default_backend: None, default_electrum: None, + simplicity_activated: None, + extra_capabilities: Vec::new(), } } } @@ -77,6 +96,36 @@ impl Config { } } +impl Config { + /// The configured network, or an error naming the accepted spellings. + pub fn network(&self) -> Result { + self.default_network + .parse::() + .map_err(|e| anyhow::anyhow!("{e} (in default_network)")) + } + + /// What the configured target provides, for checking a manifest's `requires` against. + /// + /// Unparseable `extra_capabilities` entries are an error rather than a skip: an + /// operator who misspells one is asserting a capability that then silently fails to + /// satisfy anything, and the resulting message would blame the manifest. + pub fn activation(&self, network: Network) -> Result { + let mut extensions = Capabilities::none(); + for raw in &self.extra_capabilities { + let cap: Capability = raw + .parse() + .with_context(|| format!("bad entry in extra_capabilities: {raw:?}"))?; + extensions.insert(cap); + } + Ok(Activation { + simplicity: self + .simplicity_activated + .unwrap_or_else(|| Activation::default_for(network).simplicity), + extensions, + }) + } +} + pub fn config_path() -> PathBuf { default_data_dir().join("config.json") } @@ -103,3 +152,73 @@ pub fn save(config: &Config) -> Result<()> { std::fs::write(&path, raw) .with_context(|| format!("Cannot write config: {}", path.display())) } + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(network: &str) -> Config { + Config { default_network: network.to_string(), ..Config::default() } + } + + /// A config file written before these fields existed must keep parsing, and must mean + /// what it meant then: Liquid testnet, Simplicity live. + #[test] + fn a_pre_existing_config_keeps_its_meaning() { + let old = r#"{"default_network": "testnet", "default_esplora": null}"#; + let parsed: Config = serde_json::from_str(old).expect("old config still parses"); + let net = parsed.network().expect("testnet resolves"); + assert_eq!(net, Network::LiquidTestnet); + assert!(parsed.activation(net).unwrap().simplicity); + } + + /// Simplicity defaults off on Bitcoin and on for Elements, and an explicit setting wins + /// only where it is meaningful. + #[test] + fn simplicity_activation_defaults_per_family() { + let c = cfg("bitcoin-signet"); + let net = c.network().unwrap(); + assert!(!c.activation(net).unwrap().simplicity, "must default off on Bitcoin"); + + let opted_in = Config { simplicity_activated: Some(true), ..cfg("bitcoin-signet") }; + assert!(opted_in.activation(net).unwrap().simplicity); + + // Elements has it live regardless, so the capability set carries it either way. + let c = cfg("liquid"); + let net = c.network().unwrap(); + let off = Config { simplicity_activated: Some(false), ..cfg("liquid") }; + assert!(net + .capabilities(&off.activation(net).unwrap()) + .contains(&Capability::SIMPLICITY)); + assert!(net.capabilities(&c.activation(net).unwrap()).contains(&Capability::SIMPLICITY)); + } + + #[test] + fn extra_capabilities_are_parsed_and_asserted() { + let c = Config { + extra_capabilities: vec!["custom::my-feature".to_string()], + ..cfg("bitcoin-signet") + }; + let net = c.network().unwrap(); + let want = Capabilities::from_iter(["custom::my-feature".parse().unwrap()]); + assert!(want.missing_from(&net.capabilities(&c.activation(net).unwrap())).is_empty()); + } + + /// A misspelled entry is an error, not a skip: it would otherwise satisfy nothing and + /// the resulting failure would blame the manifest rather than the config. + #[test] + fn a_malformed_extra_capability_is_an_error() { + let c = Config { + extra_capabilities: vec!["not a capability".to_string()], + ..cfg("liquid") + }; + let err = c.activation(c.network().unwrap()).unwrap_err().to_string(); + assert!(err.contains("extra_capabilities"), "{err}"); + } + + #[test] + fn an_unknown_network_names_the_accepted_spellings() { + let err = cfg("liquid-signet").network().unwrap_err().to_string(); + assert!(err.contains("default_network") && err.contains("bitcoin-signet"), "{err}"); + } +} diff --git a/txmanifest_lib/src/covenant.rs b/txmanifest_lib/src/covenant.rs index 02cedf3..6faa720 100644 --- a/txmanifest_lib/src/covenant.rs +++ b/txmanifest_lib/src/covenant.rs @@ -53,6 +53,15 @@ pub struct CompileOpts { /// Unstable compiler features the program may use (`simc -Z `). Purely a gate: /// enabling a feature never changes generated code, so it never moves an address. pub unstable_features: UnstableFeatures, + /// Which jet set the program is compiled against. + /// + /// Belongs here for the same reason `debug_symbols` does: it changes the CMR, and + /// therefore every covenant address. A jet's CMR depends on its position in its jet + /// set, so a program touching a single jet commits to a different CMR under Bitcoin + /// than under Elements — `p2pk.simf` compiles unchanged on both and yields two + /// different addresses. Applying it on some paths and forgetting it on others is the + /// failure this struct exists to prevent. + pub family: ChainFamily, } impl Default for CompileOpts { @@ -60,6 +69,7 @@ impl Default for CompileOpts { Self { debug_symbols: false, unstable_features: UnstableFeatures::none(), + family: ChainFamily::DEFAULT, } } } @@ -94,11 +104,39 @@ fn compile_program( &opts.unstable_features, arguments, opts.debug_symbols, - Box::new(ElementsJetHinter::new()), + jet_hinter(opts.family)?, ) .map_err(|e| anyhow::anyhow!("SimplicityHL compilation failed: {e}")) } +/// The jet set to compile against, as a SimplicityHL hinter. +/// +/// The chain's jet sets are genuinely different vocabularies, not dialects: `output_value` +/// exists only for Bitcoin, `output_asset` and `genesis_block_hash` only for Elements, and +/// a program naming the wrong one fails to compile rather than misbehaving. That much is +/// already the right behaviour. +/// +/// What is missing is the Bitcoin hinter itself. Upstream SimplicityHL ships +/// `ElementsJetHinter` and `CoreJetHinter` only; `BitcoinJetHinter` exists on the +/// Bitcoin-enabled fork this crate does not yet pin. So this returns an error naming the +/// fork rather than silently compiling a Bitcoin manifest against the Elements jet set — +/// which would succeed for any program using only shared jets, and produce an address on +/// the wrong chain. +fn jet_hinter(family: ChainFamily) -> Result> { + match family { + ChainFamily::Elements => Ok(Box::new(ElementsJetHinter::new())), + ChainFamily::Bitcoin => Err(anyhow::anyhow!( + "this build cannot compile covenants for Bitcoin: SimplicityHL's \ + `BitcoinJetHinter` is not in the pinned version. It exists on \ + https://github.com/delta1/SimplicityHL branch `bitcoin` (with \ + https://github.com/delta1/rust-simplicity branch `2025-12/update-libsimplicity`), \ + where compilation, address derivation and Bit Machine execution against a \ + `BitcoinEnv` all work. Until this crate pins those, a manifest with \ + `\"chain\": \"bitcoin\"` may not declare covenant `utxo_types`." + )), + } +} + /// Compile a `.simf` file and return the Simplicity tapleaf hash (32 bytes, natural byte order). /// /// This is an intermediate taproot value (TapLeafHash). To get the value that the Simplicity @@ -800,7 +838,22 @@ pub fn compute_covenant_address( opts: impl Into, ) -> Result
{ let opts = opts.into(); - let family = Network::from(network).family(); + // The taproot tag domain and the jet set must come from one place. They enter by + // different doors — the tag from the network the wallet is pointed at, the jet set from + // the manifest's `chain` — and if those disagree the result is an address that is wrong + // in a way nothing downstream can detect: a well-formed p2tr output built from one + // chain's tag over another chain's CMR, spendable by nothing. So take the manifest's + // family as the single source and refuse outright when the network contradicts it. + let family = opts.family; + let network_family = Network::from(network).family(); + if family != network_family { + anyhow::bail!( + "manifest targets chain '{family}' but the wallet is on {network_family} network \ + '{}': refusing to derive a covenant address, because the taproot tag domain and \ + the jet set would come from different chains", + Network::from(network), + ); + } eprintln!( "[covenant] compute_covenant_address: {} extra leaf(s), simf={}", extra_leaf_payloads.len(), @@ -1244,6 +1297,26 @@ fn network_to_params(network: lwk_wollet::ElementsNetwork) -> &'static AddressPa mod tests { use super::*; + /// A Bitcoin covenant must fail with something actionable, not by silently compiling + /// against the Elements jet set. + /// + /// The silent path is the dangerous one: `p2pk.simf` uses only jets present in both + /// sets, so it would compile clean under the wrong hinter and yield an address on the + /// wrong chain. The error has to name the fork that does work. + #[test] + fn bitcoin_covenants_are_refused_with_a_pointer_to_the_fork() { + let opts = CompileOpts { family: ChainFamily::Bitcoin, ..CompileOpts::default() }; + let err = compile_program("fn main() { }".to_string(), Arguments::default(), &opts) + .expect_err("Bitcoin has no jet hinter in the pinned SimplicityHL") + .to_string(); + assert!(err.contains("BitcoinJetHinter"), "{err}"); + assert!(err.contains("delta1/SimplicityHL"), "{err}"); + + // Elements still compiles, so the gate is the family and nothing else. + let opts = CompileOpts { family: ChainFamily::Elements, ..CompileOpts::default() }; + assert!(compile_program("fn main() { }".to_string(), Arguments::default(), &opts).is_ok()); + } + /// The tag domain must actually change the tree, and must match Elements' published /// tag on the chain this engine already ships against. /// diff --git a/txmanifest_lib/src/lifecycle.rs b/txmanifest_lib/src/lifecycle.rs index a700d1b..31a1ab1 100644 --- a/txmanifest_lib/src/lifecycle.rs +++ b/txmanifest_lib/src/lifecycle.rs @@ -332,6 +332,11 @@ pub fn run( let manifest: Manifest = Manifest::from_json_str(&raw).with_context(|| { format!("Failed to parse manifest file: {}", manifest_file.display()) })?; + // Refuse before anything is derived, signed or broadcast if the target cannot run + // this manifest. `validate` cannot do this: it is offline and has no idea which node + // the wallet points at, and Simplicity on Bitcoin is a property of the node. + check_target_capabilities(&manifest, network)?; + // How every `.simf` in this run compiles: debug symbols (which affect every CMR and // address, so interop targets like simplicity-lending can be matched without // hardcoding) and any unstable `-Z` features the programs need. Sourced from the @@ -4991,3 +4996,65 @@ mod tests { ); } } + + +/// Refuse a run whose target cannot provide what the manifest declares in `requires`. +/// +/// The counterpart to `validate`'s static check. That one asks "could this manifest ever +/// run on this chain"; this one asks "will it run here, now, against this node" — a +/// question only the wallet's configuration can answer, since Simplicity on Bitcoin is a +/// soft fork some nodes honour and most do not. +/// +/// Placed before any address derivation, signing or broadcast. A capability gap discovered +/// later shows up as a rejected transaction, by which point a covenant address may already +/// hold funds that nothing on that chain can spend. +fn check_target_capabilities(manifest: &Manifest, network: Option<&str>) -> Result<()> { + let cfg = crate::config::load(); + let network_name = network.unwrap_or(&cfg.default_network); + let target: crate::chain::Network = network_name + .parse() + .map_err(|e| anyhow::anyhow!("{e}"))?; + + // The manifest declares a family; the wallet points at a network. Disagreement is + // caught here rather than deep in address derivation, where the message would be about + // taproot tags instead of about the mistake the user made. + let declared = manifest.chain_family(); + if declared != target.family() { + anyhow::bail!( + "manifest targets chain '{declared}' but this run is on network '{target}' \ + ({}). Point the wallet at a {declared} network, or change the manifest's \ + `chain`.", + target.family(), + ); + } + + let activation = cfg.activation(target)?; + let provided = target.capabilities(&activation); + let missing = manifest.requires.missing_from(&provided); + if missing.is_empty() { + return Ok(()); + } + + let mut msg = format!( + "network '{target}' cannot provide what this manifest requires ({}):", + manifest.requires.describe() + ); + for cap in &missing { + msg.push_str(&format!("\n - {cap}: {}", cap.unsupported_hint())); + } + // Say how to proceed, but only where proceeding is a configuration question rather + // than a fact about the chain. + if missing.iter().any(|c| *c == crate::chain::Capability::SIMPLICITY) { + msg.push_str( + "\n\nIf this node does run Simplicity, set `simplicity_activated: true` in the \ + wallet config.", + ); + } + if missing.iter().any(|c| !c.is_core()) { + msg.push_str( + "\n\nNamespaced capabilities are satisfied only by listing them in the wallet \ + config's `extra_capabilities`.", + ); + } + anyhow::bail!(msg) +} diff --git a/txmanifest_lib/src/manifest.rs b/txmanifest_lib/src/manifest.rs index 36323c4..e45bb6c 100644 --- a/txmanifest_lib/src/manifest.rs +++ b/txmanifest_lib/src/manifest.rs @@ -1670,6 +1670,38 @@ impl Manifest { out } + /// Can a wallet supporting `chain` and `capabilities` execute this manifest? + /// + /// The support check `requires` exists for. A third-party wallet answers "do I handle + /// this file" by passing what it implements and reading the verdict, rather than + /// reimplementing this crate's inference over the manifest body. + /// + /// Both halves are checked because both can disqualify a wallet, and for different + /// reasons. A capability gap is about the wallet: it could be closed by implementing + /// something. A chain mismatch is about the file: an Elements manifest is not going to + /// become executable by a Bitcoin wallet. + /// + /// Note the contract this honours and the one it does not. If the verdict is + /// [`Support::Yes`], a wallet implementing `capabilities` on `chain` has everything the + /// *ledger* must provide. It does not certify that the wallet can construct every + /// transaction shape the manifest asks for — OP_RETURN outputs, relative timelocks and + /// the like are not in the capability vocabulary, so an implementor still reads the + /// manifest body for those. + pub fn supported_by(&self, chain: ChainFamily, capabilities: &Capabilities) -> Support { + if self.chain_family() != chain { + return Support::WrongChain { + manifest: self.chain_family(), + wallet: chain, + }; + } + let missing = self.requires.missing_from(capabilities); + if missing.is_empty() { + Support::Yes + } else { + Support::Missing(missing) + } + } + /// Whether covenants should be compiled with SimplicityHL debug symbols included. /// Defaults to `false`; see [`SimplicityHl::debug_symbols`]. pub fn include_debug_symbols(&self) -> bool { @@ -1693,6 +1725,7 @@ impl Manifest { crate::covenant::CompileOpts { debug_symbols: self.include_debug_symbols(), unstable_features: self.unstable_features(), + family: self.chain_family(), } } @@ -1707,6 +1740,58 @@ impl Manifest { #[cfg(test)] mod tests { + + /// The support check `requires` exists for: a wallet passes what it implements and + /// gets a verdict, without reimplementing this crate's inference over the body. + #[test] + fn supported_by_answers_a_wallets_question() { + use crate::chain::{Capabilities, Capability, ChainFamily}; + + let covenant = Manifest::from_json_str( + r#"{ "manifest_version": "0.3.0", "protocol": "t", "chain": "bitcoin", + "requires": ["simplicity"], + "utxo_types": { "v": { "description": "d", + "script": { "type": "simplicity", "source": "./x.simf" } } }, + "actions": { "A": { "outputs": [ { "id": "o0", "amount_sat": "1", + "destination": { "utxo_type": "v" } } ] } } }"#, + ) + .expect("manifest parses"); + + let none = Capabilities::none(); + let simplicity = Capabilities::from_iter([Capability::SIMPLICITY]); + + assert_eq!( + covenant.supported_by(ChainFamily::Bitcoin, &none), + Support::Missing(vec![Capability::SIMPLICITY]) + ); + assert!(covenant + .supported_by(ChainFamily::Bitcoin, &simplicity) + .is_supported()); + + // The chain disqualifies a wallet on its own, and says so differently: a capability + // gap is closable by implementing something, a wrong chain is not. + assert_eq!( + covenant.supported_by(ChainFamily::Elements, &simplicity), + Support::WrongChain { manifest: ChainFamily::Bitcoin, wallet: ChainFamily::Elements } + ); + } + + /// A manifest needing nothing is supported by a wallet implementing nothing — the case + /// that makes an empty `requires` meaningful rather than degenerate. + #[test] + fn a_plain_manifest_is_supported_by_a_plain_wallet() { + use crate::chain::{Capabilities, ChainFamily}; + let plain = Manifest::from_json_str( + r#"{ "manifest_version": "0.3.0", "protocol": "t", "chain": "bitcoin", + "requires": [], + "actions": { "Pay": { "outputs": [ { "id": "o0", "amount_sat": "1000", + "destination": "wallet" } ] } } }"#, + ) + .expect("manifest parses"); + assert!(plain + .supported_by(ChainFamily::Bitcoin, &Capabilities::none()) + .is_supported()); + } use super::*; /// The version this build implements must read, and every other 0.x line must not. @@ -2457,6 +2542,37 @@ mod tests { } } +/// The verdict from [`Manifest::supported_by`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Support { + /// The wallet provides everything this manifest declares. + Yes, + /// Capabilities the manifest declares that the wallet did not. + Missing(Vec), + /// The manifest is for a different ledger entirely. + WrongChain { manifest: ChainFamily, wallet: ChainFamily }, +} + +impl Support { + pub fn is_supported(&self) -> bool { + matches!(self, Support::Yes) + } + + /// One line explaining the verdict, suitable for printing to a user. + pub fn describe(&self) -> String { + match self { + Support::Yes => "supported".to_string(), + Support::Missing(caps) => format!( + "unsupported: missing {}", + caps.iter().map(Capability::to_string).collect::>().join(", ") + ), + Support::WrongChain { manifest, wallet } => { + format!("unsupported: manifest targets {manifest}, wallet supports {wallet}") + } + } + } +} + /// One place a manifest uses something its declared chain does not have. /// /// Carries the dot-path so `validate` can point at the offending field rather than at the diff --git a/txmanifest_wallet/src/main.rs b/txmanifest_wallet/src/main.rs index e8b3362..34bfad2 100644 --- a/txmanifest_wallet/src/main.rs +++ b/txmanifest_wallet/src/main.rs @@ -124,6 +124,25 @@ enum Commands { manifest_file: PathBuf, }, + /// Report what a wallet must support to execute a manifest, or check a given wallet + /// against it + Capabilities { + /// Path to the manifest (txmanifest.json) file + manifest_file: PathBuf, + /// Comma-separated capabilities your wallet implements, e.g. + /// `simplicity,custom::my-feature`. With this, the command exits non-zero when the + /// manifest is unsupported, so it can gate CI. + #[arg(long, value_name = "LIST")] + supports: Option, + /// Chain your wallet implements. Defaults to the manifest's own `chain`, which + /// makes `--supports` a pure capability check. + #[arg(long, value_name = "CHAIN")] + chain: Option, + /// Emit JSON instead of prose. + #[arg(long)] + json: bool, + }, + /// Interactively explore a manifest file's contract_templates and actions Describe { /// Path to the manifest (txmanifest.json) file @@ -718,6 +737,9 @@ fn main() -> Result<()> { } Commands::Validate { manifest_file } => cmd_validate(&manifest_file), + Commands::Capabilities { manifest_file, supports, chain, json } => { + cmd_capabilities(&manifest_file, supports.as_deref(), chain.as_deref(), json) + } Commands::Describe { manifest_file, action_name } => { cmd_describe(&manifest_file, action_name.as_deref()) } @@ -732,3 +754,93 @@ fn main() -> Result<()> { cmd_split(count, &asset, amount_each, &wallet, esplora.as_deref(), data_dir.as_deref()), } } + + +/// Report a manifest's support contract, and optionally check a wallet against it. +/// +/// This is the consumer `requires` is for: a wallet implementor asking "do I handle this +/// file". Without `--supports` it prints the contract; with it, the exit code is the +/// answer, so it can gate CI without parsing output. +fn cmd_capabilities( + manifest_file: &Path, + supports: Option<&str>, + chain: Option<&str>, + json: bool, +) -> Result<()> { + use tx_manifest_lib::chain::{Capabilities, Capability, ChainFamily}; + use tx_manifest_lib::manifest::{Manifest, Support}; + + let raw = std::fs::read_to_string(manifest_file) + .with_context(|| format!("Failed to read manifest file: {}", manifest_file.display()))?; + let manifest = Manifest::from_json_str(&raw) + .with_context(|| format!("Failed to parse manifest file: {}", manifest_file.display()))?; + + // No `--supports` is a question about the manifest, not about a wallet: print the + // contract and stop. Reporting "supported" against an unstated wallet would be + // meaningless, and exiting 0 would read as a passing check. + let Some(supports) = supports else { + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "chain": manifest.chain_family().as_str(), + "requires": manifest.requires.iter().map(|c| c.to_string()).collect::>(), + }))? + ); + } else { + println!("chain : {}", manifest.chain_family()); + println!("requires : {}", manifest.requires.describe()); + println!( + "\nA wallet supporting {} on {} can execute this manifest's ledger \ + requirements.\nCheck yours with: --supports ", + manifest.requires.describe(), + manifest.chain_family(), + ); + } + return Ok(()); + }; + + let mut wallet_caps = Capabilities::none(); + for entry in supports.split(',').map(str::trim).filter(|e| !e.is_empty()) { + let cap: Capability = entry + .parse() + .map_err(|e| anyhow::anyhow!("{e}")) + .with_context(|| format!("bad --supports entry {entry:?}"))?; + wallet_caps.insert(cap); + } + let wallet_chain = match chain { + Some(c) => c.parse::().map_err(|e| anyhow::anyhow!("{e}"))?, + None => manifest.chain_family(), + }; + + let verdict = manifest.supported_by(wallet_chain, &wallet_caps); + if json { + let missing = match &verdict { + Support::Missing(caps) => caps.iter().map(|c| c.to_string()).collect(), + _ => Vec::::new(), + }; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "supported": verdict.is_supported(), + "verdict": verdict.describe(), + "chain": manifest.chain_family().as_str(), + "requires": manifest.requires.iter().map(|c| c.to_string()).collect::>(), + "missing": missing, + }))? + ); + } else { + match &verdict { + Support::Yes => println!("{} {}", console::style("✓").green(), verdict.describe()), + _ => println!("{} {}", console::style("✗").red(), verdict.describe()), + } + } + + if verdict.is_supported() { + Ok(()) + } else { + // A non-zero exit is the whole point of `--supports`; the message is already + // printed, so keep the error itself terse. + std::process::exit(1); + } +} From f71d1cc0f52562679eacbbff655fa1a7aa32654a Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 4 Sep 2026 16:23:23 +0200 Subject: [PATCH 03/16] add psbt builder --- CHANGELOG.md | 18 + txmanifest_lib/src/lib.rs | 1 + txmanifest_lib/src/psbt_builder.rs | 615 +++++++++++++++++++++++++++++ 3 files changed, 634 insertions(+) create mode 100644 txmanifest_lib/src/psbt_builder.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8275f0d..ccb996a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,24 @@ the aliases `liquid` and `btc`. It was a free-form string that nothing read; are written, and counting it as multi-asset marked `p2pk` and `last_will` unportable when they are the two that port most cleanly. +- **`psbt_builder` — Bitcoin transaction construction.** The counterpart to + `pset_builder`, as a separate module rather than a generic one: the two chains + share the shape of the job and almost none of its substance, and roughly two + thirds of `pset_builder` is machinery for things Bitcoin does not have. What is + genuinely common — the input/output vocabulary and the two-pass fee loop — is + mirrored under the same names, including the guarantee that a declared output's + index in the request is its index in the transaction. + + The difference that reaches furthest is that **the fee is not an output**. + Elements places a fee `TxOut` and the transaction balances by construction; + Bitcoin defines the fee as inputs minus outputs, so nothing writes it down. A + slip that would produce a visibly wrong fee output on Elements produces a + silently overpaid fee here, so the balance is asserted rather than assumed and + `BuildPsbtResult::fee` reports what was actually left over. Change below the + dust threshold folds into the fee, and the reported number says so. + + Not yet wired into `lifecycle` — see below. + - **`capabilities` command and `Manifest::supported_by` — the support check `requires` exists for.** A third-party wallet answers "do I handle this file" by passing what it implements and reading a verdict, instead of reimplementing diff --git a/txmanifest_lib/src/lib.rs b/txmanifest_lib/src/lib.rs index 9104edb..2c9a1be 100644 --- a/txmanifest_lib/src/lib.rs +++ b/txmanifest_lib/src/lib.rs @@ -13,6 +13,7 @@ pub mod params; pub mod prepare; pub mod preview; pub mod prompt; +pub mod psbt_builder; pub mod pset_builder; pub mod schema; pub mod state; diff --git a/txmanifest_lib/src/psbt_builder.rs b/txmanifest_lib/src/psbt_builder.rs new file mode 100644 index 0000000..8eb2cf1 --- /dev/null +++ b/txmanifest_lib/src/psbt_builder.rs @@ -0,0 +1,615 @@ +//! Bitcoin PSBT construction — the counterpart to [`crate::pset_builder`]. +//! +//! A separate module rather than a generic one. The two chains share the *shape* of the +//! job (gather inputs, place declared outputs, size the fee, return change) and almost +//! none of its substance: this module has no assets, no blinding, no issuance, no +//! rangeproofs, and no fee output. Roughly two thirds of `pset_builder` is machinery for +//! things Bitcoin does not have, so unifying them would mean a request type whose fields +//! are half-inapplicable on each chain, and a builder threading `if family.is_elements()` +//! through the parts that differ most. +//! +//! What is genuinely common — the input/output vocabulary the lifecycle speaks, and the +//! two-pass fee loop — is mirrored here deliberately, with the same names and the same +//! ordering guarantees, so a reader moving between the two files finds the same landmarks. +//! +//! # The fee is not an output +//! +//! This is the difference that reaches furthest. Elements carries the fee as a real +//! `TxOut`, so the builder *places* it and the transaction balances by construction. +//! Bitcoin defines the fee as inputs minus outputs, so nothing here writes it down: it is +//! whatever is left over, and the builder's job is to make sure that leftover is the +//! number it intended. A bug that would have produced a visibly wrong fee output on +//! Elements produces a silently overpaid fee here — so the balance is asserted explicitly +//! rather than assumed, and [`BuildPsbtResult::fee`] reports what was actually left. + +use std::collections::HashMap; + +use anyhow::{bail, Result}; +use lwk_wollet::elements::bitcoin::{ + absolute::LockTime, + psbt::{Input as PsbtInputData, Output as PsbtOutputData, Psbt}, + transaction::Version, + Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, +}; + +/// Weight units per virtual byte. +const WU_PER_VBYTE: usize = 4; + +/// Assumed witness weight for one taproot script-path covenant input, in weight units. +/// +/// A draft PSBT has no witnesses, so a fee computed from it alone underpays. This is the +/// same allowance [`crate::pset_builder`] makes, for the same reason and with the same +/// caveat: it is an estimate, and a Simplicity witness is not a fixed size. Overshooting +/// costs a slightly high fee; undershooting produces a transaction the network will not +/// relay, so the number errs high. +const COVENANT_WITNESS_WU: usize = 1024; + +/// Assumed witness weight for one key-path (P2TR keyspend) wallet input, in weight units. +/// A BIP341 keyspend witness is one 64-byte signature plus its length prefix. +const KEYSPEND_WITNESS_WU: usize = 66; + +// --------------------------------------------------------------------------- +// Public input/output spec types +// --------------------------------------------------------------------------- + +/// One input to spend. Mirrors `pset_builder::PsetInput` minus the Elements-only arms. +pub enum PsbtInput { + /// A wallet-owned UTXO, spent by key path. + Wallet { + input_id: String, + outpoint: OutPoint, + /// The output being spent. Required, not optional: a taproot sighash commits to + /// every spent output's value and scriptPubKey, so signing without it produces a + /// signature that is simply invalid. + witness_utxo: TxOut, + /// Raw `nSequence` (BIP68 relative timelock). `None` leaves it at `Sequence::MAX`. + sequence: Option, + }, + /// A covenant UTXO, spent through a Simplicity tapleaf. + /// + /// The prevout is reconstructed from `amount` and `script_pubkey` rather than fetched, + /// exactly as `pset_builder::add_covenant_input` does: a taproot sighash commits to a + /// spent output's value and scriptPubKey and nothing else, so those two rebuild it + /// byte-for-byte as far as anything reading it is concerned. An offline run works. + Covenant { + input_id: String, + outpoint: OutPoint, + script_pubkey: ScriptBuf, + amount: u64, + sequence: Option, + }, +} + +impl PsbtInput { + pub fn input_id(&self) -> &str { + match self { + PsbtInput::Wallet { input_id, .. } | PsbtInput::Covenant { input_id, .. } => input_id, + } + } + + /// Value this input brings in, in satoshis. + pub fn amount(&self) -> u64 { + match self { + PsbtInput::Wallet { witness_utxo, .. } => witness_utxo.value.to_sat(), + PsbtInput::Covenant { amount, .. } => *amount, + } + } + + fn sequence(&self) -> Sequence { + let raw = match self { + PsbtInput::Wallet { sequence, .. } | PsbtInput::Covenant { sequence, .. } => *sequence, + }; + raw.map_or(Sequence::MAX, Sequence::from_consensus) + } + + fn outpoint(&self) -> OutPoint { + match self { + PsbtInput::Wallet { outpoint, .. } | PsbtInput::Covenant { outpoint, .. } => *outpoint, + } + } + + /// The output this input spends, real or reconstructed. + fn witness_utxo(&self) -> TxOut { + match self { + PsbtInput::Wallet { witness_utxo, .. } => witness_utxo.clone(), + PsbtInput::Covenant { script_pubkey, amount, .. } => TxOut { + value: Amount::from_sat(*amount), + script_pubkey: script_pubkey.clone(), + }, + } + } + + fn estimated_witness_wu(&self) -> usize { + match self { + PsbtInput::Wallet { .. } => KEYSPEND_WITNESS_WU, + PsbtInput::Covenant { .. } => COVENANT_WITNESS_WU, + } + } +} + +/// One declared output. +pub struct PsbtOutputSpec { + pub script_pubkey: ScriptBuf, + pub amount: u64, +} + +pub struct BuildPsbtRequest { + pub inputs: Vec, + pub outputs: Vec, + pub fee_rate: f32, + /// Where a surplus goes, when the manifest declared a change output. + /// + /// `None` means the action declared none, and any surplus is an error rather than a + /// silently-invented output — the same rule `pset_builder` applies per asset. The + /// alternative is a transaction that moves value the manifest never mentioned. + pub change_script: Option, + /// Transaction-level `nLockTime`. `None` leaves it at zero (no absolute timelock). + pub lock_time: Option, +} + +#[derive(Debug)] +pub struct BuildPsbtResult { + pub psbt: Psbt, + /// What the transaction actually pays in fees — inputs minus outputs. + /// + /// Reported rather than assumed. On Bitcoin the fee is a leftover, so an arithmetic + /// slip does not produce a wrong-looking fee output the way it would on Elements; it + /// produces a correct-looking transaction that overpays. Returning the number lets the + /// caller show it and lets tests assert on it. + pub fee: u64, + /// Index of the change output in the transaction, when one was added. + pub change_index: Option, +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Build an unsigned PSBT for `req`. +/// +/// Two passes, mirroring [`crate::pset_builder::build_pset`]: a draft to measure the +/// transaction, then the real build with the fee that measurement implies. The draft is +/// necessary because the fee depends on the size, and on Bitcoin the size depends on the +/// fee — a change output may appear or vanish as the fee moves. +pub fn build_psbt(req: &BuildPsbtRequest) -> Result { + // Draft with a nominal fee purely to measure. Its change output may differ from the + // final one by a few satoshis, which does not change the transaction's size. + let draft = build_inner(req, 0, Pass::Draft)?; + let fee = estimate_fee_for(&draft.psbt, req); + + let built = build_inner(req, fee, Pass::Final)?; + + // The fee is a leftover here, so verify it rather than trusting the arithmetic that + // produced it. This is the check that has no counterpart in `pset_builder`, where a + // mistake would show up as a visibly wrong fee output. + let in_total = total_in(req); + let out_total: u64 = built + .psbt + .unsigned_tx + .output + .iter() + .map(|o| o.value.to_sat()) + .sum(); + let actual = in_total + .checked_sub(out_total) + .ok_or_else(|| anyhow::anyhow!("outputs exceed inputs after fee placement"))?; + if actual != built.fee { + bail!( + "internal error: transaction pays {actual} sat in fees but {} was intended", + built.fee + ); + } + + Ok(built) +} + +/// Estimate the fee for a transaction shaped like `psbt`, at `req`'s rate. +/// +/// Public because the lifecycle resolves a `fee` keyword in manifest formulas before it +/// commits to amounts — the same role [`crate::pset_builder::estimate_fee`] plays. +pub fn estimate_fee(req: &BuildPsbtRequest) -> Result { + let draft = build_inner(req, 0, Pass::Draft)?; + Ok(estimate_fee_for(&draft.psbt, req)) +} + +/// Which of the two passes a [`build_inner`] call is. +/// +/// The distinction exists for one rule. A draft is built at fee zero, which makes its +/// surplus the largest it can be — so a manifest whose outputs correctly account for the +/// fee looks, at that moment, like it has an unexplained surplus. Enforcing the +/// no-undeclared-change rule there would reject exactly the manifests that got the +/// arithmetic right. The draft exists only to be measured; the rule belongs on the +/// transaction that will actually be broadcast. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Pass { + Draft, + Final, +} + +fn estimate_fee_for(psbt: &Psbt, req: &BuildPsbtRequest) -> u64 { + // `unsigned_tx.weight()` counts a witness-less transaction. Every input will carry a + // witness, so add an allowance for each; without it the fee underpays and the + // transaction does not relay. + let base_wu = psbt.unsigned_tx.weight().to_wu() as usize; + let witness_wu: usize = req.inputs.iter().map(PsbtInput::estimated_witness_wu).sum(); + // A segwit transaction also carries a 2-byte marker+flag, which a witness-less + // serialization omits. + let marker_wu = 2; + let vsize = (base_wu + witness_wu + marker_wu).div_ceil(WU_PER_VBYTE) as f32; + (vsize * req.fee_rate).ceil() as u64 +} + +fn total_in(req: &BuildPsbtRequest) -> u64 { + req.inputs.iter().map(PsbtInput::amount).sum() +} + +// --------------------------------------------------------------------------- +// Construction +// --------------------------------------------------------------------------- + +/// Build the transaction with `fee` as the intended leftover. +/// +/// Declared outputs are appended first, in `req.outputs` order, so a declared output's +/// index in the request is its index in the transaction. Change lands after them. The +/// lifecycle relies on that correspondence to attach per-output data, and +/// `pset_builder::build_inner` guarantees the same thing. +fn build_inner(req: &BuildPsbtRequest, fee: u64, pass: Pass) -> Result { + if req.inputs.is_empty() { + bail!("cannot build a transaction with no inputs"); + } + + let in_total = total_in(req); + let declared_total: u64 = req + .outputs + .iter() + .map(|o| o.amount) + .try_fold(0u64, |acc, a| acc.checked_add(a)) + .ok_or_else(|| anyhow::anyhow!("declared output amounts overflow"))?; + + let spent = declared_total + .checked_add(fee) + .ok_or_else(|| anyhow::anyhow!("outputs plus fee overflow"))?; + let surplus = in_total.checked_sub(spent).ok_or_else(|| { + anyhow::anyhow!( + "inputs total {in_total} sat but outputs plus fee need {spent} sat \ + ({declared_total} declared + {fee} fee): {} sat short", + spent - in_total + ) + })?; + + let mut tx_out: Vec = req + .outputs + .iter() + .map(|o| TxOut { + value: Amount::from_sat(o.amount), + script_pubkey: o.script_pubkey.clone(), + }) + .collect(); + + // Change, and the two ways there is none. + let mut change_index = None; + let mut fee = fee; + if surplus > 0 { + let Some(change_script) = &req.change_script else { + if pass == Pass::Draft { + // Measuring only: no change output would be emitted here anyway, so size + // the draft as-is and let the final pass judge the real surplus. + return finish(req, fee + surplus, tx_out, None); + } + bail!( + "{surplus} sat left over and this action declares no change output; \ + declare one or account for the full input value" + ); + }; + let dust = dust_threshold(change_script); + if surplus >= dust { + change_index = Some(tx_out.len()); + tx_out.push(TxOut { + value: Amount::from_sat(surplus), + script_pubkey: change_script.clone(), + }); + } else { + // A change output below the dust limit is unrelayable, so the surplus has + // nowhere to go but the fee. Recording it keeps `fee` equal to what the + // transaction actually pays, which is what `build_psbt` asserts on. + fee += surplus; + } + } + + finish(req, fee, tx_out, change_index) +} + +/// Assemble the transaction and wrap it in a PSBT. +fn finish( + req: &BuildPsbtRequest, + fee: u64, + output: Vec, + change_index: Option, +) -> Result { + let unsigned_tx = Transaction { + version: Version::TWO, + lock_time: req + .lock_time + .map(LockTime::from_consensus) + .unwrap_or(LockTime::ZERO), + input: req + .inputs + .iter() + .map(|i| TxIn { + previous_output: i.outpoint(), + script_sig: ScriptBuf::new(), + sequence: i.sequence(), + witness: Witness::new(), + }) + .collect(), + output, + }; + + let mut psbt = Psbt::from_unsigned_tx(unsigned_tx) + .map_err(|e| anyhow::anyhow!("cannot start PSBT: {e}"))?; + + for (idx, input) in req.inputs.iter().enumerate() { + psbt.inputs[idx] = PsbtInputData { + witness_utxo: Some(input.witness_utxo()), + ..Default::default() + }; + } + for out in psbt.outputs.iter_mut() { + *out = PsbtOutputData::default(); + } + + Ok(BuildPsbtResult { psbt, fee, change_index }) +} + +/// Minimum relayable value for an output paying `script_pubkey`. +/// +/// Bitcoin Core's rule: the output is dust if its value is below the cost of spending it +/// at the dust relay rate of 3000 sat/kvB. For the witness programs this builder emits — +/// P2TR and P2WPKH — that works out to the familiar 330 and 294 sat. Anything else gets +/// the conservative legacy figure rather than a guess, because emitting an unrelayable +/// change output is worse than folding a few extra satoshis into the fee. +fn dust_threshold(script_pubkey: &ScriptBuf) -> u64 { + if script_pubkey.is_p2tr() { + 330 + } else if script_pubkey.is_p2wpkh() { + 294 + } else if script_pubkey.is_witness_program() { + 330 + } else { + 546 + } +} + +/// Map from input id to its index in the built transaction. +/// +/// Inputs keep `req.inputs` order, so this is positional — but the lifecycle addresses +/// inputs by manifest id when attaching witnesses, and open-coding the lookup at each site +/// is how an off-by-one becomes a signature over the wrong input. +pub fn input_indices(req: &BuildPsbtRequest) -> HashMap { + req.inputs + .iter() + .enumerate() + .map(|(i, inp)| (inp.input_id().to_string(), i)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use lwk_wollet::elements::bitcoin::hashes::Hash as _; + + fn outpoint(n: u8) -> OutPoint { + OutPoint { + txid: lwk_wollet::elements::bitcoin::Txid::from_byte_array([n; 32]), + vout: 0, + } + } + + /// A P2TR script, which is what every output this engine builds actually looks like. + fn p2tr(n: u8) -> ScriptBuf { + let mut v = vec![0x51, 0x20]; + v.extend_from_slice(&[n; 32]); + ScriptBuf::from_bytes(v) + } + + fn wallet_input(id: &str, sats: u64) -> PsbtInput { + PsbtInput::Wallet { + input_id: id.to_string(), + outpoint: outpoint(1), + witness_utxo: TxOut { + value: Amount::from_sat(sats), + script_pubkey: p2tr(9), + }, + sequence: None, + } + } + + fn req(inputs: Vec, outputs: Vec) -> BuildPsbtRequest { + BuildPsbtRequest { + inputs, + outputs, + fee_rate: 1.0, + change_script: Some(p2tr(7)), + lock_time: None, + } + } + + /// The property the whole module exists to get right: on Bitcoin nothing writes the + /// fee down, so it must equal exactly what is left over. + #[test] + fn the_fee_is_the_leftover_and_nothing_else() { + let r = req( + vec![wallet_input("i0", 100_000)], + vec![PsbtOutputSpec { script_pubkey: p2tr(1), amount: 60_000 }], + ); + let built = build_psbt(&r).expect("builds"); + + let out_total: u64 = built.psbt.unsigned_tx.output.iter().map(|o| o.value.to_sat()).sum(); + assert_eq!(100_000 - out_total, built.fee); + // No fee output: Bitcoin has no such thing, and inventing one would be a burn. + assert_eq!(built.psbt.unsigned_tx.output.len(), 2, "declared output + change only"); + } + + #[test] + fn declared_outputs_keep_their_request_order_and_change_lands_after() { + let r = req( + vec![wallet_input("i0", 100_000)], + vec![ + PsbtOutputSpec { script_pubkey: p2tr(1), amount: 10_000 }, + PsbtOutputSpec { script_pubkey: p2tr(2), amount: 20_000 }, + ], + ); + let built = build_psbt(&r).expect("builds"); + let outs = &built.psbt.unsigned_tx.output; + assert_eq!(outs[0].script_pubkey, p2tr(1)); + assert_eq!(outs[1].script_pubkey, p2tr(2)); + assert_eq!(built.change_index, Some(2)); + assert_eq!(outs[2].script_pubkey, p2tr(7)); + } + + /// The same rule `pset_builder` applies per asset: an undeclared surplus is an error, + /// never a silently-invented output. + #[test] + fn a_surplus_with_no_declared_change_is_refused() { + let mut r = req( + vec![wallet_input("i0", 100_000)], + vec![PsbtOutputSpec { script_pubkey: p2tr(1), amount: 10_000 }], + ); + r.change_script = None; + let err = build_psbt(&r).expect_err("surplus with no change output").to_string(); + assert!(err.contains("declares no change output"), "{err}"); + } + + /// Dust change cannot be emitted, so it goes to the fee — and `fee` must say so, or + /// the balance assertion in `build_psbt` would be reporting a number the transaction + /// does not pay. + #[test] + fn dust_change_is_folded_into_the_fee() { + // Leave ~200 sat over: below the 330 sat P2TR dust threshold. + let r = req( + vec![wallet_input("i0", 10_000)], + vec![PsbtOutputSpec { script_pubkey: p2tr(1), amount: 9_600 }], + ); + let built = build_psbt(&r).expect("builds"); + assert_eq!(built.change_index, None, "dust change must not be emitted"); + assert_eq!(built.psbt.unsigned_tx.output.len(), 1); + assert_eq!(built.fee, 10_000 - 9_600); + } + + /// A manifest that accounts for the fee exactly, with no change output declared, must + /// build. + /// + /// This is the case the two-pass structure originally broke: the draft is built at fee + /// zero, so its surplus is the whole fee, and enforcing the no-undeclared-change rule + /// there rejected precisely the manifests that got the arithmetic right. The rule + /// belongs on the transaction that gets broadcast, not on the one built to be measured. + #[test] + fn outputs_that_account_for_the_fee_exactly_need_no_change_output() { + let inputs = 100_000u64; + let mut probe = req( + vec![wallet_input("i0", inputs)], + vec![PsbtOutputSpec { script_pubkey: p2tr(1), amount: 1 }], + ); + probe.change_script = None; + // What the fee will be for a transaction of this shape... + let fee = estimate_fee(&probe).expect("estimates"); + + // ...so declaring outputs that consume exactly the rest must build cleanly. + let mut r = req( + vec![wallet_input("i0", inputs)], + vec![PsbtOutputSpec { script_pubkey: p2tr(1), amount: inputs - fee }], + ); + r.change_script = None; + let built = build_psbt(&r).expect("exact accounting with no change must build"); + assert_eq!(built.change_index, None); + assert_eq!(built.psbt.unsigned_tx.output.len(), 1); + assert_eq!(built.fee, fee); + } + + #[test] + fn insufficient_funds_names_the_shortfall() { + let r = req( + vec![wallet_input("i0", 5_000)], + vec![PsbtOutputSpec { script_pubkey: p2tr(1), amount: 10_000 }], + ); + let err = build_psbt(&r).expect_err("cannot fund").to_string(); + assert!(err.contains("short"), "{err}"); + } + + /// Every input needs a `witness_utxo`, including a covenant input whose prevout was + /// never fetched — a taproot sighash commits to the spent output, so signing without + /// one produces an invalid signature. + #[test] + fn covenant_inputs_carry_a_reconstructed_prevout() { + let r = req( + vec![PsbtInput::Covenant { + input_id: "cov".to_string(), + outpoint: outpoint(3), + script_pubkey: p2tr(4), + amount: 50_000, + sequence: None, + }], + vec![PsbtOutputSpec { script_pubkey: p2tr(1), amount: 40_000 }], + ); + let built = build_psbt(&r).expect("builds"); + let utxo = built.psbt.inputs[0].witness_utxo.as_ref().expect("witness_utxo present"); + assert_eq!(utxo.value.to_sat(), 50_000); + assert_eq!(utxo.script_pubkey, p2tr(4)); + } + + #[test] + fn sequence_and_locktime_are_carried_through() { + let mut r = req( + vec![PsbtInput::Wallet { + input_id: "i0".to_string(), + outpoint: outpoint(1), + witness_utxo: TxOut { value: Amount::from_sat(100_000), script_pubkey: p2tr(9) }, + sequence: Some(144), + }], + vec![PsbtOutputSpec { script_pubkey: p2tr(1), amount: 60_000 }], + ); + r.lock_time = Some(800_000); + let built = build_psbt(&r).expect("builds"); + assert_eq!(built.psbt.unsigned_tx.input[0].sequence.to_consensus_u32(), 144); + assert_eq!(built.psbt.unsigned_tx.lock_time.to_consensus_u32(), 800_000); + + // Default: no relative timelock. + let plain = build_psbt(&req( + vec![wallet_input("i0", 100_000)], + vec![PsbtOutputSpec { script_pubkey: p2tr(1), amount: 60_000 }], + )) + .expect("builds"); + assert_eq!(plain.psbt.unsigned_tx.input[0].sequence, Sequence::MAX); + } + + /// A covenant input must be budgeted a much larger witness than a keyspend, or the + /// fee underpays and the transaction will not relay. + #[test] + fn covenant_inputs_are_budgeted_more_witness_weight() { + let outputs = || vec![PsbtOutputSpec { script_pubkey: p2tr(1), amount: 40_000 }]; + let keyspend = estimate_fee(&req(vec![wallet_input("i0", 100_000)], outputs())).unwrap(); + let covenant = estimate_fee(&req( + vec![PsbtInput::Covenant { + input_id: "cov".to_string(), + outpoint: outpoint(3), + script_pubkey: p2tr(4), + amount: 100_000, + sequence: None, + }], + outputs(), + )) + .unwrap(); + assert!(covenant > keyspend, "covenant {covenant} should cost more than keyspend {keyspend}"); + } + + #[test] + fn input_indices_track_request_order() { + let r = req( + vec![wallet_input("first", 50_000), wallet_input("second", 50_000)], + vec![PsbtOutputSpec { script_pubkey: p2tr(1), amount: 60_000 }], + ); + let idx = input_indices(&r); + assert_eq!(idx["first"], 0); + assert_eq!(idx["second"], 1); + } +} From d6fdc5f16e19d0f61fd6b4c41533a4cdaef33461 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 4 Sep 2026 16:43:28 +0200 Subject: [PATCH 04/16] add esplora scanning --- CHANGELOG.md | 70 +++- txmanifest_lib/src/bitcoin_backend.rs | 530 ++++++++++++++++++++++++++ txmanifest_lib/src/bitcoin_wallet.rs | 402 +++++++++++++++++++ txmanifest_lib/src/config.rs | 50 ++- txmanifest_lib/src/lib.rs | 2 + txmanifest_lib/src/lifecycle.rs | 13 +- txmanifest_lib/src/psbt_builder.rs | 487 ++++++++++++++++++++++- 7 files changed, 1543 insertions(+), 11 deletions(-) create mode 100644 txmanifest_lib/src/bitcoin_backend.rs create mode 100644 txmanifest_lib/src/bitcoin_wallet.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb996a..d9f04e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,53 @@ the aliases `liquid` and `btc`. It was a free-form string that nothing read; are written, and counting it as multi-asset marked `p2pk` and `last_will` unportable when they are the two that port most cleanly. +- **Esplora defaults follow the configured network** across both chains, rather + than choosing between two Liquid URLs on `is_mainnet`. An explicit + `default_esplora` still wins; pointing a Bitcoin wallet at a Liquid instance + would otherwise surface as confusing decode failures rather than an obvious + misconfiguration. + +- **`bitcoin_backend` — Esplora chain access for Bitcoin.** Esplora serves + Bitcoin and Liquid from the same REST shape, so only the base URL differs + (`/api` vs `/liquid/api`) — but `lwk_wollet`'s client decodes Elements + transactions, whose outputs carry asset ids and commitments no Bitcoin response + has. Built on `ureq`, which this crate already used to POST transactions. + + Response decoding is split from fetching, as free functions over `&str`: the + HTTP calls cannot be unit-tested, and the decoding is where the mistakes live — + an amount read as a float, a txid byte order flipped, a missing field defaulted + to zero. The fixtures include a response captured verbatim from + `blockstream.info/signet/api`, so the decoder is checked against what Esplora + actually sends rather than against a fixture written from the same assumptions + as the code. + + Scanning ends on a gap of unused addresses measured by transaction *history*, + not by the presence of UTXOs. The distinction is not hypothetical: the BIP86 + test mnemonic's first signet address has 153 transactions and an empty UTXO + set, and a scan keyed on UTXOs would call it unused and stop early. Regtest has + no default URL, so an operator configures one rather than being pointed at + somebody else's chain. + +- **`bitcoin_wallet` — BIP86 key derivation, addresses and signing** on + `rust-bitcoin` directly rather than on a wallet framework. What this engine + asks of a wallet is small: derive a key, produce an address, sign a hash, know + which UTXOs are ours. Covenant inputs are self-describing and the descriptor is + single-key, so a framework would mostly contribute a descriptor language, + persistence model and coin-selection policy that none of this uses. + + Addresses are single-key P2TR with no script tree, checked against the vectors + published in BIP86 rather than against this implementation's own output — a + wrong derivation still produces valid-looking addresses the wallet will hand + out and watch and then be unable to spend from, and nothing catches that except + an external reference. + + Key-path and covenant signing are separate methods because they are not + interchangeable: a key-path spend must be signed with the *tweaked* key the + output commits to, while a Simplicity program checks against the *untweaked* + key baked into it. Both directions are tested, including that each signature + fails to verify against the other key. `Debug` is written by hand and redacts + the root key, since `Xpriv`'s own `Debug` prints spendable material. + - **`psbt_builder` — Bitcoin transaction construction.** The counterpart to `pset_builder`, as a separate module rather than a generic one: the two chains share the shape of the job and almost none of its substance, and roughly two @@ -88,7 +135,28 @@ the aliases `liquid` and `btc`. It was a free-form string that nothing read; `BuildPsbtResult::fee` reports what was actually left over. Change below the dust threshold folds into the fee, and the reported number says so. - Not yet wired into `lifecycle` — see below. + Also carries the Bitcoin **signing** path: BIP341 key-path sighashes, + signatures stored as `tap_key_sig`, and a finalizer that turns each into a + one-element witness. Only the inputs a caller names are touched, so a + transaction mixing wallet and covenant inputs can be signed here and have its + covenant inputs finalized by `covenant` without either clobbering the other. + Computing a sighash requires *every* input's prevout — a taproot sighash + commits to all spent outputs, so one missing `witness_utxo` would silently + change every signature — and a missing one is refused rather than worked + around. + + `from_pset_request` narrows the Elements request the lifecycle already + assembles into a Bitcoin one. That assembly is ~800 lines of destination + resolution, covenant address derivation and state metadata, almost none of it + chain-specific, so there is one assembly path and the chains part company at + the build boundary rather than in two copies that drift. The conversion is a + narrowing, not a translation: assets, issuance, blinding and confidential + outputs are **refused rather than dropped**. `validate` already rejects those + on a Bitcoin manifest, so anything arriving here with them set got past a check + that should have caught it, and silently ignoring it would turn a bug in that + check into a transaction meaning something other than the manifest said. + + Not yet dispatched from `lifecycle` — that remains the last integration step. - **`capabilities` command and `Manifest::supported_by` — the support check `requires` exists for.** A third-party wallet answers "do I handle this file" diff --git a/txmanifest_lib/src/bitcoin_backend.rs b/txmanifest_lib/src/bitcoin_backend.rs new file mode 100644 index 0000000..fe2fc73 --- /dev/null +++ b/txmanifest_lib/src/bitcoin_backend.rs @@ -0,0 +1,530 @@ +//! Bitcoin chain access over Esplora — the counterpart to [`crate::backend`]. +//! +//! Esplora serves Bitcoin and Liquid from the same REST shape, so the API knowledge +//! carries over even though the client does not: `lwk_wollet`'s `EsploraClient` decodes +//! Elements transactions, whose outputs carry asset ids and commitments that no Bitcoin +//! response has. Only the base URL differs between the two — `/api` versus `/liquid/api`. +//! +//! Built on `ureq`, which this crate already uses to POST transactions, rather than on a +//! wallet framework's client. The surface needed here is four endpoints. +//! +//! # Parsing is separated from fetching +//! +//! Every response is decoded by a free function taking `&str`, with the HTTP call kept +//! separate. That is deliberate: the network calls cannot be exercised in a unit test, and +//! the decoding is where the mistakes actually live — a satoshi amount read as a float, a +//! txid byte order flipped, a missing field defaulted to zero. Splitting them means the +//! risky half is tested against captured responses and the untested half is a URL and a +//! `GET`. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use lwk_wollet::elements::bitcoin::{ + consensus::encode::{deserialize, serialize_hex}, + Address, Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid, +}; +use serde::Deserialize; + +use crate::bitcoin_wallet::{BitcoinWallet, Branch}; +use crate::chain::Network; + +/// How many consecutive unused addresses end a scan. +/// +/// The BIP44 standard value. Raising it costs one request per extra address; lowering it +/// risks missing funds sent to an address past the gap, which is unrecoverable by scanning +/// alone. +pub const DEFAULT_GAP_LIMIT: u32 = 20; + +/// Esplora's default base URLs, per network. +/// +/// `None` for regtest: there is no public instance, so the operator must configure one +/// rather than be silently pointed at somebody else's chain. +pub fn default_esplora_url(network: Network) -> Option<&'static str> { + match network { + Network::Bitcoin => Some("https://blockstream.info/api"), + Network::BitcoinTestnet => Some("https://blockstream.info/testnet/api"), + Network::BitcoinSignet => Some("https://blockstream.info/signet/api"), + Network::BitcoinRegtest => None, + // Elements networks are served by `crate::backend`; their URLs live in `config`. + _ => None, + } +} + +/// One unspent output belonging to the wallet. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Utxo { + pub outpoint: OutPoint, + pub value: u64, + /// The scriptPubKey paying this output. + /// + /// Filled in from the address that was queried rather than read from the response — + /// Esplora's UTXO listing omits it, and it is not optional downstream: a taproot + /// sighash commits to it, so a spend built without it is unsignable. + pub script_pubkey: ScriptBuf, + /// Which branch and index derived this output's key, so a signer can find it again. + pub branch: Branch, + pub index: u32, + /// Block height, or `None` while unconfirmed. + pub height: Option, +} + +impl Utxo { + pub fn txout(&self) -> TxOut { + TxOut { + value: Amount::from_sat(self.value), + script_pubkey: self.script_pubkey.clone(), + } + } + + pub fn is_confirmed(&self) -> bool { + self.height.is_some() + } +} + +// --------------------------------------------------------------------------- +// Wire types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct WireStatus { + #[serde(default)] + confirmed: bool, + #[serde(default)] + block_height: Option, +} + +#[derive(Debug, Deserialize)] +struct WireUtxo { + txid: String, + vout: u32, + /// Satoshis. Esplora sends an integer here; `u64` rather than `f64` so a value that + /// somehow arrives as a float is a parse error rather than a silently rounded amount. + value: u64, + status: WireStatus, +} + +#[derive(Debug, Deserialize)] +struct WireStats { + #[serde(default)] + tx_count: u64, +} + +#[derive(Debug, Deserialize)] +struct WireAddress { + #[serde(default)] + chain_stats: Option, + #[serde(default)] + mempool_stats: Option, +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/// Decode `GET /address/{addr}/utxo`. +/// +/// `script_pubkey`, `branch` and `index` come from the caller, because the response +/// describes outputs without saying whose they are — the request already settled that. +fn parse_utxos(body: &str, script_pubkey: &ScriptBuf, branch: Branch, index: u32) -> Result> { + let wire: Vec = + serde_json::from_str(body).context("cannot decode Esplora UTXO listing")?; + wire.into_iter() + .map(|u| { + // Esplora prints txids in the reversed (display) order that `Txid`'s FromStr + // expects, so parsing the string is correct where reading raw bytes would not + // be. + let txid: Txid = u.txid.parse().with_context(|| format!("bad txid {:?}", u.txid))?; + Ok(Utxo { + outpoint: OutPoint { txid, vout: u.vout }, + value: u.value, + script_pubkey: script_pubkey.clone(), + branch, + index, + height: if u.status.confirmed { u.status.block_height } else { None }, + }) + }) + .collect() +} + +/// Decode `GET /address/{addr}`, returning whether the address has ever been used. +/// +/// Mempool activity counts. An address with an unconfirmed payment is used, and treating +/// it as free would hand it out again and merge two payments into one address. +fn parse_address_used(body: &str) -> Result { + let wire: WireAddress = + serde_json::from_str(body).context("cannot decode Esplora address stats")?; + let chain = wire.chain_stats.map_or(0, |s| s.tx_count); + let mempool = wire.mempool_stats.map_or(0, |s| s.tx_count); + Ok(chain + mempool > 0) +} + +/// Decode `GET /blocks/tip/height`, which is a bare number in the body. +fn parse_tip_height(body: &str) -> Result { + body.trim() + .parse() + .with_context(|| format!("cannot decode tip height from {:?}", body.trim())) +} + +/// Decode `GET /fee-estimates`: `{"": , ...}`. +/// +/// Returns the estimate for the smallest target at or above `target_blocks`. Esplora does +/// not promise every target is present, so picking the nearest available one above the +/// request errs toward confirming sooner rather than failing. +fn parse_fee_estimate(body: &str, target_blocks: u16) -> Result> { + let map: HashMap = + serde_json::from_str(body).context("cannot decode Esplora fee estimates")?; + let mut best: Option<(u16, f64)> = None; + for (k, v) in map { + let Ok(blocks) = k.parse::() else { continue }; + if blocks < target_blocks { + continue; + } + if best.is_none_or(|(b, _)| blocks < b) { + best = Some((blocks, v)); + } + } + Ok(best.map(|(_, rate)| rate as f32)) +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/// A blocking Esplora client for Bitcoin. +pub struct EsploraClient { + base_url: String, + agent: ureq::Agent, +} + +impl std::fmt::Debug for EsploraClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EsploraClient").field("base_url", &self.base_url).finish() + } +} + +impl EsploraClient { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_string(), + agent: ureq::AgentBuilder::new().build(), + } + } + + /// The client for `network`'s default Esplora instance. + pub fn for_network(network: Network) -> Result { + let url = default_esplora_url(network).ok_or_else(|| { + anyhow::anyhow!( + "no default Esplora instance for '{network}'; set one in the wallet config" + ) + })?; + Ok(Self::new(url)) + } + + fn get(&self, path: &str) -> Result { + let url = format!("{}/{}", self.base_url, path.trim_start_matches('/')); + match self.agent.get(&url).call() { + Ok(resp) => resp + .into_string() + .with_context(|| format!("cannot read response body from {url}")), + Err(ureq::Error::Status(status, resp)) => { + let body = resp.into_string().unwrap_or_default(); + anyhow::bail!("GET {url} failed with HTTP {status}: {}", body.trim()) + } + Err(e) => anyhow::bail!("GET {url} failed: {e}"), + } + } + + /// Unspent outputs paying `address`. + pub fn utxos(&self, address: &Address, branch: Branch, index: u32) -> Result> { + let body = self.get(&format!("address/{address}/utxo"))?; + parse_utxos(&body, &address.script_pubkey(), branch, index) + } + + /// Whether `address` has any transaction history, confirmed or in the mempool. + pub fn address_used(&self, address: &Address) -> Result { + parse_address_used(&self.get(&format!("address/{address}"))?) + } + + pub fn tip_height(&self) -> Result { + parse_tip_height(&self.get("blocks/tip/height")?) + } + + /// Fee rate in sat/vB for confirmation within `target_blocks`, if Esplora offers one. + pub fn fee_estimate(&self, target_blocks: u16) -> Result> { + parse_fee_estimate(&self.get("fee-estimates")?, target_blocks) + } + + /// Fetch a whole transaction. + pub fn transaction(&self, txid: Txid) -> Result { + let hex = self.get(&format!("tx/{txid}/hex"))?; + let bytes = hex_to_bytes(hex.trim()) + .with_context(|| format!("Esplora returned non-hex for tx {txid}"))?; + deserialize(&bytes).with_context(|| format!("cannot decode transaction {txid}")) + } + + /// One output of an on-chain transaction. + /// + /// `None` when the transaction exists but has no such output, which is a caller error + /// rather than a network condition and so is distinguished from a failure. + pub fn txout(&self, outpoint: OutPoint) -> Result> { + let tx = self.transaction(outpoint.txid)?; + Ok(tx.output.get(outpoint.vout as usize).cloned()) + } + + /// Broadcast a signed transaction, returning its txid. + pub fn broadcast(&self, tx: &Transaction) -> Result { + let url = format!("{}/tx", self.base_url); + let hex = serialize_hex(tx); + let resp = match self.agent.post(&url).set("Content-Type", "text/plain").send_string(&hex) { + Ok(r) => r.into_string().unwrap_or_default(), + Err(ureq::Error::Status(status, r)) => { + let body = r.into_string().unwrap_or_default(); + // Esplora returns the node's own rejection reason here, which is the only + // useful thing in the failure, so it is surfaced verbatim. + anyhow::bail!("broadcast rejected (HTTP {status}): {}", body.trim()) + } + Err(e) => anyhow::bail!("broadcast failed: {e}"), + }; + let returned: Txid = resp + .trim() + .parse() + .with_context(|| format!("Esplora returned {:?} instead of a txid", resp.trim()))?; + // The node echoes the txid it accepted. If it differs from what was sent, something + // rewrote the transaction, and reporting the sent txid would mean tracking one that + // does not exist. + let expected = tx.compute_txid(); + if returned != expected { + anyhow::bail!("broadcast returned txid {returned}, but the sent transaction is {expected}"); + } + Ok(returned) + } + + /// Scan a wallet's addresses and return every unspent output. + /// + /// Walks both branches from index 0, stopping after `gap_limit` consecutive addresses + /// with no history. Address *history* ends the scan rather than the presence of UTXOs: + /// an address that received and then spent has no UTXOs but is plainly used, and + /// treating it as free would stop the scan early and hide funds beyond it. + pub fn scan(&self, wallet: &BitcoinWallet, gap_limit: u32) -> Result> { + let mut found = Vec::new(); + for branch in [Branch::Receive, Branch::Change] { + let mut gap = 0; + let mut index = 0u32; + while gap < gap_limit { + let address = wallet.address(branch, index)?; + if self.address_used(&address)? { + gap = 0; + found.extend(self.utxos(&address, branch, index)?); + } else { + gap += 1; + } + index += 1; + } + } + Ok(found) + } + + /// The first address on `branch` with no history — the next one safe to hand out. + pub fn next_unused(&self, wallet: &BitcoinWallet, branch: Branch) -> Result<(Address, u32)> { + let mut index = 0u32; + loop { + let address = wallet.address(branch, index)?; + if !self.address_used(&address)? { + return Ok((address, index)); + } + index += 1; + } + } +} + +/// Decode a hex string. Written here rather than pulled in, since this is the only place +/// the crate parses hex from the network. +fn hex_to_bytes(s: &str) -> Result> { + if !s.len().is_multiple_of(2) { + anyhow::bail!("hex string has an odd length"); + } + (0..s.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&s[i..i + 2], 16) + .map_err(|e| anyhow::anyhow!("bad hex at byte {}: {e}", i / 2)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spk() -> ScriptBuf { + let mut v = vec![0x51, 0x20]; + v.extend_from_slice(&[0xab; 32]); + ScriptBuf::from_bytes(v) + } + + /// A captured `GET /address/{addr}/utxo` response, confirmed and unconfirmed. + const UTXO_BODY: &str = r#"[ + {"txid":"5e3a7b1c8f2d4e6a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a", + "vout":1, + "status":{"confirmed":true,"block_height":812345, + "block_hash":"0000000000000000000000000000000000000000000000000000000000000000", + "block_time":1690000000}, + "value":123456}, + {"txid":"1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809", + "vout":0, + "status":{"confirmed":false}, + "value":7} + ]"#; + + #[test] + fn utxos_decode_with_amounts_and_confirmation_intact() { + let utxos = parse_utxos(UTXO_BODY, &spk(), Branch::Receive, 3).expect("decodes"); + assert_eq!(utxos.len(), 2); + + assert_eq!(utxos[0].value, 123_456); + assert_eq!(utxos[0].outpoint.vout, 1); + assert_eq!(utxos[0].height, Some(812_345)); + assert!(utxos[0].is_confirmed()); + + // Unconfirmed: no height, even though the response carries no `block_height` key. + assert_eq!(utxos[1].value, 7); + assert_eq!(utxos[1].height, None); + assert!(!utxos[1].is_confirmed()); + + // The scriptPubKey and derivation come from the request, since the response omits + // them — and without the scriptPubKey the output cannot be signed for. + assert_eq!(utxos[0].script_pubkey, spk()); + assert_eq!(utxos[0].txout().script_pubkey, spk()); + assert_eq!((utxos[0].branch, utxos[0].index), (Branch::Receive, 3)); + } + + /// Esplora prints txids in reversed (display) order, so the decoded id must match what + /// the string says — reading raw bytes instead would flip it and produce an outpoint + /// that names no transaction. + #[test] + fn txids_keep_their_display_byte_order() { + let utxos = parse_utxos(UTXO_BODY, &spk(), Branch::Receive, 0).unwrap(); + assert_eq!( + utxos[0].outpoint.txid.to_string(), + "5e3a7b1c8f2d4e6a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a" + ); + } + + /// A fractional value would be a rounded amount if it were accepted. Refusing it is + /// the safe direction: an amount off by a satoshi makes every signature invalid. + #[test] + fn a_non_integer_value_is_refused_rather_than_rounded() { + let body = r#"[{"txid":"5e3a7b1c8f2d4e6a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a", + "vout":0,"status":{"confirmed":true,"block_height":1},"value":1.5}]"#; + assert!(parse_utxos(body, &spk(), Branch::Receive, 0).is_err()); + } + + /// A response captured verbatim from `blockstream.info/signet/api`, so the decoder is + /// checked against what Esplora actually sends rather than against a fixture written + /// from the same assumptions as the code. + /// + /// Note the amount: 2_503_134_036 sat exceeds `f64`'s exact-integer comfort only + /// slightly, but it is the kind of value that a float-typed decoder would eventually + /// round — and an amount off by one satoshi invalidates every signature over it. + const LIVE_SIGNET_UTXO_BODY: &str = r#"[ + {"txid":"2d3f2a2a71f12377fd502c2555be9f43e12b207bbc48c9905814e824034dc348", + "vout":0, + "status":{"confirmed":true,"block_height":320630, + "block_hash":"00000010ad7530a587c7fd927e6e3346eb90f6162e0e07fb22dd6382aeed9f95", + "block_time":1788504191}, + "value":2503134036} + ]"#; + + #[test] + fn a_captured_live_response_decodes() { + let utxos = parse_utxos(LIVE_SIGNET_UTXO_BODY, &spk(), Branch::Change, 1).expect("decodes"); + assert_eq!(utxos.len(), 1); + assert_eq!(utxos[0].value, 2_503_134_036); + assert_eq!(utxos[0].height, Some(320_630)); + assert_eq!( + utxos[0].outpoint.txid.to_string(), + "2d3f2a2a71f12377fd502c2555be9f43e12b207bbc48c9905814e824034dc348" + ); + // Fields Esplora sends but this decoder ignores must not make it fail. + assert!(utxos[0].is_confirmed()); + } + + /// An address with history but no unspent outputs — the case that makes the gap limit + /// count *history* rather than UTXOs. + /// + /// Not hypothetical: the BIP86 test mnemonic's first signet address has 153 + /// transactions and an empty UTXO set. A scan keyed on UTXO presence would treat it as + /// unused, and with enough such addresses in a row would stop early and miss funds + /// beyond them. + #[test] + fn a_used_address_with_no_utxos_still_counts_as_used() { + let stats = r#"{"chain_stats":{"funded_txo_count":141,"spent_txo_count":141,"tx_count":153}, + "mempool_stats":{"tx_count":0}}"#; + assert!(parse_address_used(stats).unwrap()); + assert!(parse_utxos("[]", &spk(), Branch::Receive, 0).unwrap().is_empty()); + } + + #[test] + fn address_use_counts_mempool_as_well_as_chain() { + let unused = r#"{"chain_stats":{"tx_count":0},"mempool_stats":{"tx_count":0}}"#; + assert!(!parse_address_used(unused).unwrap()); + + let confirmed = r#"{"chain_stats":{"tx_count":2},"mempool_stats":{"tx_count":0}}"#; + assert!(parse_address_used(confirmed).unwrap()); + + // An unconfirmed payment makes the address used. Handing it out again would merge + // two payments onto one address. + let pending = r#"{"chain_stats":{"tx_count":0},"mempool_stats":{"tx_count":1}}"#; + assert!(parse_address_used(pending).unwrap()); + } + + #[test] + fn tip_height_decodes_a_bare_number() { + assert_eq!(parse_tip_height("812345\n").unwrap(), 812_345); + assert!(parse_tip_height("not a height").is_err()); + } + + /// Esplora offers a sparse set of targets, so the nearest one at or above the request + /// is used — erring toward confirming sooner rather than failing outright. + #[test] + fn fee_estimates_pick_the_nearest_target_at_or_above_the_request() { + let body = r#"{"1":30.5,"3":12.0,"6":6.25,"144":1.0}"#; + assert_eq!(parse_fee_estimate(body, 1).unwrap(), Some(30.5)); + assert_eq!(parse_fee_estimate(body, 3).unwrap(), Some(12.0)); + // 4 is not offered; 6 is the next one up. + assert_eq!(parse_fee_estimate(body, 4).unwrap(), Some(6.25)); + // Nothing slow enough: report absence rather than substituting a faster rate, + // which would silently overpay. + assert_eq!(parse_fee_estimate(body, 1000).unwrap(), None); + } + + #[test] + fn default_urls_cover_the_public_networks_and_refuse_regtest() { + assert_eq!( + default_esplora_url(Network::Bitcoin), + Some("https://blockstream.info/api") + ); + assert_eq!( + default_esplora_url(Network::BitcoinSignet), + Some("https://blockstream.info/signet/api") + ); + // No public regtest instance: an operator must say where theirs is, rather than + // being pointed at somebody else's chain. + assert_eq!(default_esplora_url(Network::BitcoinRegtest), None); + assert!(EsploraClient::for_network(Network::BitcoinRegtest).is_err()); + } + + #[test] + fn base_urls_are_normalised_so_paths_do_not_double_up() { + let c = EsploraClient::new("https://example.invalid/api/"); + assert_eq!(c.base_url, "https://example.invalid/api"); + } + + #[test] + fn hex_decoding_rejects_malformed_input() { + assert_eq!(hex_to_bytes("00ff10").unwrap(), vec![0x00, 0xff, 0x10]); + assert!(hex_to_bytes("abc").is_err(), "odd length"); + assert!(hex_to_bytes("zz").is_err(), "non-hex digits"); + } +} diff --git a/txmanifest_lib/src/bitcoin_wallet.rs b/txmanifest_lib/src/bitcoin_wallet.rs new file mode 100644 index 0000000..7977dcf --- /dev/null +++ b/txmanifest_lib/src/bitcoin_wallet.rs @@ -0,0 +1,402 @@ +//! Bitcoin key derivation, addresses and signing — the counterpart to the LWK-backed +//! half of [`crate::wallet`]. +//! +//! Built directly on `rust-bitcoin` rather than on a wallet framework. What this engine +//! actually asks of a wallet is small: derive a key, produce an address, sign a hash, and +//! know which UTXOs are ours. Covenant inputs are self-describing — a taproot sighash +//! commits to a spent output's value and scriptPubKey and nothing else, so +//! `psbt_builder` reconstructs their prevouts rather than looking them up — and the +//! descriptor is single-key. A framework would bring its own descriptor language, +//! persistence model and coin-selection policy, none of which this engine would use. +//! +//! # BIP86, not a choice +//! +//! Addresses are single-key P2TR under BIP86: `m/86'/coin'/account'/change/index`, with +//! the derived key as the taproot internal key and **no script tree**, so the output key +//! is `internal + H_TapTweak(internal)·G`. +//! +//! This is the one part of the module that must not be improvised. An address derived a +//! slightly different way — a different path, or the untweaked key used directly — is +//! still a perfectly valid address that this wallet will happily hand out, watch, and +//! never be able to spend from, because the key it signs with does not match the output. +//! Nothing catches that except getting it right, so the tests below check against the +//! published BIP86 vectors rather than against this implementation's own output. + +use std::str::FromStr; + +use anyhow::{Context, Result}; +use lwk_wollet::elements::bitcoin::{ + self, + bip32::{ChildNumber, DerivationPath, Fingerprint, Xpriv, Xpub}, + key::{Keypair, TapTweak, TweakedKeypair}, + secp256k1::{All, Message, Secp256k1}, + Address, ScriptBuf, XOnlyPublicKey, +}; + +use crate::chain::{ChainFamily, Network}; + +/// BIP86 purpose: single-key P2TR. +const PURPOSE: u32 = 86; + +/// SLIP-44 coin type. `0'` is Bitcoin mainnet; every test network shares `1'`. +const COIN_MAINNET: u32 = 0; +const COIN_TESTNET: u32 = 1; + +/// Which branch of an account a key sits on. A bool would read as `derive(.., true, 0)` at +/// the call site, where the reader has to remember which way round it goes — and handing +/// out a change address as a receive address is the kind of mistake that is invisible +/// until someone audits the wallet's history. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Branch { + /// Addresses handed out to receive funds. + Receive, + /// Addresses that take a transaction's surplus. + Change, +} + +impl Branch { + fn index(self) -> u32 { + match self { + Branch::Receive => 0, + Branch::Change => 1, + } + } +} + +/// A single-account BIP86 Bitcoin wallet derived from a mnemonic. +pub struct BitcoinWallet { + root: Xpriv, + network: Network, + account: u32, + secp: Secp256k1, +} + +impl BitcoinWallet { + /// Derive a wallet from a BIP39 mnemonic. + /// + /// No passphrase. The Elements side does not take one either, and accepting one here + /// would mean the same mnemonic yields different wallets on the two chains for reasons + /// invisible in the wallet file. + pub fn from_mnemonic(mnemonic: &str, network: Network) -> Result { + if network.family() != ChainFamily::Bitcoin { + anyhow::bail!( + "BitcoinWallet cannot be built for '{network}', which is an Elements network" + ); + } + let parsed = bip39::Mnemonic::parse(mnemonic.trim()) + .map_err(|e| anyhow::anyhow!("invalid mnemonic: {e}"))?; + let seed = parsed.to_seed(""); + let root = Xpriv::new_master(bitcoin_network(network), &seed) + .map_err(|e| anyhow::anyhow!("cannot derive master key: {e}"))?; + Ok(Self { root, network, account: 0, secp: Secp256k1::new() }) + } + + pub fn network(&self) -> Network { + self.network + } + + /// `m/86'/coin'/account'` — the account this wallet's addresses hang off. + pub fn account_path(&self) -> DerivationPath { + let coin = if self.network.is_mainnet() { COIN_MAINNET } else { COIN_TESTNET }; + DerivationPath::from(vec![ + hardened(PURPOSE), + hardened(coin), + hardened(self.account), + ]) + } + + /// Full path to one address key: `m/86'/coin'/account'/branch/index`. + pub fn key_path(&self, branch: Branch, index: u32) -> DerivationPath { + self.account_path().extend([ + ChildNumber::from_normal_idx(branch.index()).expect("branch index is in range"), + ChildNumber::from_normal_idx(index).expect("address index is in range"), + ]) + } + + /// The keypair at one address path, untweaked — the taproot *internal* key. + /// + /// Signing a key-path spend requires the tweaked keypair, not this one; see + /// [`Self::sign_key_path`]. This is exposed for callers that need the internal key + /// itself, such as building a control block. + pub fn keypair(&self, branch: Branch, index: u32) -> Result { + let xpriv = self + .root + .derive_priv(&self.secp, &self.key_path(branch, index)) + .map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?; + Ok(Keypair::from_secret_key(&self.secp, &xpriv.private_key)) + } + + /// The x-only internal key at one address path. + pub fn internal_key(&self, branch: Branch, index: u32) -> Result { + Ok(self.keypair(branch, index)?.x_only_public_key().0) + } + + /// The BIP86 address at one path: P2TR over the internal key with no script tree. + pub fn address(&self, branch: Branch, index: u32) -> Result
{ + let internal = self.internal_key(branch, index)?; + Ok(Address::p2tr( + &self.secp, + internal, + // No merkle root. This is what makes it BIP86 rather than an arbitrary P2TR: + // the output key commits to an empty script tree, so only the key path spends. + None, + bitcoin_network(self.network), + )) + } + + pub fn script_pubkey(&self, branch: Branch, index: u32) -> Result { + Ok(self.address(branch, index)?.script_pubkey()) + } + + /// Sign `sighash` for a key-path spend of the output at `branch`/`index`. + /// + /// The signature must be made with the **tweaked** key, because that is the key the + /// output commits to. Signing with the internal key produces a well-formed BIP340 + /// signature that simply does not verify — which is why the tweak happens here rather + /// than being left to the caller to remember. + pub fn sign_key_path(&self, branch: Branch, index: u32, sighash: &[u8; 32]) -> Result<[u8; 64]> { + let keypair = self.keypair(branch, index)?; + let tweaked: TweakedKeypair = keypair.tap_tweak(&self.secp, None); + let msg = Message::from_digest(*sighash); + let sig = self.secp.sign_schnorr_no_aux_rand(&msg, &tweaked.to_keypair()); + Ok(sig.serialize()) + } + + /// Sign `sighash` with the raw (untweaked) key at an arbitrary path. + /// + /// This is what a covenant witness wants: a Simplicity program checks a BIP340 + /// signature against a public key baked into the program, and that key is the + /// untweaked one. Distinct from [`Self::sign_key_path`] for exactly that reason — + /// the two produce different signatures and are not interchangeable. + pub fn sign_with_path(&self, path: &str, sighash: &[u8; 32]) -> Result<[u8; 64]> { + let dp = DerivationPath::from_str(path) + .with_context(|| format!("invalid derivation path '{path}'"))?; + let xpriv = self + .root + .derive_priv(&self.secp, &dp) + .map_err(|e| anyhow::anyhow!("key derivation failed for '{path}': {e}"))?; + let keypair = Keypair::from_secret_key(&self.secp, &xpriv.private_key); + let msg = Message::from_digest(*sighash); + Ok(self.secp.sign_schnorr_no_aux_rand(&msg, &keypair).serialize()) + } + + /// The x-only public key at an arbitrary path, as 64 hex chars. + /// + /// Mirrors [`crate::wallet::derive_schnorr_pubkey`] so a manifest's `wallet` witness + /// resolves the same way on both chains. + pub fn schnorr_pubkey_at(&self, path: &str) -> Result { + let dp = DerivationPath::from_str(path) + .with_context(|| format!("invalid derivation path '{path}'"))?; + let xpriv = self + .root + .derive_priv(&self.secp, &dp) + .map_err(|e| anyhow::anyhow!("key derivation failed for '{path}': {e}"))?; + let keypair = Keypair::from_secret_key(&self.secp, &xpriv.private_key); + Ok(format!("{}", keypair.x_only_public_key().0)) + } + + pub fn master_fingerprint(&self) -> Fingerprint { + self.root.fingerprint(&self.secp) + } + + /// The account-level xpub, for watch-only export. + pub fn account_xpub(&self) -> Result { + let xpriv = self + .root + .derive_priv(&self.secp, &self.account_path()) + .map_err(|e| anyhow::anyhow!("account derivation failed: {e}"))?; + Ok(Xpub::from_priv(&self.secp, &xpriv)) + } + + /// Output descriptor for this account, in the form other wallets accept. + /// + /// `tr(...)` with no script tree, matching what [`Self::address`] builds. Emitted + /// without a checksum, which every consumer this engine targets computes itself. + pub fn descriptor(&self) -> Result { + Ok(format!( + "tr([{}/{}]{}/<0;1>/*)", + self.master_fingerprint(), + self.account_path().to_string().trim_start_matches("m/"), + self.account_xpub()? + )) + } +} + +/// Redacted by hand rather than derived. `Xpriv`'s own `Debug` prints the extended +/// private key, so a derived impl would put spendable key material into any log line, +/// panic message or `{:?}` a caller reaches for — including the assertion messages in +/// this module's own tests. +impl std::fmt::Debug for BitcoinWallet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BitcoinWallet") + .field("network", &self.network) + .field("account", &self.account) + .field("fingerprint", &self.master_fingerprint()) + .field("root", &"") + .finish() + } +} + +fn hardened(n: u32) -> ChildNumber { + ChildNumber::from_hardened_idx(n).expect("constant index is in range") +} + +/// The `rust-bitcoin` network for one of our Bitcoin networks. +/// +/// Panics for an Elements network, which [`BitcoinWallet::from_mnemonic`] has already +/// refused — the two cannot be confused past that point. +fn bitcoin_network(network: Network) -> bitcoin::Network { + match network { + Network::Bitcoin => bitcoin::Network::Bitcoin, + Network::BitcoinTestnet => bitcoin::Network::Testnet, + Network::BitcoinSignet => bitcoin::Network::Signet, + Network::BitcoinRegtest => bitcoin::Network::Regtest, + other => unreachable!("{other} is not a Bitcoin network"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The BIP86 test mnemonic. + const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about"; + + fn wallet() -> BitcoinWallet { + BitcoinWallet::from_mnemonic(MNEMONIC, Network::Bitcoin).expect("wallet builds") + } + + /// Checked against the vectors published in BIP86 itself, not against this code's own + /// output. + /// + /// This is the assertion the module turns on. A wrong derivation still produces valid + /// addresses that this wallet will hand out and watch, and the error only becomes + /// visible when a spend fails — by which point the funds are at an address whose key + /// this wallet cannot reproduce. Comparing against an external source is the only way + /// to catch it. + #[test] + fn addresses_match_the_published_bip86_vectors() { + let w = wallet(); + + assert_eq!( + w.address(Branch::Receive, 0).unwrap().to_string(), + "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr" + ); + assert_eq!( + w.address(Branch::Receive, 1).unwrap().to_string(), + "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh" + ); + assert_eq!( + w.address(Branch::Change, 0).unwrap().to_string(), + "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7" + ); + } + + /// BIP86 also publishes the internal (untweaked) key for each address. Checking it + /// separately localises a failure: a wrong internal key is a derivation-path bug, a + /// right internal key with a wrong address is a tweak bug. + #[test] + fn internal_keys_match_the_published_bip86_vectors() { + let w = wallet(); + assert_eq!( + w.internal_key(Branch::Receive, 0).unwrap().to_string(), + "cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" + ); + assert_eq!( + w.internal_key(Branch::Receive, 1).unwrap().to_string(), + "83dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145" + ); + assert_eq!( + w.internal_key(Branch::Change, 0).unwrap().to_string(), + "399f1b2f4393f29a18c937859c5dd8a77350103157eb880f02e8c08214277cef" + ); + } + + #[test] + fn account_path_is_bip86_and_coin_type_follows_the_network() { + assert_eq!(wallet().account_path().to_string(), "86'/0'/0'"); + let signet = BitcoinWallet::from_mnemonic(MNEMONIC, Network::BitcoinSignet).unwrap(); + assert_eq!(signet.account_path().to_string(), "86'/1'/0'"); + // Every test network shares coin type 1'. + let regtest = BitcoinWallet::from_mnemonic(MNEMONIC, Network::BitcoinRegtest).unwrap(); + assert_eq!(regtest.account_path(), signet.account_path()); + assert_eq!(wallet().key_path(Branch::Change, 7).to_string(), "86'/0'/0'/1/7"); + } + + /// Test networks share a derivation path but not an address encoding, so the same key + /// must render differently — otherwise a signet address would be spendable-looking on + /// mainnet. + #[test] + fn test_networks_share_keys_but_not_address_encodings() { + let signet = BitcoinWallet::from_mnemonic(MNEMONIC, Network::BitcoinSignet).unwrap(); + let regtest = BitcoinWallet::from_mnemonic(MNEMONIC, Network::BitcoinRegtest).unwrap(); + assert_eq!( + signet.internal_key(Branch::Receive, 0).unwrap(), + regtest.internal_key(Branch::Receive, 0).unwrap() + ); + let signet_addr = signet.address(Branch::Receive, 0).unwrap().to_string(); + let regtest_addr = regtest.address(Branch::Receive, 0).unwrap().to_string(); + assert!(signet_addr.starts_with("tb1p"), "{signet_addr}"); + assert!(regtest_addr.starts_with("bcrt1p"), "{regtest_addr}"); + } + + /// A key-path signature must verify against the *tweaked* output key, and the + /// untweaked one must not — the mistake this API exists to prevent. + #[test] + fn key_path_signatures_verify_against_the_tweaked_key() { + use lwk_wollet::elements::bitcoin::secp256k1::schnorr::Signature; + + let w = wallet(); + let secp = Secp256k1::new(); + let sighash = [7u8; 32]; + let sig = Signature::from_slice(&w.sign_key_path(Branch::Receive, 0, &sighash).unwrap()) + .expect("valid signature encoding"); + let msg = Message::from_digest(sighash); + + let internal = w.internal_key(Branch::Receive, 0).unwrap(); + let tweaked = internal.tap_tweak(&secp, None).0.to_x_only_public_key(); + + assert!(secp.verify_schnorr(&sig, &msg, &tweaked).is_ok()); + assert!( + secp.verify_schnorr(&sig, &msg, &internal).is_err(), + "a key-path signature must not verify against the untweaked key" + ); + } + + /// A covenant signature is checked against a key baked into the program — the + /// untweaked one. The two signing methods are therefore not interchangeable. + #[test] + fn covenant_signatures_use_the_untweaked_key() { + use lwk_wollet::elements::bitcoin::secp256k1::schnorr::Signature; + + let w = wallet(); + let secp = Secp256k1::new(); + let sighash = [9u8; 32]; + let path = "m/86'/0'/0'/0/0"; + + let sig = Signature::from_slice(&w.sign_with_path(path, &sighash).unwrap()).unwrap(); + let msg = Message::from_digest(sighash); + let internal = w.internal_key(Branch::Receive, 0).unwrap(); + + assert!(secp.verify_schnorr(&sig, &msg, &internal).is_ok()); + // ...and the advertised pubkey at that path is the one it verifies against. + assert_eq!(w.schnorr_pubkey_at(path).unwrap(), internal.to_string()); + } + + #[test] + fn an_elements_network_is_refused_rather_than_silently_reinterpreted() { + let err = BitcoinWallet::from_mnemonic(MNEMONIC, Network::LiquidTestnet) + .expect_err("Elements network must be refused") + .to_string(); + assert!(err.contains("Elements network"), "{err}"); + } + + #[test] + fn descriptor_names_the_account_and_both_branches() { + let d = wallet().descriptor().unwrap(); + assert!(d.starts_with("tr(["), "{d}"); + assert!(d.contains("86'/0'/0'"), "{d}"); + assert!(d.ends_with("/<0;1>/*)"), "{d}"); + } +} diff --git a/txmanifest_lib/src/config.rs b/txmanifest_lib/src/config.rs index ddf0ec4..c156964 100644 --- a/txmanifest_lib/src/config.rs +++ b/txmanifest_lib/src/config.rs @@ -58,14 +58,29 @@ impl Config { } /// Return the Esplora URL: explicit override > network-appropriate default. + /// + /// The default follows the configured network across both chains — Esplora serves + /// Bitcoin and Liquid from the same REST shape at different base paths. A network this + /// build does not recognize keeps the historical Liquid default rather than erroring, + /// because this accessor has no way to report one and every config that reaches it + /// today is an Elements config. pub fn esplora_url(&self) -> &str { - self.default_esplora.as_deref().unwrap_or_else(|| { - if self.is_mainnet() { - "https://blockstream.info/liquid/api" - } else { - "https://blockstream.info/liquidtestnet/api" - } - }) + if let Some(explicit) = self.default_esplora.as_deref() { + return explicit; + } + match self.network() { + Ok(net) => match net { + Network::Liquid => "https://blockstream.info/liquid/api", + Network::LiquidTestnet => "https://blockstream.info/liquidtestnet/api", + // No public Elements or Bitcoin regtest instance exists, so there is + // nothing honest to default to; the caller gets the testnet URL and will + // fail loudly against it rather than being pointed somewhere plausible. + Network::ElementsRegtest => "https://blockstream.info/liquidtestnet/api", + bitcoin_net => crate::bitcoin_backend::default_esplora_url(bitcoin_net) + .unwrap_or("https://blockstream.info/signet/api"), + }, + Err(_) => "https://blockstream.info/liquidtestnet/api", + } } /// Resolve the configured backend kind (defaults to Esplora). @@ -216,6 +231,27 @@ mod tests { assert!(err.contains("extra_capabilities"), "{err}"); } + /// The Esplora default must follow the configured network across both chains, and an + /// explicit override must always win — pointing a Bitcoin wallet at a Liquid instance + /// would produce confusing decode failures rather than an obvious misconfiguration. + #[test] + fn esplora_defaults_follow_the_network() { + assert_eq!(cfg("liquid").esplora_url(), "https://blockstream.info/liquid/api"); + assert_eq!(cfg("testnet").esplora_url(), "https://blockstream.info/liquidtestnet/api"); + assert_eq!(cfg("bitcoin").esplora_url(), "https://blockstream.info/api"); + assert_eq!(cfg("bitcoin-signet").esplora_url(), "https://blockstream.info/signet/api"); + assert_eq!( + cfg("bitcoin-testnet").esplora_url(), + "https://blockstream.info/testnet/api" + ); + + let overridden = Config { + default_esplora: Some("http://localhost:3000".to_string()), + ..cfg("bitcoin-signet") + }; + assert_eq!(overridden.esplora_url(), "http://localhost:3000"); + } + #[test] fn an_unknown_network_names_the_accepted_spellings() { let err = cfg("liquid-signet").network().unwrap_err().to_string(); diff --git a/txmanifest_lib/src/lib.rs b/txmanifest_lib/src/lib.rs index 2c9a1be..d283aa3 100644 --- a/txmanifest_lib/src/lib.rs +++ b/txmanifest_lib/src/lib.rs @@ -1,5 +1,7 @@ pub mod manifest; pub mod backend; +pub mod bitcoin_backend; +pub mod bitcoin_wallet; pub mod chain; pub mod canonical; pub mod config; diff --git a/txmanifest_lib/src/lifecycle.rs b/txmanifest_lib/src/lifecycle.rs index 31a1ab1..ef500e3 100644 --- a/txmanifest_lib/src/lifecycle.rs +++ b/txmanifest_lib/src/lifecycle.rs @@ -1833,7 +1833,18 @@ pub fn run( } } } - Err(e) => println!(" {} Fee estimation failed (`fee` stays 0): {e}", style("[warn]").yellow()), + // A formula that reads `fee` has no sane value to fall back on. The + // old behaviour left `fee` at 0 and carried on, which turns "estimation + // failed" into "every output computed as if the transaction were free" — + // an amount that is wrong by exactly the fee. On Elements that surfaces + // later as a build failure; on Bitcoin, where the fee is a leftover + // rather than an output, it surfaces as a transaction that overpays by + // whatever the outputs left behind. Neither is worth reaching by + // default, so stop where the cause is still legible. + Err(e) => return Err(e.context( + "cannot resolve the `fee` keyword: an output amount depends on it, \ + so there is no safe value to continue with", + )), } } diff --git a/txmanifest_lib/src/psbt_builder.rs b/txmanifest_lib/src/psbt_builder.rs index 8eb2cf1..9398eb9 100644 --- a/txmanifest_lib/src/psbt_builder.rs +++ b/txmanifest_lib/src/psbt_builder.rs @@ -24,14 +24,19 @@ use std::collections::HashMap; -use anyhow::{bail, Result}; +use anyhow::{bail, Context as _, Result}; use lwk_wollet::elements::bitcoin::{ absolute::LockTime, + hashes::Hash as _, psbt::{Input as PsbtInputData, Output as PsbtOutputData, Psbt}, + sighash::{Prevouts, SighashCache, TapSighashType}, + taproot::Signature as TaprootSignature, transaction::Version, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, }; +use crate::bitcoin_wallet::{BitcoinWallet, Branch}; + /// Weight units per virtual byte. const WU_PER_VBYTE: usize = 4; @@ -53,6 +58,7 @@ const KEYSPEND_WITNESS_WU: usize = 66; // --------------------------------------------------------------------------- /// One input to spend. Mirrors `pset_builder::PsetInput` minus the Elements-only arms. +#[derive(Debug)] pub enum PsbtInput { /// A wallet-owned UTXO, spent by key path. Wallet { @@ -128,11 +134,13 @@ impl PsbtInput { } /// One declared output. +#[derive(Debug)] pub struct PsbtOutputSpec { pub script_pubkey: ScriptBuf, pub amount: u64, } +#[derive(Debug)] pub struct BuildPsbtRequest { pub inputs: Vec, pub outputs: Vec, @@ -396,7 +404,6 @@ pub fn input_indices(req: &BuildPsbtRequest) -> HashMap { #[cfg(test)] mod tests { use super::*; - use lwk_wollet::elements::bitcoin::hashes::Hash as _; fn outpoint(n: u8) -> OutPoint { OutPoint { @@ -602,6 +609,247 @@ mod tests { assert!(covenant > keyspend, "covenant {covenant} should cost more than keyspend {keyspend}"); } + // -- narrowing from the Elements request ------------------------------- + + fn policy() -> lwk_wollet::elements::AssetId { + lwk_wollet::elements::AssetId::from_slice(&[1u8; 32]).unwrap() + } + + fn other_asset() -> lwk_wollet::elements::AssetId { + lwk_wollet::elements::AssetId::from_slice(&[2u8; 32]).unwrap() + } + + fn el_script() -> lwk_wollet::elements::Script { + let mut v = vec![0x51, 0x20]; + v.extend_from_slice(&[0xcd; 32]); + lwk_wollet::elements::Script::from(v) + } + + fn el_outpoint() -> lwk_wollet::elements::OutPoint { + lwk_wollet::elements::OutPoint { + txid: lwk_wollet::elements::Txid::from_slice(&[3u8; 32]).unwrap(), + vout: 2, + } + } + + fn covenant_pset_request(asset: lwk_wollet::elements::AssetId) -> crate::pset_builder::BuildPsetRequest { + crate::pset_builder::BuildPsetRequest { + inputs: vec![crate::pset_builder::PsetInput::Covenant { + input_id: "cov".to_string(), + outpoint: el_outpoint(), + script_pubkey: el_script(), + asset, + amount: 100_000, + issuance: None, + sequence: Some(144), + blinding: None, + }], + outputs: vec![crate::pset_builder::PsetOutputSpec { + script_pubkey: el_script(), + amount: 60_000, + asset, + blinding_key: None, + blinding: None, + }], + fee_rate: 2.0, + policy_asset: policy(), + change_assets: std::collections::HashSet::from([policy()]), + } + } + + /// Scripts and outpoints carry across unchanged — an Elements and a Bitcoin P2TR + /// scriptPubKey are the same bytes, and only the address encoding differs. + #[test] + fn narrowing_preserves_scripts_outpoints_and_sequences() { + let pset = covenant_pset_request(policy()); + let psbt = from_pset_request(&pset, Some(p2tr(7))).expect("narrows"); + + assert_eq!(psbt.inputs.len(), 1); + assert_eq!(psbt.inputs[0].amount(), 100_000); + let PsbtInput::Covenant { script_pubkey, outpoint: op, .. } = &psbt.inputs[0] else { + panic!("covenant input should stay a covenant input"); + }; + assert_eq!(script_pubkey.as_bytes(), el_script().as_bytes()); + assert_eq!(op.vout, 2); + assert_eq!(op.txid.to_string(), el_outpoint().txid.to_string()); + + assert_eq!(psbt.outputs[0].amount, 60_000); + assert_eq!(psbt.outputs[0].script_pubkey.as_bytes(), el_script().as_bytes()); + assert_eq!(psbt.fee_rate, 2.0); + assert_eq!(psbt.change_script, Some(p2tr(7))); + } + + /// Change is emitted only where the action declared it, matching the Elements rule + /// that an undeclared surplus is an error rather than an invented output. + #[test] + fn change_is_dropped_when_the_action_declared_none() { + let mut pset = covenant_pset_request(policy()); + pset.change_assets.clear(); + let psbt = from_pset_request(&pset, Some(p2tr(7))).expect("narrows"); + assert_eq!(psbt.change_script, None); + } + + /// The refusals are the point of this function. Each of these got past `validate`, + /// which should have caught it — so dropping the field silently would turn a bug in + /// that check into a transaction meaning something other than the manifest said. + #[test] + fn anything_bitcoin_cannot_express_is_refused_rather_than_dropped() { + // A second asset, on an input. + let mut pset = covenant_pset_request(other_asset()); + pset.outputs[0].asset = policy(); + let err = from_pset_request(&pset, None).expect_err("second asset").to_string(); + assert!(err.contains("only one asset"), "{err}"); + + // A second asset, on an output. + let mut pset = covenant_pset_request(policy()); + pset.outputs[0].asset = other_asset(); + let err = from_pset_request(&pset, None).expect_err("second asset").to_string(); + assert!(err.contains("only one asset"), "{err}"); + + // A confidential output. + let mut pset = covenant_pset_request(policy()); + pset.outputs[0].blinding_key = Some(lwk_wollet::elements::bitcoin::PublicKey::from_slice( + &[ + 2, 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, + 0xe9, 0x7a, 0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, + 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0, + ], + ).unwrap()); + let err = from_pset_request(&pset, None).expect_err("confidential").to_string(); + assert!(err.contains("always explicit"), "{err}"); + + // Pinned blinding factors on a covenant input. + let mut pset = covenant_pset_request(policy()); + if let crate::pset_builder::PsetInput::Covenant { blinding, .. } = &mut pset.inputs[0] { + *blinding = Some(crate::pset_builder::PinnedBlinding::default()); + } + let err = from_pset_request(&pset, None).expect_err("blinding").to_string(); + assert!(err.contains("always explicit"), "{err}"); + + // Change declared in a non-policy asset. + let mut pset = covenant_pset_request(policy()); + pset.change_assets.insert(other_asset()); + let err = from_pset_request(&pset, None).expect_err("change asset").to_string(); + assert!(err.contains("only one asset"), "{err}"); + } + + /// A narrowed request must build, so the two halves actually compose. + #[test] + fn a_narrowed_request_builds() { + let pset = covenant_pset_request(policy()); + let psbt_req = from_pset_request(&pset, Some(p2tr(7))).expect("narrows"); + let built = build_psbt(&psbt_req).expect("builds"); + assert_eq!(built.psbt.unsigned_tx.input[0].sequence.to_consensus_u32(), 144); + let total_out: u64 = built.psbt.unsigned_tx.output.iter().map(|o| o.value.to_sat()).sum(); + assert_eq!(100_000 - total_out, built.fee); + } + + // -- signing ---------------------------------------------------------- + + const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about"; + + fn signing_wallet() -> BitcoinWallet { + BitcoinWallet::from_mnemonic(MNEMONIC, crate::chain::Network::BitcoinSignet).unwrap() + } + + /// A request spending one wallet-owned output, so the prevout's scriptPubKey really is + /// the one the signing key controls. + fn owned_request(w: &BitcoinWallet) -> BuildPsbtRequest { + BuildPsbtRequest { + inputs: vec![PsbtInput::Wallet { + input_id: "i0".to_string(), + outpoint: outpoint(1), + witness_utxo: TxOut { + value: Amount::from_sat(100_000), + script_pubkey: w.script_pubkey(Branch::Receive, 0).unwrap(), + }, + sequence: None, + }], + outputs: vec![PsbtOutputSpec { script_pubkey: p2tr(1), amount: 60_000 }], + fee_rate: 1.0, + change_script: Some(w.script_pubkey(Branch::Change, 0).unwrap()), + lock_time: None, + } + } + + /// The signature must verify against the output key the spent output actually commits + /// to — the tweaked one. Nothing else in this pipeline checks that, and a signature + /// over the wrong key is well-formed and simply never spends. + #[test] + fn key_path_signatures_verify_against_the_spent_output() { + use lwk_wollet::elements::bitcoin::secp256k1::{schnorr::Signature, Message, Secp256k1}; + use lwk_wollet::elements::bitcoin::key::TapTweak; + + let w = signing_wallet(); + let mut built = build_psbt(&owned_request(&w)).expect("builds"); + let plan = [KeyPathSigner { input_index: 0, branch: Branch::Receive, index: 0 }]; + + let sighash = key_path_sighash(&built.psbt, 0).expect("sighash"); + sign_key_path_inputs(&mut built.psbt, &w, &plan).expect("signs"); + + let sig = built.psbt.inputs[0].tap_key_sig.expect("signature stored"); + let secp = Secp256k1::new(); + let internal = w.internal_key(Branch::Receive, 0).unwrap(); + let output_key = internal.tap_tweak(&secp, None).0.to_x_only_public_key(); + assert!(secp + .verify_schnorr(&sig.signature, &Message::from_digest(sighash), &output_key) + .is_ok()); + let _: Signature = sig.signature; + } + + /// A taproot sighash commits to every spent output, so one missing prevout would + /// silently change every signature in the transaction. Refuse rather than sign. + #[test] + fn a_missing_prevout_blocks_signing_of_every_input() { + let w = signing_wallet(); + let mut built = build_psbt(&owned_request(&w)).expect("builds"); + built.psbt.inputs[0].witness_utxo = None; + let err = key_path_sighash(&built.psbt, 0).expect_err("must refuse").to_string(); + assert!(err.contains("commits to every spent output"), "{err}"); + } + + /// Only the planned inputs are signed, so a mixed transaction can be signed here and + /// have its covenant inputs finalized elsewhere without either clobbering the other. + #[test] + fn covenant_inputs_are_left_for_the_covenant_finalizer() { + let w = signing_wallet(); + let mut r = owned_request(&w); + r.inputs.push(PsbtInput::Covenant { + input_id: "cov".to_string(), + outpoint: outpoint(5), + script_pubkey: p2tr(4), + amount: 50_000, + sequence: None, + }); + r.outputs[0].amount = 140_000; + + let mut built = build_psbt(&r).expect("builds"); + sign_key_path_inputs( + &mut built.psbt, + &w, + &[KeyPathSigner { input_index: 0, branch: Branch::Receive, index: 0 }], + ) + .expect("signs"); + + assert!(built.psbt.inputs[0].tap_key_sig.is_some()); + assert!(built.psbt.inputs[1].tap_key_sig.is_none(), "covenant input must be untouched"); + + finalize_key_path_inputs(&mut built.psbt).expect("finalizes"); + let wit = built.psbt.inputs[0].final_script_witness.as_ref().expect("witness built"); + // A SIGHASH_DEFAULT key-path witness is exactly one 64-byte signature. + assert_eq!(wit.len(), 1); + assert_eq!(wit.iter().next().unwrap().len(), 64); + assert!(built.psbt.inputs[1].final_script_witness.is_none()); + } + + #[test] + fn signing_an_out_of_range_input_is_refused() { + let w = signing_wallet(); + let built = build_psbt(&owned_request(&w)).expect("builds"); + assert!(key_path_sighash(&built.psbt, 7).is_err()); + } + #[test] fn input_indices_track_request_order() { let r = req( @@ -613,3 +861,238 @@ mod tests { assert_eq!(idx["second"], 1); } } + + +// --------------------------------------------------------------------------- +// Signing +// --------------------------------------------------------------------------- + +/// Which wallet key signs one input, for callers assembling a signing plan. +/// +/// Covenant inputs are absent by construction: they are satisfied by a Simplicity witness, +/// not by a wallet signature, and `covenant::finalize_covenant_input` handles them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeyPathSigner { + pub input_index: usize, + pub branch: Branch, + pub index: u32, +} + +/// The BIP341 sighash for a key-path spend of `input_index`. +/// +/// `SIGHASH_DEFAULT` — the taproot default, committing to every input and output. The +/// sighash commits to *all* spent outputs, not just this one, which is why every input's +/// `witness_utxo` must be present before any of them can be signed: one missing prevout +/// silently changes every signature in the transaction. +pub fn key_path_sighash(psbt: &Psbt, input_index: usize) -> Result<[u8; 32]> { + let prevouts: Vec = psbt + .inputs + .iter() + .enumerate() + .map(|(i, inp)| { + inp.witness_utxo.clone().ok_or_else(|| { + anyhow::anyhow!( + "input {i} has no witness_utxo, so no input in this transaction can be \ + signed: a taproot sighash commits to every spent output" + ) + }) + }) + .collect::>()?; + + if input_index >= prevouts.len() { + bail!("input {input_index} is out of range for a transaction with {} inputs", prevouts.len()); + } + + let mut cache = SighashCache::new(&psbt.unsigned_tx); + let sighash = cache + .taproot_key_spend_signature_hash( + input_index, + &Prevouts::All(&prevouts), + TapSighashType::Default, + ) + .map_err(|e| anyhow::anyhow!("cannot compute taproot sighash: {e}"))?; + Ok(sighash.to_byte_array()) +} + +/// Sign the listed key-path inputs in place, filling each one's `tap_key_sig`. +/// +/// Only the inputs named in `plan` are touched, so a transaction mixing wallet and +/// covenant inputs can be signed here and finalized elsewhere without either step +/// clobbering the other's work. +pub fn sign_key_path_inputs( + psbt: &mut Psbt, + wallet: &BitcoinWallet, + plan: &[KeyPathSigner], +) -> Result<()> { + for entry in plan { + let sighash = key_path_sighash(psbt, entry.input_index)?; + let raw = wallet + .sign_key_path(entry.branch, entry.index, &sighash) + .with_context(|| format!("cannot sign input {}", entry.input_index))?; + let signature = lwk_wollet::elements::bitcoin::secp256k1::schnorr::Signature::from_slice(&raw) + .map_err(|e| anyhow::anyhow!("wallet produced an invalid signature: {e}"))?; + psbt.inputs[entry.input_index].tap_key_sig = Some(TaprootSignature { + signature, + // Must match the sighash type the signature was computed over. Storing a + // different one produces a witness the network rejects for a reason that + // points nowhere near the mistake. + sighash_type: TapSighashType::Default, + }); + } + Ok(()) +} + +/// Move each signed key-path input's signature into its final witness. +/// +/// A `SIGHASH_DEFAULT` key-path witness is exactly one 64-byte signature. Inputs with no +/// `tap_key_sig` are left alone — those are the covenant inputs, whose witness is built by +/// `covenant::finalize_covenant_input`. +pub fn finalize_key_path_inputs(psbt: &mut Psbt) -> Result<()> { + for (i, input) in psbt.inputs.iter_mut().enumerate() { + let Some(sig) = input.tap_key_sig.take() else { continue }; + if !input.final_script_witness.as_ref().is_none_or(Witness::is_empty) { + bail!("input {i} already has a final witness"); + } + let mut witness = Witness::new(); + witness.push(sig.to_vec()); + input.final_script_witness = Some(witness); + } + Ok(()) +} + +/// Extract the signed transaction, ready to broadcast. +pub fn extract_tx(psbt: Psbt) -> Result { + psbt.extract_tx() + .map_err(|e| anyhow::anyhow!("cannot extract transaction from PSBT: {e}")) +} + + +// --------------------------------------------------------------------------- +// Narrowing from the Elements request +// --------------------------------------------------------------------------- + +/// Build a Bitcoin request from the Elements one the lifecycle already assembles. +/// +/// The lifecycle's input/output assembly is ~800 lines that resolve destinations, derive +/// covenant addresses, evaluate amounts and record state metadata. Almost none of that is +/// chain-specific, and duplicating it for Bitcoin would produce two copies that drift. +/// So there is one assembly path, and the chains part company here. +/// +/// This is a **narrowing**, not a translation: the Elements request is strictly richer, +/// and every field Bitcoin cannot express is refused rather than dropped. That matters +/// more than the convenience. `validate` already rejects a Bitcoin manifest that uses +/// assets, issuance or blinding, so anything reaching this function with those set got +/// past a check that should have caught it — and silently ignoring it would turn a bug in +/// that check into a transaction that quietly means something other than the manifest +/// said. Refusing keeps the failure loud and local. +/// +/// `change_script` is supplied by the caller because the Elements request carries a set of +/// change *assets* rather than a script; the Elements builder derives the address from the +/// wallet at build time, and the Bitcoin builder cannot see a wallet. +pub fn from_pset_request( + pset: &crate::pset_builder::BuildPsetRequest, + change_script: Option, +) -> Result { + use crate::pset_builder::PsetInput as EIn; + + let policy = pset.policy_asset; + + let mut inputs = Vec::with_capacity(pset.inputs.len()); + for input in &pset.inputs { + let id = input.input_id(); + match input { + EIn::Wallet { utxo, issuance, sequence, .. } => { + if issuance.is_some() { + bail!("input '{id}' carries an asset issuance, which Bitcoin has no way to express"); + } + if utxo.unblinded.asset != policy { + bail!( + "input '{id}' holds asset {}, but Bitcoin has only one asset", + utxo.unblinded.asset + ); + } + inputs.push(PsbtInput::Wallet { + input_id: id.to_string(), + outpoint: convert_outpoint(utxo.outpoint), + witness_utxo: TxOut { + value: Amount::from_sat(utxo.unblinded.value), + script_pubkey: convert_script(&utxo.script_pubkey), + }, + sequence: *sequence, + }); + } + EIn::Covenant { outpoint, script_pubkey, asset, amount, issuance, sequence, blinding, .. } => { + if issuance.is_some() { + bail!("input '{id}' carries a reissuance, which Bitcoin has no way to express"); + } + if blinding.is_some() { + bail!("input '{id}' has blinding factors, but Bitcoin amounts are always explicit"); + } + if *asset != policy { + bail!("input '{id}' holds asset {asset}, but Bitcoin has only one asset"); + } + inputs.push(PsbtInput::Covenant { + input_id: id.to_string(), + outpoint: convert_outpoint(*outpoint), + script_pubkey: convert_script(script_pubkey), + amount: *amount, + sequence: *sequence, + }); + } + } + } + + let mut outputs = Vec::with_capacity(pset.outputs.len()); + for (i, out) in pset.outputs.iter().enumerate() { + if out.blinding_key.is_some() { + bail!("output #{i} is confidential, but Bitcoin amounts are always explicit"); + } + if out.blinding.is_some() { + bail!("output #{i} pins blinding factors, which Bitcoin has no way to express"); + } + if out.asset != policy { + bail!("output #{i} pays asset {}, but Bitcoin has only one asset", out.asset); + } + outputs.push(PsbtOutputSpec { + script_pubkey: convert_script(&out.script_pubkey), + amount: out.amount, + }); + } + + for asset in &pset.change_assets { + if *asset != policy { + bail!("change was declared in asset {asset}, but Bitcoin has only one asset"); + } + } + + Ok(BuildPsbtRequest { + inputs, + outputs, + fee_rate: pset.fee_rate, + // A change output is emitted only where the action declared one, which on Bitcoin + // means the policy asset appeared in `change_assets`. + change_script: change_script.filter(|_| pset.change_assets.contains(&policy)), + // The Elements request has no transaction-level locktime; absolute timelocks reach + // a covenant through its own `check_lock_height` rather than through the builder. + lock_time: None, + }) +} + +/// An Elements script and a Bitcoin script are the same bytes. +/// +/// Only the *address* encodings differ — a P2TR scriptPubKey is `OP_1 <32 bytes>` on both +/// chains — so the conversion is a byte copy rather than a re-derivation. +fn convert_script(script: &lwk_wollet::elements::Script) -> ScriptBuf { + ScriptBuf::from_bytes(script.as_bytes().to_vec()) +} + +/// Outpoints differ only in the txid's type; the bytes and display order match. +fn convert_outpoint(outpoint: lwk_wollet::elements::OutPoint) -> OutPoint { + use lwk_wollet::elements::bitcoin::hashes::Hash as _; + OutPoint { + txid: lwk_wollet::elements::bitcoin::Txid::from_byte_array( + outpoint.txid.to_byte_array(), + ), + vout: outpoint.vout, + } +} From 1ef5f47aff00300a87bffd0e752556156f56f9d6 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 4 Sep 2026 17:36:14 +0200 Subject: [PATCH 05/16] build bitcoin payments through the shared assembly `lifecycle::run` now dispatches a Bitcoin manifest: load a `BitcoinWallet`, scan Esplora for UTXOs, run the shared assembly, then build, sign and broadcast a PSBT. Elements is untouched. The two paths diverge at the build, not before. Elements carries on to separate signing, covenant dry-run and finalize steps, because a PSET passes through three stages that can each fail in a way worth reporting. Bitcoin does all three at once -- covenant execution needs the upstream jet FFI, so the only transactions reachable here are plain payments, and splitting three mechanical operations across three steps would invent places to stop. A covenant input on a Bitcoin run is refused with that explanation rather than silently skipped. `assembly` is the seam that makes one path serve both chains. The input/output assembly in `lifecycle::run` is ~800 lines of destination resolution, covenant address derivation, amount evaluation and state metadata, and it turns out to need exactly six things from the chain under it: a covenant's scriptPubKey, an asset label resolved to an id, a change address, the next receive address, the policy asset, and whether outputs are confidential by default. `AssemblyContext` is those six, with `ElementsContext` over LWK and `BitcoinContext` over `BitcoinWallet`. One assembly rather than two, because a duplicate would agree on the day it was written and drift by the next release, in ways only a funded transaction would reveal. The rewiring is a pure refactor and the existing tests are what say so. The assembly still speaks the Elements vocabulary on both chains: outputs carry an `AssetId` and an optional blinding key, and on Bitcoin the asset is a single synthetic constant and the blinding key is always `None`. That leak is deliberate. The alternative -- a neutral vocabulary both chains widen from -- means rewriting all 800 lines against it, which is the risk the seam exists to avoid. The fiction does not have to hold together on its own either, because `psbt_builder::from_pset_request` refuses any second asset or blinding key, so the narrowing enforces the invariant rather than this module being trusted to maintain it. `assembly::bitcoin_spendable_utxos` is the input-side counterpart: real outpoint, value and scriptPubKey in LWK's `WalletTxOut` shape, synthetic asset and blinding factors, which keeps input selection -- several hundred lines of amount and asset matching, sitting in the path funds move along -- off the list of things being rewritten. A test carries a UTXO through the assembly's vocabulary and back out through the narrowing to confirm the real fields survive. `BitcoinRun` carries its own `Network` rather than deriving one from `network_for_asset`, which is an `ElementsNetwork` computed from the wallet file's mainnet flag and is meaningless on Bitcoin; taking it from there would hand the covenant derivation the wrong chain. Also found here: a covenant's scriptPubKey is chain-specific in its *bytes*, not only in its address encoding. The taproot tweak is domain-separated the same way the tag hashes are (`TapTweak/elements` versus `TapTweak`), so one covenant tree yields different script bytes on the two chains. That is a third independent reason a covenant address is chain-specific, alongside the jet CMRs and the TapBranch tag, and the only one of the three with no visible symptom: an Elements-derived script is a perfectly well-formed P2TR output on Bitcoin, and a transaction paying it looks entirely normal right up until nobody can ever spend it. `covenant_script_pubkey_for` dispatches on the network and `compute_bitcoin_covenant_address` is its Bitcoin half; both share one merkle-root computation, so they cannot fold different trees while disagreeing (correctly) about the tweak. `psbt_builder::convert_script` claimed the opposite in its doc comment -- the same bytes on both chains, "a byte copy rather than a re-derivation" -- and now says what it actually is: correct only for a script already derived for the target chain. --- CHANGELOG.md | 74 ++++ txmanifest_lib/src/assembly.rs | 558 +++++++++++++++++++++++++++++ txmanifest_lib/src/covenant.rs | 255 +++++++++++++ txmanifest_lib/src/lib.rs | 1 + txmanifest_lib/src/lifecycle.rs | 230 ++++++++++-- txmanifest_lib/src/psbt_builder.rs | 13 +- 6 files changed, 1100 insertions(+), 31 deletions(-) create mode 100644 txmanifest_lib/src/assembly.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f04e7..27b5f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,63 @@ the aliases `liquid` and `btc`. It was a free-form string that nothing read; are written, and counting it as multi-asset marked `p2pk` and `last_will` unportable when they are the two that port most cleanly. +- **Bitcoin runs are dispatched from `lifecycle::run`.** A Bitcoin manifest now + loads a `BitcoinWallet`, scans Esplora for UTXOs, runs the shared assembly, and + then builds, signs and broadcasts a PSBT. Elements is untouched. + + The two paths diverge at the build, not before: Elements carries on to the + separate signing, covenant dry-run and finalize steps, because a PSET passes + through three stages that can each fail in a way worth reporting. Bitcoin does + all three at once — covenant execution needs the upstream jet FFI, so the only + transactions reachable there are plain payments, and splitting three mechanical + operations across three steps would invent places to stop. A covenant input on + a Bitcoin run is refused with that explanation rather than silently skipped. + + Bitcoin UTXOs are presented to input selection in LWK's `WalletTxOut` shape by + `assembly::bitcoin_spendable_utxos` — the input-side counterpart of the + synthetic policy asset, and safe for the same reason: the outpoint, value and + scriptPubKey are real, the asset and blinding factors are synthetic, and the + narrowing checks and drops the synthetic ones. Doing it this way keeps input + selection — several hundred lines of amount and asset matching — off the list + of things being rewritten, which matters because that code is in the path funds + move along. A test carries a UTXO through the assembly's vocabulary and back + out through the narrowing to confirm the real fields survive. + + `BitcoinRun` carries its own `Network` rather than deriving one from + `network_for_asset`, which is an `ElementsNetwork` computed from the wallet + file's mainnet flag and is meaningless on Bitcoin — taking it from there would + hand the covenant derivation the wrong chain. + +- **`assembly` — the seam between the shared lifecycle and the chain under it.** + `lifecycle::run`'s input/output assembly is ~800 lines of destination + resolution, covenant address derivation, amount evaluation and state metadata. + It turns out to need exactly **six** things from the chain: a covenant's + scriptPubKey, an asset label resolved to an id, a change address, the next + receive address, the policy asset, and whether outputs are confidential by + default. `AssemblyContext` is those six, with `ElementsContext` over LWK and + `BitcoinContext` over `BitcoinWallet`. + + One assembly path rather than two: a duplicate would agree on the day it was + written and drift by the next release, in ways only a funded transaction would + reveal. The lifecycle rewiring is a pure refactor — the existing tests are what + say so. + + The assembly still speaks the Elements vocabulary on both chains: outputs carry + an `AssetId` and an optional blinding key, and on Bitcoin the asset is a single + synthetic constant and the blinding key is always `None`. That is a deliberate + leak. The alternative — a neutral vocabulary both chains widen from — means + rewriting all 800 lines against it, which is the risk the seam exists to avoid. + The synthetic asset is not a fiction that has to hold together on its own: + `psbt_builder::from_pset_request` refuses any second asset or blinding key, so + the narrowing enforces the invariant rather than this module being trusted to + maintain it. + + `AddressInfo` carries both the script and its encoding, because the assembly + genuinely uses both — the script goes into the transaction, the encoding into + the line a user reads to check where their money went. Deriving one from the + other at the call site would mean the shared assembly picking an encoding, + which is exactly what it must not do. + - **Esplora defaults follow the configured network** across both chains, rather than choosing between two Liquid URLs on `is_mainnet`. An explicit `default_esplora` still wins; pointing a Bitcoin wallet at a Liquid instance @@ -222,6 +279,23 @@ the aliases `liquid` and `btc`. It was a free-form string that nothing read; so it is now pinned by a test that cross-checks the Elements branch against `rust-elements`' own tag. +- **Covenant scriptPubKeys are derived per chain, not just addresses.** The + taproot *tweak* is domain-separated the same way the tag hashes are + (`TapTweak/elements` versus `TapTweak`), so one covenant tree yields different + scriptPubKey **bytes** on the two chains — not merely a different address + string. That is a third independent reason a covenant address is chain-specific, + alongside the jet CMRs and the TapBranch tag, and the only one with no visible + symptom: an Elements-derived script is a perfectly well-formed P2TR output on + Bitcoin, and a transaction paying it looks entirely normal right up until nobody + can ever spend it. + + `covenant_script_pubkey_for` dispatches on the network and + `compute_bitcoin_covenant_address` is its Bitcoin half; both share one + merkle-root computation so they cannot fold different trees while disagreeing + (correctly) about the tweak. A Bitcoin address built from Elements compile + options is refused, so the tweak and the jet set cannot come from different + chains. + - **The Simplicity leaf version comes from one constant for both chains.** `simplicity::leaf_version()` returns an `elements::taproot::LeafVersion`, which is the wrong type the moment a Bitcoin tree is built. The byte is the diff --git a/txmanifest_lib/src/assembly.rs b/txmanifest_lib/src/assembly.rs new file mode 100644 index 0000000..f98dbc1 --- /dev/null +++ b/txmanifest_lib/src/assembly.rs @@ -0,0 +1,558 @@ +//! What the lifecycle's input/output assembly needs from a wallet and a chain. +//! +//! [`lifecycle::run`](crate::lifecycle::run) contains roughly eight hundred lines that +//! resolve destinations, derive covenant addresses, evaluate amounts and record state +//! metadata. Almost none of it is chain-specific — and duplicating it for Bitcoin would +//! produce two copies that agree on the day they are written and drift by the next +//! release, in ways only a funded transaction would reveal. +//! +//! So there is one assembly path, and this trait is the whole of what it asks of the +//! chain underneath. Six things, no more: +//! +//! 1. a covenant's scriptPubKey, +//! 2. an asset label resolved to an id, +//! 3. a change address, +//! 4. the next receive address, +//! 5. the chain's own unit of account, +//! 6. whether outputs are confidential unless told otherwise. +//! +//! # Why the assembly still speaks Elements +//! +//! The assembly builds [`PsetOutputSpec`](crate::pset_builder::PsetOutputSpec) values, +//! which carry an `AssetId` and an optional blinding key, on both chains. On Bitcoin the +//! asset is a single synthetic constant and the blinding key is always `None`. +//! +//! That looks like a leak, and it is a deliberate one. The alternative — a neutral +//! vocabulary both chains widen from — means every one of those eight hundred lines has to +//! be rewritten against it, which is exactly the risk this trait exists to avoid. Instead +//! the assembly keeps the richer vocabulary and +//! [`psbt_builder::from_pset_request`](crate::psbt_builder::from_pset_request) narrows it, +//! *refusing* anything Bitcoin cannot express. So the synthetic asset is not a fiction +//! that has to hold together on its own: if the assembly ever produced a second asset or a +//! blinding key on a Bitcoin run, the narrowing would reject it rather than let it through. + +use anyhow::Result; +use lwk_wollet::elements::bitcoin::PublicKey; +use lwk_wollet::elements::{AssetId, Script}; + +use crate::chain::{ChainFamily, Network}; +use crate::covenant::CompileOpts; + +/// An address the assembly is about to pay. +pub struct AddressInfo { + pub script_pubkey: Script, + /// The address as a user would read it, encoded for this chain. + /// + /// Carried alongside the script because the assembly genuinely needs both: the script + /// goes into the transaction, and the encoding goes into the line a user reads to + /// check where their money went. Deriving one from the other at the call site would + /// mean the shared assembly picking an encoding, which is precisely what it must not + /// do — a Liquid address and a Bitcoin one are different strings for the same script. + pub display: String, + /// The blinding key, when the address has one. Always `None` on Bitcoin. + pub blinding_pubkey: Option, + /// The derivation index this address came from, so the caller can ask for the next one + /// and not hand out the same address twice in one transaction. + pub index: u32, +} + +/// The chain-specific half of the lifecycle's assembly. +pub trait AssemblyContext { + /// Which ledger this run targets. + fn family(&self) -> ChainFamily; + + /// The chain's own unit of account — L-BTC's asset id on Elements, a synthetic + /// constant on Bitcoin. + fn policy_asset(&self) -> AssetId; + + /// Resolve a manifest asset label (`"lbtc"`, or a hex id) to an asset id. + /// + /// On Bitcoin every label that survives `validate` denotes the policy asset, so this + /// is near-trivial there — but it stays on the trait rather than being special-cased + /// in the assembly, because "which asset is this" is precisely the question the two + /// chains answer differently. + fn resolve_asset(&self, label: &str) -> Result; + + /// The scriptPubKey of a covenant with these compile parameters and extra leaves. + /// + /// Chain-specific in three independent ways — the jet set fixes the CMR, the TapBranch + /// tag fixes the merkle root, and the TapTweak tag fixes the output key — so this can + /// never be hoisted into the shared assembly. + fn covenant_script_pubkey( + &self, + simf_path: &std::path::Path, + compile_params: &std::collections::HashMap, + type_hints: &std::collections::HashMap, + extra_leaf_payloads: &[Vec], + opts: &CompileOpts, + ) -> Result