From ee09113bc24ea8c1443fc64dfae3f2af2dbf99f1 Mon Sep 17 00:00:00 2001 From: lukachi Date: Thu, 20 Aug 2026 14:22:52 +0300 Subject: [PATCH 1/6] feat(wasm): build and sign covenant spends from JavaScript A wallet driving this SDK from JavaScript could not express several things a covenant spend needs, and one of them was silently wrong. - Issuance is exposed on both input shapes, so a transaction that creates an asset can be assembled from JavaScript. - A contract's parameters carry their declared types across the boundary, instead of arriving as untyped values the caller had to guess at. - A covenant being spent is rebuilt from the same parts it was committed to, so the leaf that is revealed matches the one in the tree. - A transaction can declare the height it may not be mined before, which a timelocked covenant requires. A zero height is treated as no height, because writing zero into an input reads back as a constraint that can never be met and refuses every spend. - A covenant that refuses now says what the transaction declared, naming the locktime and the sequence, so a refusal can be diagnosed without a debugger. --- crates/sdk/src/signer/core.rs | 11 +- crates/sdk/src/signer/error.rs | 12 +- .../sdk/src/transaction/final_transaction.rs | 80 ++- crates/sdk/src/transaction/partial_input.rs | 8 +- crates/wasm/src/lib.rs | 541 +++++++++++++++--- 5 files changed, 565 insertions(+), 87 deletions(-) diff --git a/crates/sdk/src/signer/core.rs b/crates/sdk/src/signer/core.rs index b650621..a7438f4 100644 --- a/crates/sdk/src/signer/core.rs +++ b/crates/sdk/src/signer/core.rs @@ -602,7 +602,16 @@ impl Signer { let pruned_witness = program_input .program .finalize(&pst, &signed_witness.unwrap(), index, &self.network) - .map_err(|source| SignerError::CovenantExecution { index, source })?; + .map_err(|source| SignerError::CovenantExecution { + index, + locktime: pst + .locktime() + .map_or(0, simplicityhl::elements::LockTime::to_consensus_u32), + sequence: pst.inputs()[index] + .sequence + .map_or(u32::MAX, simplicityhl::elements::Sequence::to_consensus_u32), + source, + })?; pst.inputs_mut()[index].final_script_witness = Some(pruned_witness); } else { diff --git a/crates/sdk/src/signer/error.rs b/crates/sdk/src/signer/error.rs index 410cd42..aed5939 100644 --- a/crates/sdk/src/signer/error.rs +++ b/crates/sdk/src/signer/error.rs @@ -9,10 +9,20 @@ pub enum SignerError { Program(#[from] ProgramError), /// Error indicating that a Simplicity program failed to satisfy, prune or execute. - #[error("Covenant input {index} did not execute: {source}")] + /// + /// Carries the two transaction-level facts a time-locked or replaceability-sensitive + /// branch reads, because a jet that fails on either of them says only that a jet failed. + /// Reading them out of the transaction afterwards is not possible: it was never built. + #[error( + "Covenant input {index} did not execute (transaction locktime {locktime}, input sequence {sequence}): {source}" + )] CovenantExecution { /// The index of the input whose program failed. index: usize, + /// The locktime the transaction being satisfied carries. + locktime: u32, + /// The sequence of the failing input. + sequence: u32, /// The underlying program failure. source: ProgramError, }, diff --git a/crates/sdk/src/transaction/final_transaction.rs b/crates/sdk/src/transaction/final_transaction.rs index 57e8091..5a5d2c4 100644 --- a/crates/sdk/src/transaction/final_transaction.rs +++ b/crates/sdk/src/transaction/final_transaction.rs @@ -145,6 +145,12 @@ pub struct FinalTransaction { inputs: Vec, outputs: Vec, change: Option, + /// The height this transaction may not be mined before, when it declares one. + /// + /// A property of the transaction rather than of any one input: PSET carries it per input + /// and takes the greatest, so it is written onto every input at extraction rather than + /// asked of whichever input happened to be added first. + locktime_height: Option, } impl FinalTransaction { @@ -156,9 +162,19 @@ impl FinalTransaction { inputs: Vec::new(), outputs: Vec::new(), change: None, + locktime_height: None, } } + /// Sets the block height this transaction may not be mined before. + /// + /// A contract checking `check_lock_height` reads the transaction's own locktime, so a + /// covenant whose spending path is time-locked cannot be satisfied without one. Nothing + /// here decides the value: the caller states it. + pub fn set_locktime_height(&mut self, height: u32) { + self.locktime_height = Some(height); + } + /// Sets where this transaction's change should go. /// /// Left unset, the signer sends change to the single address it derives internally. @@ -402,7 +418,19 @@ impl FinalTransaction { for i in 0..self.inputs.len() { let final_input = &self.inputs[i]; - let pst_input = final_input.to_input(); + let mut pst_input = final_input.to_input(); + + // Written onto every input, because PSET derives the transaction's locktime from + // the greatest one its inputs require. An input that already declares its own is + // left alone: that one was asked for deliberately and is not this to overwrite. + if let Some(height) = self.locktime_height + && pst_input.required_height_locktime.is_none() + { + pst_input.required_height_locktime = Some( + simplicityhl::elements::locktime::Height::from_consensus(height) + .expect("a block height is a valid locktime"), + ); + } match final_input.partial_input.secrets { // insert input secrets if present @@ -505,6 +533,56 @@ mod tests { assert_eq!(secrets, expected_secrets); } + /// A covenant branch guarded by `check_lock_height` reads the transaction's own locktime, + /// so the height a caller declares has to survive into the PSET the program executes + /// against. PSET derives it from the greatest its inputs require, which is why it is + /// written onto every input rather than onto whichever was added first. + #[test] + fn declared_height_becomes_the_transactions_locktime() { + let policy = dummy_asset_id(0xAA); + + let mut ft = FinalTransaction::new(); + ft.add_input( + PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), + RequiredSignature::None, + ); + ft.add_input( + PartialInput::new(explicit_utxo(0x02, 0, 5000, policy)), + RequiredSignature::None, + ); + ft.add_output(PartialOutput::new(Script::new(), 9000, policy)); + ft.set_locktime_height(2_580_990); + + let (pst, _) = ft.extract_pst(); + + assert_eq!( + pst.locktime().expect("one height, so no conflict"), + simplicityhl::elements::LockTime::from_height(2_580_990).unwrap() + ); + assert!( + pst.inputs() + .iter() + .all(|input| input.required_height_locktime.is_some()) + ); + } + + /// Declaring none leaves the transaction where it was: locktime zero, no input constrained. + #[test] + fn a_transaction_that_declares_no_height_still_has_none() { + let policy = dummy_asset_id(0xAA); + + let mut ft = FinalTransaction::new(); + ft.add_input( + PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), + RequiredSignature::None, + ); + ft.add_output(PartialOutput::new(Script::new(), 4000, policy)); + + let (pst, _) = ft.extract_pst(); + + assert_eq!(pst.locktime().unwrap(), simplicityhl::elements::LockTime::ZERO); + } + #[test] fn extract_pst_single_confidential_input() { let policy = dummy_asset_id(0xAA); diff --git a/crates/sdk/src/transaction/partial_input.rs b/crates/sdk/src/transaction/partial_input.rs index baecae7..cd64fad 100644 --- a/crates/sdk/src/transaction/partial_input.rs +++ b/crates/sdk/src/transaction/partial_input.rs @@ -164,10 +164,12 @@ impl PartialInput { LockTime::Seconds(value) => Some(value), LockTime::Blocks(_) => None, }; - // zero height locktime is essentially ignored + // A zero height is no height. The comment here always said so, while the code wrote it + // as a requirement of zero — which PSET reads as an input that constrains the locktime, + // so nothing else could raise it and every transaction came out locked at zero. let height_locktime = match self.locktime { - LockTime::Blocks(value) => Some(value), - LockTime::Seconds(_) => None, + LockTime::Blocks(value) if value.to_consensus_u32() > 0 => Some(value), + LockTime::Blocks(_) | LockTime::Seconds(_) => None, }; Input { diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 11b6a38..f4430fb 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -4,20 +4,23 @@ //! This crate exists so the SDK itself stays free of `wasm-bindgen` annotations //! and follows the arrangement of `lwk_wasm`. +use std::collections::BTreeMap; use std::str::FromStr; use std::sync::Arc; use elements_miniscript::bitcoin::PublicKey; +use simplicityhl::ast::ElementsJetHinter; use simplicityhl::elements; use simplicityhl::elements::{AssetId, OutPoint, Script, Sequence, TxOut, Txid}; -use simplicityhl::{Arguments, WitnessValues}; +use simplicityhl::{Arguments, TemplateProgram, UnstableFeatures, WitnessValues}; use smplx_sdk::program::{ArgumentsTrait, Program, WitnessTrait}; use smplx_sdk::provider::SimplicityNetwork; use smplx_sdk::signer::Signer; +use smplx_sdk::transaction::partial_input::IssuanceInput; use smplx_sdk::transaction::{ - ChangeOutput, FinalTransaction, PartialInput, PartialOutput, ProgramInput, RequiredSignature, UTXO, + ChangeOutput, FinalTransaction, IssuanceDetails, PartialInput, PartialOutput, ProgramInput, RequiredSignature, UTXO, }; use wasm_bindgen::prelude::*; @@ -32,6 +35,89 @@ fn network_from_str(network: &str) -> Result { } } +/// An id as it is written turned into the bytes it is made of, which run the other way. +fn read_id(written: &str) -> Result<[u8; 32], String> { + let decoded = hex::decode(written).map_err(|e| e.to_string())?; + let mut bytes: [u8; 32] = decoded + .try_into() + .map_err(|_| "an id is thirty-two bytes".to_string())?; + + bytes.reverse(); + + Ok(bytes) +} + +/// The same conversion back, because an id leaves here in the form everything else reads. +fn write_id(bytes: [u8; 32]) -> String { + let mut written = bytes; + + written.reverse(); + + hex::encode(written) +} + +/// The issuer contract an issuance commits to, which is nothing unless one is named. +/// +/// Elements derives an asset from the output being spent and the issuer contract together. +/// Every asset in Liquid's public registry commits to one; a transaction manifest declares no +/// such thing at any position, so its issuances commit to the empty one. Taken as an argument +/// rather than assumed, because the wallet derives the same asset for itself and two +/// derivations that agree only because neither was told anything have not agreed about +/// anything. +fn issuer_contract(written: Option<&str>) -> Result<[u8; 32], String> { + match written.map(str::trim).filter(|hex_id| !hex_id.is_empty()) { + Some(hex_id) => read_id(hex_id), + None => Ok([0_u8; 32]), + } +} + +/// The module's account of one issuance, in the form ids are written in. +fn issuance_report(details: &IssuanceDetails) -> IssuanceReport { + IssuanceReport { + asset_id: details.asset_id.to_string(), + entropy: write_id(details.asset_entropy.to_byte_array()), + reissuance_token_id: details.inflation_asset_id.to_string(), + } +} + +/// What the module made of an issuance, reported rather than kept to itself. +/// +/// The caller derives the same three values before any of this runs, from the same output. +/// Returning them is what lets the two derivations be compared instead of one being trusted. +#[wasm_bindgen] +pub struct IssuanceReport { + asset_id: String, + entropy: String, + reissuance_token_id: String, +} + +#[wasm_bindgen] +impl IssuanceReport { + /// The asset this issuance creates. + #[wasm_bindgen(getter, js_name = assetId)] + #[must_use] + pub fn asset_id(&self) -> String { + self.asset_id.clone() + } + + /// What a later reissuance of this same asset would be derived from. + /// + /// The output this issuance spends is gone once the transaction confirms, so this is the + /// only part of the derivation that survives it. + #[wasm_bindgen(getter)] + #[must_use] + pub fn entropy(&self) -> String { + self.entropy.clone() + } + + /// The token that authorises reissuing this asset, derived whether or not any is minted. + #[wasm_bindgen(getter, js_name = reissuanceTokenId)] + #[must_use] + pub fn reissuance_token_id(&self) -> String { + self.reissuance_token_id.clone() + } +} + /// Compile-time parameters for a contract, resolved before construction. #[derive(Clone)] struct FixedArguments(Arguments); @@ -84,33 +170,14 @@ impl Contract { extra_leaves_json: Option, include_debug_symbols: Option, ) -> Result { - let arguments = match arguments_json.as_deref() { - Some(json) if !json.trim().is_empty() => serde_json::from_str::(json) - .map_err(|e| JsError::new(&format!("Invalid contract arguments: {e}")))?, - _ => Arguments::default(), - }; - - let mut program = Program::new(Arc::::from(source), &FixedArguments(arguments)); - - if let Some(include) = include_debug_symbols { - program = program.with_debug_symbols(include); - } - - if let Some(json) = extra_leaves_json.as_deref().filter(|json| !json.trim().is_empty()) { - let leaves: Vec = - serde_json::from_str(json).map_err(|e| JsError::new(&format!("Invalid extra leaves: {e}")))?; - - program = program.with_storage_capacity(leaves.len()); - - for (index, leaf) in leaves.iter().enumerate() { - let bytes = hex::decode(leaf.strip_prefix("0x").unwrap_or(leaf)) - .map_err(|e| JsError::new(&format!("Extra leaf {index} is not hex: {e}")))?; - - program.set_storage_at(index, bytes); - } - } - - Ok(Self { program }) + Ok(Self { + program: TransactionBuilder::program_of( + source, + arguments_json.as_deref(), + extra_leaves_json.as_deref(), + include_debug_symbols, + )?, + }) } /// Compiles the contract and returns its Commitment Merkle Root as lowercase hex. @@ -156,6 +223,47 @@ impl Contract { } } +/// The compile-time parameters a contract source declares, as JSON of name to type. +/// +/// `SimplicityHL` has no syntax that declares a parameter's type. `param::NAME` is written +/// where a value is wanted, and the type checker gives it the type that position demands +/// (`simplicityhl` 0.6.0, `src/ast.rs` L1346-1350: the parameter is inserted into the global +/// map with the expected type of the expression it stands in for). So the type of a parameter +/// exists only as a result of analysing the program, and the only thing that can state it is +/// the compiler. +/// +/// Reading it needs no arguments, which is what makes it usable before they are built: a +/// `TemplateProgram` is the program analysed but not instantiated, so its parameters are +/// resolved while its arguments are still unknown. Asking a compiled program instead would be +/// circular, since compiling one requires the arguments this is being asked in order to encode. +/// +/// The alternative is a caller guessing a width. A guess is not silent here — the compiler +/// requires an argument's type to equal the parameter's exactly — but it is still a guess, and +/// the value written at a correct width is where the silence lives. +/// +/// Types are spelled as the compiler spells them, which is the spelling the argument JSON's +/// `type` field is read back in. +/// +/// # Errors +/// Returns an error if the source does not parse or does not type-check. +#[wasm_bindgen(js_name = contractParameterTypes)] +pub fn contract_parameter_types(source: &str) -> Result { + let template = TemplateProgram::new_with_unstable( + Arc::::from(source), + &UnstableFeatures::all(), + Box::new(ElementsJetHinter), + ) + .map_err(|e| JsError::new(&format!("Contract does not compile: {e}")))?; + + let declared: BTreeMap = template + .parameters() + .iter() + .map(|(name, ty)| (name.as_ref().to_string(), ty.to_string())) + .collect(); + + serde_json::to_string(&declared).map_err(|e| JsError::new(&format!("Cannot report parameter types: {e}"))) +} + /// The wallet's signer that understands how to work with Simplicity. #[wasm_bindgen] pub struct WalletSigner { @@ -316,30 +424,66 @@ impl TransactionBuilder { tx_out_hex: &str, sequence: Option, ) -> Result<(), JsError> { - let outpoint = OutPoint { - txid: Txid::from_str(txid).map_err(|e| JsError::new(&format!("Invalid txid: {e}")))?, - vout, - }; - - let bytes = hex::decode(tx_out_hex).map_err(|e| JsError::new(&format!("Invalid output encoding: {e}")))?; - let txout: TxOut = - elements::encode::deserialize(&bytes).map_err(|e| JsError::new(&format!("Invalid output: {e}")))?; - self.transaction.add_input( - Self::with_sequence( - PartialInput::new(UTXO { - outpoint, - secrets: None, - txout, - }), - sequence, - ), + Self::with_sequence(PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), sequence), RequiredSignature::NativeEcdsa, ); Ok(()) } + /// Adds an ordinary wallet input that also creates a new asset. + /// + /// The asset is a function of the output this input spends, so it cannot exist before that + /// output is chosen, and moving the issuance to another input mints a different asset. + /// + /// `issuer_contract_hex` is the issuer contract the issuance commits to, written the way an + /// id is written. Left unset it is the empty commitment, which is what a transaction + /// manifest declares. + /// + /// Returns the asset, the token that would authorise reissuing it, and the entropy both are + /// derived from. + /// + /// # Errors + /// Returns an error if the txid, the encoded output or the issuer contract cannot be parsed. + #[wasm_bindgen(js_name = addWalletIssuanceInput)] + #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] + pub fn add_wallet_issuance_input( + &mut self, + txid: &str, + vout: u32, + tx_out_hex: &str, + asset_amount_sats: u64, + inflation_amount_sats: u64, + issuer_contract_hex: Option, + sequence: Option, + ) -> Result { + let contract = issuer_contract(issuer_contract_hex.as_deref()) + .map_err(|e| JsError::new(&format!("Invalid issuer contract: {e}")))?; + + // The SDK panics here when the input requires a witness signature, and a panic inside + // wasm aborts the module instead of returning. This binding chooses the signature + // itself and an ordinary wallet input never needs a witness one, so the panicking case + // cannot be reached from JavaScript. + let details = self.transaction.add_issuance_input( + Self::with_sequence(PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), sequence), + IssuanceInput::new_issuance(asset_amount_sats, inflation_amount_sats, contract), + RequiredSignature::NativeEcdsa, + ); + + Ok(issuance_report(&details)) + } + + /// Declares the block height this transaction may not be mined before. + /// + /// A covenant whose spending path checks a lock height cannot be satisfied by a + /// transaction that declares none, so a wallet spending one has to state it. Which height + /// is the caller's to decide; this only carries it. + #[wasm_bindgen(js_name = setLocktimeHeight)] + pub fn set_locktime_height(&mut self, height: u32) { + self.transaction.set_locktime_height(height); + } + /// Adds a Simplicity contract input, spent by satisfying it. /// /// `witness_json` carries the witness values in `SimplicityHL` `.wit` shape. @@ -363,49 +507,75 @@ impl TransactionBuilder { witness_json: Option, signature_witness: Option, sequence: Option, + extra_leaves_json: Option, + include_debug_symbols: Option, ) -> Result<(), JsError> { - let outpoint = OutPoint { - txid: Txid::from_str(txid).map_err(|e| JsError::new(&format!("Invalid txid: {e}")))?, - vout, - }; - - let bytes = hex::decode(tx_out_hex).map_err(|e| JsError::new(&format!("Invalid output encoding: {e}")))?; - let txout: TxOut = - elements::encode::deserialize(&bytes).map_err(|e| JsError::new(&format!("Invalid output: {e}")))?; - - let arguments = match arguments_json.as_deref() { - Some(json) if !json.trim().is_empty() => serde_json::from_str::(json) - .map_err(|e| JsError::new(&format!("Invalid contract arguments: {e}")))?, - _ => Arguments::default(), - }; - - let witness = match witness_json.as_deref() { - Some(json) if !json.trim().is_empty() => serde_json::from_str::(json) - .map_err(|e| JsError::new(&format!("Invalid witness values: {e}")))?, - _ => WitnessValues::default(), - }; - - let program = Program::new(Arc::::from(source), &FixedArguments(arguments)); - self.transaction.add_program_input( - Self::with_sequence( - PartialInput::new(UTXO { - outpoint, - secrets: None, - txout, - }), - sequence, - ), - ProgramInput { - program: Box::new(program), - witness: Box::new(FixedWitness(witness)), - }, + Self::with_sequence(PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), sequence), + Self::program_input( + source, + arguments_json.as_deref(), + witness_json.as_deref(), + extra_leaves_json.as_deref(), + include_debug_symbols, + )?, Self::required_signature(signature_witness.as_deref()), ); Ok(()) } + /// Adds a Simplicity contract input that also creates a new asset. + /// + /// The contract half is the same as `addContractInput` and the issuance half the same as + /// `addWalletIssuanceInput`: the asset is derived from the output this input spends, and + /// `issuer_contract_hex` is what the issuance commits to, empty unless one is named. + /// + /// # Errors + /// Returns an error if the txid, the encoded output, the arguments, the witness or the + /// issuer contract cannot be parsed. + #[wasm_bindgen(js_name = addContractIssuanceInput)] + #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] + pub fn add_contract_issuance_input( + &mut self, + txid: &str, + vout: u32, + tx_out_hex: &str, + source: &str, + arguments_json: Option, + witness_json: Option, + signature_witness: Option, + asset_amount_sats: u64, + inflation_amount_sats: u64, + issuer_contract_hex: Option, + sequence: Option, + extra_leaves_json: Option, + include_debug_symbols: Option, + ) -> Result { + let contract = issuer_contract(issuer_contract_hex.as_deref()) + .map_err(|e| JsError::new(&format!("Invalid issuer contract: {e}")))?; + + // The SDK panics here when the input requires the native signature rather than a + // witness one, and a panic inside wasm aborts the module instead of returning. The + // signature is derived from `signature_witness`, which yields no signature or a witness + // one and never the native kind, so the panicking case cannot be reached from + // JavaScript. + let details = self.transaction.add_program_issuance_input( + Self::with_sequence(PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), sequence), + Self::program_input( + source, + arguments_json.as_deref(), + witness_json.as_deref(), + extra_leaves_json.as_deref(), + include_debug_symbols, + )?, + IssuanceInput::new_issuance(asset_amount_sats, inflation_amount_sats, contract), + Self::required_signature(signature_witness.as_deref()), + ); + + Ok(issuance_report(&details)) + } + /// Adds an output paying `amount_sats` of `asset_hex` to `script_pubkey_hex`. /// /// A blinding key makes the output confidential. Covenant and `OP_RETURN` outputs are always unblinded. @@ -507,6 +677,95 @@ impl TransactionBuilder { RequiredSignature::witness_with_path(name, path) } + /// The output an input spends, from what the wallet already holds about it. + /// + /// `tx_out_hex` is the consensus encoding of that output rather than a summary of it, + /// because a re-encoded summary is a second opinion about what the chain holds. + fn utxo_at(txid: &str, vout: u32, tx_out_hex: &str) -> Result { + let outpoint = OutPoint { + txid: Txid::from_str(txid).map_err(|e| JsError::new(&format!("Invalid txid: {e}")))?, + vout, + }; + + let bytes = hex::decode(tx_out_hex).map_err(|e| JsError::new(&format!("Invalid output encoding: {e}")))?; + let txout: TxOut = + elements::encode::deserialize(&bytes).map_err(|e| JsError::new(&format!("Invalid output: {e}")))?; + + Ok(UTXO { + outpoint, + secrets: None, + txout, + }) + } + + /// The compiled contract and the witness values it is spent with. + /// + /// Both are parsed here so a malformed set is rejected where the caller supplied it rather + /// than in the middle of signing. + fn program_input( + source: &str, + arguments_json: Option<&str>, + witness_json: Option<&str>, + extra_leaves_json: Option<&str>, + include_debug_symbols: Option, + ) -> Result { + let witness = match witness_json { + Some(json) if !json.trim().is_empty() => serde_json::from_str::(json) + .map_err(|e| JsError::new(&format!("Invalid witness values: {e}")))?, + _ => WitnessValues::default(), + }; + + // The same build a `Contract` gets, because it has to be: the covenant being spent was + // committed to on chain by whatever built it, and a program compiled here in a different + // mode, or without the leaves the deployment declared, locks to a different script. The + // spend then fails at execution, after a person has already approved it. + Ok(ProgramInput { + program: Box::new(Self::program_of( + source, + arguments_json, + extra_leaves_json, + include_debug_symbols, + )?), + witness: Box::new(FixedWitness(witness)), + }) + } + + /// One program, built exactly as `Contract::new` builds it. + fn program_of( + source: &str, + arguments_json: Option<&str>, + extra_leaves_json: Option<&str>, + include_debug_symbols: Option, + ) -> Result { + let arguments = match arguments_json { + Some(json) if !json.trim().is_empty() => serde_json::from_str::(json) + .map_err(|e| JsError::new(&format!("Invalid contract arguments: {e}")))?, + _ => Arguments::default(), + }; + + let mut program = Program::new(Arc::::from(source), &FixedArguments(arguments)); + + if let Some(include) = include_debug_symbols { + program = program.with_debug_symbols(include); + } + + if let Some(json) = extra_leaves_json.filter(|json| !json.trim().is_empty()) { + let leaves: Vec = + serde_json::from_str(json).map_err(|e| JsError::new(&format!("Invalid extra leaves: {e}")))?; + + program = program.with_storage_capacity(leaves.len()); + + for (index, leaf) in leaves.iter().enumerate() { + let bytes = hex::decode(leaf.strip_prefix("0x").unwrap_or(leaf)) + .map_err(|e| JsError::new(&format!("Extra leaf {index} is not hex: {e}")))?; + + program.set_storage_at(index, bytes); + } + } + + Ok(program) + } + /// Applies a declared sequence to an input. fn with_sequence(input: PartialInput, sequence: Option) -> PartialInput { match sequence { @@ -565,3 +824,123 @@ impl SignedTransaction { pub fn sdk_version() -> String { env!("CARGO_PKG_VERSION").to_string() } + +#[cfg(test)] +mod tests { + use simplicityhl::elements::hashes::sha256::Midstate; + use simplicityhl::elements::{AssetId, OutPoint, Txid}; + use smplx_sdk::utils::asset_entropy; + + use super::{FromStr, IssuanceDetails, IssuanceReport, issuance_report, issuer_contract, read_id, write_id}; + + /// Assets Liquid already carries, and the outputs they were issued from. + /// + /// The point of asserting against these rather than against this module's own output is + /// that an id has two written forms and only one of them is the one anybody reads. A + /// derivation consistent with itself reproduces none of these; the exact rule Elements + /// uses, written the way Elements writes it, reproduces all four. + /// + /// Each entry is a transaction id, an output index, the issuer contract that issuance + /// committed to, the asset that came out, and its reissuance token. Taken from Blockstream's + /// Liquid Esplora, `GET /liquid/api/asset/`, which reports `issuance_prevout`, + /// `contract_hash` and `reissuance_token`. They are the same four the wallet's own + /// derivation is measured against, deliberately: two implementations checked against + /// different vectors can both pass and still disagree with each other. + const ON_CHAIN: [(&str, u32, &str, &str, &str); 4] = [ + ( + "9596d259270ef5bac0020435e6d859aea633409483ba64e232b8ba04ce288668", + 0, + "3c7f0a53c2ff5b99590620d7f6604a7a3a7bfbaaa6aa61f7bfc7833ca03cde82", + "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2", + "59fe4d2127ba9f16bd6850a3e6271a166e7ed2e1669f6c107d655791c94ee98f", + ), + // The index is part of what is hashed, so at least one case is issued from somewhere + // other than the first output. + ( + "fc2535f2e4fc2ef1d19b832248e3edc2c3f4c4e3ee9c2bc51777bd738a6f9582", + 10, + "d6cb01732239e8c317699c33ef525a8a1419ebf9a2ad318edbf8135f1665a773", + "123465c803ae336c62180e52d94ee80d80828db54df9bedbb9860060f49de2eb", + "2f7179e260a8046f02be25dec6abcf0a2c1bd3e6e13dd29ed67570e1e71a55b7", + ), + ( + "839e819d74ac98110fce63a3dab3a1075bbddcad811e0e125641989581919ab0", + 1, + "56cbf179ec75145ef54d88ff50284175852f926bf2d8d06f3e2deedbdf623779", + "4d4354944366ea1e33f27c37fec97504025d6062c551208f68597d1ed40ec53e", + "bc1e0094f30bc863610baf601ede6b3dda5cdb1b7d1a7831c93f011282924da3", + ), + ( + "27e6bd36daef786775768a6b106053d0f2f10e03b6f278715931caa00662138d", + 3, + "6e8198a20900717b87437261967214e2af0bb4d73c1134580b25ec597887203a", + "beebee1a548fbb20280e539b697de076d87859a25c2983ebc55f2d8bec40abc3", + "fc061c7585a4f166d251ef4f5afd7c63e33358582426f06070cfb286249926cb", + ), + ]; + + /// What the module reports for one issuance on one output, without assembling a transaction. + fn report_for(txid: &str, vout: u32, contract: &str) -> IssuanceReport { + let outpoint = OutPoint { + txid: Txid::from_str(txid).expect("a chain vector's txid"), + vout, + }; + let entropy = asset_entropy( + &outpoint, + issuer_contract(Some(contract)).expect("a chain vector's contract"), + ); + + issuance_report(&IssuanceDetails { + asset_id: AssetId::from_entropy(entropy), + inflation_asset_id: AssetId::reissuance_token_from_entropy(entropy, false), + asset_entropy: entropy, + }) + } + + #[test] + fn reports_the_assets_liquid_actually_holds() { + for (txid, vout, contract, asset, _) in ON_CHAIN { + assert_eq!(report_for(txid, vout, contract).asset_id, asset); + } + } + + #[test] + fn reports_the_reissuance_tokens_liquid_actually_holds() { + for (txid, vout, contract, _, token) in ON_CHAIN { + assert_eq!(report_for(txid, vout, contract).reissuance_token_id, token); + } + } + + #[test] + fn reports_an_entropy_its_own_asset_can_be_rederived_from() { + for (txid, vout, contract, asset, _) in ON_CHAIN { + let reported = report_for(txid, vout, contract).entropy; + let read_back = Midstate::from_byte_array(read_id(&reported).expect("a reported entropy")); + + assert_eq!(AssetId::from_entropy(read_back).to_string(), asset); + } + } + + #[test] + fn commits_to_no_issuer_contract_unless_one_is_named() { + let empty = [0_u8; 32]; + + assert_eq!(issuer_contract(None), Ok(empty)); + assert_eq!(issuer_contract(Some("")), Ok(empty)); + assert_eq!(issuer_contract(Some(" ")), Ok(empty)); + assert_eq!(issuer_contract(Some(&"0".repeat(64))), Ok(empty)); + } + + #[test] + fn an_id_leaves_in_the_form_it_arrived_in() { + let written = "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2"; + + assert_eq!(write_id(read_id(written).expect("a written id")), written); + } + + #[test] + fn refuses_an_issuer_contract_that_is_not_an_id() { + assert!(issuer_contract(Some("not hex at all")).is_err()); + assert!(issuer_contract(Some("00ff")).is_err()); + } +} From be24fed754134b29adb1de687e90ed6bdd063df7 Mon Sep 17 00:00:00 2001 From: Artem Chystiakov Date: Fri, 21 Aug 2026 15:15:23 +0300 Subject: [PATCH 2/6] some fixes --- crates/sdk/src/signer/core.rs | 8 +- crates/sdk/src/signer/error.rs | 4 - .../sdk/src/transaction/final_transaction.rs | 85 +++++++++---------- crates/sdk/src/transaction/partial_input.rs | 3 - crates/wasm/src/lib.rs | 66 ++++++-------- 5 files changed, 69 insertions(+), 97 deletions(-) diff --git a/crates/sdk/src/signer/core.rs b/crates/sdk/src/signer/core.rs index a7438f4..2fca599 100644 --- a/crates/sdk/src/signer/core.rs +++ b/crates/sdk/src/signer/core.rs @@ -8,7 +8,7 @@ use simplicityhl::Value; use simplicityhl::WitnessValues; use simplicityhl::elements::pset::PartiallySignedTransaction; use simplicityhl::elements::secp256k1_zkp::{All, Keypair, Message, Secp256k1, ecdsa, schnorr}; -use simplicityhl::elements::{Address, Script, Transaction}; +use simplicityhl::elements::{Address, LockTime, Script, Sequence, Transaction}; #[cfg(feature = "provider")] use simplicityhl::elements::{AssetId, OutPoint, Txid}; use simplicityhl::simplicity::bitcoin::XOnlyPublicKey; @@ -604,12 +604,10 @@ impl Signer { .finalize(&pst, &signed_witness.unwrap(), index, &self.network) .map_err(|source| SignerError::CovenantExecution { index, - locktime: pst - .locktime() - .map_or(0, simplicityhl::elements::LockTime::to_consensus_u32), + locktime: pst.locktime().map_or(0, LockTime::to_consensus_u32), sequence: pst.inputs()[index] .sequence - .map_or(u32::MAX, simplicityhl::elements::Sequence::to_consensus_u32), + .map_or(u32::MAX, Sequence::to_consensus_u32), source, })?; diff --git a/crates/sdk/src/signer/error.rs b/crates/sdk/src/signer/error.rs index aed5939..20db372 100644 --- a/crates/sdk/src/signer/error.rs +++ b/crates/sdk/src/signer/error.rs @@ -9,10 +9,6 @@ pub enum SignerError { Program(#[from] ProgramError), /// Error indicating that a Simplicity program failed to satisfy, prune or execute. - /// - /// Carries the two transaction-level facts a time-locked or replaceability-sensitive - /// branch reads, because a jet that fails on either of them says only that a jet failed. - /// Reading them out of the transaction afterwards is not possible: it was never built. #[error( "Covenant input {index} did not execute (transaction locktime {locktime}, input sequence {sequence}): {source}" )] diff --git a/crates/sdk/src/transaction/final_transaction.rs b/crates/sdk/src/transaction/final_transaction.rs index 5a5d2c4..e8bf165 100644 --- a/crates/sdk/src/transaction/final_transaction.rs +++ b/crates/sdk/src/transaction/final_transaction.rs @@ -4,7 +4,7 @@ use bitcoin_hashes::sha256; use simplicityhl::elements::pset::{Input, PartiallySignedTransaction}; use simplicityhl::elements::{ - AssetId, TxOutSecrets, + AssetId, LockTime, Sequence, TxOutSecrets, confidential::{AssetBlindingFactor, ValueBlindingFactor}, }; @@ -108,8 +108,8 @@ impl FinalInput { /// # Panics /// /// This function will panic if the `issuance_input` is of type `Reissuance` - /// and the `partial_input.secrets` field is `None` or does not contain the necessary - /// confidential information. Specifically, a panic occurs when attempting to unwrap the `asset_bf` value. + /// and the `partial_input.secrets` field is `None` or does not contain the necessary + /// confidential information. Specifically, a panic occurs when attempting to unwrap the `asset_bf` value. #[must_use] pub fn to_input(&self) -> Input { let mut pst_input = self.partial_input.to_input(); @@ -145,12 +145,8 @@ pub struct FinalTransaction { inputs: Vec, outputs: Vec, change: Option, - /// The height this transaction may not be mined before, when it declares one. - /// - /// A property of the transaction rather than of any one input: PSET carries it per input - /// and takes the greatest, so it is written onto every input at extraction rather than - /// asked of whichever input happened to be added first. - locktime_height: Option, + sequence: Sequence, + locktime: LockTime, } impl FinalTransaction { @@ -162,17 +158,22 @@ impl FinalTransaction { inputs: Vec::new(), outputs: Vec::new(), change: None, - locktime_height: None, + sequence: Sequence::default(), + locktime: LockTime::ZERO, } } + /// Sets a specific `Sequence` for the transaction. + /// + /// Injects this value into the inputs that don't declare their own sequence. + pub fn set_sequence(&mut self, sequence: Sequence) { + self.sequence = sequence; + } - /// Sets the block height this transaction may not be mined before. + /// Sets a specific `LockTime` for the transaction. /// - /// A contract checking `check_lock_height` reads the transaction's own locktime, so a - /// covenant whose spending path is time-locked cannot be satisfied without one. Nothing - /// here decides the value: the caller states it. - pub fn set_locktime_height(&mut self, height: u32) { - self.locktime_height = Some(height); + /// Injects this value into the inputs that don't declare their own locktime. + pub fn set_locktime(&mut self, locktime: LockTime) { + self.locktime = locktime; } /// Sets where this transaction's change should go. @@ -363,13 +364,13 @@ impl FinalTransaction { for input in &self.inputs { match input.partial_input.secrets { - // this is an unblinded confidential input + // This is an unblinded confidential input Some(secrets) => { if secrets.asset == network.policy_asset() { available_amount += secrets.value; } } - // this is an explicit input + // This is an explicit input None => { if input.partial_input.asset.unwrap() == network.policy_asset() { available_amount += input.partial_input.amount.unwrap(); @@ -417,25 +418,24 @@ impl FinalTransaction { let mut pst = PartiallySignedTransaction::new_v2(); for i in 0..self.inputs.len() { - let final_input = &self.inputs[i]; - let mut pst_input = final_input.to_input(); - - // Written onto every input, because PSET derives the transaction's locktime from - // the greatest one its inputs require. An input that already declares its own is - // left alone: that one was asked for deliberately and is not this to overwrite. - if let Some(height) = self.locktime_height - && pst_input.required_height_locktime.is_none() - { - pst_input.required_height_locktime = Some( - simplicityhl::elements::locktime::Height::from_consensus(height) - .expect("a block height is a valid locktime"), - ); + let mut final_input = self.inputs[i].clone(); + + // Inject sequence if the input has none + if final_input.partial_input.sequence == Sequence::default() { + final_input.partial_input = final_input.partial_input.with_sequence(self.sequence); + } + + // Inject locktime if the input has none + if final_input.partial_input.locktime == LockTime::ZERO { + final_input.partial_input = final_input.partial_input.with_locktime(self.locktime); } + let pst_input = final_input.to_input(); + match final_input.partial_input.secrets { - // insert input secrets if present + // Insert input secrets if present Some(secrets) => input_secrets.insert(i, secrets), - // else populate input secrets with "explicit" amounts + // Else populate input secrets with "explicit" amounts None => input_secrets.insert( i, TxOutSecrets { @@ -470,7 +470,7 @@ impl FinalTransaction { mod tests { use bitcoin_hashes::Hash; - use simplicityhl::elements::{OutPoint, Script, TxOut, Txid}; + use simplicityhl::elements::{LockTime, OutPoint, Script, TxOut, Txid}; use crate::transaction::UTXO; @@ -533,15 +533,11 @@ mod tests { assert_eq!(secrets, expected_secrets); } - /// A covenant branch guarded by `check_lock_height` reads the transaction's own locktime, - /// so the height a caller declares has to survive into the PSET the program executes - /// against. PSET derives it from the greatest its inputs require, which is why it is - /// written onto every input rather than onto whichever was added first. #[test] fn declared_height_becomes_the_transactions_locktime() { let policy = dummy_asset_id(0xAA); - let mut ft = FinalTransaction::new(); + ft.add_input( PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), RequiredSignature::None, @@ -551,13 +547,13 @@ mod tests { RequiredSignature::None, ); ft.add_output(PartialOutput::new(Script::new(), 9000, policy)); - ft.set_locktime_height(2_580_990); + ft.set_locktime(LockTime::from_height(2_580_990).unwrap()); let (pst, _) = ft.extract_pst(); assert_eq!( pst.locktime().expect("one height, so no conflict"), - simplicityhl::elements::LockTime::from_height(2_580_990).unwrap() + LockTime::from_height(2_580_990).unwrap() ); assert!( pst.inputs() @@ -566,12 +562,11 @@ mod tests { ); } - /// Declaring none leaves the transaction where it was: locktime zero, no input constrained. #[test] - fn a_transaction_that_declares_no_height_still_has_none() { + fn transaction_that_declares_no_height_still_has_none() { let policy = dummy_asset_id(0xAA); - let mut ft = FinalTransaction::new(); + ft.add_input( PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), RequiredSignature::None, @@ -580,7 +575,7 @@ mod tests { let (pst, _) = ft.extract_pst(); - assert_eq!(pst.locktime().unwrap(), simplicityhl::elements::LockTime::ZERO); + assert_eq!(pst.locktime().unwrap(), LockTime::ZERO); } #[test] diff --git a/crates/sdk/src/transaction/partial_input.rs b/crates/sdk/src/transaction/partial_input.rs index cd64fad..4c05b05 100644 --- a/crates/sdk/src/transaction/partial_input.rs +++ b/crates/sdk/src/transaction/partial_input.rs @@ -164,9 +164,6 @@ impl PartialInput { LockTime::Seconds(value) => Some(value), LockTime::Blocks(_) => None, }; - // A zero height is no height. The comment here always said so, while the code wrote it - // as a requirement of zero — which PSET reads as an input that constrains the locktime, - // so nothing else could raise it and every transaction came out locked at zero. let height_locktime = match self.locktime { LockTime::Blocks(value) if value.to_consensus_u32() > 0 => Some(value), LockTime::Blocks(_) | LockTime::Seconds(_) => None, diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index f4430fb..01268c7 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -11,8 +11,8 @@ use std::sync::Arc; use elements_miniscript::bitcoin::PublicKey; use simplicityhl::ast::ElementsJetHinter; -use simplicityhl::elements; -use simplicityhl::elements::{AssetId, OutPoint, Script, Sequence, TxOut, Txid}; +use simplicityhl::elements::{self, Sequence}; +use simplicityhl::elements::{AssetId, LockTime, OutPoint, Script, TxOut, Txid}; use simplicityhl::{Arguments, TemplateProgram, UnstableFeatures, WitnessValues}; use smplx_sdk::program::{ArgumentsTrait, Program, WitnessTrait}; @@ -343,7 +343,7 @@ impl WalletSigner { ) -> Result { let (transaction, fee_sats) = self .signer - .finalize_strict(builder.inner(), fee_rate) + .finalize_strict(&builder.transaction, fee_rate) .map_err(|e| JsError::new(&format!("Could not finalize the transaction: {e}")))?; Ok(SignedTransaction { @@ -376,6 +376,24 @@ impl TransactionBuilder { } } + /// Sets the block height this transaction may not be mined before. + #[wasm_bindgen(js_name = setLocktimeHeight)] + pub fn set_locktime_height(&mut self, height: u32) { + self.transaction.set_locktime(LockTime::from_height(height).unwrap()); + } + + /// Sets the block time this transaction may not be mined before. + #[wasm_bindgen(js_name = setLocktimeTime)] + pub fn set_locktime_time(&mut self, time: u32) { + self.transaction.set_locktime(LockTime::from_time(time).unwrap()); + } + + /// Sets the sequence of for this transaction. + #[wasm_bindgen(js_name = setSequence)] + pub fn set_sequence(&mut self, sequence: u32) { + self.transaction.set_sequence(Sequence::from_consensus(sequence)); + } + /// Sets where this transaction's change should go. /// /// Left unset, change returns to the signer's own derived address. @@ -417,15 +435,9 @@ impl TransactionBuilder { /// # Errors /// Returns an error if the txid or the encoded output cannot be parsed. #[wasm_bindgen(js_name = addWalletInput)] - pub fn add_wallet_input( - &mut self, - txid: &str, - vout: u32, - tx_out_hex: &str, - sequence: Option, - ) -> Result<(), JsError> { + pub fn add_wallet_input(&mut self, txid: &str, vout: u32, tx_out_hex: &str) -> Result<(), JsError> { self.transaction.add_input( - Self::with_sequence(PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), sequence), + PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), RequiredSignature::NativeEcdsa, ); @@ -456,7 +468,6 @@ impl TransactionBuilder { asset_amount_sats: u64, inflation_amount_sats: u64, issuer_contract_hex: Option, - sequence: Option, ) -> Result { let contract = issuer_contract(issuer_contract_hex.as_deref()) .map_err(|e| JsError::new(&format!("Invalid issuer contract: {e}")))?; @@ -466,7 +477,7 @@ impl TransactionBuilder { // itself and an ordinary wallet input never needs a witness one, so the panicking case // cannot be reached from JavaScript. let details = self.transaction.add_issuance_input( - Self::with_sequence(PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), sequence), + PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), IssuanceInput::new_issuance(asset_amount_sats, inflation_amount_sats, contract), RequiredSignature::NativeEcdsa, ); @@ -474,16 +485,6 @@ impl TransactionBuilder { Ok(issuance_report(&details)) } - /// Declares the block height this transaction may not be mined before. - /// - /// A covenant whose spending path checks a lock height cannot be satisfied by a - /// transaction that declares none, so a wallet spending one has to state it. Which height - /// is the caller's to decide; this only carries it. - #[wasm_bindgen(js_name = setLocktimeHeight)] - pub fn set_locktime_height(&mut self, height: u32) { - self.transaction.set_locktime_height(height); - } - /// Adds a Simplicity contract input, spent by satisfying it. /// /// `witness_json` carries the witness values in `SimplicityHL` `.wit` shape. @@ -506,12 +507,11 @@ impl TransactionBuilder { arguments_json: Option, witness_json: Option, signature_witness: Option, - sequence: Option, extra_leaves_json: Option, include_debug_symbols: Option, ) -> Result<(), JsError> { self.transaction.add_program_input( - Self::with_sequence(PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), sequence), + PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), Self::program_input( source, arguments_json.as_deref(), @@ -548,7 +548,6 @@ impl TransactionBuilder { asset_amount_sats: u64, inflation_amount_sats: u64, issuer_contract_hex: Option, - sequence: Option, extra_leaves_json: Option, include_debug_symbols: Option, ) -> Result { @@ -561,7 +560,7 @@ impl TransactionBuilder { // one and never the native kind, so the panicking case cannot be reached from // JavaScript. let details = self.transaction.add_program_issuance_input( - Self::with_sequence(PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), sequence), + PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), Self::program_input( source, arguments_json.as_deref(), @@ -765,19 +764,6 @@ impl TransactionBuilder { Ok(program) } - - /// Applies a declared sequence to an input. - fn with_sequence(input: PartialInput, sequence: Option) -> PartialInput { - match sequence { - Some(value) => input.with_sequence(Sequence(value)), - None => input, - } - } - - /// The assembled transaction, for the signer in this crate. - fn inner(&self) -> &FinalTransaction { - &self.transaction - } } impl Default for TransactionBuilder { From 6619225cd1179a756452a830f2ed74e31ce1f0e2 Mon Sep 17 00:00:00 2001 From: lukachi Date: Sun, 23 Aug 2026 17:16:07 +0300 Subject: [PATCH 3/6] refactor(wasm): move the issuance helpers onto the types that use them Four free functions sat at the crate root where the review asked why they were there. None of them is used by more than one type. - Reading an id and the issuer contract built from it move onto `TransactionBuilder`, beside the other private helpers its inputs already use. - Writing an id and the report built from it move onto `IssuanceReport`, which is the only thing that returns them. - Both locktime setters document the panic they carry: `LockTime` is a height below `500_000_000` and a time at or above it, and the wrong side of that boundary aborts the module. No exported binding changes. --- crates/wasm/src/lib.rs | 123 +++++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 60 deletions(-) diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 01268c7..dbe2ee3 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -35,51 +35,6 @@ fn network_from_str(network: &str) -> Result { } } -/// An id as it is written turned into the bytes it is made of, which run the other way. -fn read_id(written: &str) -> Result<[u8; 32], String> { - let decoded = hex::decode(written).map_err(|e| e.to_string())?; - let mut bytes: [u8; 32] = decoded - .try_into() - .map_err(|_| "an id is thirty-two bytes".to_string())?; - - bytes.reverse(); - - Ok(bytes) -} - -/// The same conversion back, because an id leaves here in the form everything else reads. -fn write_id(bytes: [u8; 32]) -> String { - let mut written = bytes; - - written.reverse(); - - hex::encode(written) -} - -/// The issuer contract an issuance commits to, which is nothing unless one is named. -/// -/// Elements derives an asset from the output being spent and the issuer contract together. -/// Every asset in Liquid's public registry commits to one; a transaction manifest declares no -/// such thing at any position, so its issuances commit to the empty one. Taken as an argument -/// rather than assumed, because the wallet derives the same asset for itself and two -/// derivations that agree only because neither was told anything have not agreed about -/// anything. -fn issuer_contract(written: Option<&str>) -> Result<[u8; 32], String> { - match written.map(str::trim).filter(|hex_id| !hex_id.is_empty()) { - Some(hex_id) => read_id(hex_id), - None => Ok([0_u8; 32]), - } -} - -/// The module's account of one issuance, in the form ids are written in. -fn issuance_report(details: &IssuanceDetails) -> IssuanceReport { - IssuanceReport { - asset_id: details.asset_id.to_string(), - entropy: write_id(details.asset_entropy.to_byte_array()), - reissuance_token_id: details.inflation_asset_id.to_string(), - } -} - /// What the module made of an issuance, reported rather than kept to itself. /// /// The caller derives the same three values before any of this runs, from the same output. @@ -116,6 +71,24 @@ impl IssuanceReport { pub fn reissuance_token_id(&self) -> String { self.reissuance_token_id.clone() } + + /// The module's account of one issuance, in the form ids are written in. + fn of(details: &IssuanceDetails) -> Self { + Self { + asset_id: details.asset_id.to_string(), + entropy: Self::write_id(details.asset_entropy.to_byte_array()), + reissuance_token_id: details.inflation_asset_id.to_string(), + } + } + + /// The bytes of an id written the way everything else reads it, which runs the other way. + fn write_id(bytes: [u8; 32]) -> String { + let mut written = bytes; + + written.reverse(); + + hex::encode(written) + } } /// Compile-time parameters for a contract, resolved before construction. @@ -377,12 +350,18 @@ impl TransactionBuilder { } /// Sets the block height this transaction may not be mined before. + /// + /// # Panics + /// Panics if `height` is not a block height, which Elements ends at `500_000_000`. #[wasm_bindgen(js_name = setLocktimeHeight)] pub fn set_locktime_height(&mut self, height: u32) { self.transaction.set_locktime(LockTime::from_height(height).unwrap()); } /// Sets the block time this transaction may not be mined before. + /// + /// # Panics + /// Panics if `time` is not a block time, which Elements starts at `500_000_000`. #[wasm_bindgen(js_name = setLocktimeTime)] pub fn set_locktime_time(&mut self, time: u32) { self.transaction.set_locktime(LockTime::from_time(time).unwrap()); @@ -469,7 +448,7 @@ impl TransactionBuilder { inflation_amount_sats: u64, issuer_contract_hex: Option, ) -> Result { - let contract = issuer_contract(issuer_contract_hex.as_deref()) + let contract = Self::issuer_contract(issuer_contract_hex.as_deref()) .map_err(|e| JsError::new(&format!("Invalid issuer contract: {e}")))?; // The SDK panics here when the input requires a witness signature, and a panic inside @@ -482,7 +461,7 @@ impl TransactionBuilder { RequiredSignature::NativeEcdsa, ); - Ok(issuance_report(&details)) + Ok(IssuanceReport::of(&details)) } /// Adds a Simplicity contract input, spent by satisfying it. @@ -551,7 +530,7 @@ impl TransactionBuilder { extra_leaves_json: Option, include_debug_symbols: Option, ) -> Result { - let contract = issuer_contract(issuer_contract_hex.as_deref()) + let contract = Self::issuer_contract(issuer_contract_hex.as_deref()) .map_err(|e| JsError::new(&format!("Invalid issuer contract: {e}")))?; // The SDK panics here when the input requires the native signature rather than a @@ -572,7 +551,7 @@ impl TransactionBuilder { Self::required_signature(signature_witness.as_deref()), ); - Ok(issuance_report(&details)) + Ok(IssuanceReport::of(&details)) } /// Adds an output paying `amount_sats` of `asset_hex` to `script_pubkey_hex`. @@ -652,6 +631,26 @@ impl TransactionBuilder { self.transaction.n_outputs() } + /// The issuer contract an issuance commits to, which is nothing unless one is named. + fn issuer_contract(written: Option<&str>) -> Result<[u8; 32], String> { + match written.map(str::trim).filter(|hex_id| !hex_id.is_empty()) { + Some(hex_id) => Self::read_id(hex_id), + None => Ok([0_u8; 32]), + } + } + + /// An id as it is written turned into the bytes it is made of, which run the other way. + fn read_id(written: &str) -> Result<[u8; 32], String> { + let decoded = hex::decode(written).map_err(|e| e.to_string())?; + let mut bytes: [u8; 32] = decoded + .try_into() + .map_err(|_| "an id is thirty-two bytes".to_string())?; + + bytes.reverse(); + + Ok(bytes) + } + /// Which signature a covenant input needs. Can either be a witness name like `SIGNATURE`, /// or a withess path if the signature is embedded like `SIGNATURE.Left.Right.1` /// @@ -817,7 +816,7 @@ mod tests { use simplicityhl::elements::{AssetId, OutPoint, Txid}; use smplx_sdk::utils::asset_entropy; - use super::{FromStr, IssuanceDetails, IssuanceReport, issuance_report, issuer_contract, read_id, write_id}; + use super::{FromStr, IssuanceDetails, IssuanceReport, TransactionBuilder}; /// Assets Liquid already carries, and the outputs they were issued from. /// @@ -873,10 +872,10 @@ mod tests { }; let entropy = asset_entropy( &outpoint, - issuer_contract(Some(contract)).expect("a chain vector's contract"), + TransactionBuilder::issuer_contract(Some(contract)).expect("a chain vector's contract"), ); - issuance_report(&IssuanceDetails { + IssuanceReport::of(&IssuanceDetails { asset_id: AssetId::from_entropy(entropy), inflation_asset_id: AssetId::reissuance_token_from_entropy(entropy, false), asset_entropy: entropy, @@ -901,7 +900,8 @@ mod tests { fn reports_an_entropy_its_own_asset_can_be_rederived_from() { for (txid, vout, contract, asset, _) in ON_CHAIN { let reported = report_for(txid, vout, contract).entropy; - let read_back = Midstate::from_byte_array(read_id(&reported).expect("a reported entropy")); + let read_back = + Midstate::from_byte_array(TransactionBuilder::read_id(&reported).expect("a reported entropy")); assert_eq!(AssetId::from_entropy(read_back).to_string(), asset); } @@ -911,22 +911,25 @@ mod tests { fn commits_to_no_issuer_contract_unless_one_is_named() { let empty = [0_u8; 32]; - assert_eq!(issuer_contract(None), Ok(empty)); - assert_eq!(issuer_contract(Some("")), Ok(empty)); - assert_eq!(issuer_contract(Some(" ")), Ok(empty)); - assert_eq!(issuer_contract(Some(&"0".repeat(64))), Ok(empty)); + assert_eq!(TransactionBuilder::issuer_contract(None), Ok(empty)); + assert_eq!(TransactionBuilder::issuer_contract(Some("")), Ok(empty)); + assert_eq!(TransactionBuilder::issuer_contract(Some(" ")), Ok(empty)); + assert_eq!(TransactionBuilder::issuer_contract(Some(&"0".repeat(64))), Ok(empty)); } #[test] fn an_id_leaves_in_the_form_it_arrived_in() { let written = "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2"; - assert_eq!(write_id(read_id(written).expect("a written id")), written); + assert_eq!( + IssuanceReport::write_id(TransactionBuilder::read_id(written).expect("a written id")), + written + ); } #[test] fn refuses_an_issuer_contract_that_is_not_an_id() { - assert!(issuer_contract(Some("not hex at all")).is_err()); - assert!(issuer_contract(Some("00ff")).is_err()); + assert!(TransactionBuilder::issuer_contract(Some("not hex at all")).is_err()); + assert!(TransactionBuilder::issuer_contract(Some("00ff")).is_err()); } } From 4230063ae5b9e5f54970511ba66421a8addc32d9 Mon Sep 17 00:00:00 2001 From: lukachi Date: Sun, 23 Aug 2026 17:42:27 +0300 Subject: [PATCH 4/6] docs(wasm): put the locktime boundary on the side Elements puts it A height ends at 499_999_999 and 500_000_000 is already a time, so the note named the first value that aborts as though it were allowed. --- crates/wasm/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index dbe2ee3..510e6ab 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -352,7 +352,7 @@ impl TransactionBuilder { /// Sets the block height this transaction may not be mined before. /// /// # Panics - /// Panics if `height` is not a block height, which Elements ends at `500_000_000`. + /// Panics if `height` is `500_000_000` or greater, which Elements reads as a time. #[wasm_bindgen(js_name = setLocktimeHeight)] pub fn set_locktime_height(&mut self, height: u32) { self.transaction.set_locktime(LockTime::from_height(height).unwrap()); @@ -361,7 +361,7 @@ impl TransactionBuilder { /// Sets the block time this transaction may not be mined before. /// /// # Panics - /// Panics if `time` is not a block time, which Elements starts at `500_000_000`. + /// Panics if `time` is below `500_000_000`, which Elements reads as a height. #[wasm_bindgen(js_name = setLocktimeTime)] pub fn set_locktime_time(&mut self, time: u32) { self.transaction.set_locktime(LockTime::from_time(time).unwrap()); From cc89e3bc146c2e570cd8e4b94be24ed2c12744a7 Mon Sep 17 00:00:00 2001 From: lukachi Date: Mon, 24 Aug 2026 16:47:08 +0300 Subject: [PATCH 5/6] refactor(wasm)!: a money-locking program is a covenant, not a contract Two unrelated things carried the word. Elements calls the document an issuer publishes about an asset its *contract*, and mixes a hash of it with the outpoint being spent to derive the asset id; that sense stays, because `ContractHash` is Elements' own type and the SDK uses the word at `crates/sdk/src/utils.rs`. The other sense was borrowed from Ethereum for a Simplicity program that locks money. In Bitcoin and Elements that is a covenant, which is what this crate already called it at the two places nobody exports: the error `dryRunContractInput` raised said "is not a covenant input", and the comment in `program_input` said "the covenant being spent". `Contract`, `contractAddress`, `contractParameterTypes`, `addContractInput`, `addContractIssuanceInput` and `dryRunContractInput` take the chain's word. `read_id` and `write_id` go with them. Neither read nor wrote: each was one direction of the byte-order flip between an id as displayed and the bytes it is made of. `ContractHash` is `#[hash_newtype(backward)]` and `Midstate` sets `DISPLAY_BACKWARD`, so `from_str` and `to_string` already do exactly this. The four on-chain issuance vectors still derive the assets Liquid holds, which is what proves the substitution. The length check `read_id` carried stays, because `from_str` alone reports a wrong-length id and a non-hex id with the same sentence. Written by Claude. --- crates/wasm/src/lib.rs | 112 +++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 65 deletions(-) diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 510e6ab..58f4f3f 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -11,8 +11,9 @@ use std::sync::Arc; use elements_miniscript::bitcoin::PublicKey; use simplicityhl::ast::ElementsJetHinter; +use simplicityhl::elements::hashes::Hash; use simplicityhl::elements::{self, Sequence}; -use simplicityhl::elements::{AssetId, LockTime, OutPoint, Script, TxOut, Txid}; +use simplicityhl::elements::{AssetId, ContractHash, LockTime, OutPoint, Script, TxOut, Txid}; use simplicityhl::{Arguments, TemplateProgram, UnstableFeatures, WitnessValues}; use smplx_sdk::program::{ArgumentsTrait, Program, WitnessTrait}; @@ -76,22 +77,13 @@ impl IssuanceReport { fn of(details: &IssuanceDetails) -> Self { Self { asset_id: details.asset_id.to_string(), - entropy: Self::write_id(details.asset_entropy.to_byte_array()), + entropy: details.asset_entropy.to_string(), reissuance_token_id: details.inflation_asset_id.to_string(), } } - - /// The bytes of an id written the way everything else reads it, which runs the other way. - fn write_id(bytes: [u8; 32]) -> String { - let mut written = bytes; - - written.reverse(); - - hex::encode(written) - } } -/// Compile-time parameters for a contract, resolved before construction. +/// Compile-time parameters for a covenant, resolved before construction. #[derive(Clone)] struct FixedArguments(Arguments); @@ -101,7 +93,7 @@ impl ArgumentsTrait for FixedArguments { } } -/// Witness values for a contract input, resolved before the transaction is assembled. +/// Witness values for a covenant input, resolved before the transaction is assembled. /// /// Held as parsed `WitnessValues` so a malformed set is rejected when the caller supplies /// it rather than in the middle of signing. @@ -114,20 +106,20 @@ impl WitnessTrait for FixedWitness { } } -/// A compiled `SimplicityHL` contract. +/// A compiled `SimplicityHL` covenant. #[wasm_bindgen] -pub struct Contract { +pub struct Covenant { program: Program, } #[wasm_bindgen] -impl Contract { - /// Creates a contract from `SimplicityHL` source text delivered at runtime. +impl Covenant { + /// Creates a covenant from `SimplicityHL` source text delivered at runtime. /// - /// `argumentsJson` carries the contract's compile-time parameters. + /// `argumentsJson` carries the covenant's compile-time parameters. /// /// Shape: `{"NAME": {"value": "0x…", "type": "Pubkey"}}`. - /// Pass `None` for a contract that declares no parameters. + /// Pass `None` for a covenant that declares no parameters. /// /// `extraLeavesJson` is a JSON array of hex strings, each an encoded taproot /// leaf payload appended to the tree in declaration order. @@ -142,7 +134,7 @@ impl Contract { arguments_json: Option, extra_leaves_json: Option, include_debug_symbols: Option, - ) -> Result { + ) -> Result { Ok(Self { program: TransactionBuilder::program_of( source, @@ -153,7 +145,7 @@ impl Contract { }) } - /// Compiles the contract and returns its Commitment Merkle Root as lowercase hex. + /// Compiles the covenant and returns its Commitment Merkle Root as lowercase hex. #[wasm_bindgen(js_name = commitmentMerkleRoot)] #[must_use] pub fn commitment_merkle_root(&self) -> String { @@ -162,7 +154,7 @@ impl Contract { hex::encode(cmr) } - /// Compiles the contract and returns the scriptPubKey its funds are locked with, as hex. + /// Compiles the covenant and returns the `scriptPubKey` its funds are locked with, as hex. /// /// # Errors /// Returns an error if the network name is unknown or the source fails to compile. @@ -173,7 +165,7 @@ impl Contract { Ok(hex::encode(self.program.get_script_pubkey(&network).as_bytes())) } - /// Compiles the contract and returns its scriptPubKey hash. + /// Compiles the covenant and returns its `scriptPubKey` hash. /// /// # Errors /// Returns an error if the network name is unknown or the source fails to compile. @@ -184,19 +176,19 @@ impl Contract { Ok(hex::encode(self.program.get_script_hash(&network))) } - /// Compiles the contract and returns the taproot address its funds would sit at. + /// Compiles the covenant and returns the taproot address its funds would sit at. /// /// # Errors /// Returns an error if the network name is unknown or the source fails to compile. - #[wasm_bindgen(js_name = contractAddress)] - pub fn contract_address(&self, network: &str) -> Result { + #[wasm_bindgen(js_name = covenantAddress)] + pub fn covenant_address(&self, network: &str) -> Result { let network = network_from_str(network)?; Ok(self.program.get_tr_address(&network).to_string()) } } -/// The compile-time parameters a contract source declares, as JSON of name to type. +/// The compile-time parameters a covenant source declares, as JSON of name to type. /// /// `SimplicityHL` has no syntax that declares a parameter's type. `param::NAME` is written /// where a value is wanted, and the type checker gives it the type that position demands @@ -219,14 +211,14 @@ impl Contract { /// /// # Errors /// Returns an error if the source does not parse or does not type-check. -#[wasm_bindgen(js_name = contractParameterTypes)] -pub fn contract_parameter_types(source: &str) -> Result { +#[wasm_bindgen(js_name = covenantParameterTypes)] +pub fn covenant_parameter_types(source: &str) -> Result { let template = TemplateProgram::new_with_unstable( Arc::::from(source), &UnstableFeatures::all(), Box::new(ElementsJetHinter), ) - .map_err(|e| JsError::new(&format!("Contract does not compile: {e}")))?; + .map_err(|e| JsError::new(&format!("Covenant does not compile: {e}")))?; let declared: BTreeMap = template .parameters() @@ -464,7 +456,7 @@ impl TransactionBuilder { Ok(IssuanceReport::of(&details)) } - /// Adds a Simplicity contract input, spent by satisfying it. + /// Adds a Simplicity covenant input, spent by satisfying it. /// /// `witness_json` carries the witness values in `SimplicityHL` `.wit` shape. /// Passing `None` leaves them unset. @@ -475,9 +467,9 @@ impl TransactionBuilder { /// /// # Errors /// Returns an error if the txid, the encoded output, the arguments or the witness cannot be parsed. - #[wasm_bindgen(js_name = addContractInput)] + #[wasm_bindgen(js_name = addCovenantInput)] #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] - pub fn add_contract_input( + pub fn add_covenant_input( &mut self, txid: &str, vout: u32, @@ -504,18 +496,18 @@ impl TransactionBuilder { Ok(()) } - /// Adds a Simplicity contract input that also creates a new asset. + /// Adds a Simplicity covenant input that also creates a new asset. /// - /// The contract half is the same as `addContractInput` and the issuance half the same as + /// The covenant half is the same as `addCovenantInput` and the issuance half the same as /// `addWalletIssuanceInput`: the asset is derived from the output this input spends, and /// `issuer_contract_hex` is what the issuance commits to, empty unless one is named. /// /// # Errors /// Returns an error if the txid, the encoded output, the arguments, the witness or the /// issuer contract cannot be parsed. - #[wasm_bindgen(js_name = addContractIssuanceInput)] + #[wasm_bindgen(js_name = addCovenantIssuanceInput)] #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] - pub fn add_contract_issuance_input( + pub fn add_covenant_issuance_input( &mut self, txid: &str, vout: u32, @@ -587,16 +579,16 @@ impl TransactionBuilder { Ok(()) } - /// Runs the Simplicity program of one contract input against this transaction. + /// Runs the Simplicity program of one covenant input against this transaction. /// /// This is the dry-run: it satisfies the witness, prunes the branches the spend does not /// take, and executes the result on a `BitMachine`. /// /// # Errors - /// Returns an error if the input is not a contract input, or if the program fails to + /// Returns an error if the input is not a covenant input, or if the program fails to /// satisfy, prune or execute. - #[wasm_bindgen(js_name = dryRunContractInput)] - pub fn dry_run_contract_input(&self, input_index: usize, network: &str) -> Result<(), JsError> { + #[wasm_bindgen(js_name = dryRunCovenantInput)] + pub fn dry_run_covenant_input(&self, input_index: usize, network: &str) -> Result<(), JsError> { let network = network_from_str(network)?; let inputs = self.transaction.inputs(); let input = inputs @@ -634,23 +626,14 @@ impl TransactionBuilder { /// The issuer contract an issuance commits to, which is nothing unless one is named. fn issuer_contract(written: Option<&str>) -> Result<[u8; 32], String> { match written.map(str::trim).filter(|hex_id| !hex_id.is_empty()) { - Some(hex_id) => Self::read_id(hex_id), + Some(hex_id) if hex_id.len() != 64 => Err("an id is thirty-two bytes".to_string()), + Some(hex_id) => ContractHash::from_str(hex_id) + .map(ContractHash::to_byte_array) + .map_err(|e| e.to_string()), None => Ok([0_u8; 32]), } } - /// An id as it is written turned into the bytes it is made of, which run the other way. - fn read_id(written: &str) -> Result<[u8; 32], String> { - let decoded = hex::decode(written).map_err(|e| e.to_string())?; - let mut bytes: [u8; 32] = decoded - .try_into() - .map_err(|_| "an id is thirty-two bytes".to_string())?; - - bytes.reverse(); - - Ok(bytes) - } - /// Which signature a covenant input needs. Can either be a witness name like `SIGNATURE`, /// or a withess path if the signature is embedded like `SIGNATURE.Left.Right.1` /// @@ -696,7 +679,7 @@ impl TransactionBuilder { }) } - /// The compiled contract and the witness values it is spent with. + /// The compiled covenant and the witness values it is spent with. /// /// Both are parsed here so a malformed set is rejected where the caller supplied it rather /// than in the middle of signing. @@ -713,7 +696,7 @@ impl TransactionBuilder { _ => WitnessValues::default(), }; - // The same build a `Contract` gets, because it has to be: the covenant being spent was + // The same build a `Covenant` gets, because it has to be: the covenant being spent was // committed to on chain by whatever built it, and a program compiled here in a different // mode, or without the leaves the deployment declared, locks to a different script. The // spend then fails at execution, after a person has already approved it. @@ -728,7 +711,7 @@ impl TransactionBuilder { }) } - /// One program, built exactly as `Contract::new` builds it. + /// One program, built exactly as `Covenant::new` builds it. fn program_of( source: &str, arguments_json: Option<&str>, @@ -737,7 +720,7 @@ impl TransactionBuilder { ) -> Result { let arguments = match arguments_json { Some(json) if !json.trim().is_empty() => serde_json::from_str::(json) - .map_err(|e| JsError::new(&format!("Invalid contract arguments: {e}")))?, + .map_err(|e| JsError::new(&format!("Invalid covenant arguments: {e}")))?, _ => Arguments::default(), }; @@ -816,7 +799,7 @@ mod tests { use simplicityhl::elements::{AssetId, OutPoint, Txid}; use smplx_sdk::utils::asset_entropy; - use super::{FromStr, IssuanceDetails, IssuanceReport, TransactionBuilder}; + use super::{ContractHash, FromStr, Hash, IssuanceDetails, IssuanceReport, TransactionBuilder}; /// Assets Liquid already carries, and the outputs they were issued from. /// @@ -900,8 +883,7 @@ mod tests { fn reports_an_entropy_its_own_asset_can_be_rederived_from() { for (txid, vout, contract, asset, _) in ON_CHAIN { let reported = report_for(txid, vout, contract).entropy; - let read_back = - Midstate::from_byte_array(TransactionBuilder::read_id(&reported).expect("a reported entropy")); + let read_back = Midstate::from_str(&reported).expect("a reported entropy"); assert_eq!(AssetId::from_entropy(read_back).to_string(), asset); } @@ -920,11 +902,11 @@ mod tests { #[test] fn an_id_leaves_in_the_form_it_arrived_in() { let written = "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2"; + let read = TransactionBuilder::issuer_contract(Some(written)).expect("a written id"); - assert_eq!( - IssuanceReport::write_id(TransactionBuilder::read_id(written).expect("a written id")), - written - ); + assert_eq!(read[0], 0xd2); + assert_eq!(read[31], 0xce); + assert_eq!(ContractHash::from_byte_array(read).to_string(), written); } #[test] From 2472b57220408a8327bfdf46a2824f821ae9b032 Mon Sep 17 00:00:00 2001 From: Artem Chystiakov Date: Mon, 24 Aug 2026 18:30:58 +0300 Subject: [PATCH 6/6] slight refactor --- crates/wasm/src/lib.rs | 220 +++++++++++++---------------------------- 1 file changed, 68 insertions(+), 152 deletions(-) diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 58f4f3f..275c3b1 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -36,10 +36,7 @@ fn network_from_str(network: &str) -> Result { } } -/// What the module made of an issuance, reported rather than kept to itself. -/// -/// The caller derives the same three values before any of this runs, from the same output. -/// Returning them is what lets the two derivations be compared instead of one being trusted. +/// Asset issuance details. #[wasm_bindgen] pub struct IssuanceReport { asset_id: String, @@ -49,6 +46,14 @@ pub struct IssuanceReport { #[wasm_bindgen] impl IssuanceReport { + fn from_details(details: &IssuanceDetails) -> Self { + Self { + asset_id: details.asset_id.to_string(), + entropy: details.asset_entropy.to_string(), + reissuance_token_id: details.inflation_asset_id.to_string(), + } + } + /// The asset this issuance creates. #[wasm_bindgen(getter, js_name = assetId)] #[must_use] @@ -56,31 +61,19 @@ impl IssuanceReport { self.asset_id.clone() } - /// What a later reissuance of this same asset would be derived from. - /// - /// The output this issuance spends is gone once the transaction confirms, so this is the - /// only part of the derivation that survives it. + /// The entropy to derive the reissuance asset. #[wasm_bindgen(getter)] #[must_use] pub fn entropy(&self) -> String { self.entropy.clone() } - /// The token that authorises reissuing this asset, derived whether or not any is minted. + /// The reissuance asset this issuance creates. #[wasm_bindgen(getter, js_name = reissuanceTokenId)] #[must_use] pub fn reissuance_token_id(&self) -> String { self.reissuance_token_id.clone() } - - /// The module's account of one issuance, in the form ids are written in. - fn of(details: &IssuanceDetails) -> Self { - Self { - asset_id: details.asset_id.to_string(), - entropy: details.asset_entropy.to_string(), - reissuance_token_id: details.inflation_asset_id.to_string(), - } - } } /// Compile-time parameters for a covenant, resolved before construction. @@ -94,9 +87,6 @@ impl ArgumentsTrait for FixedArguments { } /// Witness values for a covenant input, resolved before the transaction is assembled. -/// -/// Held as parsed `WitnessValues` so a malformed set is rejected when the caller supplies -/// it rather than in the middle of signing. #[derive(Clone)] struct FixedWitness(WitnessValues); @@ -136,7 +126,7 @@ impl Covenant { include_debug_symbols: Option, ) -> Result { Ok(Self { - program: TransactionBuilder::program_of( + program: Self::from_source( source, arguments_json.as_deref(), extra_leaves_json.as_deref(), @@ -180,35 +170,51 @@ impl Covenant { /// /// # Errors /// Returns an error if the network name is unknown or the source fails to compile. - #[wasm_bindgen(js_name = covenantAddress)] - pub fn covenant_address(&self, network: &str) -> Result { + #[wasm_bindgen(js_name = address)] + pub fn address(&self, network: &str) -> Result { let network = network_from_str(network)?; Ok(self.program.get_tr_address(&network).to_string()) } + + fn from_source( + source: &str, + arguments_json: Option<&str>, + extra_leaves_json: Option<&str>, + include_debug_symbols: Option, + ) -> Result { + let arguments = match arguments_json { + Some(json) if !json.trim().is_empty() => serde_json::from_str::(json) + .map_err(|e| JsError::new(&format!("Invalid covenant arguments: {e}")))?, + _ => Arguments::default(), + }; + + let mut program = Program::new(Arc::::from(source), &FixedArguments(arguments)); + + if let Some(include) = include_debug_symbols { + program = program.with_debug_symbols(include); + } + + if let Some(json) = extra_leaves_json.filter(|json| !json.trim().is_empty()) { + let leaves: Vec = + serde_json::from_str(json).map_err(|e| JsError::new(&format!("Invalid extra leaves: {e}")))?; + + program = program.with_storage_capacity(leaves.len()); + + for (index, leaf) in leaves.iter().enumerate() { + let bytes = hex::decode(leaf.strip_prefix("0x").unwrap_or(leaf)) + .map_err(|e| JsError::new(&format!("Extra leaf {index} is not hex: {e}")))?; + + program.set_storage_at(index, bytes); + } + } + + Ok(program) + } } /// The compile-time parameters a covenant source declares, as JSON of name to type. /// -/// `SimplicityHL` has no syntax that declares a parameter's type. `param::NAME` is written -/// where a value is wanted, and the type checker gives it the type that position demands -/// (`simplicityhl` 0.6.0, `src/ast.rs` L1346-1350: the parameter is inserted into the global -/// map with the expected type of the expression it stands in for). So the type of a parameter -/// exists only as a result of analysing the program, and the only thing that can state it is -/// the compiler. -/// -/// Reading it needs no arguments, which is what makes it usable before they are built: a -/// `TemplateProgram` is the program analysed but not instantiated, so its parameters are -/// resolved while its arguments are still unknown. Asking a compiled program instead would be -/// circular, since compiling one requires the arguments this is being asked in order to encode. -/// -/// The alternative is a caller guessing a width. A guess is not silent here — the compiler -/// requires an argument's type to equal the parameter's exactly — but it is still a guess, and -/// the value written at a correct width is where the silence lives. -/// -/// Types are spelled as the compiler spells them, which is the spelling the argument JSON's -/// `type` field is read back in. -/// /// # Errors /// Returns an error if the source does not parse or does not type-check. #[wasm_bindgen(js_name = covenantParameterTypes)] @@ -323,8 +329,6 @@ impl WalletSigner { /// /// Inputs are expected as an outpoint plus the raw `TxOut` they spend. /// Coin selection and unblinding are the caller's responsibility. -/// -/// Assembles exactly what it is given and adds only the change and fee outputs. #[wasm_bindgen] pub struct TransactionBuilder { transaction: FinalTransaction, @@ -417,16 +421,6 @@ impl TransactionBuilder { /// Adds an ordinary wallet input that also creates a new asset. /// - /// The asset is a function of the output this input spends, so it cannot exist before that - /// output is chosen, and moving the issuance to another input mints a different asset. - /// - /// `issuer_contract_hex` is the issuer contract the issuance commits to, written the way an - /// id is written. Left unset it is the empty commitment, which is what a transaction - /// manifest declares. - /// - /// Returns the asset, the token that would authorise reissuing it, and the entropy both are - /// derived from. - /// /// # Errors /// Returns an error if the txid, the encoded output or the issuer contract cannot be parsed. #[wasm_bindgen(js_name = addWalletIssuanceInput)] @@ -443,20 +437,16 @@ impl TransactionBuilder { let contract = Self::issuer_contract(issuer_contract_hex.as_deref()) .map_err(|e| JsError::new(&format!("Invalid issuer contract: {e}")))?; - // The SDK panics here when the input requires a witness signature, and a panic inside - // wasm aborts the module instead of returning. This binding chooses the signature - // itself and an ordinary wallet input never needs a witness one, so the panicking case - // cannot be reached from JavaScript. let details = self.transaction.add_issuance_input( PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), IssuanceInput::new_issuance(asset_amount_sats, inflation_amount_sats, contract), RequiredSignature::NativeEcdsa, ); - Ok(IssuanceReport::of(&details)) + Ok(IssuanceReport::from_details(&details)) } - /// Adds a Simplicity covenant input, spent by satisfying it. + /// Adds a Simplicity covenant input. /// /// `witness_json` carries the witness values in `SimplicityHL` `.wit` shape. /// Passing `None` leaves them unset. @@ -485,9 +475,9 @@ impl TransactionBuilder { PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), Self::program_input( source, - arguments_json.as_deref(), - witness_json.as_deref(), - extra_leaves_json.as_deref(), + arguments_json, + witness_json, + extra_leaves_json, include_debug_symbols, )?, Self::required_signature(signature_witness.as_deref()), @@ -498,9 +488,8 @@ impl TransactionBuilder { /// Adds a Simplicity covenant input that also creates a new asset. /// - /// The covenant half is the same as `addCovenantInput` and the issuance half the same as - /// `addWalletIssuanceInput`: the asset is derived from the output this input spends, and - /// `issuer_contract_hex` is what the issuance commits to, empty unless one is named. + /// The covenant half is the same as `addCovenantInput` and + /// the issuance half the same as `addWalletIssuanceInput`. /// /// # Errors /// Returns an error if the txid, the encoded output, the arguments, the witness or the @@ -525,25 +514,20 @@ impl TransactionBuilder { let contract = Self::issuer_contract(issuer_contract_hex.as_deref()) .map_err(|e| JsError::new(&format!("Invalid issuer contract: {e}")))?; - // The SDK panics here when the input requires the native signature rather than a - // witness one, and a panic inside wasm aborts the module instead of returning. The - // signature is derived from `signature_witness`, which yields no signature or a witness - // one and never the native kind, so the panicking case cannot be reached from - // JavaScript. let details = self.transaction.add_program_issuance_input( PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), Self::program_input( source, - arguments_json.as_deref(), - witness_json.as_deref(), - extra_leaves_json.as_deref(), + arguments_json, + witness_json, + extra_leaves_json, include_debug_symbols, )?, IssuanceInput::new_issuance(asset_amount_sats, inflation_amount_sats, contract), Self::required_signature(signature_witness.as_deref()), ); - Ok(IssuanceReport::of(&details)) + Ok(IssuanceReport::from_details(&details)) } /// Adds an output paying `amount_sats` of `asset_hex` to `script_pubkey_hex`. @@ -658,10 +642,6 @@ impl TransactionBuilder { RequiredSignature::witness_with_path(name, path) } - /// The output an input spends, from what the wallet already holds about it. - /// - /// `tx_out_hex` is the consensus encoding of that output rather than a summary of it, - /// because a re-encoded summary is a second opinion about what the chain holds. fn utxo_at(txid: &str, vout: u32, tx_out_hex: &str) -> Result { let outpoint = OutPoint { txid: Txid::from_str(txid).map_err(|e| JsError::new(&format!("Invalid txid: {e}")))?, @@ -679,73 +659,24 @@ impl TransactionBuilder { }) } - /// The compiled covenant and the witness values it is spent with. - /// - /// Both are parsed here so a malformed set is rejected where the caller supplied it rather - /// than in the middle of signing. fn program_input( source: &str, - arguments_json: Option<&str>, - witness_json: Option<&str>, - extra_leaves_json: Option<&str>, + arguments_json: Option, + witness_json: Option, + extra_leaves_json: Option, include_debug_symbols: Option, ) -> Result { let witness = match witness_json { - Some(json) if !json.trim().is_empty() => serde_json::from_str::(json) + Some(json) if !json.trim().is_empty() => serde_json::from_str::(&json) .map_err(|e| JsError::new(&format!("Invalid witness values: {e}")))?, _ => WitnessValues::default(), }; - // The same build a `Covenant` gets, because it has to be: the covenant being spent was - // committed to on chain by whatever built it, and a program compiled here in a different - // mode, or without the leaves the deployment declared, locks to a different script. The - // spend then fails at execution, after a person has already approved it. Ok(ProgramInput { - program: Box::new(Self::program_of( - source, - arguments_json, - extra_leaves_json, - include_debug_symbols, - )?), + program: Box::new(Covenant::new(source, arguments_json, extra_leaves_json, include_debug_symbols)?.program), witness: Box::new(FixedWitness(witness)), }) } - - /// One program, built exactly as `Covenant::new` builds it. - fn program_of( - source: &str, - arguments_json: Option<&str>, - extra_leaves_json: Option<&str>, - include_debug_symbols: Option, - ) -> Result { - let arguments = match arguments_json { - Some(json) if !json.trim().is_empty() => serde_json::from_str::(json) - .map_err(|e| JsError::new(&format!("Invalid covenant arguments: {e}")))?, - _ => Arguments::default(), - }; - - let mut program = Program::new(Arc::::from(source), &FixedArguments(arguments)); - - if let Some(include) = include_debug_symbols { - program = program.with_debug_symbols(include); - } - - if let Some(json) = extra_leaves_json.filter(|json| !json.trim().is_empty()) { - let leaves: Vec = - serde_json::from_str(json).map_err(|e| JsError::new(&format!("Invalid extra leaves: {e}")))?; - - program = program.with_storage_capacity(leaves.len()); - - for (index, leaf) in leaves.iter().enumerate() { - let bytes = hex::decode(leaf.strip_prefix("0x").unwrap_or(leaf)) - .map_err(|e| JsError::new(&format!("Extra leaf {index} is not hex: {e}")))?; - - program.set_storage_at(index, bytes); - } - } - - Ok(program) - } } impl Default for TransactionBuilder { @@ -797,23 +728,11 @@ pub fn sdk_version() -> String { mod tests { use simplicityhl::elements::hashes::sha256::Midstate; use simplicityhl::elements::{AssetId, OutPoint, Txid}; + use smplx_sdk::utils::asset_entropy; use super::{ContractHash, FromStr, Hash, IssuanceDetails, IssuanceReport, TransactionBuilder}; - /// Assets Liquid already carries, and the outputs they were issued from. - /// - /// The point of asserting against these rather than against this module's own output is - /// that an id has two written forms and only one of them is the one anybody reads. A - /// derivation consistent with itself reproduces none of these; the exact rule Elements - /// uses, written the way Elements writes it, reproduces all four. - /// - /// Each entry is a transaction id, an output index, the issuer contract that issuance - /// committed to, the asset that came out, and its reissuance token. Taken from Blockstream's - /// Liquid Esplora, `GET /liquid/api/asset/`, which reports `issuance_prevout`, - /// `contract_hash` and `reissuance_token`. They are the same four the wallet's own - /// derivation is measured against, deliberately: two implementations checked against - /// different vectors can both pass and still disagree with each other. const ON_CHAIN: [(&str, u32, &str, &str, &str); 4] = [ ( "9596d259270ef5bac0020435e6d859aea633409483ba64e232b8ba04ce288668", @@ -822,8 +741,6 @@ mod tests { "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2", "59fe4d2127ba9f16bd6850a3e6271a166e7ed2e1669f6c107d655791c94ee98f", ), - // The index is part of what is hashed, so at least one case is issued from somewhere - // other than the first output. ( "fc2535f2e4fc2ef1d19b832248e3edc2c3f4c4e3ee9c2bc51777bd738a6f9582", 10, @@ -847,7 +764,6 @@ mod tests { ), ]; - /// What the module reports for one issuance on one output, without assembling a transaction. fn report_for(txid: &str, vout: u32, contract: &str) -> IssuanceReport { let outpoint = OutPoint { txid: Txid::from_str(txid).expect("a chain vector's txid"), @@ -858,7 +774,7 @@ mod tests { TransactionBuilder::issuer_contract(Some(contract)).expect("a chain vector's contract"), ); - IssuanceReport::of(&IssuanceDetails { + IssuanceReport::from_details(&IssuanceDetails { asset_id: AssetId::from_entropy(entropy), inflation_asset_id: AssetId::reissuance_token_from_entropy(entropy, false), asset_entropy: entropy, @@ -900,7 +816,7 @@ mod tests { } #[test] - fn an_id_leaves_in_the_form_it_arrived_in() { + fn id_leaves_in_the_form_it_arrived_in() { let written = "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2"; let read = TransactionBuilder::issuer_contract(Some(written)).expect("a written id");