diff --git a/crates/sdk/src/signer/core.rs b/crates/sdk/src/signer/core.rs index b650621..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; @@ -602,7 +602,14 @@ 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, LockTime::to_consensus_u32), + sequence: pst.inputs()[index] + .sequence + .map_or(u32::MAX, 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..20db372 100644 --- a/crates/sdk/src/signer/error.rs +++ b/crates/sdk/src/signer/error.rs @@ -9,10 +9,16 @@ 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}")] + #[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..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,6 +145,8 @@ pub struct FinalTransaction { inputs: Vec, outputs: Vec, change: Option, + sequence: Sequence, + locktime: LockTime, } impl FinalTransaction { @@ -156,8 +158,23 @@ impl FinalTransaction { inputs: Vec::new(), outputs: Vec::new(), change: 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 a specific `LockTime` for the transaction. + /// + /// 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. /// @@ -347,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(); @@ -401,13 +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 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 { @@ -442,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; @@ -505,6 +533,51 @@ mod tests { assert_eq!(secrets, expected_secrets); } + #[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(LockTime::from_height(2_580_990).unwrap()); + + let (pst, _) = ft.extract_pst(); + + assert_eq!( + pst.locktime().expect("one height, so no conflict"), + LockTime::from_height(2_580_990).unwrap() + ); + assert!( + pst.inputs() + .iter() + .all(|input| input.required_height_locktime.is_some()) + ); + } + + #[test] + 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, + ); + ft.add_output(PartialOutput::new(Script::new(), 4000, policy)); + + let (pst, _) = ft.extract_pst(); + + assert_eq!(pst.locktime().unwrap(), 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..4c05b05 100644 --- a/crates/sdk/src/transaction/partial_input.rs +++ b/crates/sdk/src/transaction/partial_input.rs @@ -164,10 +164,9 @@ impl PartialInput { LockTime::Seconds(value) => Some(value), LockTime::Blocks(_) => None, }; - // zero height locktime is essentially ignored 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..275c3b1 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -4,20 +4,24 @@ //! 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::elements; -use simplicityhl::elements::{AssetId, OutPoint, Script, Sequence, TxOut, Txid}; -use simplicityhl::{Arguments, WitnessValues}; +use simplicityhl::ast::ElementsJetHinter; +use simplicityhl::elements::hashes::Hash; +use simplicityhl::elements::{self, Sequence}; +use simplicityhl::elements::{AssetId, ContractHash, LockTime, OutPoint, Script, TxOut, Txid}; +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,7 +36,47 @@ fn network_from_str(network: &str) -> Result { } } -/// Compile-time parameters for a contract, resolved before construction. +/// Asset issuance details. +#[wasm_bindgen] +pub struct IssuanceReport { + asset_id: String, + entropy: String, + reissuance_token_id: String, +} + +#[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] + pub fn asset_id(&self) -> String { + self.asset_id.clone() + } + + /// The entropy to derive the reissuance asset. + #[wasm_bindgen(getter)] + #[must_use] + pub fn entropy(&self) -> String { + self.entropy.clone() + } + + /// 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() + } +} + +/// Compile-time parameters for a covenant, resolved before construction. #[derive(Clone)] struct FixedArguments(Arguments); @@ -42,10 +86,7 @@ impl ArgumentsTrait for FixedArguments { } } -/// Witness values for a contract 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. +/// Witness values for a covenant input, resolved before the transaction is assembled. #[derive(Clone)] struct FixedWitness(WitnessValues); @@ -55,20 +96,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. @@ -83,37 +124,18 @@ impl Contract { arguments_json: Option, 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 }) + ) -> Result { + Ok(Self { + program: Self::from_source( + 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. + /// 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 { @@ -122,7 +144,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. @@ -133,7 +155,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. @@ -144,16 +166,73 @@ 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 = 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. +/// +/// # Errors +/// Returns an error if the source does not parse or does not type-check. +#[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!("Covenant 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. @@ -235,7 +314,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 { @@ -250,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, @@ -268,6 +345,30 @@ impl TransactionBuilder { } } + /// Sets the block height this transaction may not be mined before. + /// + /// # Panics + /// 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()); + } + + /// Sets the block time this transaction may not be mined before. + /// + /// # Panics + /// 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()); + } + + /// 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. @@ -309,38 +410,43 @@ 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( + pub fn add_wallet_input(&mut self, txid: &str, vout: u32, tx_out_hex: &str) -> Result<(), JsError> { + self.transaction.add_input( + PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), + RequiredSignature::NativeEcdsa, + ); + + Ok(()) + } + + /// Adds an ordinary wallet input that also creates a new asset. + /// + /// # 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, - 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, - ), + asset_amount_sats: u64, + inflation_amount_sats: u64, + issuer_contract_hex: Option, + ) -> Result { + let contract = Self::issuer_contract(issuer_contract_hex.as_deref()) + .map_err(|e| JsError::new(&format!("Invalid issuer contract: {e}")))?; + + 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(()) + Ok(IssuanceReport::from_details(&details)) } - /// Adds a Simplicity contract 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. @@ -351,9 +457,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, @@ -362,50 +468,68 @@ impl TransactionBuilder { arguments_json: Option, 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)), - }, + PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), + Self::program_input( + source, + arguments_json, + witness_json, + extra_leaves_json, + include_debug_symbols, + )?, Self::required_signature(signature_witness.as_deref()), ); Ok(()) } + /// 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`. + /// + /// # 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 = addCovenantIssuanceInput)] + #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] + pub fn add_covenant_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, + extra_leaves_json: Option, + include_debug_symbols: Option, + ) -> Result { + let contract = Self::issuer_contract(issuer_contract_hex.as_deref()) + .map_err(|e| JsError::new(&format!("Invalid issuer contract: {e}")))?; + + let details = self.transaction.add_program_issuance_input( + PartialInput::new(Self::utxo_at(txid, vout, tx_out_hex)?), + Self::program_input( + source, + 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::from_details(&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. @@ -439,16 +563,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 @@ -483,6 +607,17 @@ 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) 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]), + } + } + /// 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` /// @@ -507,17 +642,40 @@ impl TransactionBuilder { RequiredSignature::witness_with_path(name, path) } - /// 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, - } + 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 assembled transaction, for the signer in this crate. - fn inner(&self) -> &FinalTransaction { - &self.transaction + fn program_input( + source: &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) + .map_err(|e| JsError::new(&format!("Invalid witness values: {e}")))?, + _ => WitnessValues::default(), + }; + + Ok(ProgramInput { + program: Box::new(Covenant::new(source, arguments_json, extra_leaves_json, include_debug_symbols)?.program), + witness: Box::new(FixedWitness(witness)), + }) } } @@ -565,3 +723,111 @@ 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::{ContractHash, FromStr, Hash, IssuanceDetails, IssuanceReport, TransactionBuilder}; + + const ON_CHAIN: [(&str, u32, &str, &str, &str); 4] = [ + ( + "9596d259270ef5bac0020435e6d859aea633409483ba64e232b8ba04ce288668", + 0, + "3c7f0a53c2ff5b99590620d7f6604a7a3a7bfbaaa6aa61f7bfc7833ca03cde82", + "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2", + "59fe4d2127ba9f16bd6850a3e6271a166e7ed2e1669f6c107d655791c94ee98f", + ), + ( + "fc2535f2e4fc2ef1d19b832248e3edc2c3f4c4e3ee9c2bc51777bd738a6f9582", + 10, + "d6cb01732239e8c317699c33ef525a8a1419ebf9a2ad318edbf8135f1665a773", + "123465c803ae336c62180e52d94ee80d80828db54df9bedbb9860060f49de2eb", + "2f7179e260a8046f02be25dec6abcf0a2c1bd3e6e13dd29ed67570e1e71a55b7", + ), + ( + "839e819d74ac98110fce63a3dab3a1075bbddcad811e0e125641989581919ab0", + 1, + "56cbf179ec75145ef54d88ff50284175852f926bf2d8d06f3e2deedbdf623779", + "4d4354944366ea1e33f27c37fec97504025d6062c551208f68597d1ed40ec53e", + "bc1e0094f30bc863610baf601ede6b3dda5cdb1b7d1a7831c93f011282924da3", + ), + ( + "27e6bd36daef786775768a6b106053d0f2f10e03b6f278715931caa00662138d", + 3, + "6e8198a20900717b87437261967214e2af0bb4d73c1134580b25ec597887203a", + "beebee1a548fbb20280e539b697de076d87859a25c2983ebc55f2d8bec40abc3", + "fc061c7585a4f166d251ef4f5afd7c63e33358582426f06070cfb286249926cb", + ), + ]; + + 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, + TransactionBuilder::issuer_contract(Some(contract)).expect("a chain vector's contract"), + ); + + IssuanceReport::from_details(&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_str(&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!(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 id_leaves_in_the_form_it_arrived_in() { + let written = "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2"; + let read = TransactionBuilder::issuer_contract(Some(written)).expect("a written id"); + + assert_eq!(read[0], 0xd2); + assert_eq!(read[31], 0xce); + assert_eq!(ContractHash::from_byte_array(read).to_string(), written); + } + + #[test] + fn refuses_an_issuer_contract_that_is_not_an_id() { + assert!(TransactionBuilder::issuer_contract(Some("not hex at all")).is_err()); + assert!(TransactionBuilder::issuer_contract(Some("00ff")).is_err()); + } +}