From 5bfaeddb8e26be50f016d8350a73aadb9caa6cab Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Wed, 24 Dec 2025 17:10:33 +0200 Subject: [PATCH 1/2] Separated on different files. Completed split native command --- crates/cli-client/src/cli/basic.rs | 85 +++++++++++++++++++ crates/cli-client/src/cli/helper.rs | 108 ++++++++++++++++++++++++ crates/cli-client/src/cli/mod.rs | 124 +++------------------------- crates/cli-client/src/error.rs | 12 +++ crates/coin-store/src/entry.rs | 2 + crates/coin-store/src/store.rs | 27 ++---- crates/signer/src/lib.rs | 31 ++++++- 7 files changed, 256 insertions(+), 133 deletions(-) create mode 100644 crates/cli-client/src/cli/basic.rs create mode 100644 crates/cli-client/src/cli/helper.rs diff --git a/crates/cli-client/src/cli/basic.rs b/crates/cli-client/src/cli/basic.rs new file mode 100644 index 0000000..ad6bf88 --- /dev/null +++ b/crates/cli-client/src/cli/basic.rs @@ -0,0 +1,85 @@ +use crate::cli::{BasicCommand, Cli}; +use crate::config::Config; +use crate::error::Error; + +use simplicityhl::elements::pset::serialize::Serialize; +use simplicityhl::simplicity::hex::DisplayHex; + +use simplicityhl_core::{LIQUID_TESTNET_GENESIS, finalize_p2pk_transaction}; + +impl Cli { + pub(crate) async fn run_basic(&self, config: Config, command: &BasicCommand) -> Result<(), Error> { + match command { + BasicCommand::SplitNative { parts, fee, broadcast } => { + let wallet = self.get_wallet(&config).await?; + + let native_asset = simplicityhl_core::LIQUID_TESTNET_BITCOIN_ASSET; + let filter = coin_store::Filter::new() + .asset_id(native_asset) + .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()); + + let results = wallet.store().query(&[filter]).await?; + + let entry = results + .into_iter() + .next() + .and_then(|r| match r { + coin_store::QueryResult::Found(entries) => entries.into_iter().next(), + coin_store::QueryResult::InsufficientValue(_) | coin_store::QueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config("No native UTXO found".to_string()))?; + + let outpoint = entry.outpoint(); + let txout = entry.txout().clone(); + + let pst = contracts::sdk::split_native_any((*outpoint, txout.clone()), *parts, *fee)?; + + let tx = pst.extract_tx()?; + let utxos = &[txout]; + + let signature = + wallet + .signer() + .sign_p2pk(&tx, utxos, 0, config.address_params(), *LIQUID_TESTNET_GENESIS)?; + + let tx = finalize_p2pk_transaction( + tx, + utxos, + &wallet.signer().public_key(), + &signature, + 0, + config.address_params(), + *LIQUID_TESTNET_GENESIS, + )?; + + if *broadcast { + cli_helper::explorer::broadcast_tx(&tx).await?; + + wallet.store().mark_as_spent(*outpoint).await?; + + let txid = tx.txid(); + for (vout, output) in tx.output.iter().enumerate() { + if output.is_fee() { + continue; + } + + #[allow(clippy::cast_possible_truncation)] + let new_outpoint = simplicityhl::elements::OutPoint::new(txid, vout as u32); + + wallet.store().insert(new_outpoint, output.clone(), None).await?; + } + + println!("Broadcasted: {txid}"); + } else { + println!("{}", tx.serialize().to_lower_hex_string()); + } + + Ok(()) + } + BasicCommand::TransferNative { .. } => todo!(), + BasicCommand::TransferAsset { .. } => todo!(), + BasicCommand::IssueAsset { .. } => todo!(), + BasicCommand::ReissueAsset { .. } => todo!(), + } + } +} diff --git a/crates/cli-client/src/cli/helper.rs b/crates/cli-client/src/cli/helper.rs new file mode 100644 index 0000000..d5179a2 --- /dev/null +++ b/crates/cli-client/src/cli/helper.rs @@ -0,0 +1,108 @@ +use crate::cli::{Cli, HelperCommand}; +use crate::config::Config; +use crate::error::Error; +use crate::wallet::Wallet; + +impl Cli { + pub(crate) async fn run_helper(&self, config: Config, command: &HelperCommand) -> Result<(), Error> { + match command { + HelperCommand::Init => { + let seed = self.parse_seed()?; + let db_path = config.database_path(); + + std::fs::create_dir_all(&config.storage.data_dir)?; + Wallet::create(&seed, &db_path, config.address_params()).await?; + + println!("Wallet initialized at {}", db_path.display()); + Ok(()) + } + HelperCommand::Address => { + let wallet = self.get_wallet(&config).await?; + + wallet.signer().print_details()?; + + Ok(()) + } + HelperCommand::Balance => { + let wallet = self.get_wallet(&config).await?; + + let filter = coin_store::Filter::new() + .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()); + let results = wallet.store().query(&[filter]).await?; + + let mut balances: std::collections::HashMap = + std::collections::HashMap::new(); + + if let Some(coin_store::QueryResult::Found(entries)) = results.into_iter().next() { + for entry in entries { + let (asset, value) = match entry { + coin_store::UtxoEntry::Confidential { secrets, .. } => (secrets.asset, secrets.value), + coin_store::UtxoEntry::Explicit { txout, .. } => { + let asset = txout.asset.explicit().unwrap(); + let value = txout.value.explicit().unwrap(); + (asset, value) + } + }; + *balances.entry(asset).or_insert(0) += value; + } + } + + if balances.is_empty() { + println!("No UTXOs found"); + } else { + for (asset, value) in &balances { + println!("{asset}: {value}"); + } + } + Ok(()) + } + HelperCommand::Utxos => { + let wallet = self.get_wallet(&config).await?; + + let filter = coin_store::Filter::new(); + let results = wallet.store().query(&[filter]).await?; + + if let Some(coin_store::QueryResult::Found(entries)) = results.into_iter().next() { + for entry in &entries { + let outpoint = entry.outpoint(); + let (asset, value) = match entry { + coin_store::UtxoEntry::Confidential { secrets, .. } => (secrets.asset, secrets.value), + coin_store::UtxoEntry::Explicit { txout, .. } => { + let asset = txout.asset.explicit().unwrap(); + let value = txout.value.explicit().unwrap(); + (asset, value) + } + }; + println!("{outpoint} | {asset} | {value}"); + } + println!("Total: {} UTXOs", entries.len()); + } else { + println!("No UTXOs found"); + } + Ok(()) + } + HelperCommand::Import { outpoint, blinding_key } => { + let wallet = self.get_wallet(&config).await?; + + let txout = cli_helper::explorer::fetch_utxo(*outpoint).await?; + + let blinder = match blinding_key { + Some(key_hex) => { + let bytes: [u8; 32] = hex::decode(key_hex) + .map_err(|e| Error::Config(format!("Invalid blinding key hex: {e}")))? + .try_into() + .map_err(|_| Error::Config("Blinding key must be 32 bytes".to_string()))?; + Some(bytes) + } + None => None, + }; + + wallet.store().insert(*outpoint, txout, blinder).await?; + + println!("Imported {outpoint}"); + + Ok(()) + } + } + } +} diff --git a/crates/cli-client/src/cli/mod.rs b/crates/cli-client/src/cli/mod.rs index 37d8544..babc3f3 100644 --- a/crates/cli-client/src/cli/mod.rs +++ b/crates/cli-client/src/cli/mod.rs @@ -1,4 +1,6 @@ +mod basic; mod commands; +mod helper; use std::path::PathBuf; @@ -6,9 +8,9 @@ use clap::Parser; use crate::config::{Config, default_config_path}; use crate::error::Error; -use crate::wallet::Wallet; -pub use commands::{Command, HelperCommand, MakerCommand, TakerCommand}; +use crate::wallet::Wallet; +pub use commands::{BasicCommand, Command, HelperCommand, MakerCommand, TakerCommand}; #[derive(Debug, Parser)] #[command(name = "simplicity-dex")] @@ -43,11 +45,18 @@ impl Cli { .map_err(|_| Error::Config("Seed must be exactly 32 bytes (64 hex chars)".to_string())) } + async fn get_wallet(&self, config: &Config) -> Result { + let seed = self.parse_seed()?; + let db_path = config.database_path(); + + Wallet::open(&seed, &db_path, config.address_params()).await + } + pub async fn run(&self) -> Result<(), Error> { let config = self.load_config(); match &self.command { - Command::Basic { command: _ } => todo!(), + Command::Basic { command } => self.run_basic(config, command).await, Command::Maker { command: _ } => todo!(), Command::Taker { command: _ } => todo!(), Command::Helper { command } => self.run_helper(config, command).await, @@ -57,113 +66,4 @@ impl Cli { } } } - - async fn run_helper(&self, config: Config, command: &HelperCommand) -> Result<(), Error> { - match command { - HelperCommand::Init => { - let seed = self.parse_seed()?; - let db_path = config.database_path(); - - std::fs::create_dir_all(&config.storage.data_dir)?; - Wallet::create(&seed, &db_path, config.address_params()).await?; - - println!("Wallet initialized at {}", db_path.display()); - Ok(()) - } - HelperCommand::Address => { - let seed = self.parse_seed()?; - let db_path = config.database_path(); - let wallet = Wallet::open(&seed, &db_path, config.address_params()).await?; - - wallet.signer().print_details()?; - - Ok(()) - } - HelperCommand::Balance => { - let seed = self.parse_seed()?; - let db_path = config.database_path(); - let wallet = Wallet::open(&seed, &db_path, config.address_params()).await?; - - let filter = coin_store::Filter::new() - .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()); - let results = wallet.store().query(&[filter]).await?; - - let mut balances: std::collections::HashMap = - std::collections::HashMap::new(); - - if let Some(coin_store::QueryResult::Found(entries)) = results.into_iter().next() { - for entry in entries { - let (asset, value) = match entry { - coin_store::UtxoEntry::Confidential { secrets, .. } => (secrets.asset, secrets.value), - coin_store::UtxoEntry::Explicit { txout, .. } => { - let asset = txout.asset.explicit().unwrap(); - let value = txout.value.explicit().unwrap(); - (asset, value) - } - }; - *balances.entry(asset).or_insert(0) += value; - } - } - - if balances.is_empty() { - println!("No UTXOs found"); - } else { - for (asset, value) in &balances { - println!("{asset}: {value}"); - } - } - Ok(()) - } - HelperCommand::Utxos => { - let seed = self.parse_seed()?; - let db_path = config.database_path(); - let wallet = Wallet::open(&seed, &db_path, config.address_params()).await?; - - let filter = coin_store::Filter::new(); - let results = wallet.store().query(&[filter]).await?; - - if let Some(coin_store::QueryResult::Found(entries)) = results.into_iter().next() { - for entry in &entries { - let outpoint = entry.outpoint(); - let (asset, value) = match entry { - coin_store::UtxoEntry::Confidential { secrets, .. } => (secrets.asset, secrets.value), - coin_store::UtxoEntry::Explicit { txout, .. } => { - let asset = txout.asset.explicit().unwrap(); - let value = txout.value.explicit().unwrap(); - (asset, value) - } - }; - println!("{outpoint} | {asset} | {value}"); - } - println!("Total: {} UTXOs", entries.len()); - } else { - println!("No UTXOs found"); - } - Ok(()) - } - HelperCommand::Import { outpoint, blinding_key } => { - let seed = self.parse_seed()?; - let db_path = config.database_path(); - let wallet = Wallet::open(&seed, &db_path, config.address_params()).await?; - - let txout = cli_helper::explorer::fetch_utxo(*outpoint).await?; - - let blinder = match blinding_key { - Some(key_hex) => { - let bytes: [u8; 32] = hex::decode(key_hex) - .map_err(|e| Error::Config(format!("Invalid blinding key hex: {e}")))? - .try_into() - .map_err(|_| Error::Config("Blinding key must be 32 bytes".to_string()))?; - Some(bytes) - } - None => None, - }; - - wallet.store().insert(*outpoint, txout, blinder).await?; - - println!("Imported {outpoint}"); - Ok(()) - } - } - } } diff --git a/crates/cli-client/src/error.rs b/crates/cli-client/src/error.rs index 5f54a4e..e8afc48 100644 --- a/crates/cli-client/src/error.rs +++ b/crates/cli-client/src/error.rs @@ -17,4 +17,16 @@ pub enum Error { #[error("Explorer error: {0}")] Explorer(#[from] cli_helper::explorer::ExplorerError), + + #[error("Contract error: {0}")] + Contract(#[from] contracts::error::TransactionBuildError), + + #[error("Program error: {0}")] + Program(#[from] simplicityhl_core::ProgramError), + + #[error("PSET error: {0}")] + Pset(#[from] simplicityhl::elements::pset::Error), + + #[error("Hex error: {0}")] + Hex(#[from] hex::FromHexError), } diff --git a/crates/coin-store/src/entry.rs b/crates/coin-store/src/entry.rs index 480129f..e70b6e1 100644 --- a/crates/coin-store/src/entry.rs +++ b/crates/coin-store/src/entry.rs @@ -1,5 +1,6 @@ use simplicityhl::elements::{OutPoint, TxOut, TxOutSecrets}; +#[derive(Debug)] pub enum UtxoEntry { Confidential { outpoint: OutPoint, @@ -36,6 +37,7 @@ impl UtxoEntry { } } +#[derive(Debug)] pub enum QueryResult { Found(Vec), InsufficientValue(Vec), diff --git a/crates/coin-store/src/store.rs b/crates/coin-store/src/store.rs index 17abca8..9c0c852 100644 --- a/crates/coin-store/src/store.rs +++ b/crates/coin-store/src/store.rs @@ -163,13 +163,7 @@ impl Store { self.internal_insert(tx, outpoint, txout, blinder_key).await } - pub async fn mark_as_spent( - &self, - prev_outpoint: OutPoint, - new_outpoint: OutPoint, - txout: TxOut, - blinder_key: Option<[u8; 32]>, - ) -> Result<(), StoreError> { + pub async fn mark_as_spent(&self, prev_outpoint: OutPoint) -> Result<(), StoreError> { let prev_txid: &[u8] = prev_outpoint.txid.as_ref(); let prev_vout = i64::from(prev_outpoint.vout); @@ -191,7 +185,9 @@ impl Store { .execute(&mut *tx) .await?; - self.internal_insert(tx, new_outpoint, txout, blinder_key).await + tx.commit().await?; + + Ok(()) } } @@ -555,7 +551,6 @@ mod tests { let asset = test_asset_id(); let outpoint1 = OutPoint::new(Txid::from_byte_array([1; 32]), 0); - let outpoint2 = OutPoint::new(Txid::from_byte_array([2; 32]), 0); store .insert(outpoint1, make_explicit_txout(asset, 1000), None) @@ -566,18 +561,12 @@ mod tests { let results = store.query(std::slice::from_ref(&filter)).await.unwrap(); assert!(matches!(&results[0], QueryResult::Found(e) if e.len() == 1)); - store - .mark_as_spent(outpoint1, outpoint2, make_explicit_txout(asset, 900), None) - .await - .unwrap(); + store.mark_as_spent(outpoint1).await.unwrap(); - let results = store.query(&[filter]).await.unwrap(); + let results = store.query(std::slice::from_ref(&filter)).await.unwrap(); match &results[0] { - QueryResult::Found(entries) => { - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].outpoint(), &outpoint2); - } - _ => panic!("Expected Found result"), + QueryResult::Empty => {} + _ => panic!("Expected non-Empty result"), } let _ = fs::remove_file(path); diff --git a/crates/signer/src/lib.rs b/crates/signer/src/lib.rs index 908977d..6e5d5c2 100644 --- a/crates/signer/src/lib.rs +++ b/crates/signer/src/lib.rs @@ -2,9 +2,10 @@ #![allow(clippy::missing_errors_doc)] use simplicityhl::elements::secp256k1_zkp::{self as secp256k1, Keypair, Message, schnorr::Signature}; -use simplicityhl::elements::{Address, AddressParams}; +use simplicityhl::elements::{Address, AddressParams, BlockHash, Transaction, TxOut}; use simplicityhl::simplicity::bitcoin::XOnlyPublicKey; -use simplicityhl_core::{ProgramError, get_p2pk_address, hash_script_pubkey}; +use simplicityhl::simplicity::hashes::Hash as _; +use simplicityhl_core::{ProgramError, get_and_verify_env, get_p2pk_address, get_p2pk_program, hash_script_pubkey}; #[derive(thiserror::Error, Debug)] pub enum SignerError { @@ -70,4 +71,30 @@ impl Signer { Ok(()) } + + pub fn sign_p2pk( + &self, + tx: &Transaction, + utxos: &[TxOut], + input_index: usize, + params: &'static AddressParams, + genesis_hash: BlockHash, + ) -> Result { + let x_only_public_key = self.keypair.x_only_public_key().0; + let p2pk_program = get_p2pk_program(&x_only_public_key)?; + + let env = get_and_verify_env( + tx, + &p2pk_program, + &x_only_public_key, + utxos, + params, + genesis_hash, + input_index, + )?; + + let sighash_all = Message::from_digest(env.c_tx_env().sighash_all().to_byte_array()); + + Ok(self.keypair.sign_schnorr(sighash_all)) + } } From db76675e7a92154be5e620b7f23a5221e0621a6b Mon Sep 17 00:00:00 2001 From: Illia Kripaka Date: Thu, 25 Dec 2025 14:36:28 +0200 Subject: [PATCH 2/2] Fix project complilation, bump simplicity-contracts version hash --- Cargo.toml | 4 ++-- crates/cli-client/src/cli/basic.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4d585f1..97a9ead 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,8 @@ anyhow = { version = "1.0.100" } tracing = { version = "0.1.41" } -contracts = { git = "https://github.com/BlockstreamResearch/simplicity-contracts.git", rev = "6a53bf7", package = "contracts" } -cli-helper = { git = "https://github.com/BlockstreamResearch/simplicity-contracts.git", rev = "6a53bf7", package = "cli" } +contracts = { git = "https://github.com/BlockstreamResearch/simplicity-contracts.git", rev = "b3d1ae9", package = "contracts" } +cli-helper = { git = "https://github.com/BlockstreamResearch/simplicity-contracts.git", rev = "b3d1ae9", package = "cli" } simplicityhl-core = { version = "0.3.0", features = ["encoding"] } simplicityhl = { version = "0.4.0" } diff --git a/crates/cli-client/src/cli/basic.rs b/crates/cli-client/src/cli/basic.rs index ad6bf88..e426103 100644 --- a/crates/cli-client/src/cli/basic.rs +++ b/crates/cli-client/src/cli/basic.rs @@ -13,7 +13,7 @@ impl Cli { BasicCommand::SplitNative { parts, fee, broadcast } => { let wallet = self.get_wallet(&config).await?; - let native_asset = simplicityhl_core::LIQUID_TESTNET_BITCOIN_ASSET; + let native_asset = *simplicityhl_core::LIQUID_TESTNET_BITCOIN_ASSET; let filter = coin_store::Filter::new() .asset_id(native_asset) .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey());