diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c900440..bcc060e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,14 +53,6 @@ jobs: name: lint runs-on: ubuntu-latest timeout-minutes: 30 - # NON-BLOCKING for now: the tree is not yet rustfmt-clean and clippy reports 18 - # warnings (all warnings, no errors). This job exists to make both visible on a PR - # without gating merges on a cleanup nobody has done yet. - # - # To turn it into a real gate: run `cargo fmt --all`, clear the clippy warnings - # (`cargo clippy --fix` handles most), then delete this line and add - # `-- -D warnings` to the clippy step. - continue-on-error: true steps: - uses: actions/checkout@v5 @@ -79,4 +71,4 @@ jobs: - name: Clippy # `--all-targets` so tests and examples are linted too — that is where the # shadowed-binding and unused-variable classes of bug actually show up. - run: cargo clippy --workspace --all-targets --locked + run: cargo clippy --workspace --all-targets --locked -- -D warnings diff --git a/txmanifest_lib/src/backend.rs b/txmanifest_lib/src/backend.rs index f8216af..295a610 100644 --- a/txmanifest_lib/src/backend.rs +++ b/txmanifest_lib/src/backend.rs @@ -38,6 +38,8 @@ impl BackendKind { } /// A connected blockchain backend — either Esplora (HTTP) or Electrum (TCP/TLS). +// One per process, so the variants' size difference costs nothing. +#[allow(clippy::large_enum_variant)] pub enum Backend { Esplora(blocking::EsploraClient), Electrum(ElectrumClient), diff --git a/txmanifest_lib/src/covenant.rs b/txmanifest_lib/src/covenant.rs index 5000d62..392ddc2 100644 --- a/txmanifest_lib/src/covenant.rs +++ b/txmanifest_lib/src/covenant.rs @@ -1461,6 +1461,211 @@ fn network_to_params(network: lwk_wollet::ElementsNetwork) -> &'static AddressPa } } +// --------------------------------------------------------------------------- +// Bitcoin covenant spending +// --------------------------------------------------------------------------- + +/// Finalize a Simplicity covenant input on a Bitcoin PSBT. +/// +/// The Bitcoin counterpart of [`finalize_covenant_input`], and the same shape: compile, +/// build the taproot control block, satisfy the program against the real transaction, and +/// write the four-item Simplicity tapscript witness +/// `[witness, program, cmr, control_block]`. +/// +/// Three things differ, and each is a consequence of the chain rather than a choice: +/// +/// - **The environment.** `BitcoinEnv` takes the spent outputs and the input index, with +/// no genesis hash — Bitcoin's sighash does not commit to one. +/// - **No pruning.** SimplicityHL's `satisfy_with_env` is typed to `ElementsEnv`, so the +/// Bitcoin path satisfies without an environment and the program keeps every branch. +/// Harmless for a single-path program; a program with a `match` will carry dead branches +/// into the witness, costing size and budget. +/// - **Cost padding.** Bitcoin's Simplicity validator accepts a program only when its cost +/// falls inside `(minCost, budget]`, and a cheap program has to be padded *up* into that +/// window. Elements has no equivalent, so this is the one step with no counterpart +/// above. The padding is a plain all-zero stack item; it must not be built with +/// `Cost::get_padding_bytes`, whose Elements form is an annex (`[0x50] + zeros`) that a +/// regular stack item's leading byte would be misread as. +#[allow(clippy::too_many_arguments)] +pub fn finalize_bitcoin_covenant_input( + simf_path: &Path, + compile_params: &HashMap, + type_hints: &HashMap, + extra_leaf_payloads: &[Vec], + witnesses: Option<&serde_json::Value>, + sig_signer: Option<&SigSigner>, + tx: &lwk_wollet::elements::bitcoin::Transaction, + witness_utxos: &[lwk_wollet::elements::bitcoin::TxOut], + input_index: u32, + opts: impl Into, +) -> Result>> { + use lwk_wollet::elements::bitcoin as btc; + use simplicityhl::simplicity::jet::bitcoin::BitcoinEnv; + + let opts = opts.into(); + if opts.family != ChainFamily::Bitcoin { + anyhow::bail!( + "compile options target chain '{}' but a Bitcoin witness was requested", + opts.family + ); + } + + let source = std::fs::read_to_string(simf_path) + .with_context(|| format!("Cannot read simf file: {}", simf_path.display()))?; + let args_json = build_args_json(compile_params, type_hints)?; + let arguments: Arguments = serde_json::from_str(&args_json) + .with_context(|| format!("Failed to parse Arguments from JSON:\n{args_json}"))?; + let compiled = compile_program(source, arguments, &opts)?; + let abi_meta = compiled + .generate_abi_meta() + .map_err(|e| anyhow::anyhow!("Cannot read program ABI: {e}"))?; + + let commit = compiled.commit(); + let script_cmr = commit.cmr(); + + // The taproot tree, folded exactly as the address derivation folds it — the control + // block has to reproduce the merkle root the output committed to, or the spend fails + // with nothing to point at. + let merkle_root = covenant_merkle_root( + simf_path, + compile_params, + type_hints, + extra_leaf_payloads, + &opts, + )?; + let mut sibling_hashes: Vec = Vec::new(); + for payload in extra_leaf_payloads { + sibling_hashes.push(btc::taproot::TapNodeHash::from_byte_array(tapdata_hash( + payload, + ))); + } + + use btc::key::TapTweak as _; + let secp = btc::secp256k1::Secp256k1::new(); + let nums = btc::XOnlyPublicKey::from_slice(&NUMS_KEY_BYTES).context("Invalid NUMS key")?; + let root = btc::taproot::TapNodeHash::from_byte_array(merkle_root); + let (_, parity) = nums.tap_tweak(&secp, Some(root)); + + let control_block = btc::taproot::ControlBlock { + leaf_version: btc::taproot::LeafVersion::from_consensus(SIMPLICITY_LEAF_VERSION) + .expect("simplicity leaf version"), + output_key_parity: parity, + internal_key: nums, + merkle_branch: btc::taproot::TaprootMerkleBranch::try_from(sibling_hashes) + .map_err(|e| anyhow::anyhow!("taproot merkle branch is too long: {e}"))?, + }; + + let env = BitcoinEnv::new( + tx.clone(), + witness_utxos, + input_index, + script_cmr, + control_block.clone(), + ); + + // Signature witnesses are computed against the environment's own sighash, exactly as + // on Elements — the program will recompute it with `jet::sig_all_hash`, and a + // signature over anything else simply does not verify. + let injected: Option; + let effective_witnesses = if let (Some(signer), Some(w)) = (sig_signer, witnesses) { + injected = Some( + inject_bitcoin_signatures(w, &env, signer) + .context("Failed to compute Signature witnesses")?, + ); + injected.as_ref() + } else { + witnesses + }; + + let witness_values = + build_witness_values_from_types(effective_witnesses, &abi_meta.witness_types) + .context("Cannot build witness values")?; + + let satisfied = compiled + .satisfy(witness_values) + .map_err(|e| anyhow::anyhow!("Covenant satisfaction failed: {e}"))?; + + // Prune against the real transaction, dropping every branch this spend does not take. + // + // Not optional. Simplicity's anti-DoS rule requires a redeem program to contain no + // unexecuted nodes, so an unpruned program with a `match` is rejected outright — + // `mempool-script-verify-flag-failed (Anti-DOS check failed)`, which reads like a + // resource limit and is really "this program has branches you did not take". + // + // SimplicityHL's `satisfy_with_env` does this on the Elements side but is typed to + // `ElementsEnv`, so it cannot be used here. `RedeemNode::prune` underneath it is + // generic over the environment, which is what makes the Bitcoin path possible at all. + let redeem = satisfied + .redeem() + .prune(&env) + .map_err(|e| anyhow::anyhow!("Cannot prune covenant program: {e}"))?; + + let (prog, witness) = redeem.to_vec_with_witness(); + let mut stack = vec![ + witness, + prog, + script_cmr.as_ref().to_vec(), + control_block.serialize(), + ]; + + // Pad the cost into the validator's acceptance window, if the program is too cheap. + // Sized from the *satisfied* program so it reflects which branches actually run. + // `None` means the program's cost already falls inside the budget its witness buys, so + // there is nothing to pad. Padding only ever raises a *cheap* program into the window. + if let Some(padding) = satisfied.required_padding_bytes(&stack) { + if !padding.is_empty() { + eprintln!("[covenant] cost padding: {} bytes", padding.len()); + stack.push(padding); + } + } + Ok(stack) +} + +/// Resolve `"type": "Signature"` witnesses against a Bitcoin environment. +/// +/// The Elements counterpart is [`inject_computed_signatures`]; the two differ only in the +/// environment type, and both take the hash from the environment rather than recomputing +/// it, so the value signed is by construction the one the program will check. +fn inject_bitcoin_signatures>( + witnesses: &serde_json::Value, + env: &simplicityhl::simplicity::jet::bitcoin::BitcoinEnv, + signer: &SigSigner, +) -> Result { + let mut out = witnesses.clone(); + let Some(obj) = out.as_object_mut() else { + return Ok(out); + }; + for (name, spec) in obj.iter_mut() { + let Some(map) = spec.as_object() else { + continue; + }; + if map.get("type").and_then(|v| v.as_str()) != Some("Signature") { + continue; + } + let sig_type = map + .get("sig_type") + .and_then(|v| v.as_str()) + .unwrap_or("sig_hash_all"); + let hash: [u8; 32] = match sig_type { + "sig_hash_all" => { + use lwk_wollet::elements::hashes::Hash as _; + env.c_tx_env().sighash_all().to_byte_array() + } + other => anyhow::bail!("unknown signature type '{other}' for witness '{name}'"), + }; + let key_label = map + .get("key") + .and_then(|v| v.as_str()) + .unwrap_or(name.as_str()); + let sig = signer(key_label, sig_type, &hash)?; + *spec = serde_json::json!({ + "type": "simplicityhl", + "value": format!("0x{}", hex_bytes(&sig)), + }); + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -2479,208 +2684,3 @@ mod tests { ); } } - -// --------------------------------------------------------------------------- -// Bitcoin covenant spending -// --------------------------------------------------------------------------- - -/// Finalize a Simplicity covenant input on a Bitcoin PSBT. -/// -/// The Bitcoin counterpart of [`finalize_covenant_input`], and the same shape: compile, -/// build the taproot control block, satisfy the program against the real transaction, and -/// write the four-item Simplicity tapscript witness -/// `[witness, program, cmr, control_block]`. -/// -/// Three things differ, and each is a consequence of the chain rather than a choice: -/// -/// - **The environment.** `BitcoinEnv` takes the spent outputs and the input index, with -/// no genesis hash — Bitcoin's sighash does not commit to one. -/// - **No pruning.** SimplicityHL's `satisfy_with_env` is typed to `ElementsEnv`, so the -/// Bitcoin path satisfies without an environment and the program keeps every branch. -/// Harmless for a single-path program; a program with a `match` will carry dead branches -/// into the witness, costing size and budget. -/// - **Cost padding.** Bitcoin's Simplicity validator accepts a program only when its cost -/// falls inside `(minCost, budget]`, and a cheap program has to be padded *up* into that -/// window. Elements has no equivalent, so this is the one step with no counterpart -/// above. The padding is a plain all-zero stack item; it must not be built with -/// `Cost::get_padding_bytes`, whose Elements form is an annex (`[0x50] + zeros`) that a -/// regular stack item's leading byte would be misread as. -#[allow(clippy::too_many_arguments)] -pub fn finalize_bitcoin_covenant_input( - simf_path: &Path, - compile_params: &HashMap, - type_hints: &HashMap, - extra_leaf_payloads: &[Vec], - witnesses: Option<&serde_json::Value>, - sig_signer: Option<&SigSigner>, - tx: &lwk_wollet::elements::bitcoin::Transaction, - witness_utxos: &[lwk_wollet::elements::bitcoin::TxOut], - input_index: u32, - opts: impl Into, -) -> Result>> { - use lwk_wollet::elements::bitcoin as btc; - use simplicityhl::simplicity::jet::bitcoin::BitcoinEnv; - - let opts = opts.into(); - if opts.family != ChainFamily::Bitcoin { - anyhow::bail!( - "compile options target chain '{}' but a Bitcoin witness was requested", - opts.family - ); - } - - let source = std::fs::read_to_string(simf_path) - .with_context(|| format!("Cannot read simf file: {}", simf_path.display()))?; - let args_json = build_args_json(compile_params, type_hints)?; - let arguments: Arguments = serde_json::from_str(&args_json) - .with_context(|| format!("Failed to parse Arguments from JSON:\n{args_json}"))?; - let compiled = compile_program(source, arguments, &opts)?; - let abi_meta = compiled - .generate_abi_meta() - .map_err(|e| anyhow::anyhow!("Cannot read program ABI: {e}"))?; - - let commit = compiled.commit(); - let script_cmr = commit.cmr(); - - // The taproot tree, folded exactly as the address derivation folds it — the control - // block has to reproduce the merkle root the output committed to, or the spend fails - // with nothing to point at. - let merkle_root = covenant_merkle_root( - simf_path, - compile_params, - type_hints, - extra_leaf_payloads, - &opts, - )?; - let mut sibling_hashes: Vec = Vec::new(); - for payload in extra_leaf_payloads { - sibling_hashes.push(btc::taproot::TapNodeHash::from_byte_array(tapdata_hash( - payload, - ))); - } - - use btc::key::TapTweak as _; - let secp = btc::secp256k1::Secp256k1::new(); - let nums = btc::XOnlyPublicKey::from_slice(&NUMS_KEY_BYTES).context("Invalid NUMS key")?; - let root = btc::taproot::TapNodeHash::from_byte_array(merkle_root); - let (_, parity) = nums.tap_tweak(&secp, Some(root)); - - let control_block = btc::taproot::ControlBlock { - leaf_version: btc::taproot::LeafVersion::from_consensus(SIMPLICITY_LEAF_VERSION) - .expect("simplicity leaf version"), - output_key_parity: parity, - internal_key: nums, - merkle_branch: btc::taproot::TaprootMerkleBranch::try_from(sibling_hashes) - .map_err(|e| anyhow::anyhow!("taproot merkle branch is too long: {e}"))?, - }; - - let env = BitcoinEnv::new( - tx.clone(), - witness_utxos, - input_index, - script_cmr, - control_block.clone(), - ); - - // Signature witnesses are computed against the environment's own sighash, exactly as - // on Elements — the program will recompute it with `jet::sig_all_hash`, and a - // signature over anything else simply does not verify. - let injected: Option; - let effective_witnesses = if let (Some(signer), Some(w)) = (sig_signer, witnesses) { - injected = Some( - inject_bitcoin_signatures(w, &env, signer) - .context("Failed to compute Signature witnesses")?, - ); - injected.as_ref() - } else { - witnesses - }; - - let witness_values = - build_witness_values_from_types(effective_witnesses, &abi_meta.witness_types) - .context("Cannot build witness values")?; - - let satisfied = compiled - .satisfy(witness_values) - .map_err(|e| anyhow::anyhow!("Covenant satisfaction failed: {e}"))?; - - // Prune against the real transaction, dropping every branch this spend does not take. - // - // Not optional. Simplicity's anti-DoS rule requires a redeem program to contain no - // unexecuted nodes, so an unpruned program with a `match` is rejected outright — - // `mempool-script-verify-flag-failed (Anti-DOS check failed)`, which reads like a - // resource limit and is really "this program has branches you did not take". - // - // SimplicityHL's `satisfy_with_env` does this on the Elements side but is typed to - // `ElementsEnv`, so it cannot be used here. `RedeemNode::prune` underneath it is - // generic over the environment, which is what makes the Bitcoin path possible at all. - let redeem = satisfied - .redeem() - .prune(&env) - .map_err(|e| anyhow::anyhow!("Cannot prune covenant program: {e}"))?; - - let (prog, witness) = redeem.to_vec_with_witness(); - let mut stack = vec![ - witness, - prog, - script_cmr.as_ref().to_vec(), - control_block.serialize(), - ]; - - // Pad the cost into the validator's acceptance window, if the program is too cheap. - // Sized from the *satisfied* program so it reflects which branches actually run. - // `None` means the program's cost already falls inside the budget its witness buys, so - // there is nothing to pad. Padding only ever raises a *cheap* program into the window. - if let Some(padding) = satisfied.required_padding_bytes(&stack) { - if !padding.is_empty() { - eprintln!("[covenant] cost padding: {} bytes", padding.len()); - stack.push(padding); - } - } - Ok(stack) -} - -/// Resolve `"type": "Signature"` witnesses against a Bitcoin environment. -/// -/// The Elements counterpart is [`inject_computed_signatures`]; the two differ only in the -/// environment type, and both take the hash from the environment rather than recomputing -/// it, so the value signed is by construction the one the program will check. -fn inject_bitcoin_signatures>( - witnesses: &serde_json::Value, - env: &simplicityhl::simplicity::jet::bitcoin::BitcoinEnv, - signer: &SigSigner, -) -> Result { - let mut out = witnesses.clone(); - let Some(obj) = out.as_object_mut() else { - return Ok(out); - }; - for (name, spec) in obj.iter_mut() { - let Some(map) = spec.as_object() else { - continue; - }; - if map.get("type").and_then(|v| v.as_str()) != Some("Signature") { - continue; - } - let sig_type = map - .get("sig_type") - .and_then(|v| v.as_str()) - .unwrap_or("sig_hash_all"); - let hash: [u8; 32] = match sig_type { - "sig_hash_all" => { - use lwk_wollet::elements::hashes::Hash as _; - env.c_tx_env().sighash_all().to_byte_array() - } - other => anyhow::bail!("unknown signature type '{other}' for witness '{name}'"), - }; - let key_label = map - .get("key") - .and_then(|v| v.as_str()) - .unwrap_or(name.as_str()); - let sig = signer(key_label, sig_type, &hash)?; - *spec = serde_json::json!({ - "type": "simplicityhl", - "value": format!("0x{}", hex_bytes(&sig)), - }); - } - Ok(out) -} diff --git a/txmanifest_lib/src/eval.rs b/txmanifest_lib/src/eval.rs index d808f65..ed9e118 100644 --- a/txmanifest_lib/src/eval.rs +++ b/txmanifest_lib/src/eval.rs @@ -309,7 +309,7 @@ pub fn encode_leaf_bytes(item: &serde_json::Value, resolved: &str) -> Result hex_to_bytes(&resolved)?, + "bytes32" | "bytes" | "pubkey" => hex_to_bytes(resolved)?, other => bail!("Unsupported taproot leaf value type '{other}'"), }; @@ -341,7 +341,7 @@ pub fn encode_leaf_bytes(item: &serde_json::Value, resolved: &str) -> Result Result> { let hex = s.trim().trim_start_matches("0x").trim_start_matches("0X"); - if hex.len() % 2 != 0 { + if !hex.len().is_multiple_of(2) { bail!("odd-length hex '{hex}'"); } (0..hex.len()) @@ -381,7 +381,7 @@ fn eval_op_return_concat( .trim() .trim_start_matches("0x") .trim_start_matches("0X"); - if hex.len() % 2 != 0 { + if !hex.len().is_multiple_of(2) { bail!("OP_RETURN data part '{arg}' resolved to odd-length hex '{hex}'"); } let mut bytes: Vec = (0..hex.len()) @@ -408,8 +408,8 @@ fn eval_op_return_concat( /// - `Some(true)` — the operands differ (validation passes) /// - `Some(false)` — the operands are equal (validation is violated) /// - `None` — the expression is not a `!=` comparison, or an operand -/// could not be resolved; the caller treats this as -/// informational (not enforced). +/// could not be resolved; the caller treats this as +/// informational (not enforced). /// /// Only `!=` is handled here. Equality/relational operators are intentionally /// left unenforced so existing `==` / `>=` validations keep their current diff --git a/txmanifest_lib/src/lifecycle.rs b/txmanifest_lib/src/lifecycle.rs index cf478f2..d775a29 100644 --- a/txmanifest_lib/src/lifecycle.rs +++ b/txmanifest_lib/src/lifecycle.rs @@ -3500,15 +3500,12 @@ fn select_input( Some(parsed.script_pubkey) } }; - let spk_matches_wt = |u: &lwk_wollet::WalletTxOut| { - from_spk - .as_ref() - .map_or(true, |spk| &u.script_pubkey == spk) - }; + let spk_matches_wt = + |u: &lwk_wollet::WalletTxOut| from_spk.as_ref().is_none_or(|spk| &u.script_pubkey == spk); let spk_matches_ext = |u: &lwk_wollet::ExternalUtxo| { from_spk .as_ref() - .map_or(true, |spk| &u.txout.script_pubkey == spk) + .is_none_or(|spk| &u.txout.script_pubkey == spk) }; // Check confidential UTXOs first. @@ -3988,9 +3985,6 @@ fn broadcast_finalized_tx( } } -/// Extract a human-readable message from a validation's `error` field, which may be -/// a bare string or a `{"code": ..., "message": ...}` object. - // --------------------------------------------------------------------------- // Method-level hook execution // --------------------------------------------------------------------------- @@ -4071,16 +4065,6 @@ fn run_hook_block( // create_instance field evaluation // --------------------------------------------------------------------------- -/// Evaluate all `create_instance.fields` entries and return the resulting -/// `HashMap` to be written as `instance.fields`. -/// -/// Each field value is either: -/// - A string expression (`"$params.FOO"`, `"$instance.X"`, etc.) -/// - A `ParamCompute::Tapleaf` spec (same as used in Step 3b) -/// -/// Multi-pass: fields that depend on other fields computed in the same block -/// are retried until stable (topological ordering without explicit sort). - /// Resolve a `Tapleaf.extra_leaves` spec inside `create_instance` (task 11). /// Each leaf is the concatenation of its payload items; a typed value item's `value` /// resolves against the in-progress `fields` first (for sibling computed fields such as @@ -4115,10 +4099,7 @@ fn resolve_create_instance_leaves( // A sibling computed field: only use the in-progress map (never a stale ctx value); // if not ready yet, defer. let resolved: String = if computed_field_names.contains(key) { - match fields.get(key) { - Some(v) => v.clone(), - None => return None, - } + fields.get(key)?.clone() } else { fields .get(key) @@ -4140,6 +4121,15 @@ fn resolve_create_instance_leaves( Some(leaves) } +/// Evaluate all `create_instance.fields` entries and return the resulting +/// `HashMap` to be written as `instance.fields`. +/// +/// Each field value is either: +/// - A string expression (`"$params.FOO"`, `"$instance.X"`, etc.) +/// - A `ParamCompute::Tapleaf` spec (same as used in Step 3b) +/// +/// Multi-pass: fields that depend on other fields computed in the same block +/// are retried until stable (topological ordering without explicit sort). fn eval_create_instance_fields( ci: &crate::manifest::InstanceCreate, ctx: &ExecutionContext, @@ -4577,1462 +4567,1362 @@ pub fn run_headless( Ok(HeadlessResult { tx_hex, pset_b64 }) } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::manifest::{ComputeSpec, InstanceCreate}; - use std::collections::BTreeMap; - - /// `confidential: false` must beat a confidential address's own blinding key. - /// - /// The covenant case that forces it: a payout check does `unwrap_right` on - /// `jet::output_amount(0)`, which only exists when the output is explicit. Paying a - /// blinded output to a `tlq1…` address makes the program hit the pruned confidential - /// branch — "Execution reached a pruned branch" — with nothing pointing at blinding as - /// the cause. The flag was honoured for `wallet` destinations and ignored for address - /// ones, so the manifest could ask for this and be quietly overruled. - #[test] - fn confidential_false_strips_an_addresss_blinding_key() { - // Parsed from JSON rather than hand-built, so the test exercises the same - // `confidential` field an author actually writes. - let manifests: Vec = [None, Some(true), Some(false)] - .iter() - .map(|confidential| { - let extra = match confidential { - Some(v) => format!(r#", "confidential": {v}"#), - None => String::new(), - }; - Manifest::from_json_str(&format!( - r#"{{ "manifest_version": "0.3.0", "protocol": "t", "actions": {{ "A": {{ "outputs": [ - {{ "id": "o0", "amount_sat": "1", "destination": "params.a"{extra} }} ] }} }} }}"# - )) - .expect("manifest should parse") - }) - .collect(); - let output = |i: usize| &manifests[i].actions["A"].outputs.as_ref().unwrap()[0]; - let (default, yes, no) = (output(0), output(1), output(2)); - - let confidential: lwk_wollet::elements::Address = - "tlq1qq2tmze58e74rl0tw9cx23j47zxk0ddas95gdy0j2wzkcuejxwj8yypv93ju8yay2s3r4y6fwpuw0l322965qvse6u6zdd5mf5" - .parse() - .expect("valid address"); - assert!(confidential.blinding_pubkey.is_some()); - let explicit = confidential.to_unconfidential(); - let confidential_bpk = || { - confidential - .blinding_pubkey - .map(|pk| lwk_wollet::elements::bitcoin::PublicKey { - inner: pk, - compressed: true, - }) - }; - - // The default and an explicit `true` both blind, as before. - assert!(address_blinding_key(default, confidential_bpk()).is_some()); - assert!(address_blinding_key(yes, confidential_bpk()).is_some()); - // `false` wins over the address's own key. - assert!(address_blinding_key(no, confidential_bpk()).is_none()); - // An unconfidential address has nothing to strip, whatever the flag says. - assert!(address_blinding_key(default, None).is_none()); - assert!(address_blinding_key(yes, None).is_none()); +/// 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, bound: &crate::target::Target) -> Result<()> { + let cfg = &bound.config; + let target = bound.network; - // Stripping must not move the scriptPubKey, or the covenant's committed - // sha256(spk) would stop matching the output that pays it. - assert_eq!(confidential.script_pubkey(), explicit.script_pubkey()); + // 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(), + ); } - fn broadcast_fixture(dir_name: &str) -> (Manifest, std::path::PathBuf) { - let manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.3.0", "protocol": "t", - "actions": { "A": { - "on_post_broadcast": { "set": { "instance.RAN": "1 + 1" } } - } } }"#, - ) - .expect("manifest should parse"); - let dir = std::env::temp_dir().join(format!("txm-{dir_name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - (manifest, dir) + let activation = cfg.activation(target)?; + let provided = target.capabilities(&activation); + let missing = manifest.requires.missing_from(&provided); + if missing.is_empty() { + return Ok(()); } - fn vault_meta() -> CovenantOutputMeta { - CovenantOutputMeta { - utxo_type: "vault".into(), - output_id: "locked".into(), - script_pubkey: lwk_wollet::elements::Script::from(vec![0x51, 0x20, 7, 7]), - amount_sat: 1_000, - asset: crate::assembly::bitcoin_policy_asset(), - } + 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.contains(&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) +} - /// One function now records a broadcast for both chains: the hook runs (the Bitcoin - /// copy never ran it), the covenant output is recorded, and history is appended. - #[test] - fn a_broadcast_runs_the_hook_and_records_state_and_history() { - let (manifest, dir) = broadcast_fixture("record"); - let state_out = dir.join("t.state.1.json"); - let meta = [vault_meta()]; - let rec = BroadcastRecord { - action_name: "A", - action: &manifest.actions["A"], - instance: None, - covenant_outputs: &meta, - state_out: &state_out, - history_seed: &state_out, - }; - let outputs = [OutputDescriptor { - script_pubkey: vec![0x51, 0x20, 7, 7], - amount_sat: Some(1_000), - asset: None, - }]; - let mut ctx = ExecutionContext::new(); - let mut state = None; - let txid = "ab".repeat(32); +/// Everything needed to satisfy one covenant input, resolved from the manifest. +pub(crate) struct CovenantSpendSpec { + pub simf_path: std::path::PathBuf, + pub params: std::collections::HashMap, + pub hints: std::collections::HashMap, + pub leaf_payloads: Vec>, + /// Witness values with manifest references already resolved. + pub witnesses: Option, + /// The manifest's raw witness block, which still carries the `source.key` a signature + /// witness names. Resolution strips it, so both forms are kept. + pub witness_spec: Option, +} - record_broadcast(&rec, &txid, &outputs, &mut ctx, &mut state); +/// Build, sign and finalize a Bitcoin transaction, show what it does, and broadcast it +/// once the user confirms. +/// +/// `Ok(None)` when nothing was sent — exported to `export_path`, or declined at the prompt. +#[allow(clippy::too_many_arguments)] +fn run_bitcoin_build( + req: &pset_builder::BuildPsetRequest, + run: &crate::session::BitcoinRun, + export_path: Option<&Path>, + covenant_specs: &std::collections::HashMap, + compile_params: &std::collections::HashMap, + action_params: &std::collections::HashMap, + wallet: Option<&WalletFile>, + compile_opts: &covenant::CompileOpts, + intent: Option<&str>, +) -> Result> { + use crate::psbt_builder; - assert_eq!(ctx.get_compile_param("RAN"), Some("2"), "hook ran"); - assert_eq!(ctx.get_param("broadcast_txid"), Some(txid.as_str())); - let written = ContractState::load(&state_out).expect("state written"); - assert_eq!(written.utxos_for_type("vault").len(), 1); - assert_eq!(written.utxos[0].txid, txid); - let history = StateHistory::load(&history_path(&state_out)).unwrap(); - assert_eq!(history.entries.len(), 1); + let change_script = run + .wallet + .script_pubkey(crate::bitcoin_wallet::Branch::Change, run.change_index)?; + // Narrowing, not translating: anything Elements-only that reached the request is + // refused here rather than dropped. See `psbt_builder::from_pset_request`. + let psbt_req = psbt_builder::from_pset_request(req, Some(change_script))?; + + let mut built = psbt_builder::build_psbt(&psbt_req)?; + println!( + " {} PSBT constructed ({} inputs, {} outputs, fee {} sat).", + style("✓").green(), + built.psbt.unsigned_tx.input.len(), + built.psbt.unsigned_tx.output.len(), + style(built.fee).yellow(), + ); + if let Some(idx) = built.change_index { + println!(" Change at output #{idx}"); } - /// History follows the state file: if the state could not be written, no history entry - /// claims it was. The Bitcoin copy appended regardless. - #[test] - fn no_history_is_appended_when_state_cannot_be_written() { - let (manifest, dir) = broadcast_fixture("nowrite"); - // A state path whose parent is a file, so the write must fail. - let blocker = dir.join("not-a-dir"); - std::fs::write(&blocker, "").unwrap(); - let state_out = blocker.join("t.state.1.json"); - let history_seed = dir.join("t.state.1.json"); - let rec = BroadcastRecord { - action_name: "A", - action: &manifest.actions["A"], - instance: None, - covenant_outputs: &[], - state_out: &state_out, - history_seed: &history_seed, - }; - record_broadcast( - &rec, - &"cd".repeat(32), - &[], - &mut ExecutionContext::new(), - &mut None, - ); - - assert!(!state_out.exists()); - assert!(StateHistory::load(&history_path(&history_seed)) - .unwrap() - .entries - .is_empty()); - } - - /// The end this boundary exists for: one closed `utxo_type`, two sites, two states — - /// and the state each site commits to comes from the site, not from whatever the - /// running action happens to have in scope. - /// - /// Also pins the closure itself. A leaf in a closed type reading `instance.X` must - /// fail rather than quietly pick up an ambient value, because "quietly picked up the - /// wrong value" is indistinguishable from success when the output is an address. - #[test] - fn closed_utxo_type_resolves_each_site_independently() { - let manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.3.0", "protocol": "t", - "actions": { "A": { "params": { "claim": { "type": "bytes32" } } } }, - "utxo_types": { "prize": { - "description": "d", - "params": { "STATE": { "type": "bytes32", - "default": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } }, - "script": { "type": "simplicity", "source": "./x.simf", - "extra_leaves": [ { "type": "tapdata", - "payload": [ { "value": "params.STATE", "type": "bytes32" } ] } ] } } } }"#, - ) - .expect("manifest should parse"); - let ut = manifest.utxo_type("prize").unwrap(); - let action = &manifest.actions["A"]; - let base = std::collections::HashMap::new(); - - let mut ctx = ExecutionContext::new(); - ctx.set_param("claim", format!("0x{}", "11".repeat(32))); - - // Site 1 says nothing → the declared default (the "no claim pending" state). - let creating = serde_json::json!({ "utxo_type": "prize" }); - let a = resolve_utxo_site(ut, Some(&creating), &base, &base, action, &ctx).unwrap(); - assert_eq!(a.leaf_payloads, vec![vec![0xffu8; 32]]); - - // Site 2 binds the action's param → a different leaf, hence a different address. - let claiming = - serde_json::json!({ "utxo_type": "prize", "args": { "STATE": "params.claim" } }); - let b = resolve_utxo_site(ut, Some(&claiming), &base, &base, action, &ctx).unwrap(); - assert_eq!(b.leaf_payloads, vec![vec![0x11u8; 32]]); - assert_ne!(a.leaf_payloads, b.leaf_payloads); - - // The closed type's compile params are exactly its own params — the ambient map - // is no longer poured in wholesale. - let mut ambient = std::collections::HashMap::new(); - ambient.insert("SOMETHING_ELSE".to_string(), "0xdead".to_string()); - let c = resolve_utxo_site(ut, Some(&creating), &ambient, &base, action, &ctx).unwrap(); - assert_eq!(c.compile_params.keys().collect::>(), vec!["STATE"]); - - // An unbound arg naming an action param that does not exist fails here, rather - // than being encoded as the string "params.nope". - let bad = serde_json::json!({ "utxo_type": "prize", "args": { "STATE": "params.nope" } }); - let err = resolve_utxo_site(ut, Some(&bad), &base, &base, action, &ctx).unwrap_err(); - assert!(err.to_string().contains("params.nope"), "{err}"); - } - - /// A closed type's leaves may not reach action scope, and its defaults may not either. - #[test] - fn closed_utxo_type_cannot_read_action_scope() { - let manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.3.0", "protocol": "t", - "actions": { "A": { "params": { "claim": { "type": "bytes32" } } } }, - "utxo_types": { - "leaky_leaf": { - "description": "d", - "params": {}, - "script": { "type": "simplicity", "source": "./x.simf", - "extra_leaves": [ { "type": "tapdata", - "payload": [ { "value": "params.claim", "type": "bytes32" } ] } ] } }, - "leaky_default": { - "description": "d", - "params": { "S": { "type": "bytes32", "default": "params.claim" } }, - "script": { "type": "simplicity", "source": "./x.simf" } } } }"#, - ) - .unwrap(); - let action = &manifest.actions["A"]; - let base = std::collections::HashMap::new(); - let mut ctx = ExecutionContext::new(); - ctx.set_param("claim", format!("0x{}", "11".repeat(32))); - let site = serde_json::json!({ "utxo_type": "x" }); - - // The action HAS `claim` — the point is that a closed type cannot see it. - let err = resolve_utxo_site( - manifest.utxo_type("leaky_leaf").unwrap(), - Some(&site), - &base, - &base, - action, - &ctx, - ) - .unwrap_err(); - assert!(err.to_string().contains("params.claim"), "{err}"); - - let err = resolve_utxo_site( - manifest.utxo_type("leaky_default").unwrap(), - Some(&site), - &base, - &base, - action, - &ctx, - ) - .unwrap_err(); - // `{:#}` renders the whole chain: the outer context names the param, the cause - // says why a default may not read action scope. - let chain = format!("{err:#}"); - assert!(chain.contains("action scope"), "{chain}"); - assert!(chain.contains('S'), "should name the param: {chain}"); - } - - /// Inputs and outputs must agree on what an asset may be spelled as. - /// - /// A resolved input carries a *label* (`"lbtc"`), not necessarily a hex id — that is - /// what the manifest writes and what resolution defaults to. The covenant-input branch - /// used to parse it as raw hex while the output branch resolved it, so a covenant - /// input on L-BTC failed with "failed to parse hex" for the obvious spelling. - #[test] - fn asset_labels_and_hex_ids_resolve_the_same_everywhere() { - let net = lwk_wollet::ElementsNetwork::LiquidTestnet; - assert_eq!(resolve_asset_id("lbtc", net).unwrap(), net.policy_asset()); - assert_eq!( - resolve_asset_id("bitcoin", net).unwrap(), - net.policy_asset() - ); - // The hex form of the very same asset must still resolve to it. - assert_eq!( - resolve_asset_id(&net.policy_asset().to_string(), net).unwrap(), - net.policy_asset() - ); - // And a non-asset string is still an error, not a silent policy-asset fallback. - assert!(resolve_asset_id("not-an-asset", net).is_err()); - } - - /// Every declared input must reach the built transaction, or the run must fail. - /// - /// The live failure this pins: `"utxo_source": "prize_covenant"` (a bare string - /// rather than `{"utxo_type": "prize_covenant"}`) matched neither collection branch - /// and fell through a chain with no `else`, so the covenant input vanished from the - /// PSET. Step 2 still resolved it, the preview still showed it, and the run signed - /// and broadcast a transaction that never spent the covenant UTXO at all. - #[test] - fn declared_inputs_that_never_reach_the_pset_are_detected() { - let manifest = Manifest::from_json_str( - r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "inputs": [ - { "id": "contest_in", "utxo_source": "prize_covenant" }, - { "id": "fees_in", "utxo_source": "wallet" } ] } } }"#, - ) - .expect("manifest should parse"); - let declared = manifest.actions["A"].inputs.as_deref().unwrap(); - - let collected = |ids: &[&str]| -> Vec { - ids.iter() - .map(|id| pset_builder::PsetInput::Covenant { - input_id: (*id).to_string(), - outpoint: lwk_wollet::elements::OutPoint::new( - lwk_wollet::elements::Txid::from_str(&"a".repeat(64)).unwrap(), - 0, - ), - script_pubkey: lwk_wollet::elements::Script::new(), - asset: lwk_wollet::elements::AssetId::from_str(&"b".repeat(64)).unwrap(), - amount: 1, - issuance: None, - sequence: None, - blinding: None, - }) - .collect() - }; - - // The bug: the unrecognized-source input is silently absent. - assert_eq!( - inputs_missing_from_pset(declared, &collected(&["fees_in"])), - vec!["contest_in"], - ); - // Both gone — reported in declaration order, not just the first. - assert_eq!( - inputs_missing_from_pset(declared, &collected(&[])), - vec!["contest_in", "fees_in"], - ); - // The healthy case must stay silent, whatever the ordering. - assert!( - inputs_missing_from_pset(declared, &collected(&["contest_in", "fees_in"])).is_empty() - ); - assert!( - inputs_missing_from_pset(declared, &collected(&["fees_in", "contest_in"])).is_empty() - ); - // Extra inputs the engine adds (not declared) are not this check's business. - assert!( - inputs_missing_from_pset(declared, &collected(&["contest_in", "fees_in", "x"])) - .is_empty() - ); - } - - /// A declared change output must survive having no `amount_sat`. - /// - /// It reads like an incomplete output — no amount, nothing pushed to the PSET — and - /// was skipped for exactly that reason, which dropped its asset from the - /// change-permitted set and made a manifest that *declared* change fail with "this - /// action does not permit L-BTC change". The validator has always exempted change - /// from needing an amount, so the engine must agree. - #[test] - fn change_outputs_are_never_skipped_for_a_missing_amount() { - let manifest = Manifest::from_json_str( - 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 }, - { "id": "plain_wallet", "asset": "lbtc", "destination": "wallet" } ] } } }"#, - ) - .expect("manifest should parse"); - let outputs = manifest.actions["A"].outputs.as_deref().unwrap(); - let by_id = |id: &str| outputs.iter().find(|o| o.id == id).expect(id); - - // Change: skipped for neither spelling, `optional` included — the asset has to be - // registered either way. - assert!(!skips_when_amount_absent(by_id("change_out"))); - assert!(!skips_when_amount_absent(by_id("opt_change"))); - assert!(is_change_output(by_id("change_out"))); - - // Everything else keeps the old behaviour: optional is skipped, required is not - // (and goes on to fail loudly for the missing amount). - assert!(skips_when_amount_absent(by_id("opt_wallet"))); - assert!(!skips_when_amount_absent(by_id("plain_wallet"))); - assert!(!is_change_output(by_id("plain_wallet"))); - } - - #[test] - fn outpoint_override_parses_txid_vout() { - let ov = OutpointOverride::parse_outpoint( - "fd6c7a7b01c6dee573081c8c69587eefd79df15a833a61e67a6607449418ac90:1", - ) - .unwrap(); - assert_eq!( - ov.txid, - "fd6c7a7b01c6dee573081c8c69587eefd79df15a833a61e67a6607449418ac90" - ); - assert_eq!(ov.vout, 1); - assert!(ov.amount_sat.is_none() && ov.asset.is_none()); - } - - #[test] - fn outpoint_override_rejects_bad_forms() { - // Missing vout. - assert!(OutpointOverride::parse_outpoint("fd6c7a7b").is_err()); - // Non-numeric vout. - assert!(OutpointOverride::parse_outpoint(&format!("{}:x", "a".repeat(64))).is_err()); - // Wrong-length / non-hex txid. - assert!(OutpointOverride::parse_outpoint("zz:0").is_err()); - assert!(OutpointOverride::parse_outpoint(&format!("{}:0", "a".repeat(63))).is_err()); - } - - /// When the action params handler resolves a `wallet_key` param, it must write - /// the fresh value into BOTH `params` and `compile_params`. Without the - /// `compile_params` write, tapleaf hash computations (which read - /// `ctx.all_compile_params()`) would silently use the stale key loaded from the - /// previous instance file. - #[test] - fn wallet_key_action_param_overwrites_stale_compile_param() { - let mut ctx = ExecutionContext::new(); - - // Simulate template-fields loading from a previous instance file. - ctx.set_compile_param( - "BORROWER_PUB_KEY", - "1d4c354f5f91613f50ba8f59361bc5fb0d0e01fbb90495b7fbfc744e8f5d2253", - ); - - // Simulate the fixed action-params handler: both writes now happen. - let fresh_key = "c21eda458165b99ce9309896df32ea7470ee6c03d26f54b49fbd56df2295bdb8"; - ctx.set_param("BORROWER_PUB_KEY", fresh_key); - ctx.set_compile_param("BORROWER_PUB_KEY", fresh_key); + // Wallet inputs are signed with a wallet key; covenant inputs are satisfied by a + // Simplicity witness. Split them here, since the two are finalized by different code. + let mut plan = Vec::new(); + let mut covenant_inputs: Vec<(usize, String)> = Vec::new(); + for (index, input) in psbt_req.inputs.iter().enumerate() { + match input { + psbt_builder::PsbtInput::Wallet { outpoint, .. } => { + let utxo = run + .utxos + .iter() + .find(|u| u.outpoint == *outpoint) + .ok_or_else(|| { + anyhow::anyhow!( + "input '{}' spends {outpoint}, which is not a wallet UTXO", + input.input_id() + ) + })?; + plan.push(psbt_builder::KeyPathSigner { + input_index: index, + branch: utxo.branch, + index: utxo.index, + }); + } + psbt_builder::PsbtInput::Covenant { input_id, .. } => { + covenant_inputs.push((index, input_id.clone())); + } + } + } - // compile_params must reflect the fresh key so tapleaf computations are correct. - assert_eq!( - ctx.get_compile_param("BORROWER_PUB_KEY"), - Some(fresh_key), - "compile_params must be overwritten with the fresh wallet key", - ); - assert_eq!(ctx.get_param("BORROWER_PUB_KEY"), Some(fresh_key),); - // Verify the full map that tapleaf code reads has the fresh value. - assert_eq!( - ctx.all_compile_params() - .get("BORROWER_PUB_KEY") - .map(String::as_str), - Some(fresh_key), + psbt_builder::sign_key_path_inputs(&mut built.psbt, &run.wallet, &plan)?; + psbt_builder::finalize_key_path_inputs(&mut built.psbt)?; + if !plan.is_empty() { + println!( + " {} Signed {} wallet input(s).", + style("✓").green(), + plan.len() ); } - /// `apply_utxo_compile_params` must pass literal values (e.g. `"1"`, `"true"`) through - /// unchanged when they don't exist as keys in the base compile_params map, and still - /// resolve values that ARE keys. Without this, params like `ASSET_AMOUNT: "1"` were silently - /// dropped, causing SimplicityHL to fail with "Parameter ASSET_AMOUNT is missing an argument". - #[test] - fn apply_utxo_compile_params_passes_literal_values_through() { - use crate::manifest::{UtxoScript, UtxoType}; - use std::collections::HashMap; + // Covenant inputs, once every other input's witness is settled: the Simplicity program + // reads the whole transaction, and `sig_all_hash` commits to it. + if !covenant_inputs.is_empty() { + let prevouts: Vec = + psbt_req.inputs.iter().map(|i| i.witness_utxo()).collect(); + let unsigned = built.psbt.unsigned_tx.clone(); - let mut base: HashMap = HashMap::new(); - base.insert("LENDER_NFT_ASSET_ID".to_string(), "deadbeef".to_string()); + for (index, input_id) in &covenant_inputs { + let spec = covenant_specs.get(input_id).ok_or_else(|| { + anyhow::anyhow!("covenant input '{input_id}' has no resolved program") + })?; + print!( + " {} Covenant input '{}' — satisfying… ", + style("·").dim(), + input_id + ); + use std::io::Write as _; + let _ = std::io::stdout().flush(); - let base_hints: HashMap = HashMap::new(); + let witness_spec = spec.witness_spec.clone(); + let params_snap = compile_params.clone(); + let action_snap = action_params.clone(); + let wallet_snap = wallet.cloned(); + let signer = move |name: &str, _kind: &str, hash: &[u8; 32]| -> Result<[u8; 64]> { + let w = wallet_snap + .as_ref() + .ok_or_else(|| anyhow::anyhow!("no wallet loaded — cannot sign '{name}'"))?; + let key_ref = witness_spec + .as_ref() + .and_then(|wits| wits.get(name)) + .and_then(|s| s.get("source")) + .and_then(|src| src.get("key")) + .and_then(|k| k.as_str()) + .ok_or_else(|| anyhow::anyhow!("no signing key for witness '{name}'"))?; + let resolved = resolve_witness_signing_key(key_ref, &action_snap, ¶ms_snap); + wallet::sign_schnorr_for_pubkey(w, resolved, hash) + }; - let mut cp_map: std::collections::HashMap = - std::collections::HashMap::new(); - cp_map.insert("ASSET_ID".to_string(), "LENDER_NFT_ASSET_ID".to_string()); // key reference - cp_map.insert("ASSET_AMOUNT".to_string(), "1".to_string()); // literal - cp_map.insert("WITH_ASSET_BURN".to_string(), "true".to_string()); // literal + match covenant::finalize_bitcoin_covenant_input( + &spec.simf_path, + &spec.params, + &spec.hints, + &spec.leaf_payloads, + spec.witnesses.as_ref(), + Some(&signer), + &unsigned, + &prevouts, + *index as u32, + compile_opts, + ) { + Ok(stack) => { + println!("{}", style("OK").green()); + built.psbt.inputs[*index].final_script_witness = + Some(lwk_wollet::elements::bitcoin::Witness::from_slice(&stack)); + } + Err(e) => { + println!("{}", style("FAILED").red()); + for (i, cause) in e.chain().enumerate() { + println!(" {i}: {cause}"); + } + anyhow::bail!("covenant input '{input_id}' could not be satisfied"); + } + } + } + } - let ut = UtxoType { - description: "test".to_string(), - script: Some(UtxoScript { - type_: "simplicity".to_string(), - source: None, - extra_leaves: None, - compile_params: cp_map, - }), + let tx = psbt_builder::extract_tx(built.psbt)?; + + // Exporting is not a lesser form of broadcasting: it is what a caller asks for when + // something else will relay the transaction, so it must not also send it. + // The outputs, flattened for covenant-state matching. Taken before the transaction is + // consumed, and reported whether or not it is broadcast — an exported transaction still + // describes the UTXOs it would create. + let outputs: Vec = tx + .output + .iter() + .map(|o| OutputDescriptor { + script_pubkey: o.script_pubkey.to_bytes(), + amount_sat: Some(o.value.to_sat()), + // Bitcoin has one asset, so there is nothing to match on and nothing to reject. asset: None, - state_vars: None, - params: None, - }; + }) + .collect(); - let (params, _hints) = apply_utxo_compile_params(&base, &base_hints, &ut); + // The last look before the money moves, read off the finalized transaction rather than + // the manifest: every output with its destination, which of them come back to this + // wallet, and the real fee rate. Shown for an export too — it describes exactly what + // whoever relays the file will send. + crate::bitcoin_review::TxReview::new( + &tx, + &psbt_req.inputs, + &run.wallet, + run.network, + run.change_index, + run.receive_start, + )? + .render(intent); - assert_eq!( - params.get("ASSET_ID").map(String::as_str), - Some("deadbeef"), - "key reference should resolve to value from base" - ); - assert_eq!( - params.get("ASSET_AMOUNT").map(String::as_str), - Some("1"), - "literal '1' must pass through even though it is not a key in base" - ); - assert_eq!( - params.get("WITH_ASSET_BURN").map(String::as_str), - Some("true"), - "literal 'true' must pass through even though it is not a key in base" + if let Some(path) = export_path { + let hex = lwk_wollet::elements::bitcoin::consensus::encode::serialize_hex(&tx); + let doc = serde_json::json!({ "txid": tx.compute_txid().to_string(), "tx_hex": hex }); + std::fs::write(path, serde_json::to_string_pretty(&doc)?) + .with_context(|| format!("cannot write {}", path.display()))?; + println!( + " {} Wrote signed transaction to {}", + style("✓").green(), + path.display() ); + // Reported as broadcast-less: the covenant exists only once the transaction is + // relayed, and writing it into the state file now would name a UTXO that does not + // exist and may never. + return Ok(None); } - /// Per-output `destination.compile_params` (and per-input `utxo_source.compile_params`) - /// must let a covenant param be driven by an action `param`, resolving the value through - /// the execution context AND carrying the param's declared type as the SimplicityHL hint. - /// This is the p2pk case: a `PUB_KEY` covenant param keyed off `params.pubkey`, with no - /// top-level `compile_params` block at all. - #[test] - fn site_compile_param_override_resolves_action_param_and_type() { - use std::collections::HashMap; - - let action: crate::manifest::Action = serde_json::from_value(serde_json::json!({ - "description": "Pay", - "params": { - "pubkey": { "type": "pubkey", "description": "recipient key" } - } - })) - .expect("deserialize action"); + // The same question the Elements path asks, with the same default. This used to go + // straight to `broadcast`, on every network including mainnet: the Bitcoin path leaves + // `run` before the Elements preview and prompt, and nothing here replaced them. + println!(); + if !crate::prompt::confirm_broadcast()? { + println!(" {} Not broadcast.", style("·").dim()); + return Ok(None); + } - let mut ctx = ExecutionContext::new(); - let key = "c21eda458165b99ce9309896df32ea7470ee6c03d26f54b49fbd56df2295bdb8"; - ctx.set_param("pubkey", key); + let txid = run.client.broadcast(&tx)?; + println!( + " {} Broadcast: {}", + style("✓").green(), + style(txid.to_string()).yellow() + ); + Ok(Some(BitcoinBroadcast { + txid: txid.to_string(), + outputs, + })) +} - let base_hints: HashMap = HashMap::new(); - let overrides = serde_json::json!({ "PUB_KEY": "params.pubkey" }); +/// What a broadcast Bitcoin transaction created, for the state file. +pub(crate) struct BitcoinBroadcast { + pub txid: String, + pub outputs: Vec, +} - let (params, hints) = apply_site_compile_param_overrides( - HashMap::new(), - HashMap::new(), - Some(&overrides), - &action, - &base_hints, - &ctx, - ); +/// What a run records once its transaction is on the network. +pub(crate) struct BroadcastRecord<'a> { + pub action_name: &'a str, + pub action: &'a crate::manifest::Action, + /// The instance file this contract belongs to: the one just written for a constructor, + /// else the one loaded. + pub instance: Option<&'a Path>, + pub covenant_outputs: &'a [CovenantOutputMeta], + pub state_out: &'a Path, + pub history_seed: &'a Path, +} - assert_eq!( - params.get("PUB_KEY").map(String::as_str), - Some(key), - "PUB_KEY must resolve to the action param's runtime value" - ); - assert_eq!(hints.get("PUB_KEY").map(String::as_str), Some("pubkey"), - "type hint must be carried from the referenced action param (PUB_KEY is not name-inferable)"); +/// Everything after a transaction is on the network, the same on both chains: the action's +/// `on_post_broadcast` hook, then the state file — spent covenant inputs out, the covenant +/// outputs this action created in — then its history. +/// +/// One function because it was two copies, and they had already drifted: the Bitcoin one +/// never ran the hook, and appended history even when the state file could not be written. +/// +/// A failure to write state is reported, not returned. The transaction is already out, and +/// an error here would read as "nothing happened" when the opposite is true. +pub(crate) fn record_broadcast( + rec: &BroadcastRecord, + txid: &str, + outputs: &[OutputDescriptor], + ctx: &mut ExecutionContext, + contract_state: &mut Option, +) { + if let Some(hook) = &rec.action.on_post_broadcast { + ctx.set_param("broadcast_txid", txid); + run_hook_block(hook, ctx, "[on_post_broadcast]", None); } - /// A witness `source.key` may reference an action `param` (covenant keyed by a runtime - /// value), the `$params.` / `instance.` compile-param forms, or a literal hex. - #[test] - fn witness_signing_key_resolves_action_param_and_compile_param() { - use std::collections::HashMap; - - let mut action_params = HashMap::new(); - action_params.insert("pubkey".to_string(), "aa11".to_string()); - let mut compile_params = HashMap::new(); - compile_params.insert("BORROWER_PUB_KEY".to_string(), "bb22".to_string()); + let mut new_state = contract_state + .take() + .unwrap_or_else(|| ContractState::new(rec.action_name)); + new_state.last_action = rec.action_name.to_string(); + new_state.instance = rec.instance.map(|p| p.display().to_string()); + for inp in rec.action.inputs.as_deref().unwrap_or_default() { + if inp.utxo_type_name().is_some() { + if let Some(r) = ctx.get_input(&inp.id) { + new_state.remove_spent(&r.txid, r.vout); + } + } + } + record_covenant_outputs(&mut new_state, outputs, rec.covenant_outputs, txid); - // action param (the p2pk runtime-key case) - assert_eq!( - resolve_witness_signing_key("params.pubkey", &action_params, &compile_params), - "aa11" - ); - // compile-param forms (as used by the lending example) - assert_eq!( - resolve_witness_signing_key( - "$params.BORROWER_PUB_KEY", - &action_params, - &compile_params - ), - "bb22" - ); - assert_eq!( - resolve_witness_signing_key( - "instance.BORROWER_PUB_KEY", - &action_params, - &compile_params - ), - "bb22" - ); - // The removed `compile_params.` alias is no longer a namespace, so it is not - // resolved — it falls through as a literal like any other unrecognised string. - assert_eq!( - resolve_witness_signing_key( - "compile_params.BORROWER_PUB_KEY", - &action_params, - &compile_params - ), - "compile_params.BORROWER_PUB_KEY" - ); - // unknown / literal passes through verbatim - assert_eq!( - resolve_witness_signing_key("cc33ddee", &action_params, &compile_params), - "cc33ddee" + if let Err(e) = new_state.write(rec.state_out) { + println!( + " {} Could not write state file: {e}", + style("[warn]").yellow() ); + return; } + println!( + " {} State written: {}", + style("✓").green(), + rec.state_out.display() + ); - /// A literal value in a per-site override passes through unchanged (no reference match). - #[test] - fn site_compile_param_override_passes_literal_through() { - use std::collections::HashMap; + let hist_path = history_path(rec.history_seed); + let entry = HistoryEntry { + action: rec.action_name.to_string(), + txid: txid.to_string(), + utxos: new_state.utxos.clone(), + }; + match StateHistory::load(&hist_path).and_then(|mut h| h.append(entry, &hist_path)) { + Ok(()) => println!( + " {} History appended: {}", + style("✓").green(), + hist_path.display() + ), + Err(e) => println!( + " {} Could not write history file: {e}", + style("[warn]").yellow() + ), + } +} - let action: crate::manifest::Action = - serde_json::from_value(serde_json::json!({ "description": "x" })).expect("action"); - let ctx = ExecutionContext::new(); - let overrides = serde_json::json!({ "COUNT": "7" }); +/// A covenant output this action creates, tracked so the state file can name it once the +/// transaction is broadcast and its vouts are known. +/// +/// At module scope rather than inside `run` because both chains' post-broadcast paths +/// match against it. +pub(crate) struct CovenantOutputMeta { + pub utxo_type: String, + pub output_id: String, + pub script_pubkey: lwk_wollet::elements::Script, + pub amount_sat: u64, + pub asset: lwk_wollet::elements::AssetId, +} - let (params, _hints) = apply_site_compile_param_overrides( - HashMap::new(), - HashMap::new(), - Some(&overrides), - &action, - &HashMap::new(), - &ctx, - ); +/// One transaction output, reduced to what covenant-state matching needs. +/// +/// Both chains flatten their own output type into this. Elements outputs carry confidential +/// commitments and an asset id, Bitcoin outputs carry neither — but the question being +/// asked is the same, so the matching below is written once against this. +pub(crate) struct OutputDescriptor { + pub script_pubkey: Vec, + /// `None` when the output is blinded, and therefore cannot be matched by value. + pub amount_sat: Option, + /// `None` on Bitcoin, which has one asset, and when an Elements output is blinded. + pub asset: Option, +} - assert_eq!( - params.get("COUNT").map(String::as_str), - Some("7"), - "an unreferencing literal must pass through verbatim" - ); +/// Record the covenant UTXOs a broadcast transaction just created. +/// +/// Shared by both chains because the matching rule is subtle enough that two copies would +/// diverge: several outputs can share a scriptPubKey — deadcat's four market states are one +/// program under four tapdata leaves — so an output is identified by script *and* amount +/// (and asset, where there is more than one), and each position is consumed at most once so +/// two metas cannot claim the same vout. +/// +/// Without this the next action has no way to find the covenant it is meant to spend, and +/// the operator has to name the outpoint and its amount by hand. +pub(crate) fn record_covenant_outputs( + state: &mut ContractState, + outputs: &[OutputDescriptor], + meta: &[CovenantOutputMeta], + txid: &str, +) { + // This action supersedes any existing UTXO of the types it produces. + for m in meta { + state.utxos.retain(|u| u.utxo_type != m.utxo_type); } - /// `$inputs..` reads an instance field straight off a resolved input, with - /// no hook in between (examples/deadcat's constructor). - /// - /// The assertion that matters is `issued_asset` != `asset`. On an input carrying a new - /// issuance those are different things — the spent UTXO is L-BTC, the created asset is - /// not — and confusing them writes L-BTC's id into a field meant to hold the asset the - /// transaction just created. Nothing downstream would notice: it is a well-formed - /// 32-byte id, so the covenant addresses simply come out wrong. - #[test] - fn create_instance_reads_input_refs_and_distinguishes_issued_asset() { - const LBTC: &str = "1111111111111111111111111111111111111111111111111111111111111111"; - const ISSUED: &str = "2222222222222222222222222222222222222222222222222222222222222222"; - const TOKEN: &str = "3333333333333333333333333333333333333333333333333333333333333333"; - - let mut ctx = ExecutionContext::new(); - ctx.set_input(ResolvedInput { - id: "yes_defining_in".to_string(), - txid: "aa".repeat(32), - vout: 0, - amount_sat: 5_000, - asset: LBTC.to_string(), - issuance_entropy: None, + let mut used_vouts: std::collections::HashSet = std::collections::HashSet::new(); + for m in meta { + let found = outputs.iter().enumerate().find(|(i, o)| { + if used_vouts.contains(i) || o.script_pubkey != m.script_pubkey.as_bytes() { + return false; + } + // A blinded output matches on script alone: its amount and asset are + // commitments, and refusing to match would lose the UTXO entirely. + o.amount_sat.is_none_or(|v| v == m.amount_sat) && o.asset.is_none_or(|a| a == m.asset) }); - ctx.set_input_attr("yes_defining_in", "issued_asset", ISSUED); - ctx.set_input_attr("yes_defining_in", "reissuance_token", TOKEN); - - let mut fields = BTreeMap::new(); - for (name, expr) in [ - ("YES_TOKEN_ASSET", "$inputs.yes_defining_in.issued_asset"), - ( - "YES_REISSUANCE_TOKEN", - "$inputs.yes_defining_in.reissuance_token", - ), - ("SPENT_ASSET", "$inputs.yes_defining_in.asset"), - ] { - fields.insert(name.to_string(), ComputeSpec::Expr(expr.to_string())); + if let Some((vout, _)) = found { + used_vouts.insert(vout); + state.utxos.push(StateUtxo { + utxo_type: m.utxo_type.clone(), + utxo_id: m.output_id.clone(), + txid: txid.to_string(), + vout: vout as u32, + amount_sat: m.amount_sat, + asset: m.asset.to_string(), + }); } - let ci = InstanceCreate { fields }; - - let result = eval_create_instance_fields( - &ci, - &ctx, - std::path::Path::new("/nonexistent"), - &std::collections::HashMap::new(), - lwk_wollet::ElementsNetwork::LiquidTestnet, - false, - false, - ); - - assert_eq!( - result.get("YES_TOKEN_ASSET").map(String::as_str), - Some(ISSUED) - ); - assert_eq!( - result.get("YES_REISSUANCE_TOKEN").map(String::as_str), - Some(TOKEN) - ); - assert_eq!( - result.get("SPENT_ASSET").map(String::as_str), - Some(LBTC), - "`asset` must stay the SPENT utxo's asset — if this ever aliases to the issued \ - asset, every manifest using `.asset` on a funding input changes meaning", - ); } +} - /// Build a reissuance input whose `issuance` block carries the given extra keys. - #[cfg(test)] - fn reissue_input(extra: serde_json::Value) -> crate::manifest::Input { - let mut iss = serde_json::json!({ "kind": "reissue", "asset_amount_sat": 10 }); - for (k, v) in extra.as_object().unwrap() { - iss[k] = v.clone(); - } - serde_json::from_value(serde_json::json!({ - "id": "yes_reissuance_in", - "utxo_source": "wallet", - "issuance": iss - })) - .expect("test input should deserialize") +/// Resolve a manifest asset label to an id on `network`. +/// +/// The chain's own unit (`"lbtc"` / `"bitcoin"`) resolves to whatever that network calls +/// it — Liquid's policy asset, or the synthetic constant standing in for BTC. Anything else +/// has to be an asset id already. +pub(crate) fn resolve_asset_label( + label: &str, + network: crate::chain::Network, +) -> Result { + if crate::manifest::names_policy_asset_str(label) { + return Ok(match network.family() { + crate::chain::ChainFamily::Bitcoin => crate::assembly::bitcoin_policy_asset(), + crate::chain::ChainFamily::Elements => network + .elements_network() + .ok_or_else(|| anyhow::anyhow!("{network} has no Elements mapping"))? + .policy_asset(), + }); } + lwk_wollet::elements::AssetId::from_str(label) + .with_context(|| format!("Cannot parse asset ID '{label}'")) +} - /// The entropy that mints the real testnet market's YES asset, and the asset id it - /// must produce. Taken from the live chain (mint tx 14369d64…), so this also pins the - /// byte order: a block explorer prints this entropy reversed. - #[cfg(test)] - const YES_ENTROPY: &str = "f8326827828ee2aab3c4d273fb573a8cf89a401bbc6643637d439ff36c60b6b9"; - #[cfg(test)] - const YES_ASSET: &str = "3a8b6b466346d9dbfcecd8c8d7b0c1873aee4e00fd5df0d95086a3f7eecd5a39"; +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::{ComputeSpec, InstanceCreate}; + use std::collections::BTreeMap; + /// `confidential: false` must beat a confidential address's own blinding key. + /// + /// The covenant case that forces it: a payout check does `unwrap_right` on + /// `jet::output_amount(0)`, which only exists when the output is explicit. Paying a + /// blinded output to a `tlq1…` address makes the program hit the pruned confidential + /// branch — "Execution reached a pruned branch" — with nothing pointing at blinding as + /// the cause. The flag was honoured for `wallet` destinations and ignored for address + /// ones, so the manifest could ask for this and be quietly overruled. #[test] - fn issuance_entropy_comes_from_the_reference() { - let mut ctx = ExecutionContext::new(); - ctx.set_compile_param("YES_ISSUANCE_ENTROPY", YES_ENTROPY); + fn confidential_false_strips_an_addresss_blinding_key() { + // Parsed from JSON rather than hand-built, so the test exercises the same + // `confidential` field an author actually writes. + let manifests: Vec = [None, Some(true), Some(false)] + .iter() + .map(|confidential| { + let extra = match confidential { + Some(v) => format!(r#", "confidential": {v}"#), + None => String::new(), + }; + Manifest::from_json_str(&format!( + r#"{{ "manifest_version": "0.3.0", "protocol": "t", "actions": {{ "A": {{ "outputs": [ + {{ "id": "o0", "amount_sat": "1", "destination": "params.a"{extra} }} ] }} }} }}"# + )) + .expect("manifest should parse") + }) + .collect(); + let output = |i: usize| &manifests[i].actions["A"].outputs.as_ref().unwrap()[0]; + let (default, yes, no) = (output(0), output(1), output(2)); - let inp = reissue_input(serde_json::json!({ "entropy": "instance.YES_ISSUANCE_ENTROPY" })); - let entropy = resolve_issuance_entropy(&inp, &ctx) - .expect("should resolve") - .expect("should be present"); - assert_eq!(hex_bytes(&entropy), YES_ENTROPY); - } + let confidential: lwk_wollet::elements::Address = + "tlq1qq2tmze58e74rl0tw9cx23j47zxk0ddas95gdy0j2wzkcuejxwj8yypv93ju8yay2s3r4y6fwpuw0l322965qvse6u6zdd5mf5" + .parse() + .expect("valid address"); + assert!(confidential.blinding_pubkey.is_some()); + let explicit = confidential.to_unconfidential(); + let confidential_bpk = || { + confidential + .blinding_pubkey + .map(|pk| lwk_wollet::elements::bitcoin::PublicKey { + inner: pk, + compressed: true, + }) + }; - #[test] - fn issuance_entropy_cross_check_rejects_a_reversed_value() { - // The failure this check exists for. A byte-reversed entropy — the order every - // block explorer prints — is still 32 well-formed bytes, still builds, still - // broadcasts, and reissues a completely different asset. - let mut reversed = pset_builder::decode_entropy_hex(YES_ENTROPY).unwrap(); - reversed.reverse(); + // The default and an explicit `true` both blind, as before. + assert!(address_blinding_key(default, confidential_bpk()).is_some()); + assert!(address_blinding_key(yes, confidential_bpk()).is_some()); + // `false` wins over the address's own key. + assert!(address_blinding_key(no, confidential_bpk()).is_none()); + // An unconfidential address has nothing to strip, whatever the flag says. + assert!(address_blinding_key(default, None).is_none()); + assert!(address_blinding_key(yes, None).is_none()); - let mut ctx = ExecutionContext::new(); - ctx.set_compile_param("YES_ISSUANCE_ENTROPY", hex_bytes(&reversed)); - ctx.set_compile_param("YES_TOKEN_ASSET", YES_ASSET); + // Stripping must not move the scriptPubKey, or the covenant's committed + // sha256(spk) would stop matching the output that pays it. + assert_eq!(confidential.script_pubkey(), explicit.script_pubkey()); + } - let inp = reissue_input(serde_json::json!({ - "entropy": "instance.YES_ISSUANCE_ENTROPY", - "issued_asset": "instance.YES_TOKEN_ASSET" - })); - let err = resolve_issuance_entropy(&inp, &ctx).expect_err("reversed entropy must fail"); - assert!( - err.to_string() - .contains("does not produce the declared asset"), - "unexpected error: {err}" - ); + fn broadcast_fixture(dir_name: &str) -> (Manifest, std::path::PathBuf) { + let manifest = Manifest::from_json_str( + r#"{ "manifest_version": "0.3.0", "protocol": "t", + "actions": { "A": { + "on_post_broadcast": { "set": { "instance.RAN": "1 + 1" } } + } } }"#, + ) + .expect("manifest should parse"); + let dir = std::env::temp_dir().join(format!("txm-{dir_name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + (manifest, dir) + } - // …and the correct order passes the same check. - ctx.set_compile_param("YES_ISSUANCE_ENTROPY", YES_ENTROPY); - assert!(resolve_issuance_entropy(&inp, &ctx).unwrap().is_some()); + fn vault_meta() -> CovenantOutputMeta { + CovenantOutputMeta { + utxo_type: "vault".into(), + output_id: "locked".into(), + script_pubkey: lwk_wollet::elements::Script::from(vec![0x51, 0x20, 7, 7]), + amount_sat: 1_000, + asset: crate::assembly::bitcoin_policy_asset(), + } } + /// One function now records a broadcast for both chains: the hook runs (the Bitcoin + /// copy never ran it), the covenant output is recorded, and history is appended. #[test] - fn issuance_entropy_falls_back_to_provided_inputs() { - // The old path stays working: no `entropy` reference, value from the instance - // file's provided_inputs. + fn a_broadcast_runs_the_hook_and_records_state_and_history() { + let (manifest, dir) = broadcast_fixture("record"); + let state_out = dir.join("t.state.1.json"); + let meta = [vault_meta()]; + let rec = BroadcastRecord { + action_name: "A", + action: &manifest.actions["A"], + instance: None, + covenant_outputs: &meta, + state_out: &state_out, + history_seed: &state_out, + }; + let outputs = [OutputDescriptor { + script_pubkey: vec![0x51, 0x20, 7, 7], + amount_sat: Some(1_000), + asset: None, + }]; let mut ctx = ExecutionContext::new(); - ctx.set_input(ResolvedInput { - id: "yes_reissuance_in".to_string(), - txid: "aa".repeat(32), - vout: 0, - amount_sat: 1, - asset: "bb".repeat(32), - issuance_entropy: Some(YES_ENTROPY.to_string()), - }); - let entropy = resolve_issuance_entropy(&reissue_input(serde_json::json!({})), &ctx) - .expect("should resolve") - .expect("should be present"); - assert_eq!(hex_bytes(&entropy), YES_ENTROPY); - } + let mut state = None; + let txid = "ab".repeat(32); - #[test] - fn issuance_entropy_absent_is_reported_not_guessed() { - let ctx = ExecutionContext::new(); - assert!( - resolve_issuance_entropy(&reissue_input(serde_json::json!({})), &ctx) - .expect("missing entropy is not an error, it is a None") - .is_none() - ); + record_broadcast(&rec, &txid, &outputs, &mut ctx, &mut state); + + assert_eq!(ctx.get_compile_param("RAN"), Some("2"), "hook ran"); + assert_eq!(ctx.get_param("broadcast_txid"), Some(txid.as_str())); + let written = ContractState::load(&state_out).expect("state written"); + assert_eq!(written.utxos_for_type("vault").len(), 1); + assert_eq!(written.utxos[0].txid, txid); + let history = StateHistory::load(&history_path(&state_out)).unwrap(); + assert_eq!(history.entries.len(), 1); } + /// History follows the state file: if the state could not be written, no history entry + /// claims it was. The Bitcoin copy appended regardless. #[test] - fn an_unresolvable_entropy_reference_is_an_error() { - // Distinct from "absent": the manifest named a value and it was not there, which - // is a broken manifest rather than a missing instance field. - let ctx = ExecutionContext::new(); - let inp = reissue_input(serde_json::json!({ "entropy": "instance.NOT_SET" })); - let err = resolve_issuance_entropy(&inp, &ctx).expect_err("should error"); - assert!( - err.to_string().contains("does not resolve"), - "unexpected: {err}" + fn no_history_is_appended_when_state_cannot_be_written() { + let (manifest, dir) = broadcast_fixture("nowrite"); + // A state path whose parent is a file, so the write must fail. + let blocker = dir.join("not-a-dir"); + std::fs::write(&blocker, "").unwrap(); + let state_out = blocker.join("t.state.1.json"); + let history_seed = dir.join("t.state.1.json"); + let rec = BroadcastRecord { + action_name: "A", + action: &manifest.actions["A"], + instance: None, + covenant_outputs: &[], + state_out: &state_out, + history_seed: &history_seed, + }; + record_broadcast( + &rec, + &"cd".repeat(32), + &[], + &mut ExecutionContext::new(), + &mut None, ); + + assert!(!state_out.exists()); + assert!(StateHistory::load(&history_path(&history_seed)) + .unwrap() + .entries + .is_empty()); } - /// A 32-byte id can only travel by a `$` form. The bare-expression path is arithmetic - /// (`eval_expr` returns u64), which is exactly why `$inputs.` had to be added rather - /// than relying on the existing evaluator. + /// The end this boundary exists for: one closed `utxo_type`, two sites, two states — + /// and the state each site commits to comes from the site, not from whatever the + /// running action happens to have in scope. + /// + /// Also pins the closure itself. A leaf in a closed type reading `instance.X` must + /// fail rather than quietly pick up an ambient value, because "quietly picked up the + /// wrong value" is indistinguishable from success when the output is an address. #[test] - fn a_bare_input_expression_cannot_carry_an_asset_id() { + fn closed_utxo_type_resolves_each_site_independently() { + let manifest = Manifest::from_json_str( + r#"{ "manifest_version": "0.3.0", "protocol": "t", + "actions": { "A": { "params": { "claim": { "type": "bytes32" } } } }, + "utxo_types": { "prize": { + "description": "d", + "params": { "STATE": { "type": "bytes32", + "default": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } }, + "script": { "type": "simplicity", "source": "./x.simf", + "extra_leaves": [ { "type": "tapdata", + "payload": [ { "value": "params.STATE", "type": "bytes32" } ] } ] } } } }"#, + ) + .expect("manifest should parse"); + let ut = manifest.utxo_type("prize").unwrap(); + let action = &manifest.actions["A"]; + let base = std::collections::HashMap::new(); + let mut ctx = ExecutionContext::new(); - ctx.set_input_attr("in0", "issued_asset", &"22".repeat(32)); + ctx.set_param("claim", format!("0x{}", "11".repeat(32))); - assert!( - eval::eval_expr_str("inputs.in0.issued_asset", &ctx).is_err(), - "the numeric evaluator must reject a 32-byte id rather than mangle it", - ); - assert_eq!( - eval::resolve_input_ref("in0.issued_asset", &ctx).as_deref(), - Some("22".repeat(32).as_str()), - "the string path must return it intact", - ); + // Site 1 says nothing → the declared default (the "no claim pending" state). + let creating = serde_json::json!({ "utxo_type": "prize" }); + let a = resolve_utxo_site(ut, Some(&creating), &base, &base, action, &ctx).unwrap(); + assert_eq!(a.leaf_payloads, vec![vec![0xffu8; 32]]); + + // Site 2 binds the action's param → a different leaf, hence a different address. + let claiming = + serde_json::json!({ "utxo_type": "prize", "args": { "STATE": "params.claim" } }); + let b = resolve_utxo_site(ut, Some(&claiming), &base, &base, action, &ctx).unwrap(); + assert_eq!(b.leaf_payloads, vec![vec![0x11u8; 32]]); + assert_ne!(a.leaf_payloads, b.leaf_payloads); + + // The closed type's compile params are exactly its own params — the ambient map + // is no longer poured in wholesale. + let mut ambient = std::collections::HashMap::new(); + ambient.insert("SOMETHING_ELSE".to_string(), "0xdead".to_string()); + let c = resolve_utxo_site(ut, Some(&creating), &ambient, &base, action, &ctx).unwrap(); + assert_eq!(c.compile_params.keys().collect::>(), vec!["STATE"]); + + // An unbound arg naming an action param that does not exist fails here, rather + // than being encoded as the string "params.nope". + let bad = serde_json::json!({ "utxo_type": "prize", "args": { "STATE": "params.nope" } }); + let err = resolve_utxo_site(ut, Some(&bad), &base, &base, action, &ctx).unwrap_err(); + assert!(err.to_string().contains("params.nope"), "{err}"); } - /// `eval_create_instance_fields` with a `"$params.KEY"` expression must prefer - /// `ctx.params` over `ctx.compile_params`, so the fresh wallet key is used even - /// if `compile_params` still holds a stale value (extra regression guard for the - /// `Expr` evaluation path). + /// A closed type's leaves may not reach action scope, and its defaults may not either. #[test] - fn eval_create_instance_fields_prefers_params_over_compile_params() { + fn closed_utxo_type_cannot_read_action_scope() { + let manifest = Manifest::from_json_str( + r#"{ "manifest_version": "0.3.0", "protocol": "t", + "actions": { "A": { "params": { "claim": { "type": "bytes32" } } } }, + "utxo_types": { + "leaky_leaf": { + "description": "d", + "params": {}, + "script": { "type": "simplicity", "source": "./x.simf", + "extra_leaves": [ { "type": "tapdata", + "payload": [ { "value": "params.claim", "type": "bytes32" } ] } ] } }, + "leaky_default": { + "description": "d", + "params": { "S": { "type": "bytes32", "default": "params.claim" } }, + "script": { "type": "simplicity", "source": "./x.simf" } } } }"#, + ) + .unwrap(); + let action = &manifest.actions["A"]; + let base = std::collections::HashMap::new(); let mut ctx = ExecutionContext::new(); - ctx.set_compile_param("MY_KEY", "stale"); - ctx.set_param("MY_KEY", "fresh"); - - let mut fields = BTreeMap::new(); - fields.insert( - "MY_KEY".to_string(), - ComputeSpec::Expr("$params.MY_KEY".to_string()), - ); - let ci = InstanceCreate { fields }; + ctx.set_param("claim", format!("0x{}", "11".repeat(32))); + let site = serde_json::json!({ "utxo_type": "x" }); - let result = eval_create_instance_fields( - &ci, + // The action HAS `claim` — the point is that a closed type cannot see it. + let err = resolve_utxo_site( + manifest.utxo_type("leaky_leaf").unwrap(), + Some(&site), + &base, + &base, + action, &ctx, - std::path::Path::new("/nonexistent"), - &std::collections::HashMap::new(), - lwk_wollet::ElementsNetwork::LiquidTestnet, - false, - false, - ); + ) + .unwrap_err(); + assert!(err.to_string().contains("params.claim"), "{err}"); - assert_eq!( - result.get("MY_KEY").map(String::as_str), - Some("fresh"), - "$params.KEY expressions must prefer params over compile_params", - ); + let err = resolve_utxo_site( + manifest.utxo_type("leaky_default").unwrap(), + Some(&site), + &base, + &base, + action, + &ctx, + ) + .unwrap_err(); + // `{:#}` renders the whole chain: the outer context names the param, the cause + // says why a default may not read action scope. + let chain = format!("{err:#}"); + assert!(chain.contains("action scope"), "{chain}"); + assert!(chain.contains('S'), "should name the param: {chain}"); } - /// Task 07 — the lending_v3 manifest's `CreateOffer.create_instance` nested - /// AssetAuth/AssetAuthVault cov-hash chain must reproduce the on-chain lending - /// (collateral) covenant of a real offer. Drives the ACTUAL manifest file's - /// create_instance with live offer 43ab4efe's resolved values, then folds the 2 - /// storage leaves and asserts the out[5] scriptPubKey matches byte-for-byte. + /// Inputs and outputs must agree on what an asset may be spelled as. + /// + /// A resolved input carries a *label* (`"lbtc"`), not necessarily a hex id — that is + /// what the manifest writes and what resolution defaults to. The covenant-input branch + /// used to parse it as raw hex while the output branch resolved it, so a covenant + /// input on L-BTC failed with "failed to parse hex" for the obvious spelling. #[test] - fn lending_v3_create_offer_reproduces_live_offer_out5() { - use crate::manifest::Manifest; - - // Locate examples/lending_v3 relative to the crate (CARGO_MANIFEST_DIR = txmanifest_lib). - let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../examples/lending_v3/txmanifest.json"); - let raw = std::fs::read_to_string(&manifest_path).expect("read lending_v3 manifest"); - let manifest: Manifest = Manifest::from_json_str(&raw).expect("parse lending_v3 manifest"); + fn asset_labels_and_hex_ids_resolve_the_same_everywhere() { let net = lwk_wollet::ElementsNetwork::LiquidTestnet; - - let (_class, _class_def, action) = manifest - .find_template_action("CreateOffer") - .expect("CreateOffer action exists"); - let ci = action - .create_instance - .as_ref() - .expect("CreateOffer has create_instance"); - - // Live offer 43ab4efe parameters (same as examples/lending_recon.rs). - let collateral = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; - let principal = "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5"; - let borrower_nft = "78d61185c79f855fac51a87c191b00266f02d28752f50b3d9092ccf6b978181e"; - let lender_nft = "213462821a5cdb96f435f5ea6597e8937359d6fd5a64b6ac8ef4262bc279fcfb"; - let protocol_fee = "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5"; - let out5 = "51201ae9d30d7a31f1393a289196a4dacc01fac95459540895db448aeca47fbd84e1"; - - // Populate ctx as if inputs + action params were resolved. - let mut ctx = ExecutionContext::new(); - // $params.* (offer terms + keeper + factory + zero-hash default) - ctx.set_param("COLLATERAL_ASSET_ID", collateral); - ctx.set_param("PRINCIPAL_ASSET_ID", principal); - ctx.set_param("PROTOCOL_FEE_KEEPER_ASSET_ID", protocol_fee); - ctx.set_param("COLLATERAL_AMOUNT", "21000"); - ctx.set_param("PRINCIPAL_AMOUNT", "1000"); - ctx.set_param("PRINCIPAL_INTEREST_RATE", "10000"); - ctx.set_param("LOAN_EXPIRATION_TIME", "2536857"); - ctx.set_param("ZERO_HASH", &"00".repeat(32)); - ctx.set_param( - "FACTORY_ASSET_ID", - "0101010101010101010101010101010101010101010101010101010101010101", + assert_eq!(resolve_asset_id("lbtc", net).unwrap(), net.policy_asset()); + assert_eq!( + resolve_asset_id("bitcoin", net).unwrap(), + net.policy_asset() ); - // Protocol message-type tag constant (a param default in the manifest; here set directly - // as a compile param since the test drives create_instance without Step 1). - ctx.set_compile_param("LENDING_PROGRAM_ID", "f80c6162"); - // $instance.* (issuance-resolved NFT asset ids) - ctx.set_compile_param("BORROWER_NFT_ASSET_ID", borrower_nft); - ctx.set_compile_param("LENDER_NFT_ASSET_ID", lender_nft); + // The hex form of the very same asset must still resolve to it. + assert_eq!( + resolve_asset_id(&net.policy_asset().to_string(), net).unwrap(), + net.policy_asset() + ); + // And a non-asset string is still an error, not a silent policy-asset fallback. + assert!(resolve_asset_id("not-an-asset", net).is_err()); + } - // Type hints from the template field + action param declarations. - let mut hints: std::collections::HashMap = std::collections::HashMap::new(); - if let Some((_, template_def, _)) = manifest.find_template_action("CreateOffer") { - for (fname, fdef) in &template_def.fields { - hints.insert(fname.clone(), fdef.type_.clone()); - } - } - if let Some(params) = &action.params { - for (pname, pdef) in params { - hints - .entry(pname.clone()) - .or_insert_with(|| pdef.type_.clone()); - } - } + /// Every declared input must reach the built transaction, or the run must fail. + /// + /// The live failure this pins: `"utxo_source": "prize_covenant"` (a bare string + /// rather than `{"utxo_type": "prize_covenant"}`) matched neither collection branch + /// and fell through a chain with no `else`, so the covenant input vanished from the + /// PSET. Step 2 still resolved it, the preview still showed it, and the run signed + /// and broadcast a transaction that never spent the covenant UTXO at all. + #[test] + fn declared_inputs_that_never_reach_the_pset_are_detected() { + let manifest = Manifest::from_json_str( + r#"{ "manifest_version": "0.3.0", "protocol": "t", "actions": { "A": { "inputs": [ + { "id": "contest_in", "utxo_source": "prize_covenant" }, + { "id": "fees_in", "utxo_source": "wallet" } ] } } }"#, + ) + .expect("manifest should parse"); + let declared = manifest.actions["A"].inputs.as_deref().unwrap(); - let fields = - eval_create_instance_fields(ci, &ctx, &manifest_path, &hints, net, false, true); + let collected = |ids: &[&str]| -> Vec { + ids.iter() + .map(|id| pset_builder::PsetInput::Covenant { + input_id: (*id).to_string(), + outpoint: lwk_wollet::elements::OutPoint::new( + lwk_wollet::elements::Txid::from_str(&"a".repeat(64)).unwrap(), + 0, + ), + script_pubkey: lwk_wollet::elements::Script::new(), + asset: lwk_wollet::elements::AssetId::from_str(&"b".repeat(64)).unwrap(), + amount: 1, + issuance: None, + sequence: None, + blinding: None, + }) + .collect() + }; - // The 5 nested hashes must match the independently-verified recon values. + // The bug: the unrecognized-source input is silently absent. assert_eq!( - fields - .get("FINALIZED_LENDER_VAULT_COV_HASH") - .map(String::as_str), - Some("686766f422bca200851234cc787902d105ae91e7acc97977ff32b84263b286c6"), - "F_lender" + inputs_missing_from_pset(declared, &collected(&["fees_in"])), + vec!["contest_in"], ); + // Both gone — reported in declaration order, not just the first. assert_eq!( - fields.get("LENDER_VAULT_COV_HASH").map(String::as_str), - Some("54a0e779d4324f5f5ef45e0e615b34eb0091c4b88a08bfee3ce4fe0e760cf872"), - "A_lender" + inputs_missing_from_pset(declared, &collected(&[])), + vec!["contest_in", "fees_in"], ); - assert_eq!( - fields - .get("FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH") - .map(String::as_str), - Some("9c2a221b8457112075bf80b46b32878e34a023e3f67653c54d041897926a49bb"), - "F_proto" + // The healthy case must stay silent, whatever the ordering. + assert!( + inputs_missing_from_pset(declared, &collected(&["contest_in", "fees_in"])).is_empty() ); - assert_eq!( - fields - .get("PROTOCOL_FEE_VAULT_COV_HASH") - .map(String::as_str), - Some("2a887b2cbd477c94f4b14c03d32216ccb0faeb087ab08fd3862e105ddcdf5e71"), - "A_proto" + assert!( + inputs_missing_from_pset(declared, &collected(&["fees_in", "contest_in"])).is_empty() ); - assert_eq!( - fields - .get("PRINCIPAL_OUTPUT_SCRIPT_HASH") - .map(String::as_str), - Some("88c5f4e880bed03eb4e59f99f8d60534cd8c3dc9b405f2af72da2b8c358c7eb6"), - "principal_out" + // Extra inputs the engine adds (not declared) are not this check's business. + assert!( + inputs_missing_from_pset(declared, &collected(&["contest_in", "fees_in", "x"])) + .is_empty() ); + } - // CURRENT_DEBT (task 10): principal + principal*bps/10000 = 1000 + 1000*10000/10000 = 2000. + /// A declared change output must survive having no `amount_sat`. + /// + /// It reads like an incomplete output — no amount, nothing pushed to the PSET — and + /// was skipped for exactly that reason, which dropped its asset from the + /// change-permitted set and made a manifest that *declared* change fail with "this + /// action does not permit L-BTC change". The validator has always exempted change + /// from needing an amount, so the engine must agree. + #[test] + fn change_outputs_are_never_skipped_for_a_missing_amount() { + let manifest = Manifest::from_json_str( + 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 }, + { "id": "plain_wallet", "asset": "lbtc", "destination": "wallet" } ] } } }"#, + ) + .expect("manifest should parse"); + let outputs = manifest.actions["A"].outputs.as_deref().unwrap(); + let by_id = |id: &str| outputs.iter().find(|o| o.id == id).expect(id); + + // Change: skipped for neither spelling, `optional` included — the asset has to be + // registered either way. + assert!(!skips_when_amount_absent(by_id("change_out"))); + assert!(!skips_when_amount_absent(by_id("opt_change"))); + assert!(is_change_output(by_id("change_out"))); + + // Everything else keeps the old behaviour: optional is skipped, required is not + // (and goes on to fail loudly for the missing amount). + assert!(skips_when_amount_absent(by_id("opt_wallet"))); + assert!(!skips_when_amount_absent(by_id("plain_wallet"))); + assert!(!is_change_output(by_id("plain_wallet"))); + } + + #[test] + fn outpoint_override_parses_txid_vout() { + let ov = OutpointOverride::parse_outpoint( + "fd6c7a7b01c6dee573081c8c69587eefd79df15a833a61e67a6607449418ac90:1", + ) + .unwrap(); assert_eq!( - fields.get("CURRENT_DEBT").map(String::as_str), - Some("2000"), - "current_debt" + ov.txid, + "fd6c7a7b01c6dee573081c8c69587eefd79df15a833a61e67a6607449418ac90" ); + assert_eq!(ov.vout, 1); + assert!(ov.amount_sat.is_none() && ov.asset.is_none()); + } - // Drive the ACTUAL lending_collateral utxo_type end-to-end: fold the computed create_instance - // fields into ctx, resolve the utxo_type's compile_params + computed storage leaves, and - // reproduce out[5]. This exercises the real extra_leaves wiring (task 10), not a hand copy. - for (k, v) in &fields { - ctx.set_compile_param(k, v); - } - let ut = manifest - .utxo_type("lending_collateral") - .expect("lending_collateral utxo_type exists"); - let base_params: std::collections::HashMap = ctx - .all_compile_params() - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - let (lending_params, lending_hints) = apply_utxo_compile_params(&base_params, &hints, ut); - let leaves = ut - .resolve_extra_leaf_payloads(&ctx) - .expect("resolve storage leaves"); - assert_eq!(leaves.len(), 2, "two storage slots"); - assert_eq!(leaves[0], vec![0u8; 32], "slot0 = is_active zero"); - let mut expect_slot1 = vec![0u8; 32]; - expect_slot1[24..32].copy_from_slice(&2000u64.to_be_bytes()); - assert_eq!( - leaves[1], expect_slot1, - "slot1 = current_debt u64 BE, right-aligned in 32 bytes" + #[test] + fn outpoint_override_rejects_bad_forms() { + // Missing vout. + assert!(OutpointOverride::parse_outpoint("fd6c7a7b").is_err()); + // Non-numeric vout. + assert!(OutpointOverride::parse_outpoint(&format!("{}:x", "a".repeat(64))).is_err()); + // Wrong-length / non-hex txid. + assert!(OutpointOverride::parse_outpoint("zz:0").is_err()); + assert!(OutpointOverride::parse_outpoint(&format!("{}:0", "a".repeat(63))).is_err()); + } + + /// When the action params handler resolves a `wallet_key` param, it must write + /// the fresh value into BOTH `params` and `compile_params`. Without the + /// `compile_params` write, tapleaf hash computations (which read + /// `ctx.all_compile_params()`) would silently use the stale key loaded from the + /// previous instance file. + #[test] + fn wallet_key_action_param_overwrites_stale_compile_param() { + let mut ctx = ExecutionContext::new(); + + // Simulate template-fields loading from a previous instance file. + ctx.set_compile_param( + "BORROWER_PUB_KEY", + "1d4c354f5f91613f50ba8f59361bc5fb0d0e01fbb90495b7fbfc744e8f5d2253", ); - let lending_simf = manifest_path.parent().unwrap().join("lending.simf"); - let addr = crate::covenant::compute_covenant_address( - &lending_simf, - &lending_params, - &lending_hints, - &leaves, - net, - true, - ) - .expect("compute lending covenant address"); - assert_eq!(format!("{:x}", addr.script_pubkey()), out5, - "manifest utxo_type (create_instance chain + computed storage leaves) must reproduce live offer out[5]"); + // Simulate the fixed action-params handler: both writes now happen. + let fresh_key = "c21eda458165b99ce9309896df32ea7470ee6c03d26f54b49fbd56df2295bdb8"; + ctx.set_param("BORROWER_PUB_KEY", fresh_key); + ctx.set_compile_param("BORROWER_PUB_KEY", fresh_key); - // Task 11: LENDING_COV_SCRIPT_HASH = sha256(out[5] spk, WITH storage), computed via a - // tapleaf-over-lending.simf that folds the same storage leaves. + // compile_params must reflect the fresh key so tapleaf computations are correct. assert_eq!( - fields.get("LENDING_COV_SCRIPT_HASH").map(String::as_str), - Some("2f40d78cbd15bd847a995719d707e623520dae2e223f66d77a76599f95685b19"), - "LENDING_COV_SCRIPT_HASH must equal sha256(out[5] scriptPubKey)" - ); - // Cross-check: it really is sha256 of the out[5] spk we just reproduced. - { - use lwk_wollet::elements::hashes::{sha256, Hash}; - let h = sha256::Hash::hash(addr.script_pubkey().as_bytes()).to_byte_array(); - let hh: String = h.iter().map(|b| format!("{b:02x}")).collect(); - assert_eq!( - fields.get("LENDING_COV_SCRIPT_HASH").map(String::as_str), - Some(hh.as_str()) - ); - } - // The lender_nft_script_auth covenant (out[3]) compiles from that script hash. - let sa_ut = manifest - .utxo_type("lender_nft_script_auth") - .expect("script_auth utxo_type"); - let (sa_params, sa_hints) = apply_utxo_compile_params( - &{ - let m: std::collections::HashMap = ctx - .all_compile_params() - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - m - }, - &hints, - sa_ut, + ctx.get_compile_param("BORROWER_PUB_KEY"), + Some(fresh_key), + "compile_params must be overwritten with the fresh wallet key", ); + assert_eq!(ctx.get_param("BORROWER_PUB_KEY"), Some(fresh_key),); + // Verify the full map that tapleaf code reads has the fresh value. assert_eq!( - sa_params.get("SCRIPT_HASH").map(String::as_str), - Some("2f40d78cbd15bd847a995719d707e623520dae2e223f66d77a76599f95685b19"), - "script_auth SCRIPT_HASH resolves to the with-storage lending cov hash" + ctx.all_compile_params() + .get("BORROWER_PUB_KEY") + .map(String::as_str), + Some(fresh_key), ); - let sa_simf = manifest_path.parent().unwrap().join("script_auth.simf"); - crate::covenant::compute_covenant_address(&sa_simf, &sa_params, &sa_hints, &[], net, true) - .expect("out[3] lender_nft_script_auth covenant address compiles"); + } - // out[4]: the wired OP_RETURN output reproduces the on-chain 50-byte lending metadata - // (same offer params as examples/opreturn_recon.rs → identical payload). - let op_out = action - .outputs - .as_ref() - .unwrap() - .iter() - .find(|o| o.id == "creation_op_return") - .expect("creation_op_return output"); - let op_data = op_out.data.as_ref().expect("op_return has data"); - let op_bytes = eval::eval_op_return_data(op_data, &ctx, &hints).expect("eval op_return"); - let op_hex: String = op_bytes.iter().map(|b| format!("{b:02x}")).collect(); - assert_eq!(op_bytes.len(), 50, "lending creation metadata is 50 bytes"); - assert_eq!(op_hex, - "f80c6162a5502895799e276b4af246c821423b4ed5ec5e6b4e6df7a861606939d9a2fc38e80300000000000099b526001027", - "out[4] OP_RETURN must reproduce the on-chain lending metadata for offer 43ab4efe"); + /// `apply_utxo_compile_params` must pass literal values (e.g. `"1"`, `"true"`) through + /// unchanged when they don't exist as keys in the base compile_params map, and still + /// resolve values that ARE keys. Without this, params like `ASSET_AMOUNT: "1"` were silently + /// dropped, causing SimplicityHL to fail with "Parameter ASSET_AMOUNT is missing an argument". + #[test] + fn apply_utxo_compile_params_passes_literal_values_through() { + use crate::manifest::{UtxoScript, UtxoType}; + use std::collections::HashMap; - // out[1]: factory covenant recreated resolves to the fixed (2,0) factory address even in - // the offer context (ISSUING_UTXOS_COUNT/REISSUANCE_FLAGS come from lending_contract fields). - let fac_ut = manifest - .utxo_type("issuance_factory") - .expect("issuance_factory utxo_type"); - let fac_base: std::collections::HashMap = ctx - .all_compile_params() - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - let (fac_params, fac_hints) = apply_utxo_compile_params(&fac_base, &hints, fac_ut); - let fac_simf = manifest_path - .parent() - .unwrap() - .join("issuance_factory.simf"); - let fac_addr = crate::covenant::compute_covenant_address( - &fac_simf, - &fac_params, - &fac_hints, - &[], - net, - true, - ) - .expect("factory covenant address"); - assert_eq!( - format!("{:x}", fac_addr.script_pubkey()), - "5120456881785cc7d561caaa059e02f1a2823066bd860423996bea3e92c621bb064b", - "out[1] factory covenant must be the fixed (2,0) address" - ); + let mut base: HashMap = HashMap::new(); + base.insert("LENDER_NFT_ASSET_ID".to_string(), "deadbeef".to_string()); - // --- AcceptOffer (task 08) covenant outputs --- - // ctx already holds every computed create_instance field as a compile param (set above). - let base_now: std::collections::HashMap = ctx - .all_compile_params() - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); + let base_hints: HashMap = HashMap::new(); + + let mut cp_map: std::collections::HashMap = + std::collections::HashMap::new(); + cp_map.insert("ASSET_ID".to_string(), "LENDER_NFT_ASSET_ID".to_string()); // key reference + cp_map.insert("ASSET_AMOUNT".to_string(), "1".to_string()); // literal + cp_map.insert("WITH_ASSET_BURN".to_string(), "true".to_string()); // literal + + let ut = UtxoType { + description: "test".to_string(), + script: Some(UtxoScript { + type_: "simplicity".to_string(), + source: None, + extra_leaves: None, + compile_params: cp_map, + }), + asset: None, + state_vars: None, + params: None, + }; + + let (params, _hints) = apply_utxo_compile_params(&base, &base_hints, &ut); - // out[0]: active lending covenant (storage slot0 = is_active=1) — the storage-transition - // address from examples/lending_active_recon.rs. - let act_ut = manifest - .utxo_type("lending_collateral_active") - .expect("active utxo_type"); - let (act_params, act_hints) = apply_utxo_compile_params(&base_now, &hints, act_ut); - let act_leaves = act_ut - .resolve_extra_leaf_payloads(&ctx) - .expect("active storage leaves"); assert_eq!( - act_leaves[0][31], 1, - "active slot0 byte[31] = 1 (is_active)" + params.get("ASSET_ID").map(String::as_str), + Some("deadbeef"), + "key reference should resolve to value from base" ); - let act_addr = crate::covenant::compute_covenant_address( - &lending_simf, - &act_params, - &act_hints, - &act_leaves, - net, - true, - ) - .expect("active address"); assert_eq!( - format!("{:x}", act_addr.script_pubkey()), - "51202451da2d003a9fd5cffe1ed523cded17cda7a39604f02642d56d503bdef3eb77", - "AcceptOffer out[0] active lending covenant address (storage transition)" + params.get("ASSET_AMOUNT").map(String::as_str), + Some("1"), + "literal '1' must pass through even though it is not a key in base" ); - assert_ne!( - act_addr.script_pubkey(), - addr.script_pubkey(), - "active differs from pending" + assert_eq!( + params.get("WITH_ASSET_BURN").map(String::as_str), + Some("true"), + "literal 'true' must pass through even though it is not a key in base" ); - - // out[1]: principal AssetAuth(borrower_nft, 1, false) — cross-check sha256(spk) == PRINCIPAL_OUTPUT_SCRIPT_HASH. - let pa_ut = manifest - .utxo_type("principal_asset_auth") - .expect("principal_asset_auth utxo_type"); - let (pa_params, pa_hints) = apply_utxo_compile_params(&base_now, &hints, pa_ut); - let pa_simf = manifest_path.parent().unwrap().join("asset_auth.simf"); - let pa_addr = crate::covenant::compute_covenant_address( - &pa_simf, - &pa_params, - &pa_hints, - &[], - net, - true, - ) - .expect("principal_asset_auth address"); - { - use lwk_wollet::elements::hashes::{sha256, Hash}; - let pa_hash: String = sha256::Hash::hash(pa_addr.script_pubkey().as_bytes()) - .to_byte_array() - .iter() - .map(|b| format!("{b:02x}")) - .collect(); - assert_eq!( - pa_hash, - fields.get("PRINCIPAL_OUTPUT_SCRIPT_HASH").cloned().unwrap(), - "AcceptOffer out[1] AssetAuth spk hash must equal PRINCIPAL_OUTPUT_SCRIPT_HASH" - ); - } } - /// Task 08 — RepayLoan (full repayment, NoRepayments phase). - /// - /// The lending covenant bakes `FINALIZED_LENDER_VAULT_COV_HASH` / - /// `FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH` into its params and, on the full-repayment path, - /// enforces them as the script hashes of out[1]/out[2] (`validate_vaults` → - /// `ensure_output_script_hash`). Those two hashes are themselves anchored: they are part of - /// the create_instance chain that reproduces live offer 43ab4efe's out[5] byte-exactly - /// (see `lending_v3_create_offer_reproduces_live_offer_out5`). - /// - /// So a repayment output is provably correct iff the utxo_type behind it compiles to a - /// scriptPubKey whose sha256 equals that baked-in hash. That is what this asserts — an - /// independent check of the vault utxo_types' param wiring (keeper/supplier roles, burn - /// flags, is_active, the zero finalized-hash) against the anchored values. + /// Per-output `destination.compile_params` (and per-input `utxo_source.compile_params`) + /// must let a covenant param be driven by an action `param`, resolving the value through + /// the execution context AND carrying the param's declared type as the SimplicityHL hint. + /// This is the p2pk case: a `PUB_KEY` covenant param keyed off `params.pubkey`, with no + /// top-level `compile_params` block at all. #[test] - fn lending_v3_repay_loan_vault_outputs_match_covenant_hashes() { - use crate::manifest::Manifest; - use lwk_wollet::elements::hashes::{sha256, Hash}; - - let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../examples/lending_v3/txmanifest.json"); - let raw = std::fs::read_to_string(&manifest_path).expect("read lending_v3 manifest"); - let manifest: Manifest = Manifest::from_json_str(&raw).expect("parse lending_v3 manifest"); - let net = lwk_wollet::ElementsNetwork::LiquidTestnet; + fn site_compile_param_override_resolves_action_param_and_type() { + use std::collections::HashMap; - let (_class, _class_def, create) = manifest - .find_template_action("CreateOffer") - .expect("CreateOffer action exists"); - let ci = create - .create_instance - .as_ref() - .expect("CreateOffer has create_instance"); + let action: crate::manifest::Action = serde_json::from_value(serde_json::json!({ + "description": "Pay", + "params": { + "pubkey": { "type": "pubkey", "description": "recipient key" } + } + })) + .expect("deserialize action"); - // Same live-offer 43ab4efe parameters as the out[5] reproduction test, so the vault - // hashes computed here are the verified ones. let mut ctx = ExecutionContext::new(); - ctx.set_param( - "COLLATERAL_ASSET_ID", - "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", - ); - ctx.set_param( - "PRINCIPAL_ASSET_ID", - "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5", - ); - ctx.set_param( - "PROTOCOL_FEE_KEEPER_ASSET_ID", - "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5", - ); - ctx.set_param("COLLATERAL_AMOUNT", "21000"); - ctx.set_param("PRINCIPAL_AMOUNT", "1000"); - ctx.set_param("PRINCIPAL_INTEREST_RATE", "10000"); - ctx.set_param("LOAN_EXPIRATION_TIME", "2536857"); - ctx.set_param("ZERO_HASH", &"00".repeat(32)); - ctx.set_param( - "FACTORY_ASSET_ID", - "0101010101010101010101010101010101010101010101010101010101010101", - ); - ctx.set_compile_param("LENDING_PROGRAM_ID", "f80c6162"); - ctx.set_compile_param( - "BORROWER_NFT_ASSET_ID", - "78d61185c79f855fac51a87c191b00266f02d28752f50b3d9092ccf6b978181e", + let key = "c21eda458165b99ce9309896df32ea7470ee6c03d26f54b49fbd56df2295bdb8"; + ctx.set_param("pubkey", key); + + let base_hints: HashMap = HashMap::new(); + let overrides = serde_json::json!({ "PUB_KEY": "params.pubkey" }); + + let (params, hints) = apply_site_compile_param_overrides( + HashMap::new(), + HashMap::new(), + Some(&overrides), + &action, + &base_hints, + &ctx, ); - ctx.set_compile_param( - "LENDER_NFT_ASSET_ID", - "213462821a5cdb96f435f5ea6597e8937359d6fd5a64b6ac8ef4262bc279fcfb", + + assert_eq!( + params.get("PUB_KEY").map(String::as_str), + Some(key), + "PUB_KEY must resolve to the action param's runtime value" ); + assert_eq!(hints.get("PUB_KEY").map(String::as_str), Some("pubkey"), + "type hint must be carried from the referenced action param (PUB_KEY is not name-inferable)"); + } - let mut hints: std::collections::HashMap = std::collections::HashMap::new(); - if let Some((_, template_def, _)) = manifest.find_template_action("CreateOffer") { - for (fname, fdef) in &template_def.fields { - hints.insert(fname.clone(), fdef.type_.clone()); - } - } - if let Some(params) = &create.params { - for (pname, pdef) in params { - hints - .entry(pname.clone()) - .or_insert_with(|| pdef.type_.clone()); - } - } + /// A witness `source.key` may reference an action `param` (covenant keyed by a runtime + /// value), the `$params.` / `instance.` compile-param forms, or a literal hex. + #[test] + fn witness_signing_key_resolves_action_param_and_compile_param() { + use std::collections::HashMap; - let fields = - eval_create_instance_fields(ci, &ctx, &manifest_path, &hints, net, false, true); - for (k, v) in &fields { - ctx.set_compile_param(k, v); - } + let mut action_params = HashMap::new(); + action_params.insert("pubkey".to_string(), "aa11".to_string()); + let mut compile_params = HashMap::new(); + compile_params.insert("BORROWER_PUB_KEY".to_string(), "bb22".to_string()); - // ZERO_HASH must reach the instance: the vault utxo_types reference it by name to pick up - // its declared `bytes32` type. Inlined as a literal it would infer as u64 (all digits). + // action param (the p2pk runtime-key case) assert_eq!( - fields.get("ZERO_HASH").map(String::as_str), - Some("00".repeat(32).as_str()), - "ZERO_HASH must be carried into the instance for the vault utxo_types to type it" + resolve_witness_signing_key("params.pubkey", &action_params, &compile_params), + "aa11" ); + // compile-param forms (as used by the lending example) assert_eq!( - hints.get("ZERO_HASH").map(String::as_str), - Some("bytes32"), - "ZERO_HASH must be declared bytes32, not left to value-based inference" + resolve_witness_signing_key( + "$params.BORROWER_PUB_KEY", + &action_params, + &compile_params + ), + "bb22" + ); + assert_eq!( + resolve_witness_signing_key( + "instance.BORROWER_PUB_KEY", + &action_params, + &compile_params + ), + "bb22" + ); + // The removed `compile_params.` alias is no longer a namespace, so it is not + // resolved — it falls through as a literal like any other unrecognised string. + assert_eq!( + resolve_witness_signing_key( + "compile_params.BORROWER_PUB_KEY", + &action_params, + &compile_params + ), + "compile_params.BORROWER_PUB_KEY" + ); + // unknown / literal passes through verbatim + assert_eq!( + resolve_witness_signing_key("cc33ddee", &action_params, &compile_params), + "cc33ddee" ); + } - let base: std::collections::HashMap = ctx - .all_compile_params() - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - let vault_simf = manifest_path - .parent() - .unwrap() - .join("asset_auth_vault.simf"); + /// A literal value in a per-site override passes through unchanged (no reference match). + #[test] + fn site_compile_param_override_passes_literal_through() { + use std::collections::HashMap; + + let action: crate::manifest::Action = + serde_json::from_value(serde_json::json!({ "description": "x" })).expect("action"); + let ctx = ExecutionContext::new(); + let overrides = serde_json::json!({ "COUNT": "7" }); + + let (params, _hints) = apply_site_compile_param_overrides( + HashMap::new(), + HashMap::new(), + Some(&overrides), + &action, + &HashMap::new(), + &ctx, + ); - // out[1] — the lender's finalized vault. - let lender_ut = manifest - .utxo_type("lender_vault_finalized") - .expect("lender_vault_finalized utxo_type"); - let (lp, lh) = apply_utxo_compile_params(&base, &hints, lender_ut); assert_eq!( - lh.get("FINALIZED_VAULT_COV_HASH").map(String::as_str), - Some("bytes32"), - "the zero finalized-hash must carry a bytes32 hint into the compiler" + params.get("COUNT").map(String::as_str), + Some("7"), + "an unreferencing literal must pass through verbatim" ); - let lender_addr = - crate::covenant::compute_covenant_address(&vault_simf, &lp, &lh, &[], net, true) - .expect("lender_vault_finalized address compiles"); - let lender_hash: String = sha256::Hash::hash(lender_addr.script_pubkey().as_bytes()) - .to_byte_array() - .iter() - .map(|b| format!("{b:02x}")) - .collect(); - assert_eq!(Some(lender_hash.as_str()), fields.get("FINALIZED_LENDER_VAULT_COV_HASH").map(String::as_str), - "RepayLoan out[1] spk hash must equal the FINALIZED_LENDER_VAULT_COV_HASH the covenant enforces"); + } - // out[2] — the protocol-fee finalized vault (keeper burn = false, unlike the lender's). - let proto_ut = manifest - .utxo_type("protocol_fee_vault_finalized") - .expect("protocol_fee_vault_finalized utxo_type"); - let (pp, ph) = apply_utxo_compile_params(&base, &hints, proto_ut); - let proto_addr = - crate::covenant::compute_covenant_address(&vault_simf, &pp, &ph, &[], net, true) - .expect("protocol_fee_vault_finalized address compiles"); - let proto_hash: String = sha256::Hash::hash(proto_addr.script_pubkey().as_bytes()) - .to_byte_array() - .iter() - .map(|b| format!("{b:02x}")) - .collect(); - assert_eq!(Some(proto_hash.as_str()), fields.get("FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH").map(String::as_str), - "RepayLoan out[2] spk hash must equal the FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH the covenant enforces"); + /// `$inputs..` reads an instance field straight off a resolved input, with + /// no hook in between (examples/deadcat's constructor). + /// + /// The assertion that matters is `issued_asset` != `asset`. On an input carrying a new + /// issuance those are different things — the spent UTXO is L-BTC, the created asset is + /// not — and confusing them writes L-BTC's id into a field meant to hold the asset the + /// transaction just created. Nothing downstream would notice: it is a well-formed + /// 32-byte id, so the covenant addresses simply come out wrong. + #[test] + fn create_instance_reads_input_refs_and_distinguishes_issued_asset() { + const LBTC: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const ISSUED: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + const TOKEN: &str = "3333333333333333333333333333333333333333333333333333333333333333"; - // The two vaults must be distinct covenants — a keeper/burn-flag mix-up would collapse them. - assert_ne!( - lender_hash, proto_hash, - "lender and protocol-fee vaults must be different covenants" + let mut ctx = ExecutionContext::new(); + ctx.set_input(ResolvedInput { + id: "yes_defining_in".to_string(), + txid: "aa".repeat(32), + vout: 0, + amount_sat: 5_000, + asset: LBTC.to_string(), + issuance_entropy: None, + }); + ctx.set_input_attr("yes_defining_in", "issued_asset", ISSUED); + ctx.set_input_attr("yes_defining_in", "reissuance_token", TOKEN); + + let mut fields = BTreeMap::new(); + for (name, expr) in [ + ("YES_TOKEN_ASSET", "$inputs.yes_defining_in.issued_asset"), + ( + "YES_REISSUANCE_TOKEN", + "$inputs.yes_defining_in.reissuance_token", + ), + ("SPENT_ASSET", "$inputs.yes_defining_in.asset"), + ] { + fields.insert(name.to_string(), ComputeSpec::Expr(expr.to_string())); + } + let ci = InstanceCreate { fields }; + + let result = eval_create_instance_fields( + &ci, + &ctx, + std::path::Path::new("/nonexistent"), + &std::collections::HashMap::new(), + lwk_wollet::ElementsNetwork::LiquidTestnet, + false, + false, ); - // The repayment split must reproduce the covenant's own arithmetic, floor-division and all: - // total_fee = 1000 * 10000/10000 = 1000 - // protocol_fee = 1000 * 1000/10000 = 100 (10% of the interest) - // lender share = CURRENT_DEBT(2000) - 100 = 1900 - // Sum must be exactly the debt — the covenant's split_repayment_by_fees leaves no dust. - let (_, _, repay) = manifest - .find_template_action("RepayLoan") - .expect("RepayLoan action exists"); - let rp = repay.params.as_ref().expect("RepayLoan has params"); - let formula_of = |name: &str| { - rp.get(name) - .and_then(|p| p.compute.as_ref()) - .and_then(|c| c.as_expr()) - .map(str::to_string) - .unwrap_or_else(|| panic!("{name} has a compute expression")) - }; - let protocol_fee = crate::eval::eval_expr_str(&formula_of("TOTAL_PROTOCOL_FEE"), &ctx) - .expect("TOTAL_PROTOCOL_FEE evaluates"); - let lender_amount = crate::eval::eval_expr_str(&formula_of("LENDER_VAULT_AMOUNT"), &ctx) - .expect("LENDER_VAULT_AMOUNT evaluates"); assert_eq!( - protocol_fee, "100", - "protocol fee = 10% of the 1000 interest" + result.get("YES_TOKEN_ASSET").map(String::as_str), + Some(ISSUED) ); assert_eq!( - lender_amount, "1900", - "lender receives the debt less the protocol fee" + result.get("YES_REISSUANCE_TOKEN").map(String::as_str), + Some(TOKEN) ); - let debt: u64 = fields.get("CURRENT_DEBT").unwrap().parse().unwrap(); assert_eq!( - protocol_fee.parse::().unwrap() + lender_amount.parse::().unwrap(), - debt, - "the two vault outputs must account for the whole debt exactly" + result.get("SPENT_ASSET").map(String::as_str), + Some(LBTC), + "`asset` must stay the SPENT utxo's asset — if this ever aliases to the issued \ + asset, every manifest using `.asset` on a funding input changes meaning", ); + } - // The FullRepayment witness carries the debt, so its `instance.CURRENT_DEBT` ref must - // resolve to a literal the SimplicityHL value parser can read. - let offer_in = repay - .inputs - .as_ref() - .unwrap() - .iter() - .find(|i| i.id == "active_offer_in") - .expect("active_offer_in input"); - let wits = crate::eval::resolve_witness_refs( - offer_in - .witnesses - .as_ref() - .expect("active_offer_in has witnesses"), - &ctx, + /// Build a reissuance input whose `issuance` block carries the given extra keys. + #[cfg(test)] + fn reissue_input(extra: serde_json::Value) -> crate::manifest::Input { + let mut iss = serde_json::json!({ "kind": "reissue", "asset_amount_sat": 10 }); + for (k, v) in extra.as_object().unwrap() { + iss[k] = v.clone(); + } + serde_json::from_value(serde_json::json!({ + "id": "yes_reissuance_in", + "utxo_source": "wallet", + "issuance": iss + })) + .expect("test input should deserialize") + } + + /// The entropy that mints the real testnet market's YES asset, and the asset id it + /// must produce. Taken from the live chain (mint tx 14369d64…), so this also pins the + /// byte order: a block explorer prints this entropy reversed. + #[cfg(test)] + const YES_ENTROPY: &str = "f8326827828ee2aab3c4d273fb573a8cf89a401bbc6643637d439ff36c60b6b9"; + #[cfg(test)] + const YES_ASSET: &str = "3a8b6b466346d9dbfcecd8c8d7b0c1873aee4e00fd5df0d95086a3f7eecd5a39"; + + #[test] + fn issuance_entropy_comes_from_the_reference() { + let mut ctx = ExecutionContext::new(); + ctx.set_compile_param("YES_ISSUANCE_ENTROPY", YES_ENTROPY); + + let inp = reissue_input(serde_json::json!({ "entropy": "instance.YES_ISSUANCE_ENTROPY" })); + let entropy = resolve_issuance_entropy(&inp, &ctx) + .expect("should resolve") + .expect("should be present"); + assert_eq!(hex_bytes(&entropy), YES_ENTROPY); + } + + #[test] + fn issuance_entropy_cross_check_rejects_a_reversed_value() { + // The failure this check exists for. A byte-reversed entropy — the order every + // block explorer prints — is still 32 well-formed bytes, still builds, still + // broadcasts, and reissues a completely different asset. + let mut reversed = pset_builder::decode_entropy_hex(YES_ENTROPY).unwrap(); + reversed.reverse(); + + let mut ctx = ExecutionContext::new(); + ctx.set_compile_param("YES_ISSUANCE_ENTROPY", hex_bytes(&reversed)); + ctx.set_compile_param("YES_TOKEN_ASSET", YES_ASSET); + + let inp = reissue_input(serde_json::json!({ + "entropy": "instance.YES_ISSUANCE_ENTROPY", + "issued_asset": "instance.YES_TOKEN_ASSET" + })); + let err = resolve_issuance_entropy(&inp, &ctx).expect_err("reversed entropy must fail"); + assert!( + err.to_string() + .contains("does not produce the declared asset"), + "unexpected error: {err}" ); - assert_eq!( - wits["PATH"]["value"].as_str(), - Some("Right(Left(Right(2000)))"), - "FullRepayment witness must resolve to PATH::Right(Left(Right(current_debt)))" + + // …and the correct order passes the same check. + ctx.set_compile_param("YES_ISSUANCE_ENTROPY", YES_ENTROPY); + assert!(resolve_issuance_entropy(&inp, &ctx).unwrap().is_some()); + } + + #[test] + fn issuance_entropy_falls_back_to_provided_inputs() { + // The old path stays working: no `entropy` reference, value from the instance + // file's provided_inputs. + let mut ctx = ExecutionContext::new(); + ctx.set_input(ResolvedInput { + id: "yes_reissuance_in".to_string(), + txid: "aa".repeat(32), + vout: 0, + amount_sat: 1, + asset: "bb".repeat(32), + issuance_entropy: Some(YES_ENTROPY.to_string()), + }); + let entropy = resolve_issuance_entropy(&reissue_input(serde_json::json!({})), &ctx) + .expect("should resolve") + .expect("should be present"); + assert_eq!(hex_bytes(&entropy), YES_ENTROPY); + } + + #[test] + fn issuance_entropy_absent_is_reported_not_guessed() { + let ctx = ExecutionContext::new(); + assert!( + resolve_issuance_entropy(&reissue_input(serde_json::json!({})), &ctx) + .expect("missing entropy is not an error, it is a None") + .is_none() ); + } - // The offer input spends the ACTIVE covenant AcceptOffer produced — same storage, so the - // same address (this is what the covenant re-derives from the witness debt and compares). - let act_ut = manifest - .utxo_type("lending_collateral_active") - .expect("active utxo_type"); - let (ap, ah) = apply_utxo_compile_params(&base, &hints, act_ut); - let act_leaves = act_ut - .resolve_extra_leaf_payloads(&ctx) - .expect("active storage leaves"); - let lending_simf = manifest_path.parent().unwrap().join("lending.simf"); - let act_addr = crate::covenant::compute_covenant_address( - &lending_simf, - &ap, - &ah, - &act_leaves, - net, - true, - ) - .expect("active address"); + #[test] + fn an_unresolvable_entropy_reference_is_an_error() { + // Distinct from "absent": the manifest named a value and it was not there, which + // is a broken manifest rather than a missing instance field. + let ctx = ExecutionContext::new(); + let inp = reissue_input(serde_json::json!({ "entropy": "instance.NOT_SET" })); + let err = resolve_issuance_entropy(&inp, &ctx).expect_err("should error"); + assert!( + err.to_string().contains("does not resolve"), + "unexpected: {err}" + ); + } + + /// A 32-byte id can only travel by a `$` form. The bare-expression path is arithmetic + /// (`eval_expr` returns u64), which is exactly why `$inputs.` had to be added rather + /// than relying on the existing evaluator. + #[test] + fn a_bare_input_expression_cannot_carry_an_asset_id() { + let mut ctx = ExecutionContext::new(); + ctx.set_input_attr("in0", "issued_asset", "22".repeat(32)); + + assert!( + eval::eval_expr_str("inputs.in0.issued_asset", &ctx).is_err(), + "the numeric evaluator must reject a 32-byte id rather than mangle it", + ); assert_eq!( - format!("{:x}", act_addr.script_pubkey()), - "51202451da2d003a9fd5cffe1ed523cded17cda7a39604f02642d56d503bdef3eb77", - "RepayLoan in[1] must be the same active covenant AcceptOffer created" + eval::resolve_input_ref("in0.issued_asset", &ctx).as_deref(), + Some("22".repeat(32).as_str()), + "the string path must return it intact", ); } - /// Task 10 — a computed u64 storage leaf encodes right-aligned, big-endian, in a - /// 32-byte slot (the lending covenant's `current_debt` layout). + /// `eval_create_instance_fields` with a `"$params.KEY"` expression must prefer + /// `ctx.params` over `ctx.compile_params`, so the fresh wallet key is used even + /// if `compile_params` still holds a stale value (extra regression guard for the + /// `Expr` evaluation path). #[test] - fn encode_leaf_value_u64_be_padded_to_32() { + fn eval_create_instance_fields_prefers_params_over_compile_params() { let mut ctx = ExecutionContext::new(); - ctx.set_compile_param("CURRENT_DEBT", "2000"); - let item = serde_json::json!({ - "value": "instance.CURRENT_DEBT", "type": "u64", "endian": "be", "pad_to": 32, "align": "right" - }); - let bytes = crate::eval::encode_leaf_value(&item, &ctx).expect("encode leaf"); - let mut expect = vec![0u8; 32]; - expect[24..32].copy_from_slice(&2000u64.to_be_bytes()); - assert_eq!(bytes, expect); - // Left-aligned puts the value first. - let item_left = serde_json::json!({ - "value": "8", "type": "u8", "pad_to": 4, "align": "left" - }); + ctx.set_compile_param("MY_KEY", "stale"); + ctx.set_param("MY_KEY", "fresh"); + + let mut fields = BTreeMap::new(); + fields.insert( + "MY_KEY".to_string(), + ComputeSpec::Expr("$params.MY_KEY".to_string()), + ); + let ci = InstanceCreate { fields }; + + let result = eval_create_instance_fields( + &ci, + &ctx, + std::path::Path::new("/nonexistent"), + &std::collections::HashMap::new(), + lwk_wollet::ElementsNetwork::LiquidTestnet, + false, + false, + ); + assert_eq!( - crate::eval::encode_leaf_value(&item_left, &ctx).unwrap(), - vec![8u8, 0, 0, 0] + result.get("MY_KEY").map(String::as_str), + Some("fresh"), + "$params.KEY expressions must prefer params over compile_params", ); } - /// The dex (Tessera) example's `MakeOffer.create_instance` must compute MAKER_SPK - /// from MAKER_PUB_KEY, and that hash must be what the offer address commits to. - /// - /// This is the load-bearing link in the example: tessera.simf enforces payment to a - /// scriptPubKey *hash*, so the settle output can only be built if MAKER_SPK is derived - /// from the same program the payout output targets — not pasted in by hand. Constructors - /// pre-compute create_instance tapleaf fields into compile params before the PSET is - /// built, which is what makes the offer address resolvable in the same transaction. + /// Task 07 — the lending_v3 manifest's `CreateOffer.create_instance` nested + /// AssetAuth/AssetAuthVault cov-hash chain must reproduce the on-chain lending + /// (collateral) covenant of a real offer. Drives the ACTUAL manifest file's + /// create_instance with live offer 43ab4efe's resolved values, then folds the 2 + /// storage leaves and asserts the out[5] scriptPubKey matches byte-for-byte. #[test] - fn dex_make_offer_computes_maker_spk_and_offer_address() { + fn lending_v3_create_offer_reproduces_live_offer_out5() { use crate::manifest::Manifest; - use lwk_wollet::elements::hashes::{sha256, Hash}; + // Locate examples/lending_v3 relative to the crate (CARGO_MANIFEST_DIR = txmanifest_lib). let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../examples/dex/txmanifest.json"); - let raw = std::fs::read_to_string(&manifest_path).expect("read dex manifest"); - let manifest: Manifest = Manifest::from_json_str(&raw).expect("parse dex manifest"); + .join("../examples/lending_v3/txmanifest.json"); + let raw = std::fs::read_to_string(&manifest_path).expect("read lending_v3 manifest"); + let manifest: Manifest = Manifest::from_json_str(&raw).expect("parse lending_v3 manifest"); let net = lwk_wollet::ElementsNetwork::LiquidTestnet; - let (_class, template_def, action) = manifest - .find_template_action("MakeOffer") - .expect("MakeOffer method exists"); + let (_class, _class_def, action) = manifest + .find_template_action("CreateOffer") + .expect("CreateOffer action exists"); let ci = action .create_instance .as_ref() - .expect("MakeOffer has create_instance"); + .expect("CreateOffer has create_instance"); - // Track the manifest's own settings — debug symbols change the CMR, so a hardcoded - // value here would verify a compilation mode the CLI never actually runs. - let compile_opts = manifest.compile_opts(); - let maker_pub_key = "e1512ae2f5b4ee8c12e9c57ccd0943273c6256f496516d3aefeaa16c32d3c05b"; - let lbtc_testnet = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; - let usdt_ish = "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5"; + // Live offer 43ab4efe parameters (same as examples/lending_recon.rs). + let collateral = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + let principal = "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5"; + let borrower_nft = "78d61185c79f855fac51a87c191b00266f02d28752f50b3d9092ccf6b978181e"; + let lender_nft = "213462821a5cdb96f435f5ea6597e8937359d6fd5a64b6ac8ef4262bc279fcfb"; + let protocol_fee = "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5"; + let out5 = "51201ae9d30d7a31f1393a289196a4dacc01fac95459540895db448aeca47fbd84e1"; - // Populate ctx as if Step 1 had prompted for the offer terms. + // Populate ctx as if inputs + action params were resolved. let mut ctx = ExecutionContext::new(); - ctx.set_param("OFFER_ASSET_ID", usdt_ish); - ctx.set_param("OFFER_AMOUNT", "100000"); - ctx.set_param("ASSET_B", lbtc_testnet); - ctx.set_param("AMOUNT_B", "50000"); - ctx.set_param("MAKER_PUB_KEY", maker_pub_key); - ctx.set_param("TIMEOUT", "2000000"); - ctx.set_param("MAX_FEE", "5000"); + // $params.* (offer terms + keeper + factory + zero-hash default) + ctx.set_param("COLLATERAL_ASSET_ID", collateral); + ctx.set_param("PRINCIPAL_ASSET_ID", principal); + ctx.set_param("PROTOCOL_FEE_KEEPER_ASSET_ID", protocol_fee); + ctx.set_param("COLLATERAL_AMOUNT", "21000"); + ctx.set_param("PRINCIPAL_AMOUNT", "1000"); + ctx.set_param("PRINCIPAL_INTEREST_RATE", "10000"); + ctx.set_param("LOAN_EXPIRATION_TIME", "2536857"); + ctx.set_param("ZERO_HASH", "00".repeat(32)); + ctx.set_param( + "FACTORY_ASSET_ID", + "0101010101010101010101010101010101010101010101010101010101010101", + ); + // Protocol message-type tag constant (a param default in the manifest; here set directly + // as a compile param since the test drives create_instance without Step 1). + ctx.set_compile_param("LENDING_PROGRAM_ID", "f80c6162"); + // $instance.* (issuance-resolved NFT asset ids) + ctx.set_compile_param("BORROWER_NFT_ASSET_ID", borrower_nft); + ctx.set_compile_param("LENDER_NFT_ASSET_ID", lender_nft); - // Type hints from the template field + method param declarations (mirrors Step 7's pre-pass). + // Type hints from the template field + action param declarations. let mut hints: std::collections::HashMap = std::collections::HashMap::new(); - for (fname, fdef) in &template_def.fields { - hints.insert(fname.clone(), fdef.type_.clone()); + if let Some((_, template_def, _)) = manifest.find_template_action("CreateOffer") { + for (fname, fdef) in &template_def.fields { + hints.insert(fname.clone(), fdef.type_.clone()); + } } if let Some(params) = &action.params { for (pname, pdef) in params { @@ -6042,581 +5932,681 @@ mod tests { } } - let fields = eval_create_instance_fields( - ci, - &ctx, - &manifest_path, + let fields = + eval_create_instance_fields(ci, &ctx, &manifest_path, &hints, net, false, true); + + // The 5 nested hashes must match the independently-verified recon values. + assert_eq!( + fields + .get("FINALIZED_LENDER_VAULT_COV_HASH") + .map(String::as_str), + Some("686766f422bca200851234cc787902d105ae91e7acc97977ff32b84263b286c6"), + "F_lender" + ); + assert_eq!( + fields.get("LENDER_VAULT_COV_HASH").map(String::as_str), + Some("54a0e779d4324f5f5ef45e0e615b34eb0091c4b88a08bfee3ce4fe0e760cf872"), + "A_lender" + ); + assert_eq!( + fields + .get("FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH") + .map(String::as_str), + Some("9c2a221b8457112075bf80b46b32878e34a023e3f67653c54d041897926a49bb"), + "F_proto" + ); + assert_eq!( + fields + .get("PROTOCOL_FEE_VAULT_COV_HASH") + .map(String::as_str), + Some("2a887b2cbd477c94f4b14c03d32216ccb0faeb087ab08fd3862e105ddcdf5e71"), + "A_proto" + ); + assert_eq!( + fields + .get("PRINCIPAL_OUTPUT_SCRIPT_HASH") + .map(String::as_str), + Some("88c5f4e880bed03eb4e59f99f8d60534cd8c3dc9b405f2af72da2b8c358c7eb6"), + "principal_out" + ); + + // CURRENT_DEBT (task 10): principal + principal*bps/10000 = 1000 + 1000*10000/10000 = 2000. + assert_eq!( + fields.get("CURRENT_DEBT").map(String::as_str), + Some("2000"), + "current_debt" + ); + + // Drive the ACTUAL lending_collateral utxo_type end-to-end: fold the computed create_instance + // fields into ctx, resolve the utxo_type's compile_params + computed storage leaves, and + // reproduce out[5]. This exercises the real extra_leaves wiring (task 10), not a hand copy. + for (k, v) in &fields { + ctx.set_compile_param(k, v); + } + let ut = manifest + .utxo_type("lending_collateral") + .expect("lending_collateral utxo_type exists"); + let base_params: std::collections::HashMap = ctx + .all_compile_params() + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let (lending_params, lending_hints) = apply_utxo_compile_params(&base_params, &hints, ut); + let leaves = ut + .resolve_extra_leaf_payloads(&ctx) + .expect("resolve storage leaves"); + assert_eq!(leaves.len(), 2, "two storage slots"); + assert_eq!(leaves[0], vec![0u8; 32], "slot0 = is_active zero"); + let mut expect_slot1 = vec![0u8; 32]; + expect_slot1[24..32].copy_from_slice(&2000u64.to_be_bytes()); + assert_eq!( + leaves[1], expect_slot1, + "slot1 = current_debt u64 BE, right-aligned in 32 bytes" + ); + + let lending_simf = manifest_path.parent().unwrap().join("lending.simf"); + let addr = crate::covenant::compute_covenant_address( + &lending_simf, + &lending_params, + &lending_hints, + &leaves, + net, + true, + ) + .expect("compute lending covenant address"); + assert_eq!(format!("{:x}", addr.script_pubkey()), out5, + "manifest utxo_type (create_instance chain + computed storage leaves) must reproduce live offer out[5]"); + + // Task 11: LENDING_COV_SCRIPT_HASH = sha256(out[5] spk, WITH storage), computed via a + // tapleaf-over-lending.simf that folds the same storage leaves. + assert_eq!( + fields.get("LENDING_COV_SCRIPT_HASH").map(String::as_str), + Some("2f40d78cbd15bd847a995719d707e623520dae2e223f66d77a76599f95685b19"), + "LENDING_COV_SCRIPT_HASH must equal sha256(out[5] scriptPubKey)" + ); + // Cross-check: it really is sha256 of the out[5] spk we just reproduced. + { + use lwk_wollet::elements::hashes::{sha256, Hash}; + let h = sha256::Hash::hash(addr.script_pubkey().as_bytes()).to_byte_array(); + let hh: String = h.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!( + fields.get("LENDING_COV_SCRIPT_HASH").map(String::as_str), + Some(hh.as_str()) + ); + } + // The lender_nft_script_auth covenant (out[3]) compiles from that script hash. + let sa_ut = manifest + .utxo_type("lender_nft_script_auth") + .expect("script_auth utxo_type"); + let (sa_params, sa_hints) = apply_utxo_compile_params( + &{ + let m: std::collections::HashMap = ctx + .all_compile_params() + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + m + }, &hints, - net, - false, - &compile_opts, + sa_ut, ); + assert_eq!( + sa_params.get("SCRIPT_HASH").map(String::as_str), + Some("2f40d78cbd15bd847a995719d707e623520dae2e223f66d77a76599f95685b19"), + "script_auth SCRIPT_HASH resolves to the with-storage lending cov hash" + ); + let sa_simf = manifest_path.parent().unwrap().join("script_auth.simf"); + crate::covenant::compute_covenant_address(&sa_simf, &sa_params, &sa_hints, &[], net, true) + .expect("out[3] lender_nft_script_auth covenant address compiles"); - // MAKER_SPK is sha256 of the maker_payout covenant's scriptPubKey — verify against the - // program itself rather than a copied constant. - let payout_simf = manifest_path.parent().unwrap().join("maker_payout.simf"); - let payout_params: std::collections::HashMap = - [("PUB_KEY".to_string(), maker_pub_key.to_string())] - .into_iter() - .collect(); - let payout_hints: std::collections::HashMap = - [("PUB_KEY".to_string(), "pubkey".to_string())] - .into_iter() - .collect(); - let payout_addr = crate::covenant::compute_covenant_address( - &payout_simf, - &payout_params, - &payout_hints, + // out[4]: the wired OP_RETURN output reproduces the on-chain 50-byte lending metadata + // (same offer params as examples/opreturn_recon.rs → identical payload). + let op_out = action + .outputs + .as_ref() + .unwrap() + .iter() + .find(|o| o.id == "creation_op_return") + .expect("creation_op_return output"); + let op_data = op_out.data.as_ref().expect("op_return has data"); + let op_bytes = eval::eval_op_return_data(op_data, &ctx, &hints).expect("eval op_return"); + let op_hex: String = op_bytes.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(op_bytes.len(), 50, "lending creation metadata is 50 bytes"); + assert_eq!(op_hex, + "f80c6162a5502895799e276b4af246c821423b4ed5ec5e6b4e6df7a861606939d9a2fc38e80300000000000099b526001027", + "out[4] OP_RETURN must reproduce the on-chain lending metadata for offer 43ab4efe"); + + // out[1]: factory covenant recreated resolves to the fixed (2,0) factory address even in + // the offer context (ISSUING_UTXOS_COUNT/REISSUANCE_FLAGS come from lending_contract fields). + let fac_ut = manifest + .utxo_type("issuance_factory") + .expect("issuance_factory utxo_type"); + let fac_base: std::collections::HashMap = ctx + .all_compile_params() + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let (fac_params, fac_hints) = apply_utxo_compile_params(&fac_base, &hints, fac_ut); + let fac_simf = manifest_path + .parent() + .unwrap() + .join("issuance_factory.simf"); + let fac_addr = crate::covenant::compute_covenant_address( + &fac_simf, + &fac_params, + &fac_hints, &[], net, - &compile_opts, + true, ) - .expect("maker_payout covenant address compiles"); - let expect_spk_hash: String = sha256::Hash::hash(payout_addr.script_pubkey().as_bytes()) - .to_byte_array() - .iter() - .map(|b| format!("{b:02x}")) - .collect(); - + .expect("factory covenant address"); assert_eq!( - fields.get("MAKER_SPK").map(String::as_str), - Some(expect_spk_hash.as_str()), - "create_instance MAKER_SPK must equal sha256(maker_payout scriptPubKey) for MAKER_PUB_KEY" + format!("{:x}", fac_addr.script_pubkey()), + "5120456881785cc7d561caaa059e02f1a2823066bd860423996bea3e92c621bb064b", + "out[1] factory covenant must be the fixed (2,0) address" ); - // The plain `$params.*` fields carry through untouched. - assert_eq!(fields.get("AMOUNT_B").map(String::as_str), Some("50000")); - assert_eq!(fields.get("TIMEOUT").map(String::as_str), Some("2000000")); - assert_eq!(fields.get("MAX_FEE").map(String::as_str), Some("5000")); - - // Drive the ACTUAL tessera_offer utxo_type end-to-end: fold the computed fields back into - // ctx (as the constructor pre-pass does) and resolve the offer covenant address. - for (k, v) in &fields { - ctx.set_compile_param(k, v); - } - let ut = manifest - .utxo_type("tessera_offer") - .expect("tessera_offer utxo_type exists"); - let base: std::collections::HashMap = ctx + // --- AcceptOffer (task 08) covenant outputs --- + // ctx already holds every computed create_instance field as a compile param (set above). + let base_now: std::collections::HashMap = ctx .all_compile_params() .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); - let (offer_params, offer_hints) = apply_utxo_compile_params(&base, &hints, ut); + // out[0]: active lending covenant (storage slot0 = is_active=1) — the storage-transition + // address from examples/lending_active_recon.rs. + let act_ut = manifest + .utxo_type("lending_collateral_active") + .expect("active utxo_type"); + let (act_params, act_hints) = apply_utxo_compile_params(&base_now, &hints, act_ut); + let act_leaves = act_ut + .resolve_extra_leaf_payloads(&ctx) + .expect("active storage leaves"); assert_eq!( - offer_params.get("MAKER_SPK").map(String::as_str), - Some(expect_spk_hash.as_str()), - "the offer covenant compiles against the computed MAKER_SPK" + act_leaves[0][31], 1, + "active slot0 byte[31] = 1 (is_active)" ); - let offer_simf = manifest_path.parent().unwrap().join("tessera.simf"); - let offer_addr = crate::covenant::compute_covenant_address( - &offer_simf, - &offer_params, - &offer_hints, - &[], - net, - &compile_opts, - ) - .expect("tessera_offer covenant address compiles from create_instance output"); - - // Changing an offer term must move the offer address (the terms live in the tapleaf). - let mut bumped = offer_params.clone(); - bumped.insert("AMOUNT_B".to_string(), "50001".to_string()); - let bumped_addr = crate::covenant::compute_covenant_address( - &offer_simf, - &bumped, - &offer_hints, - &[], + let act_addr = crate::covenant::compute_covenant_address( + &lending_simf, + &act_params, + &act_hints, + &act_leaves, net, - &compile_opts, + true, ) - .expect("bumped offer address compiles"); + .expect("active address"); + assert_eq!( + format!("{:x}", act_addr.script_pubkey()), + "51202451da2d003a9fd5cffe1ed523cded17cda7a39604f02642d56d503bdef3eb77", + "AcceptOffer out[0] active lending covenant address (storage transition)" + ); assert_ne!( - offer_addr.script_pubkey(), - bumped_addr.script_pubkey(), - "an offer's price is committed to by its address" + act_addr.script_pubkey(), + addr.script_pubkey(), + "active differs from pending" ); - // But asset A and its amount are NOT terms — the covenant never inspects them on the - // settle path, so the address must not move when they change (see tessera.simf). - let mut other_side = offer_params.clone(); - other_side.insert("OFFER_ASSET_ID".to_string(), lbtc_testnet.to_string()); - other_side.insert("OFFER_AMOUNT".to_string(), "999".to_string()); - let other_side_addr = crate::covenant::compute_covenant_address( - &offer_simf, - &other_side, - &offer_hints, + // out[1]: principal AssetAuth(borrower_nft, 1, false) — cross-check sha256(spk) == PRINCIPAL_OUTPUT_SCRIPT_HASH. + let pa_ut = manifest + .utxo_type("principal_asset_auth") + .expect("principal_asset_auth utxo_type"); + let (pa_params, pa_hints) = apply_utxo_compile_params(&base_now, &hints, pa_ut); + let pa_simf = manifest_path.parent().unwrap().join("asset_auth.simf"); + let pa_addr = crate::covenant::compute_covenant_address( + &pa_simf, + &pa_params, + &pa_hints, &[], net, - &compile_opts, + true, ) - .expect("offer address compiles with a different asset A"); - assert_eq!( - offer_addr.script_pubkey(), - other_side_addr.script_pubkey(), - "asset A and its amount are deliberately not offer terms — the offer is \ - 'whatever sits in this UTXO, for AMOUNT_B of ASSET_B'" - ); - } -} - -/// 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, bound: &crate::target::Target) -> Result<()> { - let cfg = &bound.config; - let target = bound.network; - - // 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.contains(&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`.", - ); + .expect("principal_asset_auth address"); + { + use lwk_wollet::elements::hashes::{sha256, Hash}; + let pa_hash: String = sha256::Hash::hash(pa_addr.script_pubkey().as_bytes()) + .to_byte_array() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + assert_eq!( + pa_hash, + fields.get("PRINCIPAL_OUTPUT_SCRIPT_HASH").cloned().unwrap(), + "AcceptOffer out[1] AssetAuth spk hash must equal PRINCIPAL_OUTPUT_SCRIPT_HASH" + ); + } } - anyhow::bail!(msg) -} -/// Everything needed to satisfy one covenant input, resolved from the manifest. -pub(crate) struct CovenantSpendSpec { - pub simf_path: std::path::PathBuf, - pub params: std::collections::HashMap, - pub hints: std::collections::HashMap, - pub leaf_payloads: Vec>, - /// Witness values with manifest references already resolved. - pub witnesses: Option, - /// The manifest's raw witness block, which still carries the `source.key` a signature - /// witness names. Resolution strips it, so both forms are kept. - pub witness_spec: Option, -} + /// Task 08 — RepayLoan (full repayment, NoRepayments phase). + /// + /// The lending covenant bakes `FINALIZED_LENDER_VAULT_COV_HASH` / + /// `FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH` into its params and, on the full-repayment path, + /// enforces them as the script hashes of out[1]/out[2] (`validate_vaults` → + /// `ensure_output_script_hash`). Those two hashes are themselves anchored: they are part of + /// the create_instance chain that reproduces live offer 43ab4efe's out[5] byte-exactly + /// (see `lending_v3_create_offer_reproduces_live_offer_out5`). + /// + /// So a repayment output is provably correct iff the utxo_type behind it compiles to a + /// scriptPubKey whose sha256 equals that baked-in hash. That is what this asserts — an + /// independent check of the vault utxo_types' param wiring (keeper/supplier roles, burn + /// flags, is_active, the zero finalized-hash) against the anchored values. + #[test] + fn lending_v3_repay_loan_vault_outputs_match_covenant_hashes() { + use crate::manifest::Manifest; + use lwk_wollet::elements::hashes::{sha256, Hash}; -/// Build, sign and finalize a Bitcoin transaction, show what it does, and broadcast it -/// once the user confirms. -/// -/// `Ok(None)` when nothing was sent — exported to `export_path`, or declined at the prompt. -#[allow(clippy::too_many_arguments)] -fn run_bitcoin_build( - req: &pset_builder::BuildPsetRequest, - run: &crate::session::BitcoinRun, - export_path: Option<&Path>, - covenant_specs: &std::collections::HashMap, - compile_params: &std::collections::HashMap, - action_params: &std::collections::HashMap, - wallet: Option<&WalletFile>, - compile_opts: &covenant::CompileOpts, - intent: Option<&str>, -) -> Result> { - use crate::psbt_builder; + let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../examples/lending_v3/txmanifest.json"); + let raw = std::fs::read_to_string(&manifest_path).expect("read lending_v3 manifest"); + let manifest: Manifest = Manifest::from_json_str(&raw).expect("parse lending_v3 manifest"); + let net = lwk_wollet::ElementsNetwork::LiquidTestnet; - let change_script = run - .wallet - .script_pubkey(crate::bitcoin_wallet::Branch::Change, run.change_index)?; - // Narrowing, not translating: anything Elements-only that reached the request is - // refused here rather than dropped. See `psbt_builder::from_pset_request`. - let psbt_req = psbt_builder::from_pset_request(req, Some(change_script))?; + let (_class, _class_def, create) = manifest + .find_template_action("CreateOffer") + .expect("CreateOffer action exists"); + let ci = create + .create_instance + .as_ref() + .expect("CreateOffer has create_instance"); - let mut built = psbt_builder::build_psbt(&psbt_req)?; - println!( - " {} PSBT constructed ({} inputs, {} outputs, fee {} sat).", - style("✓").green(), - built.psbt.unsigned_tx.input.len(), - built.psbt.unsigned_tx.output.len(), - style(built.fee).yellow(), - ); - if let Some(idx) = built.change_index { - println!(" Change at output #{idx}"); - } + // Same live-offer 43ab4efe parameters as the out[5] reproduction test, so the vault + // hashes computed here are the verified ones. + let mut ctx = ExecutionContext::new(); + ctx.set_param( + "COLLATERAL_ASSET_ID", + "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", + ); + ctx.set_param( + "PRINCIPAL_ASSET_ID", + "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5", + ); + ctx.set_param( + "PROTOCOL_FEE_KEEPER_ASSET_ID", + "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5", + ); + ctx.set_param("COLLATERAL_AMOUNT", "21000"); + ctx.set_param("PRINCIPAL_AMOUNT", "1000"); + ctx.set_param("PRINCIPAL_INTEREST_RATE", "10000"); + ctx.set_param("LOAN_EXPIRATION_TIME", "2536857"); + ctx.set_param("ZERO_HASH", "00".repeat(32)); + ctx.set_param( + "FACTORY_ASSET_ID", + "0101010101010101010101010101010101010101010101010101010101010101", + ); + ctx.set_compile_param("LENDING_PROGRAM_ID", "f80c6162"); + ctx.set_compile_param( + "BORROWER_NFT_ASSET_ID", + "78d61185c79f855fac51a87c191b00266f02d28752f50b3d9092ccf6b978181e", + ); + ctx.set_compile_param( + "LENDER_NFT_ASSET_ID", + "213462821a5cdb96f435f5ea6597e8937359d6fd5a64b6ac8ef4262bc279fcfb", + ); - // Wallet inputs are signed with a wallet key; covenant inputs are satisfied by a - // Simplicity witness. Split them here, since the two are finalized by different code. - let mut plan = Vec::new(); - let mut covenant_inputs: Vec<(usize, String)> = Vec::new(); - for (index, input) in psbt_req.inputs.iter().enumerate() { - match input { - psbt_builder::PsbtInput::Wallet { outpoint, .. } => { - let utxo = run - .utxos - .iter() - .find(|u| u.outpoint == *outpoint) - .ok_or_else(|| { - anyhow::anyhow!( - "input '{}' spends {outpoint}, which is not a wallet UTXO", - input.input_id() - ) - })?; - plan.push(psbt_builder::KeyPathSigner { - input_index: index, - branch: utxo.branch, - index: utxo.index, - }); + let mut hints: std::collections::HashMap = std::collections::HashMap::new(); + if let Some((_, template_def, _)) = manifest.find_template_action("CreateOffer") { + for (fname, fdef) in &template_def.fields { + hints.insert(fname.clone(), fdef.type_.clone()); } - psbt_builder::PsbtInput::Covenant { input_id, .. } => { - covenant_inputs.push((index, input_id.clone())); + } + if let Some(params) = &create.params { + for (pname, pdef) in params { + hints + .entry(pname.clone()) + .or_insert_with(|| pdef.type_.clone()); } } - } - - psbt_builder::sign_key_path_inputs(&mut built.psbt, &run.wallet, &plan)?; - psbt_builder::finalize_key_path_inputs(&mut built.psbt)?; - if !plan.is_empty() { - println!( - " {} Signed {} wallet input(s).", - style("✓").green(), - plan.len() - ); - } - // Covenant inputs, once every other input's witness is settled: the Simplicity program - // reads the whole transaction, and `sig_all_hash` commits to it. - if !covenant_inputs.is_empty() { - let prevouts: Vec = - psbt_req.inputs.iter().map(|i| i.witness_utxo()).collect(); - let unsigned = built.psbt.unsigned_tx.clone(); + let fields = + eval_create_instance_fields(ci, &ctx, &manifest_path, &hints, net, false, true); + for (k, v) in &fields { + ctx.set_compile_param(k, v); + } - for (index, input_id) in &covenant_inputs { - let spec = covenant_specs.get(input_id).ok_or_else(|| { - anyhow::anyhow!("covenant input '{input_id}' has no resolved program") - })?; - print!( - " {} Covenant input '{}' — satisfying… ", - style("·").dim(), - input_id - ); - use std::io::Write as _; - let _ = std::io::stdout().flush(); + // ZERO_HASH must reach the instance: the vault utxo_types reference it by name to pick up + // its declared `bytes32` type. Inlined as a literal it would infer as u64 (all digits). + assert_eq!( + fields.get("ZERO_HASH").map(String::as_str), + Some("00".repeat(32).as_str()), + "ZERO_HASH must be carried into the instance for the vault utxo_types to type it" + ); + assert_eq!( + hints.get("ZERO_HASH").map(String::as_str), + Some("bytes32"), + "ZERO_HASH must be declared bytes32, not left to value-based inference" + ); - let witness_spec = spec.witness_spec.clone(); - let params_snap = compile_params.clone(); - let action_snap = action_params.clone(); - let wallet_snap = wallet.cloned(); - let signer = move |name: &str, _kind: &str, hash: &[u8; 32]| -> Result<[u8; 64]> { - let w = wallet_snap - .as_ref() - .ok_or_else(|| anyhow::anyhow!("no wallet loaded — cannot sign '{name}'"))?; - let key_ref = witness_spec - .as_ref() - .and_then(|wits| wits.get(name)) - .and_then(|s| s.get("source")) - .and_then(|src| src.get("key")) - .and_then(|k| k.as_str()) - .ok_or_else(|| anyhow::anyhow!("no signing key for witness '{name}'"))?; - let resolved = resolve_witness_signing_key(key_ref, &action_snap, ¶ms_snap); - wallet::sign_schnorr_for_pubkey(w, resolved, hash) - }; + let base: std::collections::HashMap = ctx + .all_compile_params() + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let vault_simf = manifest_path + .parent() + .unwrap() + .join("asset_auth_vault.simf"); - match covenant::finalize_bitcoin_covenant_input( - &spec.simf_path, - &spec.params, - &spec.hints, - &spec.leaf_payloads, - spec.witnesses.as_ref(), - Some(&signer), - &unsigned, - &prevouts, - *index as u32, - compile_opts, - ) { - Ok(stack) => { - println!("{}", style("OK").green()); - built.psbt.inputs[*index].final_script_witness = - Some(lwk_wollet::elements::bitcoin::Witness::from_slice(&stack)); - } - Err(e) => { - println!("{}", style("FAILED").red()); - for (i, cause) in e.chain().enumerate() { - println!(" {i}: {cause}"); - } - anyhow::bail!("covenant input '{input_id}' could not be satisfied"); - } - } - } - } + // out[1] — the lender's finalized vault. + let lender_ut = manifest + .utxo_type("lender_vault_finalized") + .expect("lender_vault_finalized utxo_type"); + let (lp, lh) = apply_utxo_compile_params(&base, &hints, lender_ut); + assert_eq!( + lh.get("FINALIZED_VAULT_COV_HASH").map(String::as_str), + Some("bytes32"), + "the zero finalized-hash must carry a bytes32 hint into the compiler" + ); + let lender_addr = + crate::covenant::compute_covenant_address(&vault_simf, &lp, &lh, &[], net, true) + .expect("lender_vault_finalized address compiles"); + let lender_hash: String = sha256::Hash::hash(lender_addr.script_pubkey().as_bytes()) + .to_byte_array() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + assert_eq!(Some(lender_hash.as_str()), fields.get("FINALIZED_LENDER_VAULT_COV_HASH").map(String::as_str), + "RepayLoan out[1] spk hash must equal the FINALIZED_LENDER_VAULT_COV_HASH the covenant enforces"); - let tx = psbt_builder::extract_tx(built.psbt)?; + // out[2] — the protocol-fee finalized vault (keeper burn = false, unlike the lender's). + let proto_ut = manifest + .utxo_type("protocol_fee_vault_finalized") + .expect("protocol_fee_vault_finalized utxo_type"); + let (pp, ph) = apply_utxo_compile_params(&base, &hints, proto_ut); + let proto_addr = + crate::covenant::compute_covenant_address(&vault_simf, &pp, &ph, &[], net, true) + .expect("protocol_fee_vault_finalized address compiles"); + let proto_hash: String = sha256::Hash::hash(proto_addr.script_pubkey().as_bytes()) + .to_byte_array() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + assert_eq!(Some(proto_hash.as_str()), fields.get("FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH").map(String::as_str), + "RepayLoan out[2] spk hash must equal the FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH the covenant enforces"); - // Exporting is not a lesser form of broadcasting: it is what a caller asks for when - // something else will relay the transaction, so it must not also send it. - // The outputs, flattened for covenant-state matching. Taken before the transaction is - // consumed, and reported whether or not it is broadcast — an exported transaction still - // describes the UTXOs it would create. - let outputs: Vec = tx - .output - .iter() - .map(|o| OutputDescriptor { - script_pubkey: o.script_pubkey.to_bytes(), - amount_sat: Some(o.value.to_sat()), - // Bitcoin has one asset, so there is nothing to match on and nothing to reject. - asset: None, - }) - .collect(); + // The two vaults must be distinct covenants — a keeper/burn-flag mix-up would collapse them. + assert_ne!( + lender_hash, proto_hash, + "lender and protocol-fee vaults must be different covenants" + ); - // The last look before the money moves, read off the finalized transaction rather than - // the manifest: every output with its destination, which of them come back to this - // wallet, and the real fee rate. Shown for an export too — it describes exactly what - // whoever relays the file will send. - crate::bitcoin_review::TxReview::new( - &tx, - &psbt_req.inputs, - &run.wallet, - run.network, - run.change_index, - run.receive_start, - )? - .render(intent); + // The repayment split must reproduce the covenant's own arithmetic, floor-division and all: + // total_fee = 1000 * 10000/10000 = 1000 + // protocol_fee = 1000 * 1000/10000 = 100 (10% of the interest) + // lender share = CURRENT_DEBT(2000) - 100 = 1900 + // Sum must be exactly the debt — the covenant's split_repayment_by_fees leaves no dust. + let (_, _, repay) = manifest + .find_template_action("RepayLoan") + .expect("RepayLoan action exists"); + let rp = repay.params.as_ref().expect("RepayLoan has params"); + let formula_of = |name: &str| { + rp.get(name) + .and_then(|p| p.compute.as_ref()) + .and_then(|c| c.as_expr()) + .map(str::to_string) + .unwrap_or_else(|| panic!("{name} has a compute expression")) + }; + let protocol_fee = crate::eval::eval_expr_str(&formula_of("TOTAL_PROTOCOL_FEE"), &ctx) + .expect("TOTAL_PROTOCOL_FEE evaluates"); + let lender_amount = crate::eval::eval_expr_str(&formula_of("LENDER_VAULT_AMOUNT"), &ctx) + .expect("LENDER_VAULT_AMOUNT evaluates"); + assert_eq!( + protocol_fee, "100", + "protocol fee = 10% of the 1000 interest" + ); + assert_eq!( + lender_amount, "1900", + "lender receives the debt less the protocol fee" + ); + let debt: u64 = fields.get("CURRENT_DEBT").unwrap().parse().unwrap(); + assert_eq!( + protocol_fee.parse::().unwrap() + lender_amount.parse::().unwrap(), + debt, + "the two vault outputs must account for the whole debt exactly" + ); - if let Some(path) = export_path { - let hex = lwk_wollet::elements::bitcoin::consensus::encode::serialize_hex(&tx); - let doc = serde_json::json!({ "txid": tx.compute_txid().to_string(), "tx_hex": hex }); - std::fs::write(path, serde_json::to_string_pretty(&doc)?) - .with_context(|| format!("cannot write {}", path.display()))?; - println!( - " {} Wrote signed transaction to {}", - style("✓").green(), - path.display() + // The FullRepayment witness carries the debt, so its `instance.CURRENT_DEBT` ref must + // resolve to a literal the SimplicityHL value parser can read. + let offer_in = repay + .inputs + .as_ref() + .unwrap() + .iter() + .find(|i| i.id == "active_offer_in") + .expect("active_offer_in input"); + let wits = crate::eval::resolve_witness_refs( + offer_in + .witnesses + .as_ref() + .expect("active_offer_in has witnesses"), + &ctx, + ); + assert_eq!( + wits["PATH"]["value"].as_str(), + Some("Right(Left(Right(2000)))"), + "FullRepayment witness must resolve to PATH::Right(Left(Right(current_debt)))" + ); + + // The offer input spends the ACTIVE covenant AcceptOffer produced — same storage, so the + // same address (this is what the covenant re-derives from the witness debt and compares). + let act_ut = manifest + .utxo_type("lending_collateral_active") + .expect("active utxo_type"); + let (ap, ah) = apply_utxo_compile_params(&base, &hints, act_ut); + let act_leaves = act_ut + .resolve_extra_leaf_payloads(&ctx) + .expect("active storage leaves"); + let lending_simf = manifest_path.parent().unwrap().join("lending.simf"); + let act_addr = crate::covenant::compute_covenant_address( + &lending_simf, + &ap, + &ah, + &act_leaves, + net, + true, + ) + .expect("active address"); + assert_eq!( + format!("{:x}", act_addr.script_pubkey()), + "51202451da2d003a9fd5cffe1ed523cded17cda7a39604f02642d56d503bdef3eb77", + "RepayLoan in[1] must be the same active covenant AcceptOffer created" ); - // Reported as broadcast-less: the covenant exists only once the transaction is - // relayed, and writing it into the state file now would name a UTXO that does not - // exist and may never. - return Ok(None); } - // The same question the Elements path asks, with the same default. This used to go - // straight to `broadcast`, on every network including mainnet: the Bitcoin path leaves - // `run` before the Elements preview and prompt, and nothing here replaced them. - println!(); - if !crate::prompt::confirm_broadcast()? { - println!(" {} Not broadcast.", style("·").dim()); - return Ok(None); + /// Task 10 — a computed u64 storage leaf encodes right-aligned, big-endian, in a + /// 32-byte slot (the lending covenant's `current_debt` layout). + #[test] + fn encode_leaf_value_u64_be_padded_to_32() { + let mut ctx = ExecutionContext::new(); + ctx.set_compile_param("CURRENT_DEBT", "2000"); + let item = serde_json::json!({ + "value": "instance.CURRENT_DEBT", "type": "u64", "endian": "be", "pad_to": 32, "align": "right" + }); + let bytes = crate::eval::encode_leaf_value(&item, &ctx).expect("encode leaf"); + let mut expect = vec![0u8; 32]; + expect[24..32].copy_from_slice(&2000u64.to_be_bytes()); + assert_eq!(bytes, expect); + // Left-aligned puts the value first. + let item_left = serde_json::json!({ + "value": "8", "type": "u8", "pad_to": 4, "align": "left" + }); + assert_eq!( + crate::eval::encode_leaf_value(&item_left, &ctx).unwrap(), + vec![8u8, 0, 0, 0] + ); } - let txid = run.client.broadcast(&tx)?; - println!( - " {} Broadcast: {}", - style("✓").green(), - style(txid.to_string()).yellow() - ); - Ok(Some(BitcoinBroadcast { - txid: txid.to_string(), - outputs, - })) -} + /// The dex (Tessera) example's `MakeOffer.create_instance` must compute MAKER_SPK + /// from MAKER_PUB_KEY, and that hash must be what the offer address commits to. + /// + /// This is the load-bearing link in the example: tessera.simf enforces payment to a + /// scriptPubKey *hash*, so the settle output can only be built if MAKER_SPK is derived + /// from the same program the payout output targets — not pasted in by hand. Constructors + /// pre-compute create_instance tapleaf fields into compile params before the PSET is + /// built, which is what makes the offer address resolvable in the same transaction. + #[test] + fn dex_make_offer_computes_maker_spk_and_offer_address() { + use crate::manifest::Manifest; + use lwk_wollet::elements::hashes::{sha256, Hash}; -/// What a broadcast Bitcoin transaction created, for the state file. -pub(crate) struct BitcoinBroadcast { - pub txid: String, - pub outputs: Vec, -} + let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../examples/dex/txmanifest.json"); + let raw = std::fs::read_to_string(&manifest_path).expect("read dex manifest"); + let manifest: Manifest = Manifest::from_json_str(&raw).expect("parse dex manifest"); + let net = lwk_wollet::ElementsNetwork::LiquidTestnet; -/// What a run records once its transaction is on the network. -pub(crate) struct BroadcastRecord<'a> { - pub action_name: &'a str, - pub action: &'a crate::manifest::Action, - /// The instance file this contract belongs to: the one just written for a constructor, - /// else the one loaded. - pub instance: Option<&'a Path>, - pub covenant_outputs: &'a [CovenantOutputMeta], - pub state_out: &'a Path, - pub history_seed: &'a Path, -} + let (_class, template_def, action) = manifest + .find_template_action("MakeOffer") + .expect("MakeOffer method exists"); + let ci = action + .create_instance + .as_ref() + .expect("MakeOffer has create_instance"); -/// Everything after a transaction is on the network, the same on both chains: the action's -/// `on_post_broadcast` hook, then the state file — spent covenant inputs out, the covenant -/// outputs this action created in — then its history. -/// -/// One function because it was two copies, and they had already drifted: the Bitcoin one -/// never ran the hook, and appended history even when the state file could not be written. -/// -/// A failure to write state is reported, not returned. The transaction is already out, and -/// an error here would read as "nothing happened" when the opposite is true. -pub(crate) fn record_broadcast( - rec: &BroadcastRecord, - txid: &str, - outputs: &[OutputDescriptor], - ctx: &mut ExecutionContext, - contract_state: &mut Option, -) { - if let Some(hook) = &rec.action.on_post_broadcast { - ctx.set_param("broadcast_txid", txid); - run_hook_block(hook, ctx, "[on_post_broadcast]", None); - } + // Track the manifest's own settings — debug symbols change the CMR, so a hardcoded + // value here would verify a compilation mode the CLI never actually runs. + let compile_opts = manifest.compile_opts(); + let maker_pub_key = "e1512ae2f5b4ee8c12e9c57ccd0943273c6256f496516d3aefeaa16c32d3c05b"; + let lbtc_testnet = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + let usdt_ish = "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5"; - let mut new_state = contract_state - .take() - .unwrap_or_else(|| ContractState::new(rec.action_name)); - new_state.last_action = rec.action_name.to_string(); - new_state.instance = rec.instance.map(|p| p.display().to_string()); - for inp in rec.action.inputs.as_deref().unwrap_or_default() { - if inp.utxo_type_name().is_some() { - if let Some(r) = ctx.get_input(&inp.id) { - new_state.remove_spent(&r.txid, r.vout); + // Populate ctx as if Step 1 had prompted for the offer terms. + let mut ctx = ExecutionContext::new(); + ctx.set_param("OFFER_ASSET_ID", usdt_ish); + ctx.set_param("OFFER_AMOUNT", "100000"); + ctx.set_param("ASSET_B", lbtc_testnet); + ctx.set_param("AMOUNT_B", "50000"); + ctx.set_param("MAKER_PUB_KEY", maker_pub_key); + ctx.set_param("TIMEOUT", "2000000"); + ctx.set_param("MAX_FEE", "5000"); + + // Type hints from the template field + method param declarations (mirrors Step 7's pre-pass). + let mut hints: std::collections::HashMap = std::collections::HashMap::new(); + for (fname, fdef) in &template_def.fields { + hints.insert(fname.clone(), fdef.type_.clone()); + } + if let Some(params) = &action.params { + for (pname, pdef) in params { + hints + .entry(pname.clone()) + .or_insert_with(|| pdef.type_.clone()); } } - } - record_covenant_outputs(&mut new_state, outputs, rec.covenant_outputs, txid); - if let Err(e) = new_state.write(rec.state_out) { - println!( - " {} Could not write state file: {e}", - style("[warn]").yellow() + let fields = eval_create_instance_fields( + ci, + &ctx, + &manifest_path, + &hints, + net, + false, + &compile_opts, ); - return; - } - println!( - " {} State written: {}", - style("✓").green(), - rec.state_out.display() - ); - - let hist_path = history_path(rec.history_seed); - let entry = HistoryEntry { - action: rec.action_name.to_string(), - txid: txid.to_string(), - utxos: new_state.utxos.clone(), - }; - match StateHistory::load(&hist_path).and_then(|mut h| h.append(entry, &hist_path)) { - Ok(()) => println!( - " {} History appended: {}", - style("✓").green(), - hist_path.display() - ), - Err(e) => println!( - " {} Could not write history file: {e}", - style("[warn]").yellow() - ), - } -} -/// A covenant output this action creates, tracked so the state file can name it once the -/// transaction is broadcast and its vouts are known. -/// -/// At module scope rather than inside `run` because both chains' post-broadcast paths -/// match against it. -pub(crate) struct CovenantOutputMeta { - pub utxo_type: String, - pub output_id: String, - pub script_pubkey: lwk_wollet::elements::Script, - pub amount_sat: u64, - pub asset: lwk_wollet::elements::AssetId, -} + // MAKER_SPK is sha256 of the maker_payout covenant's scriptPubKey — verify against the + // program itself rather than a copied constant. + let payout_simf = manifest_path.parent().unwrap().join("maker_payout.simf"); + let payout_params: std::collections::HashMap = + [("PUB_KEY".to_string(), maker_pub_key.to_string())] + .into_iter() + .collect(); + let payout_hints: std::collections::HashMap = + [("PUB_KEY".to_string(), "pubkey".to_string())] + .into_iter() + .collect(); + let payout_addr = crate::covenant::compute_covenant_address( + &payout_simf, + &payout_params, + &payout_hints, + &[], + net, + &compile_opts, + ) + .expect("maker_payout covenant address compiles"); + let expect_spk_hash: String = sha256::Hash::hash(payout_addr.script_pubkey().as_bytes()) + .to_byte_array() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); -/// One transaction output, reduced to what covenant-state matching needs. -/// -/// Both chains flatten their own output type into this. Elements outputs carry confidential -/// commitments and an asset id, Bitcoin outputs carry neither — but the question being -/// asked is the same, so the matching below is written once against this. -pub(crate) struct OutputDescriptor { - pub script_pubkey: Vec, - /// `None` when the output is blinded, and therefore cannot be matched by value. - pub amount_sat: Option, - /// `None` on Bitcoin, which has one asset, and when an Elements output is blinded. - pub asset: Option, -} + assert_eq!( + fields.get("MAKER_SPK").map(String::as_str), + Some(expect_spk_hash.as_str()), + "create_instance MAKER_SPK must equal sha256(maker_payout scriptPubKey) for MAKER_PUB_KEY" + ); -/// Record the covenant UTXOs a broadcast transaction just created. -/// -/// Shared by both chains because the matching rule is subtle enough that two copies would -/// diverge: several outputs can share a scriptPubKey — deadcat's four market states are one -/// program under four tapdata leaves — so an output is identified by script *and* amount -/// (and asset, where there is more than one), and each position is consumed at most once so -/// two metas cannot claim the same vout. -/// -/// Without this the next action has no way to find the covenant it is meant to spend, and -/// the operator has to name the outpoint and its amount by hand. -pub(crate) fn record_covenant_outputs( - state: &mut ContractState, - outputs: &[OutputDescriptor], - meta: &[CovenantOutputMeta], - txid: &str, -) { - // This action supersedes any existing UTXO of the types it produces. - for m in meta { - state.utxos.retain(|u| u.utxo_type != m.utxo_type); - } + // The plain `$params.*` fields carry through untouched. + assert_eq!(fields.get("AMOUNT_B").map(String::as_str), Some("50000")); + assert_eq!(fields.get("TIMEOUT").map(String::as_str), Some("2000000")); + assert_eq!(fields.get("MAX_FEE").map(String::as_str), Some("5000")); - let mut used_vouts: std::collections::HashSet = std::collections::HashSet::new(); - for m in meta { - let found = outputs.iter().enumerate().find(|(i, o)| { - if used_vouts.contains(i) || o.script_pubkey != m.script_pubkey.as_bytes() { - return false; - } - // A blinded output matches on script alone: its amount and asset are - // commitments, and refusing to match would lose the UTXO entirely. - o.amount_sat.is_none_or(|v| v == m.amount_sat) && o.asset.is_none_or(|a| a == m.asset) - }); - if let Some((vout, _)) = found { - used_vouts.insert(vout); - state.utxos.push(StateUtxo { - utxo_type: m.utxo_type.clone(), - utxo_id: m.output_id.clone(), - txid: txid.to_string(), - vout: vout as u32, - amount_sat: m.amount_sat, - asset: m.asset.to_string(), - }); + // Drive the ACTUAL tessera_offer utxo_type end-to-end: fold the computed fields back into + // ctx (as the constructor pre-pass does) and resolve the offer covenant address. + for (k, v) in &fields { + ctx.set_compile_param(k, v); } - } -} + let ut = manifest + .utxo_type("tessera_offer") + .expect("tessera_offer utxo_type exists"); + let base: std::collections::HashMap = ctx + .all_compile_params() + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let (offer_params, offer_hints) = apply_utxo_compile_params(&base, &hints, ut); -/// Resolve a manifest asset label to an id on `network`. -/// -/// The chain's own unit (`"lbtc"` / `"bitcoin"`) resolves to whatever that network calls -/// it — Liquid's policy asset, or the synthetic constant standing in for BTC. Anything else -/// has to be an asset id already. -pub(crate) fn resolve_asset_label( - label: &str, - network: crate::chain::Network, -) -> Result { - if crate::manifest::names_policy_asset_str(label) { - return Ok(match network.family() { - crate::chain::ChainFamily::Bitcoin => crate::assembly::bitcoin_policy_asset(), - crate::chain::ChainFamily::Elements => network - .elements_network() - .ok_or_else(|| anyhow::anyhow!("{network} has no Elements mapping"))? - .policy_asset(), - }); + assert_eq!( + offer_params.get("MAKER_SPK").map(String::as_str), + Some(expect_spk_hash.as_str()), + "the offer covenant compiles against the computed MAKER_SPK" + ); + let offer_simf = manifest_path.parent().unwrap().join("tessera.simf"); + let offer_addr = crate::covenant::compute_covenant_address( + &offer_simf, + &offer_params, + &offer_hints, + &[], + net, + &compile_opts, + ) + .expect("tessera_offer covenant address compiles from create_instance output"); + + // Changing an offer term must move the offer address (the terms live in the tapleaf). + let mut bumped = offer_params.clone(); + bumped.insert("AMOUNT_B".to_string(), "50001".to_string()); + let bumped_addr = crate::covenant::compute_covenant_address( + &offer_simf, + &bumped, + &offer_hints, + &[], + net, + &compile_opts, + ) + .expect("bumped offer address compiles"); + assert_ne!( + offer_addr.script_pubkey(), + bumped_addr.script_pubkey(), + "an offer's price is committed to by its address" + ); + + // But asset A and its amount are NOT terms — the covenant never inspects them on the + // settle path, so the address must not move when they change (see tessera.simf). + let mut other_side = offer_params.clone(); + other_side.insert("OFFER_ASSET_ID".to_string(), lbtc_testnet.to_string()); + other_side.insert("OFFER_AMOUNT".to_string(), "999".to_string()); + let other_side_addr = crate::covenant::compute_covenant_address( + &offer_simf, + &other_side, + &offer_hints, + &[], + net, + &compile_opts, + ) + .expect("offer address compiles with a different asset A"); + assert_eq!( + offer_addr.script_pubkey(), + other_side_addr.script_pubkey(), + "asset A and its amount are deliberately not offer terms — the offer is \ + 'whatever sits in this UTXO, for AMOUNT_B of ASSET_B'" + ); } - lwk_wollet::elements::AssetId::from_str(label) - .with_context(|| format!("Cannot parse asset ID '{label}'")) } diff --git a/txmanifest_lib/src/manifest.rs b/txmanifest_lib/src/manifest.rs index f7d19ed..fea9ed8 100644 --- a/txmanifest_lib/src/manifest.rs +++ b/txmanifest_lib/src/manifest.rs @@ -1985,7 +1985,7 @@ mod tests { }"#; // 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#"{ + let _source = r#"{ "manifest_version": "0.3.0", "protocol": "test", "source": "./covenant.simf" }"#; diff --git a/txmanifest_lib/src/preview.rs b/txmanifest_lib/src/preview.rs index 42a516f..1643da9 100644 --- a/txmanifest_lib/src/preview.rs +++ b/txmanifest_lib/src/preview.rs @@ -354,7 +354,7 @@ fn wallet_nets(buckets: &[Bucket], wallet: Option<&WalletDelta>) -> Vec<(AssetMe .filter_map(|sym| { let (prec, units, exact) = acc[&sym]; // Drop assets that merely round-trip, but keep inexact ones visible. - (units != 0 || !exact).then(|| { + (units != 0 || !exact).then_some({ ( AssetMeta { symbol: sym, diff --git a/txmanifest_lib/src/validate.rs b/txmanifest_lib/src/validate.rs index dacbf72..8e673e1 100644 --- a/txmanifest_lib/src/validate.rs +++ b/txmanifest_lib/src/validate.rs @@ -151,6 +151,16 @@ fn check_hook(report: &mut Report, loc: &str, hook: &crate::manifest::HookBlock, } /// Run all structural checks against a parsed manifest file. +/// An action as `validate` walks it: dot-path location, bare name, the action, its +/// param types, and whether it is a contract-template method. +type ActionSite<'a> = ( + String, + String, + &'a Action, + std::collections::BTreeMap, + bool, +); + pub fn validate(manifest: &Manifest) -> Report { let mut report = Report::default(); @@ -160,13 +170,7 @@ pub fn validate(manifest: &Manifest) -> Report { // Collect every action, whether top-level or a class method, tagged with a // dot-path location and its bare name (for lifecycle cross-checks). - let mut actions: Vec<( - String, - String, - &Action, - std::collections::BTreeMap, - bool, - )> = Vec::new(); + let mut actions: Vec = Vec::new(); for (name, action) in &manifest.actions { actions.push(( format!("actions.{name}"), @@ -1008,6 +1012,60 @@ fn check_destination( } } +/// 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), + ); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1834,57 +1892,3 @@ 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), - ); - } -}