diff --git a/src/actions/address.rs b/src/actions/address.rs new file mode 100644 index 0000000..22b3d10 --- /dev/null +++ b/src/actions/address.rs @@ -0,0 +1,133 @@ +use elements::bitcoin::{secp256k1, PublicKey}; +use elements::{Address, Script}; + +use crate::address::{AddressInfo, Addresses}; +use crate::Network; + +#[derive(Debug, thiserror::Error)] +pub enum AddressError { + #[error("invalid blinder hex: {0}")] + BlinderHex(hex::FromHexError), + + #[error("invalid blinder: {0}")] + BlinderInvalid(secp256k1::Error), + + #[error("invalid pubkey: {0}")] + PubkeyInvalid(elements::bitcoin::key::ParsePublicKeyError), + + #[error("invalid script hex: {0}")] + ScriptHex(hex::FromHexError), + + #[error("can't create addresses without a pubkey")] + MissingInput, + + #[error("invalid address format: {0}")] + AddressParse(elements::address::AddressError), + + #[error("no address provided")] + NoAddressProvided, + + #[error("addresses always have params")] + AddressesAlwaysHaveParams, +} + +/// Create addresses from a public key or script. +pub fn address_create( + pubkey_hex: Option<&str>, + script_hex: Option<&str>, + blinder_hex: Option<&str>, + network: Network, +) -> Result { + let blinder = blinder_hex + .map(|b| { + let bytes = hex::decode(b).map_err(AddressError::BlinderHex)?; + secp256k1::PublicKey::from_slice(&bytes).map_err(AddressError::BlinderInvalid) + }) + .transpose()?; + + let created = if let Some(pubkey_hex) = pubkey_hex { + let pubkey: PublicKey = pubkey_hex.parse().map_err(AddressError::PubkeyInvalid)?; + Addresses::from_pubkey(&pubkey, blinder, network) + } else if let Some(script_hex) = script_hex { + let script_bytes = hex::decode(script_hex).map_err(AddressError::ScriptHex)?; + let script: Script = script_bytes.into(); + Addresses::from_script(&script, blinder, network) + } else { + return Err(AddressError::MissingInput); + }; + + Ok(created) +} + +/// Inspect an address and return detailed information. +pub fn address_inspect(address_str: &str) -> Result { + let address: Address = address_str.parse().map_err(AddressError::AddressParse)?; + let script_pk = address.script_pubkey(); + + let mut info = AddressInfo { + network: Network::from_params(address.params) + .ok_or(AddressError::AddressesAlwaysHaveParams)?, + script_pub_key: hal::tx::OutputScriptInfo { + hex: Some(script_pk.to_bytes().into()), + asm: Some(script_pk.asm()), + address: None, + type_: None, + }, + type_: None, + pubkey_hash: None, + script_hash: None, + witness_pubkey_hash: None, + witness_script_hash: None, + witness_program_version: None, + blinding_pubkey: address.blinding_pubkey, + unconfidential: if address.blinding_pubkey.is_some() { + Some(Address { + params: address.params, + payload: address.payload.clone(), + blinding_pubkey: None, + }) + } else { + None + }, + }; + + use elements::address::Payload; + use elements::hashes::Hash; + use elements::{WPubkeyHash, WScriptHash}; + + match address.payload { + Payload::PubkeyHash(pkh) => { + info.type_ = Some("p2pkh".to_owned()); + info.pubkey_hash = Some(pkh); + } + Payload::ScriptHash(sh) => { + info.type_ = Some("p2sh".to_owned()); + info.script_hash = Some(sh); + } + Payload::WitnessProgram { + version, + program, + } => { + let version = version.to_u8() as usize; + info.witness_program_version = Some(version); + + if version == 0 { + if program.len() == 20 { + info.type_ = Some("p2wpkh".to_owned()); + info.witness_pubkey_hash = + Some(WPubkeyHash::from_slice(&program).expect("size 20")); + } else if program.len() == 32 { + info.type_ = Some("p2wsh".to_owned()); + info.witness_script_hash = + Some(WScriptHash::from_slice(&program).expect("size 32")); + } else { + info.type_ = Some("invalid-witness-program".to_owned()); + } + } else { + info.type_ = Some("unknown-witness-program-version".to_owned()); + } + } + } + + Ok(info) +} diff --git a/src/actions/block.rs b/src/actions/block.rs new file mode 100644 index 0000000..c07086b --- /dev/null +++ b/src/actions/block.rs @@ -0,0 +1,210 @@ +use elements::encode::deserialize; +use elements::{dynafed, Block, BlockExtData, BlockHeader}; + +use crate::block::{BlockHeaderInfo, BlockInfo, ParamsInfo, ParamsType}; +use crate::Network; + +#[derive(Debug, serde::Serialize)] +#[serde(untagged)] +pub enum BlockDecodeOutput { + Info(BlockInfo), + Header(BlockHeaderInfo), +} + +#[derive(Debug, thiserror::Error)] +pub enum BlockError { + #[error("can't provide transactions both in JSON and raw.")] + ConflictingTransactions, + + #[error("no transactions provided.")] + NoTransactions, + + #[error("failed to deserialize transaction: {0}")] + TransactionDeserialize(super::tx::TxError), + + #[error("invalid raw transaction: {0}")] + InvalidRawTransaction(elements::encode::Error), + + #[error("invalid block format: {0}")] + BlockDeserialize(elements::encode::Error), + + #[error("could not decode raw block hex: {0}")] + CouldNotDecodeRawBlockHex(hex::FromHexError), + + #[error("invalid json JSON input: {0}")] + InvalidJsonInput(serde_json::Error), + + #[error("{field} missing in {context}")] + MissingField { + field: String, + context: String, + }, +} + +fn create_params(info: ParamsInfo) -> Result { + match info.params_type { + ParamsType::Null => Ok(dynafed::Params::Null), + ParamsType::Compact => Ok(dynafed::Params::Compact { + signblockscript: info + .signblockscript + .ok_or_else(|| BlockError::MissingField { + field: "signblockscript".to_string(), + context: "compact params".to_string(), + })? + .0 + .into(), + signblock_witness_limit: info.signblock_witness_limit.ok_or_else(|| { + BlockError::MissingField { + field: "signblock_witness_limit".to_string(), + context: "compact params".to_string(), + } + })?, + elided_root: info.elided_root.ok_or_else(|| BlockError::MissingField { + field: "elided_root".to_string(), + context: "compact params".to_string(), + })?, + }), + ParamsType::Full => Ok(dynafed::Params::Full(dynafed::FullParams::new( + info.signblockscript + .ok_or_else(|| BlockError::MissingField { + field: "signblockscript".to_string(), + context: "full params".to_string(), + })? + .0 + .into(), + info.signblock_witness_limit.ok_or_else(|| BlockError::MissingField { + field: "signblock_witness_limit".to_string(), + context: "full params".to_string(), + })?, + info.fedpeg_program + .ok_or_else(|| BlockError::MissingField { + field: "fedpeg_program".to_string(), + context: "full params".to_string(), + })? + .0 + .into(), + info.fedpeg_script + .ok_or_else(|| BlockError::MissingField { + field: "fedpeg_script".to_string(), + context: "full params".to_string(), + })? + .0, + info.extension_space + .ok_or_else(|| BlockError::MissingField { + field: "extension space".to_string(), + context: "full params".to_string(), + })? + .into_iter() + .map(|b| b.0) + .collect(), + ))), + } +} + +fn create_block_header(info: BlockHeaderInfo) -> Result { + Ok(BlockHeader { + version: info.version, + prev_blockhash: info.previous_block_hash, + merkle_root: info.merkle_root, + time: info.time, + height: info.height, + ext: if info.dynafed { + BlockExtData::Dynafed { + current: create_params(info.dynafed_current.ok_or_else(|| { + BlockError::MissingField { + field: "current".to_string(), + context: "dynafed params".to_string(), + } + })?)?, + proposed: create_params(info.dynafed_proposed.ok_or_else(|| { + BlockError::MissingField { + field: "proposed".to_string(), + context: "dynafed params".to_string(), + } + })?)?, + signblock_witness: info + .dynafed_witness + .ok_or_else(|| BlockError::MissingField { + field: "witness".to_string(), + context: "dynafed params".to_string(), + })? + .into_iter() + .map(|b| b.0) + .collect(), + } + } else { + BlockExtData::Proof { + challenge: info + .legacy_challenge + .ok_or_else(|| BlockError::MissingField { + field: "challenge".to_string(), + context: "proof params".to_string(), + })? + .0 + .into(), + solution: info + .legacy_solution + .ok_or_else(|| BlockError::MissingField { + field: "solution".to_string(), + context: "proof params".to_string(), + })? + .0 + .into(), + } + }, + }) +} + +/// Create a block from block info. +pub fn block_create(info: BlockInfo) -> Result { + let header = create_block_header(info.header)?; + let txdata = match (info.transactions, info.raw_transactions) { + (Some(_), Some(_)) => return Err(BlockError::ConflictingTransactions), + (None, None) => return Err(BlockError::NoTransactions), + (Some(infos), None) => infos + .into_iter() + .map(super::tx::tx_create) + .collect::, _>>() + .map_err(BlockError::TransactionDeserialize)?, + (None, Some(raws)) => raws + .into_iter() + .map(|r| deserialize(&r.0).map_err(BlockError::InvalidRawTransaction)) + .collect::, _>>()?, + }; + Ok(Block { + header, + txdata, + }) +} + +/// Decode a raw block and return block info or header info. +pub fn block_decode( + raw_block_hex: &str, + network: Network, + txids_only: bool, +) -> Result { + use crate::GetInfo; + + let raw_block = hex::decode(raw_block_hex).map_err(BlockError::CouldNotDecodeRawBlockHex)?; + + if txids_only { + let block: Block = deserialize(&raw_block).map_err(BlockError::BlockDeserialize)?; + let info = BlockInfo { + header: block.header.get_info(network), + txids: Some(block.txdata.iter().map(|t| t.txid()).collect()), + transactions: None, + raw_transactions: None, + }; + Ok(BlockDecodeOutput::Info(info)) + } else { + let header: BlockHeader = match deserialize(&raw_block) { + Ok(header) => header, + Err(_) => { + let block: Block = deserialize(&raw_block).map_err(BlockError::BlockDeserialize)?; + block.header + } + }; + let info = header.get_info(network); + Ok(BlockDecodeOutput::Header(info)) + } +} diff --git a/src/actions/keypair.rs b/src/actions/keypair.rs new file mode 100644 index 0000000..8d0a670 --- /dev/null +++ b/src/actions/keypair.rs @@ -0,0 +1,20 @@ +use elements::bitcoin::secp256k1::{self, rand}; + +#[derive(serde::Serialize)] +pub struct KeypairInfo { + pub secret: secp256k1::SecretKey, + pub x_only: secp256k1::XOnlyPublicKey, + pub parity: secp256k1::Parity, +} + +/// Generate a random keypair. +pub fn keypair_generate() -> KeypairInfo { + let (secret, public) = secp256k1::generate_keypair(&mut rand::thread_rng()); + let (x_only, parity) = public.x_only_public_key(); + + KeypairInfo { + secret, + x_only, + parity, + } +} diff --git a/src/actions/mod.rs b/src/actions/mod.rs new file mode 100644 index 0000000..781f129 --- /dev/null +++ b/src/actions/mod.rs @@ -0,0 +1,5 @@ +pub mod address; +pub mod block; +pub mod keypair; +pub mod simplicity; +pub mod tx; diff --git a/src/actions/simplicity/info.rs b/src/actions/simplicity/info.rs new file mode 100644 index 0000000..c1754e3 --- /dev/null +++ b/src/actions/simplicity/info.rs @@ -0,0 +1,87 @@ +use crate::hal_simplicity::{elements_address, Program}; +use crate::simplicity::hex::parse::FromHex as _; +use crate::simplicity::{jet, Amr, Cmr, Ihr}; +use serde::Serialize; + +#[derive(Debug, thiserror::Error)] +pub enum SimplicityInfoError { + #[error("invalid program: {0}")] + ProgramParse(simplicity::ParseError), + + #[error("invalid state: {0}")] + StateParse(elements::hashes::hex::HexToArrayError), +} + +#[derive(Serialize)] +pub struct RedeemInfo { + pub redeem_base64: String, + pub witness_hex: String, + pub amr: Amr, + pub ihr: Ihr, +} + +#[derive(Serialize)] +pub struct ProgramInfo { + pub jets: &'static str, + pub commit_base64: String, + pub commit_decode: String, + pub type_arrow: String, + pub cmr: Cmr, + pub liquid_address_unconf: String, + pub liquid_testnet_address_unconf: String, + pub is_redeem: bool, + #[serde(flatten)] + #[serde(skip_serializing_if = "Option::is_none")] + pub redeem_info: Option, +} + +/// Parse and analyze a Simplicity program. +pub fn simplicity_info( + program: &str, + witness: Option<&str>, + state: Option<&str>, +) -> Result { + // In the future we should attempt to parse as a Bitcoin program if parsing as + // Elements fails. May be tricky/annoying in Rust since Program is a + // different type from Program. + let program = Program::::from_str(program, witness) + .map_err(SimplicityInfoError::ProgramParse)?; + + let redeem_info = program.redeem_node().map(|node| { + let disp = node.display(); + let redeem_base64 = disp.program().to_string(); + let witness_hex = disp.witness().to_string(); + RedeemInfo { + redeem_base64, + witness_hex, + amr: node.amr(), + ihr: node.ihr(), + } + }); + + let state = + state.map(<[u8; 32]>::from_hex).transpose().map_err(SimplicityInfoError::StateParse)?; + + Ok(ProgramInfo { + jets: "core", + commit_base64: program.commit_prog().to_string(), + // FIXME this is, in general, exponential in size. Need to limit it somehow; probably need upstream support + commit_decode: program.commit_prog().display_expr().to_string(), + type_arrow: program.commit_prog().arrow().to_string(), + cmr: program.cmr(), + liquid_address_unconf: elements_address( + program.cmr(), + state, + &elements::AddressParams::LIQUID, + ) + .to_string(), + liquid_testnet_address_unconf: elements_address( + program.cmr(), + state, + &elements::AddressParams::LIQUID_TESTNET, + ) + .to_string(), + is_redeem: redeem_info.is_some(), + redeem_info, + }) +} diff --git a/src/actions/simplicity/mod.rs b/src/actions/simplicity/mod.rs new file mode 100644 index 0000000..d2761a3 --- /dev/null +++ b/src/actions/simplicity/mod.rs @@ -0,0 +1,77 @@ +pub mod info; +pub mod pset; +pub mod sighash; + +pub use info::*; +pub use sighash::*; + +use crate::simplicity::bitcoin::{Amount, Denomination}; +use crate::simplicity::elements::confidential; +use crate::simplicity::elements::hex::FromHex as _; +use crate::simplicity::jet::elements::ElementsUtxo; + +#[derive(Debug, thiserror::Error)] +pub enum ParseElementsUtxoError { + #[error("invalid format: expected ::")] + InvalidFormat, + + #[error("invalid scriptPubKey hex: {0}")] + ScriptPubKeyParsing(elements::hex::Error), + + #[error("invalid asset hex: {0}")] + AssetHexParsing(elements::hashes::hex::HexToArrayError), + + #[error("invalid asset commitment hex: {0}")] + AssetCommitmentHexParsing(elements::hex::Error), + + #[error("invalid asset commitment: {0}")] + AssetCommitmentDecoding(elements::encode::Error), + + #[error("invalid value commitment hex: {0}")] + ValueCommitmentHexParsing(elements::hex::Error), + + #[error("invalid value commitment: {0}")] + ValueCommitmentDecoding(elements::encode::Error), +} + +pub fn parse_elements_utxo(s: &str) -> Result { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 3 { + return Err(ParseElementsUtxoError::InvalidFormat); + } + // Parse scriptPubKey + let script_pubkey: elements::Script = + parts[0].parse().map_err(ParseElementsUtxoError::ScriptPubKeyParsing)?; + + // Parse asset - try as explicit AssetId first, then as confidential commitment + let asset = if parts[1].len() == 64 { + // 32 bytes = explicit AssetId + let asset_id: elements::AssetId = + parts[1].parse().map_err(ParseElementsUtxoError::AssetHexParsing)?; + confidential::Asset::Explicit(asset_id) + } else { + // Parse anything except 32 bytes as a confidential commitment (which must be 33 bytes) + let commitment_bytes = + Vec::from_hex(parts[1]).map_err(ParseElementsUtxoError::AssetCommitmentHexParsing)?; + elements::confidential::Asset::from_commitment(&commitment_bytes) + .map_err(ParseElementsUtxoError::AssetCommitmentDecoding)? + }; + + // Parse value - try as BTC decimal first, then as confidential commitment + let value = if let Ok(btc_amount) = Amount::from_str_in(parts[2], Denomination::Bitcoin) { + // Explicit value in BTC + elements::confidential::Value::Explicit(btc_amount.to_sat()) + } else { + // 33 bytes = confidential commitment + let commitment_bytes = + Vec::from_hex(parts[2]).map_err(ParseElementsUtxoError::ValueCommitmentHexParsing)?; + elements::confidential::Value::from_commitment(&commitment_bytes) + .map_err(ParseElementsUtxoError::ValueCommitmentDecoding)? + }; + + Ok(ElementsUtxo { + script_pubkey, + asset, + value, + }) +} diff --git a/src/actions/simplicity/pset/create.rs b/src/actions/simplicity/pset/create.rs new file mode 100644 index 0000000..20bb4db --- /dev/null +++ b/src/actions/simplicity/pset/create.rs @@ -0,0 +1,167 @@ +// Copyright 2025 Andrew Poelstra +// SPDX-License-Identifier: CC0-1.0 + +use std::collections::HashMap; + +use elements::confidential; +use elements::pset::PartiallySignedTransaction; +use elements::{Address, AssetId, OutPoint, Transaction, TxIn, TxOut, Txid}; +use serde::Deserialize; + +use super::{PsetError, UpdatedPset}; + +#[derive(Debug, thiserror::Error)] +pub enum PsetCreateError { + #[error(transparent)] + SharedError(#[from] PsetError), + + #[error("invalid inputs JSON: {0}")] + InputsJsonParse(serde_json::Error), + + #[error("invalid outputs JSON: {0}")] + OutputsJsonParse(serde_json::Error), + + #[error("invalid amount: {0}")] + AmountParse(elements::bitcoin::amount::ParseAmountError), + + #[error("invalid address: {0}")] + AddressParse(elements::address::AddressError), + + #[error("confidential addresses are not yet supported")] + ConfidentialAddressNotSupported, +} + +#[derive(Deserialize)] +struct InputSpec { + txid: Txid, + vout: u32, + #[serde(default)] + sequence: Option, +} + +#[derive(Deserialize)] +struct FlattenedOutputSpec { + address: String, + asset: AssetId, + #[serde(with = "elements::bitcoin::amount::serde::as_btc")] + amount: elements::bitcoin::Amount, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum OutputSpec { + Explicit { + address: String, + asset: AssetId, + #[serde(with = "elements::bitcoin::amount::serde::as_btc")] + amount: elements::bitcoin::Amount, + }, + Map(HashMap), +} + +impl OutputSpec { + fn flatten(self) -> Box>> { + match self { + Self::Map(map) => Box::new(map.into_iter().map(|(address, amount)| { + // Use liquid bitcoin asset as default for map format + let default_asset = AssetId::from_slice(&[ + 0x49, 0x9a, 0x81, 0x85, 0x45, 0xf6, 0xba, 0xe3, 0x9f, 0xc0, 0x3b, 0x63, 0x7f, + 0x2a, 0x4e, 0x1e, 0x64, 0xe5, 0x90, 0xca, 0xc1, 0xbc, 0x3a, 0x6f, 0x6d, 0x71, + 0xaa, 0x44, 0x43, 0x65, 0x4c, 0x14, + ]) + .expect("valid asset id"); + + Ok(FlattenedOutputSpec { + address, + asset: default_asset, + amount: elements::bitcoin::Amount::from_btc(amount) + .map_err(PsetCreateError::AmountParse)?, + }) + })), + Self::Explicit { + address, + asset, + amount, + } => Box::new( + Some(Ok(FlattenedOutputSpec { + address, + asset, + amount, + })) + .into_iter(), + ), + } + } +} + +/// Create an empty PSET +pub fn pset_create(inputs_json: &str, outputs_json: &str) -> Result { + // Parse inputs JSON + let input_specs: Vec = + serde_json::from_str(inputs_json).map_err(PsetCreateError::InputsJsonParse)?; + + // Parse outputs JSON - support both array and map formats + let output_specs: Vec = + serde_json::from_str(outputs_json).map_err(PsetCreateError::OutputsJsonParse)?; + + // Create transaction inputs + let mut inputs = Vec::new(); + for input_spec in &input_specs { + let outpoint = OutPoint::new(input_spec.txid, input_spec.vout); + let sequence = elements::Sequence(input_spec.sequence.unwrap_or(0xffffffff)); + + inputs.push(TxIn { + previous_output: outpoint, + script_sig: elements::Script::new(), + sequence, + asset_issuance: Default::default(), + witness: Default::default(), + is_pegin: false, + }); + } + + // Create transaction outputs + let mut outputs = Vec::new(); + for output_spec in output_specs.into_iter().flat_map(OutputSpec::flatten) { + let output_spec = output_spec?; // serde has crappy error messages so we defer parsing and then have to unwrap errors + + let script_pubkey = match output_spec.address.as_str() { + "fee" => elements::Script::new(), + x => { + let addr = x.parse::
().map_err(PsetCreateError::AddressParse)?; + if addr.is_blinded() { + return Err(PsetCreateError::ConfidentialAddressNotSupported); + } + addr.script_pubkey() + } + }; + + outputs.push(TxOut { + asset: confidential::Asset::Explicit(output_spec.asset), + value: confidential::Value::Explicit(output_spec.amount.to_sat()), + nonce: elements::confidential::Nonce::Null, + script_pubkey, + witness: elements::TxOutWitness::empty(), + }); + } + + // Create the transaction + let tx = Transaction { + version: 2, + lock_time: elements::LockTime::ZERO, + input: inputs, + output: outputs, + }; + + // Create PSET from transaction + let pset = PartiallySignedTransaction::from_tx(tx); + + Ok(UpdatedPset { + pset: pset.to_string(), + updated_values: vec![ + // FIXME we technically update a whole slew of fields; see the implementation + // of PartiallySignedTransaction::from_tx. Should we attempt to exhaustively + // list them here? Or list none? Or what? + ], + }) +} diff --git a/src/actions/simplicity/pset/extract.rs b/src/actions/simplicity/pset/extract.rs new file mode 100644 index 0000000..00b6bf7 --- /dev/null +++ b/src/actions/simplicity/pset/extract.rs @@ -0,0 +1,27 @@ +// Copyright 2025 Andrew Poelstra +// SPDX-License-Identifier: CC0-1.0 + +use elements::encode::serialize_hex; + +use super::PsetError; + +#[derive(Debug, thiserror::Error)] +pub enum PsetExtractError { + #[error(transparent)] + SharedError(#[from] PsetError), + + #[error("invalid PSET: {0}")] + PsetDecode(elements::pset::ParseError), + + #[error("failed to extract transaction: {0}")] + TransactionExtract(elements::pset::Error), +} + +/// Extract a raw transaction from a completed PSET +pub fn pset_extract(pset_b64: &str) -> Result { + let pset: elements::pset::PartiallySignedTransaction = + pset_b64.parse().map_err(PsetExtractError::PsetDecode)?; + + let tx = pset.extract_tx().map_err(PsetExtractError::TransactionExtract)?; + Ok(serialize_hex(&tx)) +} diff --git a/src/actions/simplicity/pset/finalize.rs b/src/actions/simplicity/pset/finalize.rs new file mode 100644 index 0000000..8b7d984 --- /dev/null +++ b/src/actions/simplicity/pset/finalize.rs @@ -0,0 +1,67 @@ +// Copyright 2025 Andrew Poelstra +// SPDX-License-Identifier: CC0-1.0 + +use crate::hal_simplicity::Program; +use crate::simplicity::jet; + +use super::{execution_environment, PsetError, UpdatedPset}; + +#[derive(Debug, thiserror::Error)] +pub enum PsetFinalizeError { + #[error(transparent)] + SharedError(#[from] PsetError), + + #[error("invalid PSET: {0}")] + PsetDecode(elements::pset::ParseError), + + #[error("invalid input index: {0}")] + InputIndexParse(std::num::ParseIntError), + + #[error("invalid program: {0}")] + ProgramParse(simplicity::ParseError), + + #[error("program does not have a redeem node")] + NoRedeemNode, + + #[error("failed to prune program: {0}")] + ProgramPrune(simplicity::bit_machine::ExecutionError), +} + +/// Attach a Simplicity program and witness to a PSET input +pub fn pset_finalize( + pset_b64: &str, + input_idx: &str, + program: &str, + witness: &str, + genesis_hash: Option<&str>, +) -> Result { + // 1. Parse everything. + let mut pset: elements::pset::PartiallySignedTransaction = + pset_b64.parse().map_err(PsetFinalizeError::PsetDecode)?; + let input_idx: u32 = input_idx.parse().map_err(PsetFinalizeError::InputIndexParse)?; + let input_idx_usize = input_idx as usize; // 32->usize cast ok on almost all systems + + let program = Program::::from_str(program, Some(witness)) + .map_err(PsetFinalizeError::ProgramParse)?; + + // 2. Extract transaction environment. + let (tx_env, control_block, tap_leaf) = + execution_environment(&pset, input_idx_usize, program.cmr(), genesis_hash)?; + let cb_serialized = control_block.serialize(); + + // 3. Prune program. + let redeem_node = program.redeem_node().ok_or(PsetFinalizeError::NoRedeemNode)?; + let pruned = redeem_node.prune(&tx_env).map_err(PsetFinalizeError::ProgramPrune)?; + + let (prog, witness) = pruned.to_vec_with_witness(); + // If `execution_environment` above succeeded we are guaranteed that this index is in bounds. + let input = &mut pset.inputs_mut()[input_idx_usize]; + input.final_script_witness = Some(vec![witness, prog, tap_leaf.into_bytes(), cb_serialized]); + + let updated_values = vec!["final_script_witness"]; + + Ok(UpdatedPset { + pset: pset.to_string(), + updated_values, + }) +} diff --git a/src/actions/simplicity/pset/mod.rs b/src/actions/simplicity/pset/mod.rs new file mode 100644 index 0000000..62286bc --- /dev/null +++ b/src/actions/simplicity/pset/mod.rs @@ -0,0 +1,125 @@ +// Copyright 2025 Andrew Poelstra +// SPDX-License-Identifier: CC0-1.0 + +mod create; +mod extract; +mod finalize; +mod run; +mod update_input; + +pub use create::*; +pub use extract::*; +pub use finalize::*; +pub use run::*; +pub use update_input::*; + +use std::sync::Arc; + +use elements::hashes::Hash as _; +use elements::pset::PartiallySignedTransaction; +use elements::taproot::ControlBlock; +use elements::Script; +use serde::Serialize; + +use crate::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; +use crate::simplicity::Cmr; + +#[derive(Debug, thiserror::Error)] +pub enum PsetError { + #[error("input index {index} out-of-range for PSET with {total} inputs")] + InputIndexOutOfRange { + index: usize, + total: usize, + }, + + #[error("failed to parse genesis hash: {0}")] + GenesisHashParse(elements::hashes::hex::HexToArrayError), + + #[error("could not find Simplicity leaf in PSET taptree with CMR {cmr})")] + MissingSimplicityLeaf { + cmr: String, + }, + + #[error("failed to extract transaction from PSET: {0}")] + PsetExtract(elements::pset::Error), + + #[error("witness_utxo field not populated for input {0}")] + MissingWitnessUtxo(usize), +} + +#[derive(Serialize)] +pub struct UpdatedPset { + pub pset: String, + pub updated_values: Vec<&'static str>, +} + +/// Helper function to create execution environment for PSET operations +pub fn execution_environment( + pset: &PartiallySignedTransaction, + input_idx: usize, + cmr: Cmr, + genesis_hash: Option<&str>, +) -> Result<(ElementsEnv>, ControlBlock, Script), PsetError> { + let n_inputs = pset.n_inputs(); + let input = pset.inputs().get(input_idx).ok_or(PsetError::InputIndexOutOfRange { + index: input_idx, + total: n_inputs, + })?; + + // Default to Liquid Testnet genesis block + let genesis_hash = match genesis_hash { + Some(s) => s.parse().map_err(PsetError::GenesisHashParse)?, + None => elements::BlockHash::from_byte_array([ + // copied out of simplicity-webide source + 0xc1, 0xb1, 0x6a, 0xe2, 0x4f, 0x24, 0x23, 0xae, 0xa2, 0xea, 0x34, 0x55, 0x22, 0x92, + 0x79, 0x3b, 0x5b, 0x5e, 0x82, 0x99, 0x9a, 0x1e, 0xed, 0x81, 0xd5, 0x6a, 0xee, 0x52, + 0x8e, 0xda, 0x71, 0xa7, + ]), + }; + + // Unlike in the 'update-input' case we don't insist on any particular form of + // the Taptree. We just look for the CMR in the list. + let mut control_block_leaf = None; + for (cb, script_ver) in &input.tap_scripts { + if script_ver.1 == simplicity::leaf_version() && &script_ver.0[..] == cmr.as_ref() { + control_block_leaf = Some((cb.clone(), script_ver.0.clone())); + } + } + let (control_block, tap_leaf) = match control_block_leaf { + Some((cb, leaf)) => (cb, leaf), + None => { + return Err(PsetError::MissingSimplicityLeaf { + cmr: cmr.to_string(), + }); + } + }; + + let tx = pset.extract_tx().map_err(PsetError::PsetExtract)?; + let tx = Arc::new(tx); + + let input_utxos = pset + .inputs() + .iter() + .enumerate() + .map(|(n, input)| match input.witness_utxo { + Some(ref utxo) => Ok(ElementsUtxo { + script_pubkey: utxo.script_pubkey.clone(), + asset: utxo.asset, + value: utxo.value, + }), + None => Err(PsetError::MissingWitnessUtxo(n)), + }) + .collect::, _>>()?; + + let tx_env = ElementsEnv::new( + tx, + input_utxos, + input_idx as u32, // cast fine, input indices are always small + cmr, + control_block.clone(), + None, // FIXME populate this; needs https://github.com/BlockstreamResearch/rust-simplicity/issues/315 first + genesis_hash, + ); + + Ok((tx_env, control_block, tap_leaf)) +} diff --git a/src/actions/simplicity/pset/run.rs b/src/actions/simplicity/pset/run.rs new file mode 100644 index 0000000..211a0a2 --- /dev/null +++ b/src/actions/simplicity/pset/run.rs @@ -0,0 +1,141 @@ +// Copyright 2025 Andrew Poelstra +// SPDX-License-Identifier: CC0-1.0 + +use serde::Serialize; + +use crate::hal_simplicity::Program; +use crate::simplicity::bit_machine::{BitMachine, ExecTracker}; +use crate::simplicity::jet; +use crate::simplicity::{Cmr, Ihr}; + +use super::{execution_environment, PsetError}; + +#[derive(Debug, thiserror::Error)] +pub enum PsetRunError { + #[error(transparent)] + SharedError(#[from] PsetError), + + #[error("invalid PSET: {0}")] + PsetDecode(elements::pset::ParseError), + + #[error("invalid input index: {0}")] + InputIndexParse(std::num::ParseIntError), + + #[error("invalid program: {0}")] + ProgramParse(simplicity::ParseError), + + #[error("program does not have a redeem node")] + NoRedeemNode, + + #[error("failed to construct bit machine: {0}")] + BitMachineConstruction(simplicity::bit_machine::LimitError), +} + +#[derive(Serialize)] +pub struct JetCall { + pub jet: String, + pub source_ty: String, + pub target_ty: String, + pub success: bool, + pub input_hex: String, + pub output_hex: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub equality_check: Option<(String, String)>, +} + +#[derive(Serialize)] +pub struct RunResponse { + pub success: bool, + pub jets: Vec, +} + +struct JetTracker(Vec); + +impl ExecTracker for JetTracker { + fn track_left(&mut self, _: Ihr) {} + fn track_right(&mut self, _: Ihr) {} + fn track_jet_call( + &mut self, + jet: &J, + input_buffer: &[simplicity::ffi::ffi::UWORD], + output_buffer: &[simplicity::ffi::ffi::UWORD], + success: bool, + ) { + // The word slices are in reverse order for some reason. + // FIXME maybe we should attempt to parse out Simplicity values here which + // can often be displayed in a better way, esp for e.g. option types. + let mut input_hex = String::new(); + for word in input_buffer.iter().rev() { + for byte in word.to_be_bytes() { + input_hex.push_str(&format!("{:02x}", byte)); + } + } + + let mut output_hex = String::new(); + for word in output_buffer.iter().rev() { + for byte in word.to_be_bytes() { + output_hex.push_str(&format!("{:02x}", byte)); + } + } + + let jet_name = jet.to_string(); + let equality_check = match jet_name.as_str() { + "eq_1" => None, // FIXME parse bits out of input + "eq_2" => None, // FIXME parse bits out of input + x if x.strip_prefix("eq_").is_some() => { + let split = input_hex.split_at(input_hex.len() / 2); + Some((split.0.to_owned(), split.1.to_owned())) + } + _ => None, + }; + self.0.push(JetCall { + jet: jet_name, + source_ty: jet.source_ty().to_final().to_string(), + target_ty: jet.target_ty().to_final().to_string(), + success, + input_hex, + output_hex, + equality_check, + }); + } + + fn track_dbg_call(&mut self, _: &Cmr, _: simplicity::Value) {} + fn is_track_debug_enabled(&self) -> bool { + false + } +} + +/// Run a Simplicity program in the context of a PSET input +pub fn pset_run( + pset_b64: &str, + input_idx: &str, + program: &str, + witness: &str, + genesis_hash: Option<&str>, +) -> Result { + // 1. Parse everything. + let pset: elements::pset::PartiallySignedTransaction = + pset_b64.parse().map_err(PsetRunError::PsetDecode)?; + let input_idx: u32 = input_idx.parse().map_err(PsetRunError::InputIndexParse)?; + let input_idx_usize = input_idx as usize; // 32->usize cast ok on almost all systems + + let program = Program::::from_str(program, Some(witness)) + .map_err(PsetRunError::ProgramParse)?; + + // 2. Extract transaction environment. + let (tx_env, _control_block, _tap_leaf) = + execution_environment(&pset, input_idx_usize, program.cmr(), genesis_hash)?; + + // 3. Prune program. + let redeem_node = program.redeem_node().ok_or(PsetRunError::NoRedeemNode)?; + + let mut mac = + BitMachine::for_program(redeem_node).map_err(PsetRunError::BitMachineConstruction)?; + let mut tracker = JetTracker(vec![]); + // Eat success/failure. FIXME should probably report this to the user. + let success = mac.exec_with_tracker(redeem_node, &tx_env, &mut tracker).is_ok(); + Ok(RunResponse { + success, + jets: tracker.0, + }) +} diff --git a/src/actions/simplicity/pset/update_input.rs b/src/actions/simplicity/pset/update_input.rs new file mode 100644 index 0000000..b334bc8 --- /dev/null +++ b/src/actions/simplicity/pset/update_input.rs @@ -0,0 +1,146 @@ +// Copyright 2025 Andrew Poelstra +// SPDX-License-Identifier: CC0-1.0 + +use core::str::FromStr; +use std::collections::BTreeMap; + +use elements::bitcoin::secp256k1; +use elements::schnorr::XOnlyPublicKey; +use simplicity::hex::parse::FromHex as _; + +use crate::hal_simplicity::taproot_spend_info; + +use super::{PsetError, UpdatedPset}; + +use crate::actions::simplicity::ParseElementsUtxoError; + +#[derive(Debug, thiserror::Error)] +pub enum PsetUpdateInputError { + #[error(transparent)] + SharedError(#[from] PsetError), + + #[error("invalid PSET: {0}")] + PsetDecode(elements::pset::ParseError), + + #[error("invalid input index: {0}")] + InputIndexParse(std::num::ParseIntError), + + #[error("input index {index} out-of-range for PSET with {total} inputs")] + InputIndexOutOfRange { + index: usize, + total: usize, + }, + + #[error("invalid CMR: {0}")] + CmrParse(elements::hashes::hex::HexToArrayError), + + #[error("invalid internal key: {0}")] + InternalKeyParse(secp256k1::Error), + + #[error("internal key must be present if CMR is; PSET requires a control block for each CMR, which in turn requires the internal key. If you don't know the internal key, good chance it is the BIP-0341 'unspendable key' 50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0 or the web IDE's 'unspendable key' (highly discouraged for use in production) of f5919fa64ce45f8306849072b26c1bfdd2937e6b81774796ff372bd1eb5362d2")] + MissingInternalKey, + + #[error("input UTXO does not appear to be a Taproot output")] + NotTaprootOutput, + + #[error("invalid state commitment: {0}")] + StateParse(elements::hashes::hex::HexToArrayError), + + #[error("CMR and internal key imply output key {output_key}, which does not match input scriptPubKey {script_pubkey}")] + OutputKeyMismatch { + output_key: String, + script_pubkey: String, + }, + + #[error("invalid elements UTXO: {0}")] + ElementsUtxoParse(ParseElementsUtxoError), +} + +/// Attach UTXO data to a PSET input +pub fn pset_update_input( + pset_b64: &str, + input_idx: &str, + input_utxo: &str, + internal_key: Option<&str>, + cmr: Option<&str>, + state: Option<&str>, +) -> Result { + let mut pset: elements::pset::PartiallySignedTransaction = + pset_b64.parse().map_err(PsetUpdateInputError::PsetDecode)?; + let input_idx: usize = input_idx.parse().map_err(PsetUpdateInputError::InputIndexParse)?; + let input_utxo = super::super::parse_elements_utxo(input_utxo) + .map_err(PsetUpdateInputError::ElementsUtxoParse)?; + + let n_inputs = pset.n_inputs(); + let input = pset.inputs_mut().get_mut(input_idx).ok_or_else(|| { + PsetUpdateInputError::InputIndexOutOfRange { + index: input_idx, + total: n_inputs, + } + })?; + + let cmr = + cmr.map(simplicity::Cmr::from_str).transpose().map_err(PsetUpdateInputError::CmrParse)?; + let internal_key = internal_key + .map(XOnlyPublicKey::from_str) + .transpose() + .map_err(PsetUpdateInputError::InternalKeyParse)?; + if cmr.is_some() && internal_key.is_none() { + return Err(PsetUpdateInputError::MissingInternalKey); + } + + if !input_utxo.script_pubkey.is_v1_p2tr() { + return Err(PsetUpdateInputError::NotTaprootOutput); + } + + // FIXME state is meaningless without CMR; should we warn here + // FIXME also should we warn if you don't provide a CMR? seems like if you're calling `simplicity pset update-input` + // you probably have a simplicity program right? maybe we should even provide a --no-cmr flag + let state = + state.map(<[u8; 32]>::from_hex).transpose().map_err(PsetUpdateInputError::StateParse)?; + + let mut updated_values = vec![]; + if let Some(internal_key) = internal_key { + updated_values.push("tap_internal_key"); + input.tap_internal_key = Some(internal_key); + // FIXME should we check whether we're using the "bad" internal key + // from the web IDE, and warn or something? + if let Some(cmr) = cmr { + // Guess that the given program is the only Tapleaf. This is the case for addresses + // generated from the web IDE, and from `hal-simplicity simplicity info`, and for + // most "test" scenarios. We need to design an API to handle more general cases. + let spend_info = taproot_spend_info(internal_key, state, cmr); + if spend_info.output_key().as_inner().serialize() != input_utxo.script_pubkey[2..] { + // If our guess was wrong, at least error out.. + return Err(PsetUpdateInputError::OutputKeyMismatch { + output_key: format!("{}", spend_info.output_key().as_inner()), + script_pubkey: format!("{}", input_utxo.script_pubkey), + }); + } + + // FIXME these unwraps and clones should be fixed by a new rust-bitcoin taproot API + let script_ver = spend_info.as_script_map().keys().next().unwrap(); + let cb = spend_info.control_block(script_ver).unwrap(); + input.tap_merkle_root = spend_info.merkle_root(); + input.tap_scripts = BTreeMap::new(); + input.tap_scripts.insert(cb, script_ver.clone()); + updated_values.push("tap_merkle_root"); + updated_values.push("tap_scripts"); + } + } + + // FIXME should we bother erroring or warning if we clobber this or other fields? + input.witness_utxo = Some(elements::TxOut { + asset: input_utxo.asset, + value: input_utxo.value, + nonce: elements::confidential::Nonce::Null, // not in UTXO set, irrelevant to PSET + script_pubkey: input_utxo.script_pubkey, + witness: elements::TxOutWitness::empty(), // not in UTXO set, irrelevant to PSET + }); + updated_values.push("witness_utxo"); + + Ok(UpdatedPset { + pset: pset.to_string(), + updated_values, + }) +} diff --git a/src/actions/simplicity/sighash.rs b/src/actions/simplicity/sighash.rs new file mode 100644 index 0000000..6ac9640 --- /dev/null +++ b/src/actions/simplicity/sighash.rs @@ -0,0 +1,270 @@ +use crate::simplicity::bitcoin::secp256k1::{ + schnorr, Keypair, Message, Secp256k1, SecretKey, XOnlyPublicKey, +}; +use crate::simplicity::elements; +use crate::simplicity::elements::hashes::sha256; +use crate::simplicity::elements::hex::FromHex; + +use crate::simplicity::jet::elements::ElementsUtxo; +use crate::simplicity::Cmr; + +use elements::bitcoin::secp256k1; +use elements::hashes::Hash as _; +use elements::pset::PartiallySignedTransaction; +use serde::Serialize; + +use crate::simplicity::elements::taproot::ControlBlock; +use crate::simplicity::jet::elements::ElementsEnv; + +use crate::actions::simplicity::ParseElementsUtxoError; + +#[derive(Debug, thiserror::Error)] +pub enum SimplicitySighashError { + #[error("failed extracting transaction from PSET: {0}")] + PsetExtraction(elements::pset::Error), + + #[error("invalid transaction hex: {0}")] + TransactionHexParsing(elements::hex::Error), + + #[error("invalid transaction decoding: {0}")] + TransactionDecoding(elements::encode::Error), + + #[error("invalid input index: {0}")] + InputIndexParsing(std::num::ParseIntError), + + #[error("invalid CMR: {0}")] + CmrParsing(elements::hashes::hex::HexToArrayError), + + #[error("invalid control block hex: {0}")] + ControlBlockHexParsing(elements::hex::Error), + + #[error("invalid control block decoding: {0}")] + ControlBlockDecoding(elements::taproot::TaprootError), + + #[error("input index {index} out-of-range for PSET with {n_inputs} inputs")] + InputIndexOutOfRange { + index: u32, + n_inputs: usize, + }, + + #[error("could not find control block in PSET for CMR {cmr}")] + ControlBlockNotFound { + cmr: String, + }, + + #[error("with a raw transaction, control-block must be provided")] + ControlBlockRequired, + + #[error("witness UTXO field not populated for input {input}")] + WitnessUtxoMissing { + input: usize, + }, + + #[error("with a raw transaction, input-utxos must be provided")] + InputUtxosRequired, + + #[error("expected {expected} input UTXOs but got {actual}")] + InputUtxoCountMismatch { + expected: usize, + actual: usize, + }, + + #[error("invalid genesis hash: {0}")] + GenesisHashParsing(elements::hashes::hex::HexToArrayError), + + #[error("invalid secret key: {0}")] + SecretKeyParsing(secp256k1::Error), + + #[error("secret key had public key {derived}, but was passed explicit public key {provided}")] + PublicKeyMismatch { + derived: String, + provided: String, + }, + + #[error("invalid public key: {0}")] + PublicKeyParsing(secp256k1::Error), + + #[error("invalid signature: {0}")] + SignatureParsing(secp256k1::Error), + + #[error("if signature is provided, public-key must be provided as well")] + SignatureWithoutPublicKey, + + #[error("invalid input UTXO: {0}")] + InputUtxoParsing(ParseElementsUtxoError), +} + +#[derive(Serialize)] +pub struct SighashInfo { + pub sighash: sha256::Hash, + pub signature: Option, + pub valid_signature: Option, +} + +/// Compute signature hash for a Simplicity program. +#[allow(clippy::too_many_arguments)] +pub fn simplicity_sighash( + tx_hex: &str, + input_idx: &str, + cmr: &str, + control_block: Option<&str>, + genesis_hash: Option<&str>, + secret_key: Option<&str>, + public_key: Option<&str>, + signature: Option<&str>, + input_utxos: Option<&[&str]>, +) -> Result { + let secp = Secp256k1::new(); + + // Attempt to decode transaction as PSET first. If it succeeds, we can extract + // a lot of information from it. If not, we assume the transaction is hex and + // will give the user an error corresponding to this. + let pset = tx_hex.parse::().ok(); + + // In the future we should attempt to parse as a Bitcoin program if parsing as + // Elements fails. May be tricky/annoying in Rust since Program is a + // different type from Program. + let tx = match pset { + Some(ref pset) => pset.extract_tx().map_err(SimplicitySighashError::PsetExtraction)?, + None => { + let tx_bytes = + Vec::from_hex(tx_hex).map_err(SimplicitySighashError::TransactionHexParsing)?; + elements::encode::deserialize(&tx_bytes) + .map_err(SimplicitySighashError::TransactionDecoding)? + } + }; + let input_idx: u32 = input_idx.parse().map_err(SimplicitySighashError::InputIndexParsing)?; + let cmr: Cmr = cmr.parse().map_err(SimplicitySighashError::CmrParsing)?; + + // If the user specifies a control block, use it. Otherwise query the PSET. + let control_block = if let Some(cb) = control_block { + let cb_bytes = Vec::from_hex(cb).map_err(SimplicitySighashError::ControlBlockHexParsing)?; + // For txes from webide, the internal key in this control block will be the hardcoded + // value f5919fa64ce45f8306849072b26c1bfdd2937e6b81774796ff372bd1eb5362d2 + ControlBlock::from_slice(&cb_bytes).map_err(SimplicitySighashError::ControlBlockDecoding)? + } else if let Some(ref pset) = pset { + let n_inputs = pset.n_inputs(); + let input = pset + .inputs() + .get(input_idx as usize) // cast u32->usize probably fine + .ok_or(SimplicitySighashError::InputIndexOutOfRange { + index: input_idx, + n_inputs, + })?; + + let mut control_block = None; + for (cb, script_ver) in &input.tap_scripts { + if script_ver.1 == simplicity::leaf_version() && &script_ver.0[..] == cmr.as_ref() { + control_block = Some(cb.clone()); + } + } + match control_block { + Some(cb) => cb, + None => { + return Err(SimplicitySighashError::ControlBlockNotFound { + cmr: cmr.to_string(), + }) + } + } + } else { + return Err(SimplicitySighashError::ControlBlockRequired); + }; + + let input_utxos = if let Some(input_utxos) = input_utxos { + input_utxos + .iter() + .map(|utxo_str| { + crate::actions::simplicity::parse_elements_utxo(utxo_str) + .map_err(SimplicitySighashError::InputUtxoParsing) + }) + .collect::, SimplicitySighashError>>()? + } else if let Some(ref pset) = pset { + pset.inputs() + .iter() + .enumerate() + .map(|(n, input)| match input.witness_utxo { + Some(ref utxo) => Ok(ElementsUtxo { + script_pubkey: utxo.script_pubkey.clone(), + asset: utxo.asset, + value: utxo.value, + }), + None => Err(SimplicitySighashError::WitnessUtxoMissing { + input: n, + }), + }) + .collect::, SimplicitySighashError>>()? + } else { + return Err(SimplicitySighashError::InputUtxosRequired); + }; + if input_utxos.len() != tx.input.len() { + return Err(SimplicitySighashError::InputUtxoCountMismatch { + expected: tx.input.len(), + actual: input_utxos.len(), + }); + } + + // Default to Bitcoin blockhash. + let genesis_hash = match genesis_hash { + Some(s) => s.parse().map_err(SimplicitySighashError::GenesisHashParsing)?, + None => elements::BlockHash::from_byte_array([ + // copied out of simplicity-webide source + 0xc1, 0xb1, 0x6a, 0xe2, 0x4f, 0x24, 0x23, 0xae, 0xa2, 0xea, 0x34, 0x55, 0x22, 0x92, + 0x79, 0x3b, 0x5b, 0x5e, 0x82, 0x99, 0x9a, 0x1e, 0xed, 0x81, 0xd5, 0x6a, 0xee, 0x52, + 0x8e, 0xda, 0x71, 0xa7, + ]), + }; + + let tx_env = ElementsEnv::new( + &tx, + input_utxos, + input_idx, + cmr, + control_block, + None, // FIXME populate this; needs https://github.com/BlockstreamResearch/rust-simplicity/issues/315 first + genesis_hash, + ); + + let (pk, sig) = match (public_key, signature) { + (Some(pk), None) => ( + Some(pk.parse::().map_err(SimplicitySighashError::PublicKeyParsing)?), + None, + ), + (Some(pk), Some(sig)) => ( + Some(pk.parse::().map_err(SimplicitySighashError::PublicKeyParsing)?), + Some( + sig.parse::() + .map_err(SimplicitySighashError::SignatureParsing)?, + ), + ), + (None, Some(_)) => return Err(SimplicitySighashError::SignatureWithoutPublicKey), + (None, None) => (None, None), + }; + + let sighash = tx_env.c_tx_env().sighash_all(); + let sighash_msg = Message::from_digest(sighash.to_byte_array()); // FIXME can remove in next version ofrust-secp + Ok(SighashInfo { + sighash, + signature: match secret_key { + Some(sk) => { + let sk: SecretKey = sk.parse().map_err(SimplicitySighashError::SecretKeyParsing)?; + let keypair = Keypair::from_secret_key(&secp, &sk); + + if let Some(ref pk) = pk { + if pk != &keypair.x_only_public_key().0 { + return Err(SimplicitySighashError::PublicKeyMismatch { + derived: keypair.x_only_public_key().0.to_string(), + provided: pk.to_string(), + }); + } + } + + Some(secp.sign_schnorr(&sighash_msg, &keypair)) + } + None => None, + }, + valid_signature: match (pk, sig) { + (Some(pk), Some(sig)) => Some(secp.verify_schnorr(&sig, &sighash_msg, &pk).is_ok()), + _ => None, + }, + }) +} diff --git a/src/actions/tx.rs b/src/actions/tx.rs new file mode 100644 index 0000000..02a494b --- /dev/null +++ b/src/actions/tx.rs @@ -0,0 +1,507 @@ +use std::convert::TryInto; + +use elements::bitcoin::{self, secp256k1}; +use elements::encode::{deserialize, serialize}; +use elements::hashes::Hash; +use elements::secp256k1_zkp::{ + Generator, PedersenCommitment, PublicKey, RangeProof, SurjectionProof, Tweak, +}; +use elements::{ + confidential, AssetIssuance, OutPoint, Script, Transaction, TxIn, TxInWitness, TxOut, + TxOutWitness, +}; + +use crate::confidential::{ + ConfidentialAssetInfo, ConfidentialNonceInfo, ConfidentialType, ConfidentialValueInfo, +}; +use crate::tx::{ + AssetIssuanceInfo, InputInfo, InputScriptInfo, InputWitnessInfo, OutputInfo, OutputScriptInfo, + OutputWitnessInfo, PeginDataInfo, PegoutDataInfo, TransactionInfo, +}; +use crate::Network; + +#[derive(Debug, thiserror::Error)] +pub enum TxError { + #[error("invalid JSON provided: {0}")] + JsonParse(serde_json::Error), + + #[error("failed to decode raw transaction hex: {0}")] + TxHex(hex::FromHexError), + + #[error("invalid tx format: {0}")] + TxDeserialize(elements::encode::Error), + + #[error("field \"{field}\" is required.")] + MissingField { + field: String, + }, + + #[error("invalid prevout format: {0}")] + PrevoutParse(bitcoin::blockdata::transaction::ParseOutPointError), + + #[error("txid field given without vout field")] + MissingVout, + + #[error("conflicting prevout information")] + ConflictingPrevout, + + #[error("no previous output provided")] + NoPrevout, + + #[error("invalid confidential commitment: {0}")] + ConfidentialCommitment(elements::secp256k1_zkp::Error), + + #[error("invalid confidential publicKey: {0}")] + ConfidentialCommitmentPublicKey(secp256k1::Error), + + #[error("wrong size of nonce field")] + NonceSize, + + #[error("invalid size of asset_entropy")] + AssetEntropySize, + + #[error("invalid asset_blinding_nonce: {0}")] + AssetBlindingNonce(elements::secp256k1_zkp::Error), + + #[error("decoding script assembly is not yet supported")] + AsmNotSupported, + + #[error("no scriptSig info provided")] + NoScriptSig, + + #[error("no scriptPubKey info provided")] + NoScriptPubKey, + + #[error("invalid outpoint in pegin_data: {0}")] + PeginOutpoint(bitcoin::blockdata::transaction::ParseOutPointError), + + #[error("outpoint in pegin_data does not correspond to input value")] + PeginOutpointMismatch, + + #[error("asset in pegin_data should be explicit")] + PeginAssetNotExplicit, + + #[error("invalid rangeproof: {0}")] + RangeProof(elements::secp256k1_zkp::Error), + + #[error("invalid sequence: {0}")] + Sequence(core::num::TryFromIntError), + + #[error("addresses for different networks are used in the output scripts")] + MixedNetworks, + + #[error("invalid surjection proof: {0}")] + SurjectionProof(elements::secp256k1_zkp::Error), + + #[error("value in pegout_data does not correspond to output value")] + PegoutValueMismatch, + + #[error("explicit value is required for pegout data")] + PegoutValueNotExplicit, + + #[error("asset in pegout_data does not correspond to output value")] + PegoutAssetMismatch, +} + +/// Check both ways to specify the outpoint and return error if conflicting. +fn outpoint_from_input_info(input: &InputInfo) -> Result { + let op1: Option = + input.prevout.as_ref().map(|op| op.parse().map_err(TxError::PrevoutParse)).transpose()?; + let op2 = match input.txid { + Some(txid) => match input.vout { + Some(vout) => Some(OutPoint { + txid, + vout, + }), + None => return Err(TxError::MissingVout), + }, + None => None, + }; + + match (op1, op2) { + (Some(op1), Some(op2)) => { + if op1 != op2 { + return Err(TxError::ConflictingPrevout); + } + Ok(op1) + } + (Some(op), None) => Ok(op), + (None, Some(op)) => Ok(op), + (None, None) => Err(TxError::NoPrevout), + } +} + +fn bytes_32(bytes: &[u8]) -> Option<[u8; 32]> { + if bytes.len() != 32 { + None + } else { + let mut array = [0; 32]; + for (x, y) in bytes.iter().zip(array.iter_mut()) { + *y = *x; + } + Some(array) + } +} + +fn create_confidential_value(info: ConfidentialValueInfo) -> Result { + match info.type_ { + ConfidentialType::Null => Ok(confidential::Value::Null), + ConfidentialType::Explicit => { + Ok(confidential::Value::Explicit(info.value.ok_or_else(|| TxError::MissingField { + field: "value".to_string(), + })?)) + } + ConfidentialType::Confidential => { + let commitment_data = info.commitment.ok_or_else(|| TxError::MissingField { + field: "commitment".to_string(), + })?; + let comm = PedersenCommitment::from_slice(&commitment_data.0[..]) + .map_err(TxError::ConfidentialCommitment)?; + Ok(confidential::Value::Confidential(comm)) + } + } +} + +fn create_confidential_asset(info: ConfidentialAssetInfo) -> Result { + match info.type_ { + ConfidentialType::Null => Ok(confidential::Asset::Null), + ConfidentialType::Explicit => { + Ok(confidential::Asset::Explicit(info.asset.ok_or_else(|| TxError::MissingField { + field: "asset".to_string(), + })?)) + } + ConfidentialType::Confidential => { + let commitment_data = info.commitment.ok_or_else(|| TxError::MissingField { + field: "commitment".to_string(), + })?; + let gen = Generator::from_slice(&commitment_data.0[..]) + .map_err(TxError::ConfidentialCommitment)?; + Ok(confidential::Asset::Confidential(gen)) + } + } +} + +fn create_confidential_nonce(info: ConfidentialNonceInfo) -> Result { + match info.type_ { + ConfidentialType::Null => Ok(confidential::Nonce::Null), + ConfidentialType::Explicit => { + let nonce = info.nonce.ok_or_else(|| TxError::MissingField { + field: "nonce".to_string(), + })?; + let bytes = bytes_32(&nonce.0[..]).ok_or(TxError::NonceSize)?; + Ok(confidential::Nonce::Explicit(bytes)) + } + ConfidentialType::Confidential => { + let commitment_data = info.commitment.ok_or_else(|| TxError::MissingField { + field: "commitment".to_string(), + })?; + let pubkey = PublicKey::from_slice(&commitment_data.0[..]) + .map_err(TxError::ConfidentialCommitmentPublicKey)?; + Ok(confidential::Nonce::Confidential(pubkey)) + } + } +} + +fn create_asset_issuance(info: AssetIssuanceInfo) -> Result { + let asset_blinding_nonce_data = + info.asset_blinding_nonce.ok_or_else(|| TxError::MissingField { + field: "asset_blinding_nonce".to_string(), + })?; + let asset_blinding_nonce = + Tweak::from_slice(&asset_blinding_nonce_data.0[..]).map_err(TxError::AssetBlindingNonce)?; + + let asset_entropy_data = info.asset_entropy.ok_or_else(|| TxError::MissingField { + field: "asset_entropy".to_string(), + })?; + let asset_entropy = bytes_32(&asset_entropy_data.0[..]).ok_or(TxError::AssetEntropySize)?; + + let amount_info = info.amount.ok_or_else(|| TxError::MissingField { + field: "amount".to_string(), + })?; + let amount = create_confidential_value(amount_info)?; + + let inflation_keys_info = info.inflation_keys.ok_or_else(|| TxError::MissingField { + field: "inflation_keys".to_string(), + })?; + let inflation_keys = create_confidential_value(inflation_keys_info)?; + + Ok(AssetIssuance { + asset_blinding_nonce, + asset_entropy, + amount, + inflation_keys, + }) +} + +fn create_script_sig(ss: InputScriptInfo) -> Result { + if let Some(hex) = ss.hex { + Ok(hex.0.into()) + } else if ss.asm.is_some() { + Err(TxError::AsmNotSupported) + } else { + Err(TxError::NoScriptSig) + } +} + +fn create_pegin_witness( + pd: PeginDataInfo, + prevout: bitcoin::OutPoint, +) -> Result>, TxError> { + let parsed_outpoint = pd.outpoint.parse().map_err(TxError::PeginOutpoint)?; + if prevout != parsed_outpoint { + return Err(TxError::PeginOutpointMismatch); + } + + let asset = match create_confidential_asset(pd.asset)? { + confidential::Asset::Explicit(asset) => asset, + _ => return Err(TxError::PeginAssetNotExplicit), + }; + Ok(vec![ + serialize(&pd.value), + serialize(&asset), + pd.genesis_hash.to_byte_array().to_vec(), + serialize(&pd.claim_script.0), + serialize(&pd.mainchain_tx_hex.0), + serialize(&pd.merkle_proof.0), + ]) +} + +fn convert_outpoint_to_btc(p: elements::OutPoint) -> bitcoin::OutPoint { + bitcoin::OutPoint { + txid: bitcoin::Txid::from_byte_array(p.txid.to_byte_array()), + vout: p.vout, + } +} + +fn create_input_witness( + info: Option, + pd: Option, + prevout: OutPoint, +) -> Result { + let pegin_witness = + if let Some(info_wit) = info.as_ref().and_then(|info| info.pegin_witness.as_ref()) { + info_wit.iter().map(|h| h.clone().0).collect() + } else if let Some(pd) = pd { + create_pegin_witness(pd, convert_outpoint_to_btc(prevout))? + } else { + Default::default() + }; + + if let Some(wi) = info { + let amount_rangeproof = wi + .amount_rangeproof + .map(|b| RangeProof::from_slice(&b.0).map_err(TxError::RangeProof).map(Box::new)) + .transpose()?; + let inflation_keys_rangeproof = wi + .inflation_keys_rangeproof + .map(|b| RangeProof::from_slice(&b.0).map_err(TxError::RangeProof).map(Box::new)) + .transpose()?; + + Ok(TxInWitness { + amount_rangeproof, + inflation_keys_rangeproof, + script_witness: match wi.script_witness { + Some(ref w) => w.iter().map(|h| h.clone().0).collect(), + None => Vec::new(), + }, + pegin_witness, + }) + } else { + Ok(TxInWitness { + pegin_witness, + ..Default::default() + }) + } +} + +fn create_input(input: InputInfo) -> Result { + let has_issuance = input.has_issuance.unwrap_or(input.asset_issuance.is_some()); + let is_pegin = input.is_pegin.unwrap_or(input.pegin_data.is_some()); + let prevout = outpoint_from_input_info(&input)?; + + let script_sig = input.script_sig.map(create_script_sig).transpose()?.unwrap_or_default(); + + let sequence = elements::Sequence::from_height( + input.sequence.unwrap_or_default().try_into().map_err(TxError::Sequence)?, + ); + + let asset_issuance = if has_issuance { + input.asset_issuance.map(create_asset_issuance).transpose()?.unwrap_or_default() + } else { + Default::default() + }; + + let witness = create_input_witness(input.witness, input.pegin_data, prevout)?; + + Ok(TxIn { + previous_output: prevout, + script_sig, + sequence, + is_pegin, + asset_issuance, + witness, + }) +} + +fn create_script_pubkey( + spk: OutputScriptInfo, + used_network: &mut Option, +) -> Result { + if let Some(hex) = spk.hex { + //TODO(stevenroose) do script sanity check to avoid blackhole? + Ok(hex.0.into()) + } else if spk.asm.is_some() { + Err(TxError::AsmNotSupported) + } else if let Some(address) = spk.address { + // Error if another network had already been used. + if let Some(network) = Network::from_params(address.params) { + if used_network.replace(network).unwrap_or(network) != network { + return Err(TxError::MixedNetworks); + } + } + Ok(address.script_pubkey()) + } else { + Err(TxError::NoScriptPubKey) + } +} + +fn create_bitcoin_script_pubkey( + spk: hal::tx::OutputScriptInfo, +) -> Result { + if let Some(hex) = spk.hex { + //TODO(stevenroose) do script sanity check to avoid blackhole? + Ok(hex.0.into()) + } else if spk.asm.is_some() { + Err(TxError::AsmNotSupported) + } else if let Some(address) = spk.address { + Ok(address.assume_checked().script_pubkey()) + } else { + Err(TxError::NoScriptPubKey) + } +} + +fn create_output_witness(w: OutputWitnessInfo) -> Result { + let surjection_proof = w + .surjection_proof + .map(|b| { + SurjectionProof::from_slice(&b.0[..]).map_err(TxError::SurjectionProof).map(Box::new) + }) + .transpose()?; + let rangeproof = w + .rangeproof + .map(|b| RangeProof::from_slice(&b.0[..]).map_err(TxError::RangeProof).map(Box::new)) + .transpose()?; + + Ok(TxOutWitness { + surjection_proof, + rangeproof, + }) +} + +fn create_script_pubkey_from_pegout_data(pd: PegoutDataInfo) -> Result { + let script_pubkey = create_bitcoin_script_pubkey(pd.script_pub_key)?; + let mut builder = elements::script::Builder::new() + .push_opcode(elements::opcodes::all::OP_RETURN) + .push_slice(&pd.genesis_hash.to_byte_array()) + .push_slice(script_pubkey.as_bytes()); + for d in pd.extra_data { + builder = builder.push_slice(&d.0); + } + Ok(builder.into_script()) +} + +fn create_output(output: OutputInfo) -> Result { + // Keep track of which network has been used in addresses and error if two different networks + // are used. + let mut used_network = None; + let value_info = output.value.ok_or_else(|| TxError::MissingField { + field: "value".to_string(), + })?; + let value = create_confidential_value(value_info)?; + + let asset_info = output.asset.ok_or_else(|| TxError::MissingField { + field: "asset".to_string(), + })?; + let asset = create_confidential_asset(asset_info)?; + + let nonce = output + .nonce + .map(create_confidential_nonce) + .transpose()? + .unwrap_or(confidential::Nonce::Null); + + let script_pubkey = if let Some(spk) = output.script_pub_key { + create_script_pubkey(spk, &mut used_network)? + } else if let Some(pd) = output.pegout_data { + match value { + confidential::Value::Explicit(v) => { + if v != pd.value { + return Err(TxError::PegoutValueMismatch); + } + } + _ => return Err(TxError::PegoutValueNotExplicit), + } + let pd_asset = create_confidential_asset(pd.asset.clone())?; + if asset != pd_asset { + return Err(TxError::PegoutAssetMismatch); + } + create_script_pubkey_from_pegout_data(pd)? + } else { + Default::default() + }; + + let witness = output.witness.map(create_output_witness).transpose()?.unwrap_or_default(); + + Ok(TxOut { + asset, + value, + nonce, + script_pubkey, + witness, + }) +} + +/// Create a transaction from transaction info. +pub fn tx_create(info: TransactionInfo) -> Result { + let version = info.version.ok_or_else(|| TxError::MissingField { + field: "version".to_string(), + })?; + let lock_time = info.locktime.ok_or_else(|| TxError::MissingField { + field: "locktime".to_string(), + })?; + + let inputs = info + .inputs + .ok_or_else(|| TxError::MissingField { + field: "inputs".to_string(), + })? + .into_iter() + .map(create_input) + .collect::, _>>()?; + + let outputs = info + .outputs + .ok_or_else(|| TxError::MissingField { + field: "outputs".to_string(), + })? + .into_iter() + .map(create_output) + .collect::, _>>()?; + + Ok(Transaction { + version, + lock_time, + input: inputs, + output: outputs, + }) +} + +/// Decode a raw transaction and return transaction info. +pub fn tx_decode(raw_tx_hex: &str, network: Network) -> Result { + use crate::GetInfo; + + let raw_tx = hex::decode(raw_tx_hex).map_err(TxError::TxHex)?; + let tx: Transaction = deserialize(&raw_tx).map_err(TxError::TxDeserialize)?; + + Ok(tx.get_info(network)) +} diff --git a/src/bin/hal-simplicity/cmd/address.rs b/src/bin/hal-simplicity/cmd/address.rs index 6d12109..6e20508 100644 --- a/src/bin/hal-simplicity/cmd/address.rs +++ b/src/bin/hal-simplicity/cmd/address.rs @@ -1,40 +1,7 @@ use clap; -use elements::bitcoin::{secp256k1, PublicKey}; -use elements::hashes::Hash; -use elements::{Address, WPubkeyHash, WScriptHash}; -use hal_simplicity::address::{AddressInfo, Addresses}; use crate::cmd; -use crate::Network; - -#[derive(Debug, thiserror::Error)] -pub enum AddressError { - #[error("invalid blinder hex: {0}")] - BlinderHex(hex::FromHexError), - - #[error("invalid blinder: {0}")] - BlinderInvalid(secp256k1::Error), - - #[error("invalid pubkey: {0}")] - PubkeyInvalid(elements::bitcoin::key::ParsePublicKeyError), - - #[error("invalid script hex: {0}")] - ScriptHex(hex::FromHexError), - - #[error("can't create addresses without a pubkey")] - MissingInput, - - #[error("invalid address format: {0}")] - AddressParse(elements::address::AddressError), - - #[error("no address provided")] - NoAddressProvided, - - #[error("addresses always have params")] - AddressesAlwaysHaveParams, -} - pub fn subcommand<'a>() -> clap::App<'a, 'a> { cmd::subcommand_group("address", "work with addresses") .subcommand(cmd_create()) @@ -60,117 +27,31 @@ fn cmd_create<'a>() -> clap::App<'a, 'a> { fn exec_create<'a>(matches: &clap::ArgMatches<'a>) { let network = cmd::network(matches); - - match exec_create_inner(matches, network) { + let pubkey_hex = matches.value_of("pubkey"); + let script_hex = matches.value_of("script"); + let blinder_hex = matches.value_of("blinder"); + + match hal_simplicity::actions::address::address_create( + pubkey_hex, + script_hex, + blinder_hex, + network, + ) { Ok(addresses) => cmd::print_output(matches, &addresses), Err(e) => panic!("{}", e), } } -fn exec_create_inner( - matches: &clap::ArgMatches<'_>, - network: Network, -) -> Result { - let blinder = matches - .value_of("blinder") - .map(|b| { - let bytes = hex::decode(b).map_err(AddressError::BlinderHex)?; - secp256k1::PublicKey::from_slice(&bytes).map_err(AddressError::BlinderInvalid) - }) - .transpose()?; - - let created = if let Some(pubkey_hex) = matches.value_of("pubkey") { - let pubkey: PublicKey = pubkey_hex.parse().map_err(AddressError::PubkeyInvalid)?; - Addresses::from_pubkey(&pubkey, blinder, network) - } else if let Some(script_hex) = matches.value_of("script") { - let script_bytes = hex::decode(script_hex).map_err(AddressError::ScriptHex)?; - let script = script_bytes.into(); - Addresses::from_script(&script, blinder, network) - } else { - return Err(AddressError::MissingInput); - }; - - Ok(created) -} - fn cmd_inspect<'a>() -> clap::App<'a, 'a> { cmd::subcommand("inspect", "inspect addresses") .args(&[cmd::opt_yaml(), cmd::arg("address", "the address").required(true)]) } fn exec_inspect<'a>(matches: &clap::ArgMatches<'a>) { - match create_inspect_inner(matches) { + let address_str = matches.value_of("address").expect("address is required"); + + match hal_simplicity::actions::address::address_inspect(address_str) { Ok(info) => cmd::print_output(matches, &info), Err(e) => panic!("{}", e), } } - -fn create_inspect_inner(matches: &clap::ArgMatches<'_>) -> Result { - let address_str = matches.value_of("address").ok_or(AddressError::NoAddressProvided)?; - let address: Address = address_str.parse().map_err(AddressError::AddressParse)?; - let script_pk = address.script_pubkey(); - - let mut info = hal_simplicity::address::AddressInfo { - network: Network::from_params(address.params) - .ok_or(AddressError::AddressesAlwaysHaveParams)?, - script_pub_key: hal::tx::OutputScriptInfo { - hex: Some(script_pk.to_bytes().into()), - asm: Some(script_pk.asm()), - address: None, - type_: None, - }, - type_: None, - pubkey_hash: None, - script_hash: None, - witness_pubkey_hash: None, - witness_script_hash: None, - witness_program_version: None, - blinding_pubkey: address.blinding_pubkey, - unconfidential: if address.blinding_pubkey.is_some() { - Some(Address { - params: address.params, - payload: address.payload.clone(), - blinding_pubkey: None, - }) - } else { - None - }, - }; - - use elements::address::Payload; - match address.payload { - Payload::PubkeyHash(pkh) => { - info.type_ = Some("p2pkh".to_owned()); - info.pubkey_hash = Some(pkh); - } - Payload::ScriptHash(sh) => { - info.type_ = Some("p2sh".to_owned()); - info.script_hash = Some(sh); - } - Payload::WitnessProgram { - version, - program, - } => { - let version = version.to_u8() as usize; - info.witness_program_version = Some(version); - - if version == 0 { - if program.len() == 20 { - info.type_ = Some("p2wpkh".to_owned()); - info.witness_pubkey_hash = - Some(WPubkeyHash::from_slice(&program).expect("size 20")); - } else if program.len() == 32 { - info.type_ = Some("p2wsh".to_owned()); - info.witness_script_hash = - Some(WScriptHash::from_slice(&program).expect("size 32")); - } else { - info.type_ = Some("invalid-witness-program".to_owned()); - } - } else { - info.type_ = Some("unknown-witness-program-version".to_owned()); - } - } - } - - Ok(info) -} diff --git a/src/bin/hal-simplicity/cmd/block.rs b/src/bin/hal-simplicity/cmd/block.rs index 7f4fe53..1f7bd44 100644 --- a/src/bin/hal-simplicity/cmd/block.rs +++ b/src/bin/hal-simplicity/cmd/block.rs @@ -1,42 +1,11 @@ use std::io::Write; -use elements::encode::{deserialize, serialize}; -use elements::{dynafed, Block, BlockExtData, BlockHeader}; +use elements::encode::serialize; use crate::cmd; -use crate::cmd::tx::create_transaction; -use hal_simplicity::block::{BlockHeaderInfo, BlockInfo, ParamsInfo, ParamsType}; -use log::warn; - -#[derive(Debug, thiserror::Error)] -pub enum BlockError { - #[error("can't provide transactions both in JSON and raw.")] - ConflictingTransactions, - - #[error("no transactions provided.")] - NoTransactions, - - #[error("failed to deserialize transaction: {0}")] - TransactionDeserialize(super::tx::TxError), - - #[error("invalid raw transaction: {0}")] - InvalidRawTransaction(elements::encode::Error), - - #[error("invalid block format: {0}")] - BlockDeserialize(elements::encode::Error), +use hal_simplicity::block::BlockInfo; - #[error("could not decode raw block hex: {0}")] - CouldNotDecodeRawBlockHex(hex::FromHexError), - - #[error("invalid json JSON input: {0}")] - InvalidJsonInput(serde_json::Error), - - #[error("{field} missing in {context}")] - MissingField { - field: String, - context: String, - }, -} +use log::warn; pub fn subcommand<'a>() -> clap::App<'a, 'a> { cmd::subcommand_group("block", "manipulate blocks") @@ -61,155 +30,16 @@ fn cmd_create<'a>() -> clap::App<'a, 'a> { ]) } -fn create_params(info: ParamsInfo) -> Result { - match info.params_type { - ParamsType::Null => Ok(dynafed::Params::Null), - ParamsType::Compact => Ok(dynafed::Params::Compact { - signblockscript: info - .signblockscript - .ok_or_else(|| BlockError::MissingField { - field: "signblockscript".to_string(), - context: "compact params".to_string(), - })? - .0 - .into(), - signblock_witness_limit: info.signblock_witness_limit.ok_or_else(|| { - BlockError::MissingField { - field: "signblock_witness_limit".to_string(), - context: "compact params".to_string(), - } - })?, - elided_root: info.elided_root.ok_or_else(|| BlockError::MissingField { - field: "elided_root".to_string(), - context: "compact params".to_string(), - })?, - }), - ParamsType::Full => Ok(dynafed::Params::Full(dynafed::FullParams::new( - info.signblockscript - .ok_or_else(|| BlockError::MissingField { - field: "signblockscript".to_string(), - context: "full params".to_string(), - })? - .0 - .into(), - info.signblock_witness_limit.ok_or_else(|| BlockError::MissingField { - field: "signblock_witness_limit".to_string(), - context: "full params".to_string(), - })?, - info.fedpeg_program - .ok_or_else(|| BlockError::MissingField { - field: "fedpeg_program".to_string(), - context: "full params".to_string(), - })? - .0 - .into(), - info.fedpeg_script - .ok_or_else(|| BlockError::MissingField { - field: "fedpeg_script".to_string(), - context: "full params".to_string(), - })? - .0, - info.extension_space - .ok_or_else(|| BlockError::MissingField { - field: "extension space".to_string(), - context: "full params".to_string(), - })? - .into_iter() - .map(|b| b.0) - .collect(), - ))), - } -} - -fn create_block_header(info: BlockHeaderInfo) -> Result { - if info.block_hash.is_some() { - warn!("Field \"block_hash\" is ignored."); - } - - Ok(BlockHeader { - version: info.version, - prev_blockhash: info.previous_block_hash, - merkle_root: info.merkle_root, - time: info.time, - height: info.height, - ext: if info.dynafed { - BlockExtData::Dynafed { - current: create_params(info.dynafed_current.ok_or_else(|| { - BlockError::MissingField { - field: "current".to_string(), - context: "dynafed params".to_string(), - } - })?)?, - proposed: create_params(info.dynafed_proposed.ok_or_else(|| { - BlockError::MissingField { - field: "proposed".to_string(), - context: "dynafed params".to_string(), - } - })?)?, - signblock_witness: info - .dynafed_witness - .ok_or_else(|| BlockError::MissingField { - field: "witness".to_string(), - context: "dynafed params".to_string(), - })? - .into_iter() - .map(|b| b.0) - .collect(), - } - } else { - BlockExtData::Proof { - challenge: info - .legacy_challenge - .ok_or_else(|| BlockError::MissingField { - field: "challenge".to_string(), - context: "proof params".to_string(), - })? - .0 - .into(), - solution: info - .legacy_solution - .ok_or_else(|| BlockError::MissingField { - field: "solution".to_string(), - context: "proof params".to_string(), - })? - .0 - .into(), - } - }, - }) -} - fn exec_create<'a>(matches: &clap::ArgMatches<'a>) { let info = serde_json::from_str::(&cmd::arg_or_stdin(matches, "block-info")) - .map_err(BlockError::InvalidJsonInput) - .unwrap_or_else(|e| panic!("{}", e)); + .unwrap_or_else(|e| panic!("invalid json JSON input: {}", e)); if info.txids.is_some() { warn!("Field \"txids\" is ignored."); } - let create_block = || -> Result { - let header = create_block_header(info.header)?; - let txdata = match (info.transactions, info.raw_transactions) { - (Some(_), Some(_)) => return Err(BlockError::ConflictingTransactions), - (None, None) => return Err(BlockError::NoTransactions), - (Some(infos), None) => infos - .into_iter() - .map(create_transaction) - .collect::, _>>() - .map_err(BlockError::TransactionDeserialize)?, - (None, Some(raws)) => raws - .into_iter() - .map(|r| deserialize(&r.0).map_err(BlockError::InvalidRawTransaction)) - .collect::, _>>()?, - }; - Ok(Block { - header, - txdata, - }) - }; - - let block = create_block().unwrap_or_else(|e| panic!("{}", e)); + let block = + hal_simplicity::actions::block::block_create(info).unwrap_or_else(|e| panic!("{}", e)); let block_bytes = serialize(&block); if matches.is_present("raw-stdout") { @@ -228,33 +58,13 @@ fn cmd_decode<'a>() -> clap::App<'a, 'a> { } fn exec_decode<'a>(matches: &clap::ArgMatches<'a>) { - let hex_tx = cmd::arg_or_stdin(matches, "raw-block"); - let raw_tx = hex::decode(hex_tx.as_ref()) - .map_err(BlockError::CouldNotDecodeRawBlockHex) - .unwrap_or_else(|e| panic!("{}", e)); + let hex_block = cmd::arg_or_stdin(matches, "raw-block"); + let network = cmd::network(matches); + let txids_only = matches.is_present("txids"); - if matches.is_present("txids") { - let block: Block = deserialize(&raw_tx) - .map_err(BlockError::BlockDeserialize) + let info = + hal_simplicity::actions::block::block_decode(hex_block.as_ref(), network, txids_only) .unwrap_or_else(|e| panic!("{}", e)); - let info = BlockInfo { - header: crate::GetInfo::get_info(&block.header, cmd::network(matches)), - txids: Some(block.txdata.iter().map(|t| t.txid()).collect()), - transactions: None, - raw_transactions: None, - }; - cmd::print_output(matches, &info) - } else { - let header: BlockHeader = match deserialize(&raw_tx) { - Ok(header) => header, - Err(_) => { - let block: Block = deserialize(&raw_tx) - .map_err(BlockError::BlockDeserialize) - .unwrap_or_else(|e| panic!("{}", e)); - block.header - } - }; - let info = crate::GetInfo::get_info(&header, cmd::network(matches)); - cmd::print_output(matches, &info) - } + + cmd::print_output(matches, &info) } diff --git a/src/bin/hal-simplicity/cmd/keypair.rs b/src/bin/hal-simplicity/cmd/keypair.rs index df84b62..861a5b2 100644 --- a/src/bin/hal-simplicity/cmd/keypair.rs +++ b/src/bin/hal-simplicity/cmd/keypair.rs @@ -1,5 +1,4 @@ use clap; -use elements::bitcoin::secp256k1::{self, rand}; use crate::cmd; @@ -20,22 +19,6 @@ fn cmd_generate<'a>() -> clap::App<'a, 'a> { } fn exec_generate<'a>(matches: &clap::ArgMatches<'a>) { - #[derive(serde::Serialize)] - struct Res { - secret: secp256k1::SecretKey, - x_only: secp256k1::XOnlyPublicKey, - parity: secp256k1::Parity, - } - - let (secret, public) = secp256k1::generate_keypair(&mut rand::thread_rng()); - let (x_only, parity) = public.x_only_public_key(); - - cmd::print_output( - matches, - &Res { - secret, - x_only, - parity, - }, - ); + let keypair = hal_simplicity::actions::keypair::keypair_generate(); + cmd::print_output(matches, &keypair); } diff --git a/src/bin/hal-simplicity/cmd/simplicity/info.rs b/src/bin/hal-simplicity/cmd/simplicity/info.rs index 1738986..19b46b4 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/info.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/info.rs @@ -5,44 +5,6 @@ use crate::cmd; use super::Error; -use hal_simplicity::hal_simplicity::{elements_address, Program}; -use hal_simplicity::simplicity::{jet, Amr, Cmr, Ihr}; -use simplicity::hex::parse::FromHex as _; - -use serde::Serialize; - -#[derive(Debug, thiserror::Error)] -pub enum SimplicityInfoError { - #[error("invalid program: {0}")] - ProgramParse(simplicity::ParseError), - - #[error("invalid state: {0}")] - StateParse(elements::hashes::hex::HexToArrayError), -} - -#[derive(Serialize)] -struct RedeemInfo { - redeem_base64: String, - witness_hex: String, - amr: Amr, - ihr: Ihr, -} - -#[derive(Serialize)] -struct ProgramInfo { - jets: &'static str, - commit_base64: String, - commit_decode: String, - type_arrow: String, - cmr: Cmr, - liquid_address_unconf: String, - liquid_testnet_address_unconf: String, - is_redeem: bool, - #[serde(flatten)] - #[serde(skip_serializing_if = "Option::is_none")] - redeem_info: Option, -} - pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand("info", "Parse a base64-encoded Simplicity program and decode it") .args(&cmd::opts_networks()) @@ -67,7 +29,7 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { let witness = matches.value_of("witness"); let state = matches.value_of("state"); - match exec_inner(program, witness, state) { + match hal_simplicity::actions::simplicity::simplicity_info(program, witness, state) { Ok(info) => cmd::print_output(matches, &info), Err(e) => cmd::print_output( matches, @@ -77,52 +39,3 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { ), } } - -fn exec_inner( - program: &str, - witness: Option<&str>, - state: Option<&str>, -) -> Result { - // In the future we should attempt to parse as a Bitcoin program if parsing as - // Elements fails. May be tricky/annoying in Rust since Program is a - // different type from Program. - let program = Program::::from_str(program, witness) - .map_err(SimplicityInfoError::ProgramParse)?; - - let redeem_info = program.redeem_node().map(|node| { - let disp = node.display(); - let x = RedeemInfo { - redeem_base64: disp.program().to_string(), - witness_hex: disp.witness().to_string(), - amr: node.amr(), - ihr: node.ihr(), - }; - x // binding needed for truly stupid borrowck reasons - }); - - let state = - state.map(<[u8; 32]>::from_hex).transpose().map_err(SimplicityInfoError::StateParse)?; - - Ok(ProgramInfo { - jets: "core", - commit_base64: program.commit_prog().to_string(), - // FIXME this is, in general, exponential in size. Need to limit it somehow; probably need upstream support - commit_decode: program.commit_prog().display_expr().to_string(), - type_arrow: program.commit_prog().arrow().to_string(), - cmr: program.cmr(), - liquid_address_unconf: elements_address( - program.cmr(), - state, - &elements::AddressParams::LIQUID, - ) - .to_string(), - liquid_testnet_address_unconf: elements_address( - program.cmr(), - state, - &elements::AddressParams::LIQUID_TESTNET, - ) - .to_string(), - is_redeem: redeem_info.is_some(), - redeem_info, - }) -} diff --git a/src/bin/hal-simplicity/cmd/simplicity/mod.rs b/src/bin/hal-simplicity/cmd/simplicity/mod.rs index a64ebb8..784798d 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/mod.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/mod.rs @@ -6,10 +6,6 @@ mod pset; mod sighash; use crate::cmd; -use hal_simplicity::simplicity::bitcoin::{Amount, Denomination}; -use hal_simplicity::simplicity::elements::confidential; -use hal_simplicity::simplicity::elements::hex::FromHex as _; -use hal_simplicity::simplicity::jet::elements::ElementsUtxo; use serde::Serialize; @@ -18,30 +14,6 @@ struct Error { error: String, } -#[derive(Debug, thiserror::Error)] -pub enum ParseElementsUtxoError { - #[error("invalid format: expected ::")] - InvalidFormat, - - #[error("invalid scriptPubKey hex: {0}")] - ScriptPubKeyParsing(elements::hex::Error), - - #[error("invalid asset hex: {0}")] - AssetHexParsing(elements::hashes::hex::HexToArrayError), - - #[error("invalid asset commitment hex: {0}")] - AssetCommitmentHexParsing(elements::hex::Error), - - #[error("invalid asset commitment: {0}")] - AssetCommitmentDecoding(elements::encode::Error), - - #[error("invalid value commitment hex: {0}")] - ValueCommitmentHexParsing(elements::hex::Error), - - #[error("invalid value commitment: {0}")] - ValueCommitmentDecoding(elements::encode::Error), -} - pub fn subcommand<'a>() -> clap::App<'a, 'a> { cmd::subcommand_group("simplicity", "manipulate Simplicity programs") .subcommand(self::info::cmd()) @@ -57,45 +29,3 @@ pub fn execute<'a>(matches: &clap::ArgMatches<'a>) { (_, _) => unreachable!("clap prints help"), }; } - -fn parse_elements_utxo(s: &str) -> Result { - let parts: Vec<&str> = s.split(':').collect(); - if parts.len() != 3 { - return Err(ParseElementsUtxoError::InvalidFormat); - } - // Parse scriptPubKey - let script_pubkey: elements::Script = - parts[0].parse().map_err(ParseElementsUtxoError::ScriptPubKeyParsing)?; - - // Parse asset - try as explicit AssetId first, then as confidential commitment - let asset = if parts[1].len() == 64 { - // 32 bytes = explicit AssetId - let asset_id: elements::AssetId = - parts[1].parse().map_err(ParseElementsUtxoError::AssetHexParsing)?; - confidential::Asset::Explicit(asset_id) - } else { - // Parse anything except 32 bytes as a confidential commitment (which must be 33 bytes) - let commitment_bytes = - Vec::from_hex(parts[1]).map_err(ParseElementsUtxoError::AssetCommitmentHexParsing)?; - elements::confidential::Asset::from_commitment(&commitment_bytes) - .map_err(ParseElementsUtxoError::AssetCommitmentDecoding)? - }; - - // Parse value - try as BTC decimal first, then as confidential commitment - let value = if let Ok(btc_amount) = Amount::from_str_in(parts[2], Denomination::Bitcoin) { - // Explicit value in BTC - elements::confidential::Value::Explicit(btc_amount.to_sat()) - } else { - // 33 bytes = confidential commitment - let commitment_bytes = - Vec::from_hex(parts[2]).map_err(ParseElementsUtxoError::ValueCommitmentHexParsing)?; - elements::confidential::Value::from_commitment(&commitment_bytes) - .map_err(ParseElementsUtxoError::ValueCommitmentDecoding)? - }; - - Ok(ElementsUtxo { - script_pubkey, - asset, - value, - }) -} diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/create.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/create.rs index 6191ba9..5276b0c 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/create.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/create.rs @@ -2,100 +2,7 @@ // SPDX-License-Identifier: CC0-1.0 use super::super::Error; -use super::UpdatedPset; use crate::cmd; -use crate::cmd::simplicity::pset::PsetError; - -use elements::confidential; -use elements::pset::PartiallySignedTransaction; -use elements::{Address, AssetId, OutPoint, Transaction, TxIn, TxOut, Txid}; -use serde::Deserialize; - -use std::collections::HashMap; - -#[derive(Debug, thiserror::Error)] -pub enum PsetCreateError { - #[error(transparent)] - SharedError(#[from] PsetError), - - #[error("invalid inputs JSON: {0}")] - InputsJsonParse(serde_json::Error), - - #[error("invalid outputs JSON: {0}")] - OutputsJsonParse(serde_json::Error), - - #[error("invalid amount: {0}")] - AmountParse(elements::bitcoin::amount::ParseAmountError), - - #[error("invalid address: {0}")] - AddressParse(elements::address::AddressError), - - #[error("confidential addresses are not yet supported")] - ConfidentialAddressNotSupported, -} - -#[derive(Deserialize)] -struct InputSpec { - txid: Txid, - vout: u32, - #[serde(default)] - sequence: Option, -} - -#[derive(Deserialize)] -struct FlattenedOutputSpec { - address: String, - asset: AssetId, - #[serde(with = "elements::bitcoin::amount::serde::as_btc")] - amount: elements::bitcoin::Amount, -} - -#[derive(Deserialize)] -#[serde(untagged)] -enum OutputSpec { - Explicit { - address: String, - asset: AssetId, - #[serde(with = "elements::bitcoin::amount::serde::as_btc")] - amount: elements::bitcoin::Amount, - }, - Map(HashMap), -} - -impl OutputSpec { - fn flatten(self) -> Box>> { - match self { - Self::Map(map) => Box::new(map.into_iter().map(|(address, amount)| { - // Use liquid bitcoin asset as default for map format - let default_asset = AssetId::from_slice(&[ - 0x49, 0x9a, 0x81, 0x85, 0x45, 0xf6, 0xba, 0xe3, 0x9f, 0xc0, 0x3b, 0x63, 0x7f, - 0x2a, 0x4e, 0x1e, 0x64, 0xe5, 0x90, 0xca, 0xc1, 0xbc, 0x3a, 0x6f, 0x6d, 0x71, - 0xaa, 0x44, 0x43, 0x65, 0x4c, 0x14, - ]) - .expect("valid asset id"); - - Ok(FlattenedOutputSpec { - address, - asset: default_asset, - amount: elements::bitcoin::Amount::from_btc(amount) - .map_err(PsetCreateError::AmountParse)?, - }) - })), - Self::Explicit { - address, - asset, - amount, - } => Box::new( - Some(Ok(FlattenedOutputSpec { - address, - asset, - amount, - })) - .into_iter(), - ), - } - } -} pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand("create", "create an empty PSET").args(&cmd::opts_networks()).args(&[ @@ -115,7 +22,7 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { let inputs_json = matches.value_of("inputs").expect("inputs mandatory"); let outputs_json = matches.value_of("outputs").expect("inputs mandatory"); - match exec_inner(inputs_json, outputs_json) { + match hal_simplicity::actions::simplicity::pset::pset_create(inputs_json, outputs_json) { Ok(info) => cmd::print_output(matches, &info), Err(e) => cmd::print_output( matches, @@ -125,74 +32,3 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { ), } } - -fn exec_inner(inputs_json: &str, outputs_json: &str) -> Result { - // Parse inputs JSON - let input_specs: Vec = - serde_json::from_str(inputs_json).map_err(PsetCreateError::InputsJsonParse)?; - - // Parse outputs JSON - support both array and map formats - let output_specs: Vec = - serde_json::from_str(outputs_json).map_err(PsetCreateError::OutputsJsonParse)?; - - // Create transaction inputs - let mut inputs = Vec::new(); - for input_spec in &input_specs { - let outpoint = OutPoint::new(input_spec.txid, input_spec.vout); - let sequence = elements::Sequence(input_spec.sequence.unwrap_or(0xffffffff)); - - inputs.push(TxIn { - previous_output: outpoint, - script_sig: elements::Script::new(), - sequence, - asset_issuance: Default::default(), - witness: Default::default(), - is_pegin: false, - }); - } - - // Create transaction outputs - let mut outputs = Vec::new(); - for output_spec in output_specs.into_iter().flat_map(OutputSpec::flatten) { - let output_spec = output_spec?; // serde has crappy error messages so we defer parsing and then have to unwrap errors - - let script_pubkey = match output_spec.address.as_str() { - "fee" => elements::Script::new(), - x => { - let addr = x.parse::
().map_err(PsetCreateError::AddressParse)?; - if addr.is_blinded() { - return Err(PsetCreateError::ConfidentialAddressNotSupported); - } - addr.script_pubkey() - } - }; - - outputs.push(TxOut { - asset: confidential::Asset::Explicit(output_spec.asset), - value: confidential::Value::Explicit(output_spec.amount.to_sat()), - nonce: elements::confidential::Nonce::Null, - script_pubkey, - witness: elements::TxOutWitness::empty(), - }); - } - - // Create the transaction - let tx = Transaction { - version: 2, - lock_time: elements::LockTime::ZERO, - input: inputs, - output: outputs, - }; - - // Create PSET from transaction - let pset = PartiallySignedTransaction::from_tx(tx); - - Ok(UpdatedPset { - pset: pset.to_string(), - updated_values: vec![ - // FIXME we technically update a whole slew of fields; see the implementation - // of PartiallySignedTransaction::from_tx. Should we attempt to exhaustively - // list them here? Or list none? Or what? - ], - }) -} diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/extract.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/extract.rs index 3b562b3..b54f3ee 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/extract.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/extract.rs @@ -1,22 +1,8 @@ // Copyright 2025 Andrew Poelstra // SPDX-License-Identifier: CC0-1.0 -use elements::encode::serialize_hex; - use super::super::Error; -use crate::cmd::{self, simplicity::pset::PsetError}; - -#[derive(Debug, thiserror::Error)] -pub enum PsetExtractError { - #[error(transparent)] - SharedError(#[from] PsetError), - - #[error("invalid PSET: {0}")] - PsetDecode(elements::pset::ParseError), - - #[error("ailed to extract transaction: {0}")] - TransactionExtract(elements::pset::Error), -} +use crate::cmd; pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand("extract", "extract a raw transaction from a completed PSET") @@ -26,7 +12,7 @@ pub fn cmd<'a>() -> clap::App<'a, 'a> { pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { let pset_b64 = matches.value_of("pset").expect("tx mandatory"); - match exec_inner(pset_b64) { + match hal_simplicity::actions::simplicity::pset::pset_extract(pset_b64) { Ok(info) => cmd::print_output(matches, &info), Err(e) => cmd::print_output( matches, @@ -36,11 +22,3 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { ), } } - -fn exec_inner(pset_b64: &str) -> Result { - let pset: elements::pset::PartiallySignedTransaction = - pset_b64.parse().map_err(PsetExtractError::PsetDecode)?; - - let tx = pset.extract_tx().map_err(PsetExtractError::TransactionExtract)?; - Ok(serialize_hex(&tx)) -} diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/finalize.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/finalize.rs index ffe1b8f..8e3a694 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/finalize.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/finalize.rs @@ -1,35 +1,8 @@ // Copyright 2025 Andrew Poelstra // SPDX-License-Identifier: CC0-1.0 -use crate::cmd; -use crate::cmd::simplicity::pset::PsetError; - -use hal_simplicity::hal_simplicity::Program; -use hal_simplicity::simplicity::jet; - use super::super::Error; -use super::UpdatedPset; - -#[derive(Debug, thiserror::Error)] -pub enum PsetFinalizeError { - #[error(transparent)] - SharedError(#[from] PsetError), - - #[error("invalid PSET: {0}")] - PsetDecode(elements::pset::ParseError), - - #[error("invalid input index: {0}")] - InputIndexParse(std::num::ParseIntError), - - #[error("invalid program: {0}")] - ProgramParse(simplicity::ParseError), - - #[error("program does not have a redeem node")] - NoRedeemNode, - - #[error("failed to prune program: {0}")] - ProgramPrune(simplicity::bit_machine::ExecutionError), -} +use crate::cmd; pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand("finalize", "Attach a Simplicity program and witness to a PSET input") @@ -59,7 +32,13 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { let witness = matches.value_of("witness").expect("witness is mandatory"); let genesis_hash = matches.value_of("genesis-hash"); - match exec_inner(pset_b64, input_idx, program, witness, genesis_hash) { + match hal_simplicity::actions::simplicity::pset::pset_finalize( + pset_b64, + input_idx, + program, + witness, + genesis_hash, + ) { Ok(info) => cmd::print_output(matches, &info), Err(e) => cmd::print_output( matches, @@ -69,42 +48,3 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { ), } } - -#[allow(clippy::too_many_arguments)] -fn exec_inner( - pset_b64: &str, - input_idx: &str, - program: &str, - witness: &str, - genesis_hash: Option<&str>, -) -> Result { - // 1. Parse everything. - let mut pset: elements::pset::PartiallySignedTransaction = - pset_b64.parse().map_err(PsetFinalizeError::PsetDecode)?; - let input_idx: u32 = input_idx.parse().map_err(PsetFinalizeError::InputIndexParse)?; - let input_idx_usize = input_idx as usize; // 32->usize cast ok on almost all systems - - let program = Program::::from_str(program, Some(witness)) - .map_err(PsetFinalizeError::ProgramParse)?; - - // 2. Extract transaction environment. - let (tx_env, control_block, tap_leaf) = - super::execution_environment(&pset, input_idx_usize, program.cmr(), genesis_hash)?; - let cb_serialized = control_block.serialize(); - - // 3. Prune program. - let redeem_node = program.redeem_node().ok_or(PsetFinalizeError::NoRedeemNode)?; - let pruned = redeem_node.prune(&tx_env).map_err(PsetFinalizeError::ProgramPrune)?; - - let (prog, witness) = pruned.to_vec_with_witness(); - // If `execution_environment` above succeeded we are guaranteed that this index is in bounds. - let input = &mut pset.inputs_mut()[input_idx_usize]; - input.final_script_witness = Some(vec![witness, prog, tap_leaf.into_bytes(), cb_serialized]); - - let updated_values = vec!["final_script_witness"]; - - Ok(UpdatedPset { - pset: pset.to_string(), - updated_values, - }) -} diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs index a39aa4f..f5f12cf 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/mod.rs @@ -7,48 +7,8 @@ mod finalize; mod run; mod update_input; -use std::sync::Arc; - use crate::cmd; -use elements::hashes::Hash as _; -use elements::pset::PartiallySignedTransaction; -use elements::taproot::ControlBlock; -use elements::Script; -use hal_simplicity::simplicity::elements::Transaction; -use hal_simplicity::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; -use hal_simplicity::simplicity::Cmr; -use serde::Serialize; - -#[derive(Debug, thiserror::Error)] -pub enum PsetError { - #[error("input index {index} out-of-range for PSET with {total} inputs")] - InputIndexOutOfRange { - index: usize, - total: usize, - }, - - #[error("failed to parse genesis hash: {0}")] - GenesisHashParse(elements::hashes::hex::HexToArrayError), - - #[error("could not find Simplicity leaf in PSET taptree with CMR {cmr})")] - MissingSimplicityLeaf { - cmr: String, - }, - - #[error("failed to extract transaction from PSET: {0}")] - PsetExtract(elements::pset::Error), - - #[error("witness_utxo field not populated for input {0}")] - MissingWitnessUtxo(usize), -} - -#[derive(Serialize)] -struct UpdatedPset { - pset: String, - updated_values: Vec<&'static str>, -} - pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand_group("pset", "manipulate PSETs for spending from Simplicity programs") .subcommand(self::create::cmd()) @@ -68,74 +28,3 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { (_, _) => unreachable!("clap prints help"), }; } - -fn execution_environment( - pset: &PartiallySignedTransaction, - input_idx: usize, - cmr: Cmr, - genesis_hash: Option<&str>, -) -> Result<(ElementsEnv>, ControlBlock, Script), PsetError> { - let n_inputs = pset.n_inputs(); - let input = pset.inputs().get(input_idx).ok_or(PsetError::InputIndexOutOfRange { - index: input_idx, - total: n_inputs, - })?; - - // Default to Liquid Testnet genesis block - let genesis_hash = match genesis_hash { - Some(s) => s.parse().map_err(PsetError::GenesisHashParse)?, - None => elements::BlockHash::from_byte_array([ - // copied out of simplicity-webide source - 0xc1, 0xb1, 0x6a, 0xe2, 0x4f, 0x24, 0x23, 0xae, 0xa2, 0xea, 0x34, 0x55, 0x22, 0x92, - 0x79, 0x3b, 0x5b, 0x5e, 0x82, 0x99, 0x9a, 0x1e, 0xed, 0x81, 0xd5, 0x6a, 0xee, 0x52, - 0x8e, 0xda, 0x71, 0xa7, - ]), - }; - - // Unlike in the 'update-input' case we don't insist on any particular form of - // the Taptree. We just look for the CMR in the list. - let mut control_block_leaf = None; - for (cb, script_ver) in &input.tap_scripts { - if script_ver.1 == simplicity::leaf_version() && &script_ver.0[..] == cmr.as_ref() { - control_block_leaf = Some((cb.clone(), script_ver.0.clone())); - } - } - let (control_block, tap_leaf) = match control_block_leaf { - Some((cb, leaf)) => (cb, leaf), - None => { - return Err(PsetError::MissingSimplicityLeaf { - cmr: cmr.to_string(), - }); - } - }; - - let tx = pset.extract_tx().map_err(PsetError::PsetExtract)?; - let tx = Arc::new(tx); - - let input_utxos = pset - .inputs() - .iter() - .enumerate() - .map(|(n, input)| match input.witness_utxo { - Some(ref utxo) => Ok(ElementsUtxo { - script_pubkey: utxo.script_pubkey.clone(), - asset: utxo.asset, - value: utxo.value, - }), - None => Err(PsetError::MissingWitnessUtxo(n)), - }) - .collect::, _>>()?; - - let tx_env = ElementsEnv::new( - tx, - input_utxos, - input_idx as u32, // cast fine, input indices are always small - cmr, - control_block.clone(), - None, // FIXME populate this; needs https://github.com/BlockstreamResearch/rust-simplicity/issues/315 first - genesis_hash, - ); - - // 3. Prune program. - Ok((tx_env, control_block, tap_leaf)) -} diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/run.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/run.rs index 64d1672..8a27d19 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/run.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/run.rs @@ -1,36 +1,8 @@ // Copyright 2025 Andrew Poelstra // SPDX-License-Identifier: CC0-1.0 -use crate::cmd; -use crate::cmd::simplicity::pset::PsetError; - -use hal_simplicity::hal_simplicity::Program; -use hal_simplicity::simplicity::bit_machine::{BitMachine, ExecTracker}; -use hal_simplicity::simplicity::jet; -use hal_simplicity::simplicity::{Cmr, Ihr}; - use super::super::Error; - -#[derive(Debug, thiserror::Error)] -pub enum PsetRunError { - #[error(transparent)] - SharedError(#[from] PsetError), - - #[error("invalid PSET: {0}")] - PsetDecode(elements::pset::ParseError), - - #[error("invalid input index: {0}")] - InputIndexParse(std::num::ParseIntError), - - #[error("invalid program: {0}")] - ProgramParse(simplicity::ParseError), - - #[error("program does not have a redeem node")] - NoRedeemNode, - - #[error("failed to construct bit machine: {0}")] - BitMachineConstruction(simplicity::bit_machine::LimitError), -} +use crate::cmd; pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand("run", "Run a Simplicity program in the context of a PSET input.") @@ -60,7 +32,13 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { let witness = matches.value_of("witness").expect("witness is mandatory"); let genesis_hash = matches.value_of("genesis-hash"); - match exec_inner(pset_b64, input_idx, program, witness, genesis_hash) { + match hal_simplicity::actions::simplicity::pset::pset_run( + pset_b64, + input_idx, + program, + witness, + genesis_hash, + ) { Ok(info) => cmd::print_output(matches, &info), Err(e) => cmd::print_output( matches, @@ -70,111 +48,3 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { ), } } - -#[derive(serde::Serialize)] -struct JetCall { - jet: String, - source_ty: String, - target_ty: String, - success: bool, - input_hex: String, - output_hex: String, - #[serde(skip_serializing_if = "Option::is_none")] - equality_check: Option<(String, String)>, -} - -#[derive(serde::Serialize)] -struct Response { - success: bool, - jets: Vec, -} - -#[allow(clippy::too_many_arguments)] -fn exec_inner( - pset_b64: &str, - input_idx: &str, - program: &str, - witness: &str, - genesis_hash: Option<&str>, -) -> Result { - struct JetTracker(Vec); - impl ExecTracker for JetTracker { - fn track_left(&mut self, _: Ihr) {} - fn track_right(&mut self, _: Ihr) {} - fn track_jet_call( - &mut self, - jet: &J, - input_buffer: &[simplicity::ffi::ffi::UWORD], - output_buffer: &[simplicity::ffi::ffi::UWORD], - success: bool, - ) { - // The word slices are in reverse order for some reason. - // FIXME maybe we should attempt to parse out Simplicity values here which - // can often be displayed in a better way, esp for e.g. option types. - let mut input_hex = String::new(); - for word in input_buffer.iter().rev() { - for byte in word.to_be_bytes() { - input_hex.push_str(&format!("{:02x}", byte)); - } - } - - let mut output_hex = String::new(); - for word in output_buffer.iter().rev() { - for byte in word.to_be_bytes() { - output_hex.push_str(&format!("{:02x}", byte)); - } - } - - let jet_name = jet.to_string(); - let equality_check = match jet_name.as_str() { - "eq_1" => None, // FIXME parse bits out of input - "eq_2" => None, // FIXME parse bits out of input - x if x.strip_prefix("eq_").is_some() => { - let split = input_hex.split_at(input_hex.len() / 2); - Some((split.0.to_owned(), split.1.to_owned())) - } - _ => None, - }; - self.0.push(JetCall { - jet: jet_name, - source_ty: jet.source_ty().to_final().to_string(), - target_ty: jet.target_ty().to_final().to_string(), - success, - input_hex, - output_hex, - equality_check, - }); - } - - fn track_dbg_call(&mut self, _: &Cmr, _: simplicity::Value) {} - fn is_track_debug_enabled(&self) -> bool { - false - } - } - - // 1. Parse everything. - let pset: elements::pset::PartiallySignedTransaction = - pset_b64.parse().map_err(PsetRunError::PsetDecode)?; - let input_idx: u32 = input_idx.parse().map_err(PsetRunError::InputIndexParse)?; - let input_idx_usize = input_idx as usize; // 32->usize cast ok on almost all systems - - let program = Program::::from_str(program, Some(witness)) - .map_err(PsetRunError::ProgramParse)?; - - // 2. Extract transaction environment. - let (tx_env, _control_block, _tap_leaf) = - super::execution_environment(&pset, input_idx_usize, program.cmr(), genesis_hash)?; - - // 3. Prune program. - let redeem_node = program.redeem_node().ok_or(PsetRunError::NoRedeemNode)?; - - let mut mac = - BitMachine::for_program(redeem_node).map_err(PsetRunError::BitMachineConstruction)?; - let mut tracker = JetTracker(vec![]); - // Eat success/failure. FIXME should probably report this to the user. - let success = mac.exec_with_tracker(redeem_node, &tx_env, &mut tracker).is_ok(); - Ok(Response { - success, - jets: tracker.0, - }) -} diff --git a/src/bin/hal-simplicity/cmd/simplicity/pset/update_input.rs b/src/bin/hal-simplicity/cmd/simplicity/pset/update_input.rs index 279dd8c..ec26132 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/pset/update_input.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/pset/update_input.rs @@ -1,62 +1,8 @@ // Copyright 2025 Andrew Poelstra // SPDX-License-Identifier: CC0-1.0 -use crate::cmd; -use crate::cmd::simplicity::pset::PsetError; -use crate::cmd::simplicity::{parse_elements_utxo, ParseElementsUtxoError}; - -use core::str::FromStr; -use std::collections::BTreeMap; - use super::super::Error; -use super::UpdatedPset; - -use elements::bitcoin::secp256k1; -use elements::schnorr::XOnlyPublicKey; -use hal_simplicity::hal_simplicity::taproot_spend_info; -use simplicity::hex::parse::FromHex as _; - -#[derive(Debug, thiserror::Error)] -pub enum PsetUpdateInputError { - #[error(transparent)] - SharedError(#[from] PsetError), - - #[error("invalid PSET: {0}")] - PsetDecode(elements::pset::ParseError), - - #[error("invalid input index: {0}")] - InputIndexParse(std::num::ParseIntError), - - #[error("input index {index} out-of-range for PSET with {total} inputs")] - InputIndexOutOfRange { - index: usize, - total: usize, - }, - - #[error("invalid CMR: {0}")] - CmrParse(elements::hashes::hex::HexToArrayError), - - #[error("invalid internal key: {0}")] - InternalKeyParse(secp256k1::Error), - - #[error("internal key must be present if CMR is; PSET requires a control block for each CMR, which in turn requires the internal key. If you don't know the internal key, good chance it is the BIP-0341 'unspendable key' 50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0 or the web IDE's 'unspendable key' (highly discouraged for use in production) of f5919fa64ce45f8306849072b26c1bfdd2937e6b81774796ff372bd1eb5362d2")] - MissingInternalKey, - - #[error("input UTXO does not appear to be a Taproot output")] - NotTaprootOutput, - - #[error("invalid state commitment: {0}")] - StateParse(elements::hashes::hex::HexToArrayError), - - #[error("CMR and internal key imply output key {output_key}, which does not match input scriptPubKey {script_pubkey}")] - OutputKeyMismatch { - output_key: String, - script_pubkey: String, - }, - - #[error("invalid elements UTXO: {0}")] - ElementsUtxoParse(ParseElementsUtxoError), -} +use crate::cmd; pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand("update-input", "Attach UTXO data to a PSET input") @@ -98,7 +44,14 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { let cmr = matches.value_of("cmr"); let state = matches.value_of("state"); - match exec_inner(pset_b64, input_idx, input_utxo, internal_key, cmr, state) { + match hal_simplicity::actions::simplicity::pset::pset_update_input( + pset_b64, + input_idx, + input_utxo, + internal_key, + cmr, + state, + ) { Ok(info) => cmd::print_output(matches, &info), Err(e) => cmd::print_output( matches, @@ -108,92 +61,3 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { ), } } - -#[allow(clippy::too_many_arguments)] -fn exec_inner( - pset_b64: &str, - input_idx: &str, - input_utxo: &str, - internal_key: Option<&str>, - cmr: Option<&str>, - state: Option<&str>, -) -> Result { - let mut pset: elements::pset::PartiallySignedTransaction = - pset_b64.parse().map_err(PsetUpdateInputError::PsetDecode)?; - let input_idx: usize = input_idx.parse().map_err(PsetUpdateInputError::InputIndexParse)?; - let input_utxo = - parse_elements_utxo(input_utxo).map_err(PsetUpdateInputError::ElementsUtxoParse)?; - - let n_inputs = pset.n_inputs(); - let input = pset.inputs_mut().get_mut(input_idx).ok_or_else(|| { - PsetUpdateInputError::InputIndexOutOfRange { - index: input_idx, - total: n_inputs, - } - })?; - - let cmr = - cmr.map(simplicity::Cmr::from_str).transpose().map_err(PsetUpdateInputError::CmrParse)?; - let internal_key = internal_key - .map(XOnlyPublicKey::from_str) - .transpose() - .map_err(PsetUpdateInputError::InternalKeyParse)?; - if cmr.is_some() && internal_key.is_none() { - return Err(PsetUpdateInputError::MissingInternalKey); - } - - if !input_utxo.script_pubkey.is_v1_p2tr() { - return Err(PsetUpdateInputError::NotTaprootOutput); - } - - // FIXME state is meaningless without CMR; should we warn here - // FIXME also should we warn if you don't provide a CMR? seems like if you're calling `simplicity pset update-input` - // you probably have a simplicity program right? maybe we should even provide a --no-cmr flag - let state = - state.map(<[u8; 32]>::from_hex).transpose().map_err(PsetUpdateInputError::StateParse)?; - - let mut updated_values = vec![]; - if let Some(internal_key) = internal_key { - updated_values.push("tap_internal_key"); - input.tap_internal_key = Some(internal_key); - // FIXME should we check whether we're using the "bad" internal key - // from the web IDE, and warn or something? - if let Some(cmr) = cmr { - // Guess that the given program is the only Tapleaf. This is the case for addresses - // generated from the web IDE, and from `hal-simplicity simplicity info`, and for - // most "test" scenarios. We need to design an API to handle more general cases. - let spend_info = taproot_spend_info(internal_key, state, cmr); - if spend_info.output_key().as_inner().serialize() != input_utxo.script_pubkey[2..] { - // If our guess was wrong, at least error out.. - return Err(PsetUpdateInputError::OutputKeyMismatch { - output_key: format!("{}", spend_info.output_key().as_inner()), - script_pubkey: format!("{}", input_utxo.script_pubkey), - }); - } - - // FIXME these unwraps and clones should be fixed by a new rust-bitcoin taproot API - let script_ver = spend_info.as_script_map().keys().next().unwrap(); - let cb = spend_info.control_block(script_ver).unwrap(); - input.tap_merkle_root = spend_info.merkle_root(); - input.tap_scripts = BTreeMap::new(); - input.tap_scripts.insert(cb, script_ver.clone()); - updated_values.push("tap_merkle_root"); - updated_values.push("tap_scripts"); - } - } - - // FIXME should we bother erroring or warning if we clobber this or other fields? - input.witness_utxo = Some(elements::TxOut { - asset: input_utxo.asset, - value: input_utxo.value, - nonce: elements::confidential::Nonce::Null, // not in UTXO set, irrelevant to PSET - script_pubkey: input_utxo.script_pubkey, - witness: elements::TxOutWitness::empty(), // not in UTXO set, irrelevant to PSET - }); - updated_values.push("witness_utxo"); - - Ok(UpdatedPset { - pset: pset.to_string(), - updated_values, - }) -} diff --git a/src/bin/hal-simplicity/cmd/simplicity/sighash.rs b/src/bin/hal-simplicity/cmd/simplicity/sighash.rs index 1a58948..a685f9e 100644 --- a/src/bin/hal-simplicity/cmd/simplicity/sighash.rs +++ b/src/bin/hal-simplicity/cmd/simplicity/sighash.rs @@ -2,109 +2,9 @@ // SPDX-License-Identifier: CC0-1.0 use crate::cmd; -use crate::cmd::simplicity::ParseElementsUtxoError; use super::Error; -use elements::bitcoin::secp256k1; -use elements::hashes::Hash as _; -use elements::pset::PartiallySignedTransaction; -use hal_simplicity::simplicity::bitcoin::secp256k1::{ - schnorr, Keypair, Message, Secp256k1, SecretKey, XOnlyPublicKey, -}; -use hal_simplicity::simplicity::elements; -use hal_simplicity::simplicity::elements::hashes::sha256; -use hal_simplicity::simplicity::elements::hex::FromHex; -use hal_simplicity::simplicity::elements::taproot::ControlBlock; - -use hal_simplicity::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; -use hal_simplicity::simplicity::Cmr; - -use serde::Serialize; - -#[derive(Debug, thiserror::Error)] -pub enum SimplicitySighashError { - #[error("failed extracting transaction from PSET: {0}")] - PsetExtraction(elements::pset::Error), - - #[error("invalid transaction hex: {0}")] - TransactionHexParsing(elements::hex::Error), - - #[error("invalid transaction decoding: {0}")] - TransactionDecoding(elements::encode::Error), - - #[error("invalid input index: {0}")] - InputIndexParsing(std::num::ParseIntError), - - #[error("invalid CMR: {0}")] - CmrParsing(elements::hashes::hex::HexToArrayError), - - #[error("invalid control block hex: {0}")] - ControlBlockHexParsing(elements::hex::Error), - - #[error("invalid control block decoding: {0}")] - ControlBlockDecoding(elements::taproot::TaprootError), - - #[error("input index {index} out-of-range for PSET with {n_inputs} inputs")] - InputIndexOutOfRange { - index: u32, - n_inputs: usize, - }, - - #[error("could not find control block in PSET for CMR {cmr}")] - ControlBlockNotFound { - cmr: String, - }, - - #[error("with a raw transaction, control-block must be provided")] - ControlBlockRequired, - - #[error("witness UTXO field not populated for input {input}")] - WitnessUtxoMissing { - input: usize, - }, - - #[error("with a raw transaction, input-utxos must be provided")] - InputUtxosRequired, - - #[error("expected {expected} input UTXOs but got {actual}")] - InputUtxoCountMismatch { - expected: usize, - actual: usize, - }, - - #[error("invalid genesis hash: {0}")] - GenesisHashParsing(elements::hashes::hex::HexToArrayError), - - #[error("invalid secret key: {0}")] - SecretKeyParsing(secp256k1::Error), - - #[error("secret key had public key {derived}, but was passed explicit public key {provided}")] - PublicKeyMismatch { - derived: String, - provided: String, - }, - - #[error("invalid public key: {0}")] - PublicKeyParsing(secp256k1::Error), - - #[error("invalid signature: {0}")] - SignatureParsing(secp256k1::Error), - - #[error("if signature is provided, public-key must be provided as well")] - SignatureWithoutPublicKey, - - #[error("invalid input UTXO: {0}")] - InputUtxoParsing(ParseElementsUtxoError), -} - -#[derive(Serialize)] -struct SighashInfo { - sighash: sha256::Hash, - signature: Option, - valid_signature: Option, -} - pub fn cmd<'a>() -> clap::App<'a, 'a> { cmd::subcommand("sighash", "Compute signature hashes or signatures for use with Simplicity") .args(&cmd::opts_networks()) @@ -152,7 +52,7 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { let signature = matches.value_of("signature"); let input_utxos: Option> = matches.values_of("input-utxo").map(|vals| vals.collect()); - match exec_inner( + match hal_simplicity::actions::simplicity::simplicity_sighash( tx_hex, input_idx, cmr, @@ -172,170 +72,3 @@ pub fn exec<'a>(matches: &clap::ArgMatches<'a>) { ), } } - -#[allow(clippy::too_many_arguments)] -fn exec_inner( - tx_hex: &str, - input_idx: &str, - cmr: &str, - control_block: Option<&str>, - genesis_hash: Option<&str>, - secret_key: Option<&str>, - public_key: Option<&str>, - signature: Option<&str>, - input_utxos: Option<&[&str]>, -) -> Result { - let secp = Secp256k1::new(); - - // Attempt to decode transaction as PSET first. If it succeeds, we can extract - // a lot of information from it. If not, we assume the transaction is hex and - // will give the user an error corresponding to this. - let pset = tx_hex.parse::().ok(); - - // In the future we should attempt to parse as a Bitcoin program if parsing as - // Elements fails. May be tricky/annoying in Rust since Program is a - // different type from Program. - let tx = match pset { - Some(ref pset) => pset.extract_tx().map_err(SimplicitySighashError::PsetExtraction)?, - None => { - let tx_bytes = - Vec::from_hex(tx_hex).map_err(SimplicitySighashError::TransactionHexParsing)?; - elements::encode::deserialize(&tx_bytes) - .map_err(SimplicitySighashError::TransactionDecoding)? - } - }; - let input_idx: u32 = input_idx.parse().map_err(SimplicitySighashError::InputIndexParsing)?; - let cmr: Cmr = cmr.parse().map_err(SimplicitySighashError::CmrParsing)?; - - // If the user specifies a control block, use it. Otherwise query the PSET. - let control_block = if let Some(cb) = control_block { - let cb_bytes = Vec::from_hex(cb).map_err(SimplicitySighashError::ControlBlockHexParsing)?; - // For txes from webide, the internal key in this control block will be the hardcoded - // value f5919fa64ce45f8306849072b26c1bfdd2937e6b81774796ff372bd1eb5362d2 - ControlBlock::from_slice(&cb_bytes).map_err(SimplicitySighashError::ControlBlockDecoding)? - } else if let Some(ref pset) = pset { - let n_inputs = pset.n_inputs(); - let input = pset - .inputs() - .get(input_idx as usize) // cast u32->usize probably fine - .ok_or(SimplicitySighashError::InputIndexOutOfRange { - index: input_idx, - n_inputs, - })?; - - let mut control_block = None; - for (cb, script_ver) in &input.tap_scripts { - if script_ver.1 == simplicity::leaf_version() && &script_ver.0[..] == cmr.as_ref() { - control_block = Some(cb.clone()); - } - } - match control_block { - Some(cb) => cb, - None => { - return Err(SimplicitySighashError::ControlBlockNotFound { - cmr: cmr.to_string(), - }) - } - } - } else { - return Err(SimplicitySighashError::ControlBlockRequired); - }; - - let input_utxos = if let Some(input_utxos) = input_utxos { - input_utxos - .iter() - .map(|utxo_str| { - super::parse_elements_utxo(utxo_str) - .map_err(SimplicitySighashError::InputUtxoParsing) - }) - .collect::, SimplicitySighashError>>()? - } else if let Some(ref pset) = pset { - pset.inputs() - .iter() - .enumerate() - .map(|(n, input)| match input.witness_utxo { - Some(ref utxo) => Ok(ElementsUtxo { - script_pubkey: utxo.script_pubkey.clone(), - asset: utxo.asset, - value: utxo.value, - }), - None => Err(SimplicitySighashError::WitnessUtxoMissing { - input: n, - }), - }) - .collect::, SimplicitySighashError>>()? - } else { - return Err(SimplicitySighashError::InputUtxosRequired); - }; - if input_utxos.len() != tx.input.len() { - return Err(SimplicitySighashError::InputUtxoCountMismatch { - expected: tx.input.len(), - actual: input_utxos.len(), - }); - } - - // Default to Bitcoin blockhash. - let genesis_hash = match genesis_hash { - Some(s) => s.parse().map_err(SimplicitySighashError::GenesisHashParsing)?, - None => elements::BlockHash::from_byte_array([ - // copied out of simplicity-webide source - 0xc1, 0xb1, 0x6a, 0xe2, 0x4f, 0x24, 0x23, 0xae, 0xa2, 0xea, 0x34, 0x55, 0x22, 0x92, - 0x79, 0x3b, 0x5b, 0x5e, 0x82, 0x99, 0x9a, 0x1e, 0xed, 0x81, 0xd5, 0x6a, 0xee, 0x52, - 0x8e, 0xda, 0x71, 0xa7, - ]), - }; - - let tx_env = ElementsEnv::new( - &tx, - input_utxos, - input_idx, - cmr, - control_block, - None, // FIXME populate this; needs https://github.com/BlockstreamResearch/rust-simplicity/issues/315 first - genesis_hash, - ); - - let (pk, sig) = match (public_key, signature) { - (Some(pk), None) => ( - Some(pk.parse::().map_err(SimplicitySighashError::PublicKeyParsing)?), - None, - ), - (Some(pk), Some(sig)) => ( - Some(pk.parse::().map_err(SimplicitySighashError::PublicKeyParsing)?), - Some( - sig.parse::() - .map_err(SimplicitySighashError::SignatureParsing)?, - ), - ), - (None, Some(_)) => return Err(SimplicitySighashError::SignatureWithoutPublicKey), - (None, None) => (None, None), - }; - - let sighash = tx_env.c_tx_env().sighash_all(); - let sighash_msg = Message::from_digest(sighash.to_byte_array()); // FIXME can remove in next version ofrust-secp - Ok(SighashInfo { - sighash, - signature: match secret_key { - Some(sk) => { - let sk: SecretKey = sk.parse().map_err(SimplicitySighashError::SecretKeyParsing)?; - let keypair = Keypair::from_secret_key(&secp, &sk); - - if let Some(ref pk) = pk { - if pk != &keypair.x_only_public_key().0 { - return Err(SimplicitySighashError::PublicKeyMismatch { - derived: keypair.x_only_public_key().0.to_string(), - provided: pk.to_string(), - }); - } - } - - Some(secp.sign_schnorr(&sighash_msg, &keypair)) - } - None => None, - }, - valid_signature: match (pk, sig) { - (Some(pk), Some(sig)) => Some(secp.verify_schnorr(&sig, &sighash_msg, &pk).is_ok()), - _ => None, - }, - }) -} diff --git a/src/bin/hal-simplicity/cmd/tx.rs b/src/bin/hal-simplicity/cmd/tx.rs index b2198b6..5aa74fb 100644 --- a/src/bin/hal-simplicity/cmd/tx.rs +++ b/src/bin/hal-simplicity/cmd/tx.rs @@ -1,111 +1,10 @@ -use std::convert::TryInto; use std::io::Write; use clap; -use elements::bitcoin::{self, secp256k1}; -use elements::encode::{deserialize, serialize}; -use elements::hashes::Hash; -use elements::secp256k1_zkp::{ - Generator, PedersenCommitment, PublicKey, RangeProof, SurjectionProof, Tweak, -}; -use elements::{ - confidential, AssetIssuance, OutPoint, Script, Transaction, TxIn, TxInWitness, TxOut, - TxOutWitness, -}; -use log::warn; +use elements::encode::serialize; use crate::cmd; -use hal_simplicity::confidential::{ - ConfidentialAssetInfo, ConfidentialNonceInfo, ConfidentialType, ConfidentialValueInfo, -}; -use hal_simplicity::tx::{ - AssetIssuanceInfo, InputInfo, InputScriptInfo, InputWitnessInfo, OutputInfo, OutputScriptInfo, - OutputWitnessInfo, PeginDataInfo, PegoutDataInfo, TransactionInfo, -}; -use hal_simplicity::Network; - -#[derive(Debug, thiserror::Error)] -pub enum TxError { - #[error("invalid JSON provided: {0}")] - JsonParse(serde_json::Error), - - #[error("failed to decode raw transaction hex: {0}")] - TxHex(hex::FromHexError), - - #[error("invalid tx format: {0}")] - TxDeserialize(elements::encode::Error), - - #[error("field \"{field}\" is required.")] - MissingField { - field: String, - }, - - #[error("invalid prevout format: {0}")] - PrevoutParse(bitcoin::blockdata::transaction::ParseOutPointError), - - #[error("txid field given without vout field")] - MissingVout, - - #[error("conflicting prevout information")] - ConflictingPrevout, - - #[error("no previous output provided")] - NoPrevout, - - #[error("invalid confidential commitment: {0}")] - ConfidentialCommitment(elements::secp256k1_zkp::Error), - - #[error("invalid confidential publicKey: {0}")] - ConfidentialCommitmentPublicKey(secp256k1::Error), - - #[error("wrong size of nonce field")] - NonceSize, - - #[error("invalid size of asset_entropy")] - AssetEntropySize, - - #[error("invalid asset_blinding_nonce: {0}")] - AssetBlindingNonce(elements::secp256k1_zkp::Error), - - #[error("decoding script assembly is not yet supported")] - AsmNotSupported, - - #[error("no scriptSig info provided")] - NoScriptSig, - - #[error("no scriptPubKey info provided")] - NoScriptPubKey, - - #[error("invalid outpoint in pegin_data: {0}")] - PeginOutpoint(bitcoin::blockdata::transaction::ParseOutPointError), - - #[error("outpoint in pegin_data does not correspond to input value")] - PeginOutpointMismatch, - - #[error("asset in pegin_data should be explicit")] - PeginAssetNotExplicit, - - #[error("invalid rangeproof: {0}")] - RangeProof(elements::secp256k1_zkp::Error), - - #[error("invalid sequence: {0}")] - Sequence(core::num::TryFromIntError), - - #[error("addresses for different networks are used in the output scripts")] - MixedNetworks, - - #[error("invalid surjection proof: {0}")] - SurjectionProof(elements::secp256k1_zkp::Error), - - #[error("value in pegout_data does not correspond to output value")] - PegoutValueMismatch, - - #[error("explicit value is required for pegout data")] - PegoutValueNotExplicit, - - #[error("asset in pegout_data does not correspond to output value")] - PegoutAssetMismatch, -} +use hal_simplicity::tx::TransactionInfo; pub fn subcommand<'a>() -> clap::App<'a, 'a> { cmd::subcommand_group("tx", "manipulate transactions") @@ -130,460 +29,11 @@ fn cmd_create<'a>() -> clap::App<'a, 'a> { ]) } -/// Check both ways to specify the outpoint and return error if conflicting. -fn outpoint_from_input_info(input: &InputInfo) -> Result { - let op1: Option = - input.prevout.as_ref().map(|op| op.parse().map_err(TxError::PrevoutParse)).transpose()?; - let op2 = match input.txid { - Some(txid) => match input.vout { - Some(vout) => Some(OutPoint { - txid, - vout, - }), - None => return Err(TxError::MissingVout), - }, - None => None, - }; - - match (op1, op2) { - (Some(op1), Some(op2)) => { - if op1 != op2 { - return Err(TxError::ConflictingPrevout); - } - Ok(op1) - } - (Some(op), None) => Ok(op), - (None, Some(op)) => Ok(op), - (None, None) => Err(TxError::NoPrevout), - } -} - -fn bytes_32(bytes: &[u8]) -> Option<[u8; 32]> { - if bytes.len() != 32 { - None - } else { - let mut array = [0; 32]; - for (x, y) in bytes.iter().zip(array.iter_mut()) { - *y = *x; - } - Some(array) - } -} - -fn create_confidential_value(info: ConfidentialValueInfo) -> Result { - match info.type_ { - ConfidentialType::Null => Ok(confidential::Value::Null), - ConfidentialType::Explicit => { - Ok(confidential::Value::Explicit(info.value.ok_or_else(|| TxError::MissingField { - field: "value".to_string(), - })?)) - } - ConfidentialType::Confidential => { - let commitment_data = info.commitment.ok_or_else(|| TxError::MissingField { - field: "commitment".to_string(), - })?; - let comm = PedersenCommitment::from_slice(&commitment_data.0[..]) - .map_err(TxError::ConfidentialCommitment)?; - Ok(confidential::Value::Confidential(comm)) - } - } -} - -fn create_confidential_asset(info: ConfidentialAssetInfo) -> Result { - match info.type_ { - ConfidentialType::Null => Ok(confidential::Asset::Null), - ConfidentialType::Explicit => { - Ok(confidential::Asset::Explicit(info.asset.ok_or_else(|| TxError::MissingField { - field: "asset".to_string(), - })?)) - } - ConfidentialType::Confidential => { - let commitment_data = info.commitment.ok_or_else(|| TxError::MissingField { - field: "commitment".to_string(), - })?; - let gen = Generator::from_slice(&commitment_data.0[..]) - .map_err(TxError::ConfidentialCommitment)?; - Ok(confidential::Asset::Confidential(gen)) - } - } -} - -fn create_confidential_nonce(info: ConfidentialNonceInfo) -> Result { - match info.type_ { - ConfidentialType::Null => Ok(confidential::Nonce::Null), - ConfidentialType::Explicit => { - let nonce = info.nonce.ok_or_else(|| TxError::MissingField { - field: "nonce".to_string(), - })?; - let bytes = bytes_32(&nonce.0[..]).ok_or(TxError::NonceSize)?; - Ok(confidential::Nonce::Explicit(bytes)) - } - ConfidentialType::Confidential => { - let commitment_data = info.commitment.ok_or_else(|| TxError::MissingField { - field: "commitment".to_string(), - })?; - let pubkey = PublicKey::from_slice(&commitment_data.0[..]) - .map_err(TxError::ConfidentialCommitmentPublicKey)?; - Ok(confidential::Nonce::Confidential(pubkey)) - } - } -} - -fn create_asset_issuance(info: AssetIssuanceInfo) -> Result { - let asset_blinding_nonce_data = - info.asset_blinding_nonce.ok_or_else(|| TxError::MissingField { - field: "asset_blinding_nonce".to_string(), - })?; - let asset_blinding_nonce = - Tweak::from_slice(&asset_blinding_nonce_data.0[..]).map_err(TxError::AssetBlindingNonce)?; - - let asset_entropy_data = info.asset_entropy.ok_or_else(|| TxError::MissingField { - field: "asset_entropy".to_string(), - })?; - let asset_entropy = bytes_32(&asset_entropy_data.0[..]).ok_or(TxError::AssetEntropySize)?; - - let amount_info = info.amount.ok_or_else(|| TxError::MissingField { - field: "amount".to_string(), - })?; - let amount = create_confidential_value(amount_info)?; - - let inflation_keys_info = info.inflation_keys.ok_or_else(|| TxError::MissingField { - field: "inflation_keys".to_string(), - })?; - let inflation_keys = create_confidential_value(inflation_keys_info)?; - - Ok(AssetIssuance { - asset_blinding_nonce, - asset_entropy, - amount, - inflation_keys, - }) -} - -fn create_script_sig(ss: InputScriptInfo) -> Result { - if let Some(hex) = ss.hex { - if ss.asm.is_some() { - warn!("Field \"asm\" of input is ignored."); - } - Ok(hex.0.into()) - } else if ss.asm.is_some() { - Err(TxError::AsmNotSupported) - } else { - Err(TxError::NoScriptSig) - } -} - -fn create_pegin_witness( - pd: PeginDataInfo, - prevout: bitcoin::OutPoint, -) -> Result>, TxError> { - let parsed_outpoint = pd.outpoint.parse().map_err(TxError::PeginOutpoint)?; - if prevout != parsed_outpoint { - return Err(TxError::PeginOutpointMismatch); - } - - let asset = match create_confidential_asset(pd.asset)? { - confidential::Asset::Explicit(asset) => asset, - _ => return Err(TxError::PeginAssetNotExplicit), - }; - Ok(vec![ - serialize(&pd.value), - serialize(&asset), - pd.genesis_hash.to_byte_array().to_vec(), - serialize(&pd.claim_script.0), - serialize(&pd.mainchain_tx_hex.0), - serialize(&pd.merkle_proof.0), - ]) -} - -fn convert_outpoint_to_btc(p: elements::OutPoint) -> bitcoin::OutPoint { - bitcoin::OutPoint { - txid: bitcoin::Txid::from_byte_array(p.txid.to_byte_array()), - vout: p.vout, - } -} - -fn create_input_witness( - info: Option, - pd: Option, - prevout: OutPoint, -) -> Result { - let pegin_witness = - if let Some(info_wit) = info.as_ref().and_then(|info| info.pegin_witness.as_ref()) { - if pd.is_some() { - warn!("Field \"pegin_data\" of input is ignored."); - } - info_wit.iter().map(|h| h.clone().0).collect() - } else if let Some(pd) = pd { - create_pegin_witness(pd, convert_outpoint_to_btc(prevout))? - } else { - Default::default() - }; - - if let Some(wi) = info { - let amount_rangeproof = wi - .amount_rangeproof - .map(|b| RangeProof::from_slice(&b.0).map_err(TxError::RangeProof).map(Box::new)) - .transpose()?; - let inflation_keys_rangeproof = wi - .inflation_keys_rangeproof - .map(|b| RangeProof::from_slice(&b.0).map_err(TxError::RangeProof).map(Box::new)) - .transpose()?; - - Ok(TxInWitness { - amount_rangeproof, - inflation_keys_rangeproof, - script_witness: match wi.script_witness { - Some(ref w) => w.iter().map(|h| h.clone().0).collect(), - None => Vec::new(), - }, - pegin_witness, - }) - } else { - Ok(TxInWitness { - pegin_witness, - ..Default::default() - }) - } -} - -fn create_input(input: InputInfo) -> Result { - let has_issuance = input.has_issuance.unwrap_or(input.asset_issuance.is_some()); - let is_pegin = input.is_pegin.unwrap_or(input.pegin_data.is_some()); - let prevout = outpoint_from_input_info(&input)?; - - let script_sig = input.script_sig.map(create_script_sig).transpose()?.unwrap_or_default(); - - let sequence = elements::Sequence::from_height( - input.sequence.unwrap_or_default().try_into().map_err(TxError::Sequence)?, - ); - - let asset_issuance = if has_issuance { - input.asset_issuance.map(create_asset_issuance).transpose()?.unwrap_or_default() - } else { - if input.asset_issuance.is_some() { - warn!("Field \"asset_issuance\" of input is ignored."); - } - Default::default() - }; - - let witness = create_input_witness(input.witness, input.pegin_data, prevout)?; - - Ok(TxIn { - previous_output: prevout, - script_sig, - sequence, - is_pegin, - asset_issuance, - witness, - }) -} - -fn create_script_pubkey( - spk: OutputScriptInfo, - used_network: &mut Option, -) -> Result { - if spk.type_.is_some() { - warn!("Field \"type\" of output is ignored."); - } - - if let Some(hex) = spk.hex { - if spk.asm.is_some() { - warn!("Field \"asm\" of output is ignored."); - } - if spk.address.is_some() { - warn!("Field \"address\" of output is ignored."); - } - - //TODO(stevenroose) do script sanity check to avoid blackhole? - Ok(hex.0.into()) - } else if spk.asm.is_some() { - if spk.address.is_some() { - warn!("Field \"address\" of output is ignored."); - } - Err(TxError::AsmNotSupported) - } else if let Some(address) = spk.address { - // Error if another network had already been used. - if let Some(network) = Network::from_params(address.params) { - if used_network.replace(network).unwrap_or(network) != network { - return Err(TxError::MixedNetworks); - } - } - Ok(address.script_pubkey()) - } else { - Err(TxError::NoScriptPubKey) - } -} - -fn create_bitcoin_script_pubkey( - spk: hal::tx::OutputScriptInfo, -) -> Result { - if spk.type_.is_some() { - warn!("Field \"type\" of output is ignored."); - } - - if let Some(hex) = spk.hex { - if spk.asm.is_some() { - warn!("Field \"asm\" of output is ignored."); - } - if spk.address.is_some() { - warn!("Field \"address\" of output is ignored."); - } - - //TODO(stevenroose) do script sanity check to avoid blackhole? - Ok(hex.0.into()) - } else if spk.asm.is_some() { - if spk.address.is_some() { - warn!("Field \"address\" of output is ignored."); - } - Err(TxError::AsmNotSupported) - } else if let Some(address) = spk.address { - Ok(address.assume_checked().script_pubkey()) - } else { - Err(TxError::NoScriptPubKey) - } -} - -fn create_output_witness(w: OutputWitnessInfo) -> Result { - let surjection_proof = w - .surjection_proof - .map(|b| { - SurjectionProof::from_slice(&b.0[..]).map_err(TxError::SurjectionProof).map(Box::new) - }) - .transpose()?; - let rangeproof = w - .rangeproof - .map(|b| RangeProof::from_slice(&b.0[..]).map_err(TxError::RangeProof).map(Box::new)) - .transpose()?; - - Ok(TxOutWitness { - surjection_proof, - rangeproof, - }) -} - -fn create_script_pubkey_from_pegout_data(pd: PegoutDataInfo) -> Result { - let script_pubkey = create_bitcoin_script_pubkey(pd.script_pub_key)?; - let mut builder = elements::script::Builder::new() - .push_opcode(elements::opcodes::all::OP_RETURN) - .push_slice(&pd.genesis_hash.to_byte_array()) - .push_slice(script_pubkey.as_bytes()); - for d in pd.extra_data { - builder = builder.push_slice(&d.0); - } - Ok(builder.into_script()) -} - -fn create_output(output: OutputInfo) -> Result { - // Keep track of which network has been used in addresses and error if two different networks - // are used. - let mut used_network = None; - let value_info = output.value.ok_or_else(|| TxError::MissingField { - field: "value".to_string(), - })?; - let value = create_confidential_value(value_info)?; - - let asset_info = output.asset.ok_or_else(|| TxError::MissingField { - field: "asset".to_string(), - })?; - let asset = create_confidential_asset(asset_info)?; - - let nonce = output - .nonce - .map(create_confidential_nonce) - .transpose()? - .unwrap_or(confidential::Nonce::Null); - - let script_pubkey = if let Some(spk) = output.script_pub_key { - if output.pegout_data.is_some() { - warn!("Field \"pegout_data\" of output is ignored."); - } - create_script_pubkey(spk, &mut used_network)? - } else if let Some(pd) = output.pegout_data { - match value { - confidential::Value::Explicit(v) => { - if v != pd.value { - return Err(TxError::PegoutValueMismatch); - } - } - _ => return Err(TxError::PegoutValueNotExplicit), - } - let pd_asset = create_confidential_asset(pd.asset.clone())?; - if asset != pd_asset { - return Err(TxError::PegoutAssetMismatch); - } - create_script_pubkey_from_pegout_data(pd)? - } else { - Default::default() - }; - - let witness = output.witness.map(create_output_witness).transpose()?.unwrap_or_default(); - - Ok(TxOut { - asset, - value, - nonce, - script_pubkey, - witness, - }) -} - -pub fn create_transaction(info: TransactionInfo) -> Result { - // Fields that are ignored. - if info.txid.is_some() { - warn!("Field \"txid\" is ignored."); - } - if info.hash.is_some() { - warn!("Field \"hash\" is ignored."); - } - if info.size.is_some() { - warn!("Field \"size\" is ignored."); - } - if info.weight.is_some() { - warn!("Field \"weight\" is ignored."); - } - if info.vsize.is_some() { - warn!("Field \"vsize\" is ignored."); - } - - let version = info.version.ok_or_else(|| TxError::MissingField { - field: "version".to_string(), - })?; - let lock_time = info.locktime.ok_or_else(|| TxError::MissingField { - field: "locktime".to_string(), - })?; - - let inputs = info - .inputs - .ok_or_else(|| TxError::MissingField { - field: "inputs".to_string(), - })? - .into_iter() - .map(create_input) - .collect::, _>>()?; - - let outputs = info - .outputs - .ok_or_else(|| TxError::MissingField { - field: "outputs".to_string(), - })? - .into_iter() - .map(create_output) - .collect::, _>>()?; - - Ok(Transaction { - version, - lock_time, - input: inputs, - output: outputs, - }) -} - fn exec_create<'a>(matches: &clap::ArgMatches<'a>) { let info = serde_json::from_str::(&cmd::arg_or_stdin(matches, "tx-info")) - .map_err(TxError::JsonParse) - .unwrap_or_else(|e| panic!("{}", e)); - let tx = create_transaction(info).unwrap_or_else(|e| panic!("{}", e)); + .unwrap_or_else(|e| panic!("invalid JSON provided: {}", e)); + + let tx = hal_simplicity::actions::tx::tx_create(info).unwrap_or_else(|e| panic!("{}", e)); let tx_bytes = serialize(&tx); if matches.is_present("raw-stdout") { @@ -601,11 +51,10 @@ fn cmd_decode<'a>() -> clap::App<'a, 'a> { fn exec_decode<'a>(matches: &clap::ArgMatches<'a>) { let hex_tx = cmd::arg_or_stdin(matches, "raw-tx"); - let raw_tx = - hex::decode(hex_tx.as_ref()).map_err(TxError::TxHex).unwrap_or_else(|e| panic!("{}", e)); - let tx: Transaction = - deserialize(&raw_tx).map_err(TxError::TxDeserialize).unwrap_or_else(|e| panic!("{}", e)); + let network = cmd::network(matches); + + let info = hal_simplicity::actions::tx::tx_decode(hex_tx.as_ref(), network) + .unwrap_or_else(|e| panic!("{}", e)); - let info = crate::GetInfo::get_info(&tx, cmd::network(matches)); cmd::print_output(matches, &info) } diff --git a/src/lib.rs b/src/lib.rs index f021541..f86b934 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ pub extern crate simplicity; +pub mod actions; + pub mod address; pub mod block; pub mod hal_simplicity;