From 5bfaeddb8e26be50f016d8350a73aadb9caa6cab Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Wed, 24 Dec 2025 17:10:33 +0200 Subject: [PATCH 1/7] 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 63d87b84bec9fc9497374c00d94bd63ab3e23a99 Mon Sep 17 00:00:00 2001 From: Illia Kripaka Date: Thu, 25 Dec 2025 14:36:28 +0200 Subject: [PATCH 2/7] Added simplicity contracts support to the coin-store --- Cargo.toml | 9 +- crates/cli-client/Cargo.toml | 7 +- crates/cli-client/src/cli/basic.rs | 502 ++++++++++++++-- crates/cli-client/src/cli/commands.rs | 8 +- crates/cli-client/src/cli/common.rs | 32 + crates/cli-client/src/cli/helper.rs | 16 +- crates/cli-client/src/cli/maker.rs | 17 + crates/cli-client/src/cli/mod.rs | 22 +- crates/cli-client/src/cli/taker.rs | 16 + crates/cli-client/src/error.rs | 5 + crates/cli-client/src/wallet.rs | 4 +- crates/coin-store/Cargo.toml | 14 +- crates/coin-store/migrations/001_initial.sql | 53 +- crates/coin-store/src/entry.rs | 163 ++++- crates/coin-store/src/error.rs | 27 +- crates/coin-store/src/executor.rs | 597 +++++++++++++++++++ crates/coin-store/src/filter.rs | 46 +- crates/coin-store/src/lib.rs | 8 +- crates/coin-store/src/store.rs | 532 +---------------- crates/options-relay/Cargo.toml | 6 +- crates/signer/Cargo.toml | 4 +- crates/signer/src/lib.rs | 4 +- 22 files changed, 1437 insertions(+), 655 deletions(-) create mode 100644 crates/cli-client/src/cli/common.rs create mode 100644 crates/cli-client/src/cli/maker.rs create mode 100644 crates/cli-client/src/cli/taker.rs create mode 100644 crates/coin-store/src/executor.rs diff --git a/Cargo.toml b/Cargo.toml index 4d585f1..e0b800f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,9 @@ resolver = "3" members = [ "crates/*" ] +exclude = [ + "crates/cli-client" +] [workspace.package] version = "0.1.0" @@ -19,8 +22,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" } -simplicityhl-core = { version = "0.3.0", features = ["encoding"] } +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.1", features = ["encoding"] } simplicityhl = { version = "0.4.0" } diff --git a/crates/cli-client/Cargo.toml b/crates/cli-client/Cargo.toml index 4f75164..908b754 100644 --- a/crates/cli-client/Cargo.toml +++ b/crates/cli-client/Cargo.toml @@ -37,7 +37,6 @@ tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1", features = ["derive"] } -toml = "0.8" -hex = "0.4" -dotenvy = "0.15" - +toml = { version = "0.8" } +hex = { version = "0.4" } +dotenvy = { version = "0.15" } diff --git a/crates/cli-client/src/cli/basic.rs b/crates/cli-client/src/cli/basic.rs index ad6bf88..b30820e 100644 --- a/crates/cli-client/src/cli/basic.rs +++ b/crates/cli-client/src/cli/basic.rs @@ -1,47 +1,50 @@ +use crate::cli::common::Broadcaster; 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}; +use coin_store::asset_entropy_store::entry::QueryResult; +use coin_store::{AssetEntropyStore, UtxoStore}; +use simplicityhl::elements::hashes::{Hash, sha256}; +use simplicityhl::elements::{AssetId, ContractHash}; +use simplicityhl_core::{ + LIQUID_TESTNET_GENESIS, PUBLIC_SECRET_BLINDER_KEY, derive_public_blinder_key, finalize_p2pk_transaction, +}; +use std::str::FromStr; impl Cli { pub(crate) async fn run_basic(&self, config: Config, command: &BasicCommand) -> Result<(), Error> { match command { BasicCommand::SplitNative { parts, fee, broadcast } => { + let liquid_genesis = *LIQUID_TESTNET_GENESIS; let wallet = self.get_wallet(&config).await?; - let native_asset = simplicityhl_core::LIQUID_TESTNET_BITCOIN_ASSET; - let filter = coin_store::Filter::new() + let native_asset = *simplicityhl_core::LIQUID_TESTNET_BITCOIN_ASSET; + let filter = coin_store::UtxoFilter::new() .asset_id(native_asset) .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()); - let results = wallet.store().query(&[filter]).await?; + let results: Vec = + <_ as UtxoStore>::query_utxos(wallet.store(), &[filter]).await?; - let entry = results + let native_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, + coin_store::UtxoQueryResult::Found(entries) => entries.into_iter().next(), + coin_store::UtxoQueryResult::InsufficientValue(_) | coin_store::UtxoQueryResult::Empty => None, }) .ok_or_else(|| Error::Config("No native UTXO found".to_string()))?; - let outpoint = entry.outpoint(); - let txout = entry.txout().clone(); + let fee_utxo = (*native_entry.outpoint(), native_entry.txout().clone()); - let pst = contracts::sdk::split_native_any((*outpoint, txout.clone()), *parts, *fee)?; + let pst = contracts::sdk::split_native_any(fee_utxo.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 utxos = &[fee_utxo.1]; + let signature = wallet + .signer() + .sign_p2pk(&tx, utxos, 0, config.address_params(), liquid_genesis)?; let tx = finalize_p2pk_transaction( tx, utxos, @@ -49,15 +52,18 @@ impl Cli { &signature, 0, config.address_params(), - *LIQUID_TESTNET_GENESIS, + liquid_genesis, )?; - if *broadcast { - cli_helper::explorer::broadcast_tx(&tx).await?; - - wallet.store().mark_as_spent(*outpoint).await?; + Broadcaster::from(*broadcast).broadcast_tx(&tx).await?; + { + let spent_outpoints = &[fee_utxo.0]; + for outpoint in spent_outpoints { + wallet.store().mark_as_spent(*outpoint).await?; + } let txid = tx.txid(); + for (vout, output) in tx.output.iter().enumerate() { if output.is_fee() { continue; @@ -66,20 +72,448 @@ impl Cli { #[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?; + <_ as UtxoStore>::insert(wallet.store(), new_outpoint, output.clone(), None).await?; } + } + } + BasicCommand::TransferNative { + to, + amount, + fee, + broadcast, + } => { + let liquid_genesis = *LIQUID_TESTNET_GENESIS; + let wallet = self.get_wallet(&config).await?; + + let native_asset = *simplicityhl_core::LIQUID_TESTNET_BITCOIN_ASSET; + let filter = coin_store::UtxoFilter::new() + .asset_id(native_asset) + .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()) + .limit(1); - println!("Broadcasted: {txid}"); - } else { - println!("{}", tx.serialize().to_lower_hex_string()); + let results: Vec = + <_ as UtxoStore>::query_utxos(wallet.store(), &[filter]).await?; + + // Todo(Illia): in future add token merging + let native_entry = results + .into_iter() + .next() + .and_then(|r| match r { + coin_store::UtxoQueryResult::Found(entries) => entries.into_iter().next(), + coin_store::UtxoQueryResult::InsufficientValue(_) | coin_store::UtxoQueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config("No native UTXO found".to_string()))?; + + let native_utxo = (*native_entry.outpoint(), native_entry.txout().clone()); + + // Todo(Illia): use fee as separate utxo to spend (I mean, don't take fees from native asset utxo) + let pst = contracts::sdk::transfer_native(native_utxo.clone(), to, *amount, *fee)?; + + let tx = pst.extract_tx()?; + let utxos = &[native_utxo.1]; + + let signature = wallet + .signer() + .sign_p2pk(&tx, utxos, 0, config.address_params(), liquid_genesis)?; + let tx = finalize_p2pk_transaction( + tx, + utxos, + &wallet.signer().public_key(), + &signature, + 0, + config.address_params(), + liquid_genesis, + )?; + + Broadcaster::from(*broadcast).broadcast_tx(&tx).await?; + + { + let outs_to_add = [(0, tx.output[0].clone(), None)]; + + let spent_outpoints = &[native_utxo.0]; + for outpoint in spent_outpoints { + wallet.store().mark_as_spent(*outpoint).await?; + } + let txid = tx.txid(); + + for (vout, tx_out, blinder) in outs_to_add { + #[allow(clippy::cast_possible_truncation)] + let new_outpoint = simplicityhl::elements::OutPoint::new(txid, vout as u32); + <_ as UtxoStore>::insert(wallet.store(), new_outpoint, tx_out, blinder).await?; + } } + } + BasicCommand::TransferAsset { + asset, + to, + amount, + fee, + broadcast, + } => { + let liquid_genesis = *LIQUID_TESTNET_GENESIS; + let wallet = self.get_wallet(&config).await?; - Ok(()) + let native_asset = *simplicityhl_core::LIQUID_TESTNET_BITCOIN_ASSET; + let transfer_asset = AssetId::from_str(asset)?; + + let filters = { + let filter_native = coin_store::UtxoFilter::new() + .asset_id(native_asset) + .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()) + .required_value(*fee) + .limit(1); + // Todo(Illia): add enum for cli which can take asset entropy both from name or as raw + // (i mean transform asset into enum and create custom fetching before passing into function) + let filter_transfer = coin_store::UtxoFilter::new() + .asset_id(transfer_asset) + .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()) + .required_value(*amount) + .limit(1); + [filter_native, filter_transfer] + }; + + let results: Vec = + <_ as UtxoStore>::query_utxos(wallet.store(), &filters).await?; + + let (asset_entry, fee_entry) = + { + let mut entries = results.into_iter(); + + // Todo(Illia): in future add token merging + let fee_entry = entries + .next() + .and_then(|r| match r { + coin_store::UtxoQueryResult::Found(entries) => entries.into_iter().next(), + coin_store::UtxoQueryResult::InsufficientValue(_) + | coin_store::UtxoQueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config("No native UTXO found".to_string()))?; + // Todo(Illia): in future add token merging + let asset_entry = entries + .next() + .and_then(|r| match r { + coin_store::UtxoQueryResult::Found(entries) => entries.into_iter().next(), + coin_store::UtxoQueryResult::InsufficientValue(_) + | coin_store::UtxoQueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config("No asset UTXO found".to_string()))?; + (asset_entry, fee_entry) + }; + + let asset_utxo = (*asset_entry.outpoint(), asset_entry.txout().clone()); + let fee_utxo = (*fee_entry.outpoint(), fee_entry.txout().clone()); + + let pst = contracts::sdk::transfer_asset(asset_utxo.clone(), fee_utxo.clone(), to, *amount, *fee)?; + + let tx = pst.extract_tx()?; + let utxos = &[asset_utxo.1, fee_utxo.1]; + + let signature = wallet + .signer() + .sign_p2pk(&tx, utxos, 0, config.address_params(), liquid_genesis)?; + + let tx = finalize_p2pk_transaction( + tx, + utxos, + &wallet.signer().public_key(), + &signature, + 0, + config.address_params(), + liquid_genesis, + )?; + + let signature = wallet + .signer() + .sign_p2pk(&tx, utxos, 1, config.address_params(), liquid_genesis)?; + + let tx = finalize_p2pk_transaction( + tx, + utxos, + &wallet.signer().public_key(), + &signature, + 1, + config.address_params(), + liquid_genesis, + )?; + + Broadcaster::from(*broadcast).broadcast_tx(&tx).await?; + + { + let outs_to_add = [ + (0, tx.output[0].clone(), Some(PUBLIC_SECRET_BLINDER_KEY)), + (1, tx.output[1].clone(), None), + (2, tx.output[2].clone(), None), + ]; + + let spent_outpoints = &[asset_utxo.0, fee_utxo.0]; + for outpoint in spent_outpoints { + wallet.store().mark_as_spent(*outpoint).await?; + } + let txid = tx.txid(); + + for (vout, tx_out, blinder) in outs_to_add { + #[allow(clippy::cast_possible_truncation)] + let new_outpoint = simplicityhl::elements::OutPoint::new(txid, vout as u32); + <_ as UtxoStore>::insert(wallet.store(), new_outpoint, tx_out, blinder).await?; + } + } + } + BasicCommand::IssueAsset { + name, + amount: issue_amount, + fee, + broadcast, + } => { + let liquid_genesis = *LIQUID_TESTNET_GENESIS; + let wallet = self.get_wallet(&config).await?; + + let native_asset = *simplicityhl_core::LIQUID_TESTNET_BITCOIN_ASSET; + + // Check whether asset is name already exists before all manipulations + { + let filter = coin_store::AssetEntropyFilter::new().name(name.clone()); + let results: Vec = + <_ as AssetEntropyStore>::query(wallet.store(), &[filter]).await?; + if results.is_empty() { + Err(Error::Config("Failed to receive result on filter retrieval".into()))?; + } else if results.len() != 1 { + Err(Error::Config("Failed to receive result on filter retrieval".into()))?; + } else if let QueryResult::Found(_) = results[0] { + Err(Error::Config(format!("Name '{name}' for asset already exist")))?; + } + } + + let filters = { + let filter_native = coin_store::UtxoFilter::new() + .asset_id(native_asset) + .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()) + .required_value(*fee) + .limit(1); + [filter_native] + }; + + let results = <_ as UtxoStore>::query_utxos(wallet.store(), &filters).await?; + + let fee_entry = { + let mut entries = results.into_iter(); + + // Todo(Illia): in future add token merging + entries + .next() + .and_then(|r| match r { + coin_store::UtxoQueryResult::Found(entries) => entries.into_iter().next(), + coin_store::UtxoQueryResult::InsufficientValue(_) | coin_store::UtxoQueryResult::Empty => { + None + } + }) + .ok_or_else(|| Error::Config("No native UTXO found".to_string()))? + }; + + let fee_utxo = (*fee_entry.outpoint(), fee_entry.txout().clone()); + + let pst = contracts::sdk::issue_asset( + &derive_public_blinder_key().public_key(), + fee_utxo.clone(), + *issue_amount, + *fee, + )?; + + let asset_entropy = { + let (asset_id, reissuance_asset_id) = pst.inputs()[0].issuance_ids(); + println!("Issued Asset id: {asset_id}, reissuance asset id: {reissuance_asset_id} "); + let asset_entropy = pst.inputs()[0].issuance_asset_entropy.expect("expected entropy"); + AssetId::generate_asset_entropy(fee_utxo.0, ContractHash::from_byte_array(asset_entropy)).0 + }; + + let tx = pst.extract_tx()?; + let utxos = &[fee_utxo.1]; + + let signature = wallet + .signer() + .sign_p2pk(&tx, utxos, 0, config.address_params(), liquid_genesis)?; + + let tx = finalize_p2pk_transaction( + tx, + utxos, + &wallet.signer().public_key(), + &signature, + 0, + config.address_params(), + liquid_genesis, + )?; + + let broadcaster = Broadcaster::from(*broadcast); + broadcaster.broadcast_tx(&tx).await?; + + { + let outs_to_add = [ + (0, tx.output[0].clone(), Some(PUBLIC_SECRET_BLINDER_KEY)), + (1, tx.output[1].clone(), None), + (2, tx.output[2].clone(), None), + ]; + + let spent_outpoints = &[fee_utxo.0]; + for outpoint in spent_outpoints { + wallet.store().mark_as_spent(*outpoint).await?; + } + let txid = tx.txid(); + + for (vout, tx_out, blinder) in outs_to_add { + #[allow(clippy::cast_possible_truncation)] + let new_outpoint = simplicityhl::elements::OutPoint::new(txid, vout as u32); + <_ as UtxoStore>::insert(wallet.store(), new_outpoint, tx_out, blinder).await?; + } + } + + if let Broadcaster::Online = broadcaster { + <_ as AssetEntropyStore>::insert(wallet.store(), name, asset_entropy).await?; + } + } + BasicCommand::ReissueAsset { + name, + amount: reissue_amount, + fee, + broadcast, + } => { + let liquid_genesis = *LIQUID_TESTNET_GENESIS; + let wallet = self.get_wallet(&config).await?; + + let native_asset = *simplicityhl_core::LIQUID_TESTNET_BITCOIN_ASSET; + let blinding_key = derive_public_blinder_key().public_key(); + + let entropy_midstate = { + let filter = coin_store::AssetEntropyFilter::new().name(name.clone()); + let results: Vec = + <_ as AssetEntropyStore>::query(wallet.store(), &[filter]).await?; + if results.is_empty() { + return Err(Error::Config(format!("No Asset entropy found for this name: {name}"))); + } else if results.len() != 1 { + return Err(Error::Config(format!( + "Found more than one Asset entropy for this name: '{name}'" + ))); + } + let asset_entropy = match results.into_iter().next().unwrap() { + QueryResult::Found(x) => x[0].asset_entropy, + QueryResult::Empty => { + return Err(Error::Config(format!("No Asset entropy found for this name: '{name}'"))); + } + }; + println!("asset entropy: {asset_entropy:X?}"); + sha256::Midstate::from_byte_array(asset_entropy) + }; + let reissuance_asset_id = AssetId::reissuance_token_from_entropy(entropy_midstate, false); + println!("midstate: {entropy_midstate}, Issued Asset id: {reissuance_asset_id} "); + + let filters = { + let filter_reissue_asset = coin_store::UtxoFilter::new() + .asset_id(reissuance_asset_id) + .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()) + .limit(1); + let filter_native = coin_store::UtxoFilter::new() + .asset_id(native_asset) + .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()) + .required_value(*fee) + .limit(1); + [filter_reissue_asset, filter_native] + }; + + // Todo(Illia): add retrieving of token hex value from wollet? + let results = <_ as UtxoStore>::query_utxos(wallet.store(), &filters).await?; + + let (reissue_token_entry, fee_entry) = + { + let mut entries = results.into_iter(); + + // Todo(Illia): in future add merging + let reissue_token_entry = entries + .next() + .and_then(|r| match r { + coin_store::UtxoQueryResult::Found(entries) => entries.into_iter().next(), + coin_store::UtxoQueryResult::InsufficientValue(_) + | coin_store::UtxoQueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config("No reissue token UTXO found".to_string()))?; + // Todo(Illia): in future add merging + let fee_entry = entries + .next() + .and_then(|r| match r { + coin_store::UtxoQueryResult::Found(entries) => entries.into_iter().next(), + coin_store::UtxoQueryResult::InsufficientValue(_) + | coin_store::UtxoQueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config("No native UTXO found".to_string()))?; + (reissue_token_entry, fee_entry) + }; + + let reissue_utxo = (*reissue_token_entry.outpoint(), reissue_token_entry.txout().clone()); + let reissue_utxo_secrets = *reissue_token_entry.secrets().unwrap(); + let fee_utxo = (*fee_entry.outpoint(), fee_entry.txout().clone()); + + let pst = contracts::sdk::reissue_asset( + &blinding_key, + reissue_utxo.clone(), + reissue_utxo_secrets, + fee_utxo.clone(), + *reissue_amount, + *fee, + entropy_midstate, + )?; + + let tx = pst.extract_tx()?; + let utxos = &[reissue_utxo.1, fee_utxo.1]; + + let signature = wallet + .signer() + .sign_p2pk(&tx, utxos, 0, config.address_params(), liquid_genesis)?; + + let tx = finalize_p2pk_transaction( + tx, + utxos, + &wallet.signer().public_key(), + &signature, + 0, + config.address_params(), + liquid_genesis, + )?; + + let signature = wallet + .signer() + .sign_p2pk(&tx, utxos, 1, config.address_params(), liquid_genesis)?; + + let tx = finalize_p2pk_transaction( + tx, + utxos, + &wallet.signer().public_key(), + &signature, + 1, + config.address_params(), + liquid_genesis, + )?; + + Broadcaster::from(*broadcast).broadcast_tx(&tx).await?; + + { + let outs_to_add = [ + (0, tx.output[0].clone(), Some(PUBLIC_SECRET_BLINDER_KEY)), + (1, tx.output[1].clone(), None), + (2, tx.output[2].clone(), None), + ]; + + let spent_outpoints = &[reissue_utxo.0, fee_utxo.0]; + for outpoint in spent_outpoints { + wallet.store().mark_as_spent(*outpoint).await?; + } + let txid = tx.txid(); + + for (vout, tx_out, blinder) in outs_to_add { + #[allow(clippy::cast_possible_truncation)] + let new_outpoint = simplicityhl::elements::OutPoint::new(txid, vout as u32); + <_ as UtxoStore>::insert(wallet.store(), new_outpoint, tx_out, blinder).await?; + } + } } - BasicCommand::TransferNative { .. } => todo!(), - BasicCommand::TransferAsset { .. } => todo!(), - BasicCommand::IssueAsset { .. } => todo!(), - BasicCommand::ReissueAsset { .. } => todo!(), } + + Ok(()) } } diff --git a/crates/cli-client/src/cli/commands.rs b/crates/cli-client/src/cli/commands.rs index 50f976f..1078122 100644 --- a/crates/cli-client/src/cli/commands.rs +++ b/crates/cli-client/src/cli/commands.rs @@ -1,5 +1,5 @@ use clap::Subcommand; -use simplicityhl::elements::OutPoint; +use simplicityhl::elements::{Address, OutPoint}; #[derive(Debug, Subcommand)] pub enum Command { @@ -75,7 +75,7 @@ pub enum HelperCommand { /// Show wallet balance Balance, - /// List UTXOs + /// List all UTXOs stored in wallet Utxos, /// Import a UTXO into the wallet @@ -96,7 +96,7 @@ pub enum BasicCommand { TransferNative { /// Recipient address #[arg(long)] - to: String, + to: Address, /// Amount to send in satoshis #[arg(long)] amount: u64, @@ -128,7 +128,7 @@ pub enum BasicCommand { asset: String, /// Recipient address #[arg(long)] - to: String, + to: Address, /// Amount to send #[arg(long)] amount: u64, diff --git a/crates/cli-client/src/cli/common.rs b/crates/cli-client/src/cli/common.rs new file mode 100644 index 0000000..3cb5cc9 --- /dev/null +++ b/crates/cli-client/src/cli/common.rs @@ -0,0 +1,32 @@ +use crate::error::Error; +use simplicityhl::elements::Transaction; +use simplicityhl::elements::pset::serialize::Serialize; +use simplicityhl::simplicity::hex::DisplayHex; + +#[derive(Debug, Clone, Copy)] +pub enum Broadcaster { + Offline, + Online, +} + +impl From for Broadcaster { + fn from(b: bool) -> Self { + if b { Broadcaster::Online } else { Broadcaster::Offline } + } +} + +impl Broadcaster { + pub async fn broadcast_tx(&self, tx: &Transaction) -> Result<(), Error> { + match self { + Broadcaster::Offline => { + println!("{}", tx.serialize().to_lower_hex_string()); + } + Broadcaster::Online => { + cli_helper::explorer::broadcast_tx(tx).await?; + let txid = tx.txid(); + println!("Broadcasted: {txid}"); + } + } + Ok(()) + } +} diff --git a/crates/cli-client/src/cli/helper.rs b/crates/cli-client/src/cli/helper.rs index d5179a2..bd199d7 100644 --- a/crates/cli-client/src/cli/helper.rs +++ b/crates/cli-client/src/cli/helper.rs @@ -2,6 +2,8 @@ use crate::cli::{Cli, HelperCommand}; use crate::config::Config; use crate::error::Error; use crate::wallet::Wallet; +use coin_store::utxo_store::UtxoStore; +use simplicityhl::elements::bitcoin::secp256k1; impl Cli { pub(crate) async fn run_helper(&self, config: Config, command: &HelperCommand) -> Result<(), Error> { @@ -26,14 +28,14 @@ impl Cli { HelperCommand::Balance => { let wallet = self.get_wallet(&config).await?; - let filter = coin_store::Filter::new() + let filter = coin_store::UtxoFilter::new() .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()); - let results = wallet.store().query(&[filter]).await?; + let results = <_ as UtxoStore>::query_utxos(wallet.store(), &[filter]).await?; let mut balances: std::collections::HashMap = std::collections::HashMap::new(); - if let Some(coin_store::QueryResult::Found(entries)) = results.into_iter().next() { + if let Some(coin_store::UtxoQueryResult::Found(entries)) = results.into_iter().next() { for entry in entries { let (asset, value) = match entry { coin_store::UtxoEntry::Confidential { secrets, .. } => (secrets.asset, secrets.value), @@ -59,10 +61,10 @@ impl Cli { HelperCommand::Utxos => { let wallet = self.get_wallet(&config).await?; - let filter = coin_store::Filter::new(); - let results = wallet.store().query(&[filter]).await?; + let filter = coin_store::UtxoFilter::new(); + let results = wallet.store().query_utxos(&[filter]).await?; - if let Some(coin_store::QueryResult::Found(entries)) = results.into_iter().next() { + if let Some(coin_store::UtxoQueryResult::Found(entries)) = results.into_iter().next() { for entry in &entries { let outpoint = entry.outpoint(); let (asset, value) = match entry { @@ -88,7 +90,7 @@ impl Cli { let blinder = match blinding_key { Some(key_hex) => { - let bytes: [u8; 32] = hex::decode(key_hex) + let bytes: [u8; secp256k1::constants::SECRET_KEY_SIZE] = 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()))?; diff --git a/crates/cli-client/src/cli/maker.rs b/crates/cli-client/src/cli/maker.rs new file mode 100644 index 0000000..c0e23c5 --- /dev/null +++ b/crates/cli-client/src/cli/maker.rs @@ -0,0 +1,17 @@ +use crate::cli::{Cli, MakerCommand}; +use crate::config::Config; +use crate::error::Error; + +impl Cli { + pub(crate) async fn run_maker(&self, config: Config, command: &MakerCommand) -> Result<(), Error> { + match command { + MakerCommand::Create => {} + MakerCommand::Fund => {} + MakerCommand::Exercise => {} + MakerCommand::Cancel => {} + MakerCommand::List => {} + } + + Ok(()) + } +} diff --git a/crates/cli-client/src/cli/mod.rs b/crates/cli-client/src/cli/mod.rs index babc3f3..415c8b4 100644 --- a/crates/cli-client/src/cli/mod.rs +++ b/crates/cli-client/src/cli/mod.rs @@ -1,6 +1,9 @@ mod basic; mod commands; +mod common; mod helper; +mod maker; +mod taker; use std::path::PathBuf; @@ -8,9 +11,10 @@ use clap::Parser; use crate::config::{Config, default_config_path}; use crate::error::Error; +pub use commands::{BasicCommand, Command, HelperCommand, MakerCommand, TakerCommand}; +use signer::Signer; use crate::wallet::Wallet; -pub use commands::{BasicCommand, Command, HelperCommand, MakerCommand, TakerCommand}; #[derive(Debug, Parser)] #[command(name = "simplicity-dex")] @@ -32,7 +36,7 @@ impl Cli { Config::load_or_default(&self.config) } - fn parse_seed(&self) -> Result<[u8; 32], Error> { + fn parse_seed(&self) -> Result<[u8; Signer::SEED_LEN], Error> { let seed_hex = self .seed .as_ref() @@ -40,9 +44,13 @@ impl Cli { let bytes = hex::decode(seed_hex).map_err(|e| Error::Config(format!("Invalid seed hex: {e}")))?; - bytes - .try_into() - .map_err(|_| Error::Config("Seed must be exactly 32 bytes (64 hex chars)".to_string())) + bytes.try_into().map_err(|_| { + Error::Config(format!( + "Seed must be exactly {} bytes ({} hex chars)", + Signer::SEED_LEN, + Signer::SEED_LEN * 2 + )) + }) } async fn get_wallet(&self, config: &Config) -> Result { @@ -57,8 +65,8 @@ impl Cli { match &self.command { Command::Basic { command } => self.run_basic(config, command).await, - Command::Maker { command: _ } => todo!(), - Command::Taker { command: _ } => todo!(), + Command::Maker { command } => self.run_maker(config, command).await, + Command::Taker { command } => self.run_taker(config, command).await, Command::Helper { command } => self.run_helper(config, command).await, Command::Config => { println!("{config:#?}"); diff --git a/crates/cli-client/src/cli/taker.rs b/crates/cli-client/src/cli/taker.rs new file mode 100644 index 0000000..35d2bfc --- /dev/null +++ b/crates/cli-client/src/cli/taker.rs @@ -0,0 +1,16 @@ +use crate::cli::{Cli, TakerCommand}; +use crate::config::Config; +use crate::error::Error; + +impl Cli { + pub(crate) async fn run_taker(&self, config: Config, command: &TakerCommand) -> Result<(), Error> { + match command { + TakerCommand::Browse => {} + TakerCommand::Take => {} + TakerCommand::Claim => {} + TakerCommand::List => {} + } + + Ok(()) + } +} diff --git a/crates/cli-client/src/error.rs b/crates/cli-client/src/error.rs index e8afc48..a8faf48 100644 --- a/crates/cli-client/src/error.rs +++ b/crates/cli-client/src/error.rs @@ -1,3 +1,5 @@ +use simplicityhl::simplicity::hex::HexToArrayError; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Configuration error: {0}")] @@ -29,4 +31,7 @@ pub enum Error { #[error("Hex error: {0}")] Hex(#[from] hex::FromHexError), + + #[error("Hex to array error: {0}")] + HexToArray(#[from] HexToArrayError), } diff --git a/crates/cli-client/src/wallet.rs b/crates/cli-client/src/wallet.rs index c32802e..3355c91 100644 --- a/crates/cli-client/src/wallet.rs +++ b/crates/cli-client/src/wallet.rs @@ -14,7 +14,7 @@ pub struct Wallet { impl Wallet { pub async fn create( - seed: &[u8; 32], + seed: &[u8; Signer::SEED_LEN], db_path: impl AsRef, params: &'static AddressParams, ) -> Result { @@ -25,7 +25,7 @@ impl Wallet { } pub async fn open( - seed: &[u8; 32], + seed: &[u8; Signer::SEED_LEN], db_path: impl AsRef, params: &'static AddressParams, ) -> Result { diff --git a/crates/coin-store/Cargo.toml b/crates/coin-store/Cargo.toml index 5ccd793..8273c73 100644 --- a/crates/coin-store/Cargo.toml +++ b/crates/coin-store/Cargo.toml @@ -14,10 +14,20 @@ categories.workspace = true [dependencies] simplicityhl = { workspace = true } +simplicityhl-core = { workspace = true } +contracts = { workspace = true } -futures = "0.3" +sha2 = { version = "0.10.9" } -thiserror = "2" +futures = { version = "0.3" } +thiserror = { version = "2" } + +async-trait = { version = "0.1.89" } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] } + +bincode = { version = "2.0.1", features = ["alloc", "derive", "serde"] } + +[dev-dependencies] +hex = { version = "0.4.3" } \ No newline at end of file diff --git a/crates/coin-store/migrations/001_initial.sql b/crates/coin-store/migrations/001_initial.sql index 2d8b7d2..a1602eb 100644 --- a/crates/coin-store/migrations/001_initial.sql +++ b/crates/coin-store/migrations/001_initial.sql @@ -1,24 +1,41 @@ -CREATE TABLE utxos ( - txid BLOB NOT NULL, - vout INTEGER NOT NULL, - script_pubkey BLOB NOT NULL, - asset_id BLOB NOT NULL, - value INTEGER NOT NULL, - serialized BLOB NOT NULL, - is_confidential INTEGER NOT NULL, - is_spent INTEGER DEFAULT 0, +CREATE TABLE utxos +( + txid BLOB NOT NULL, + vout INTEGER NOT NULL, + script_pubkey BLOB NOT NULL, + asset_id BLOB NOT NULL, + value INTEGER NOT NULL, + serialized BLOB NOT NULL, + serialized_witness BLOB NOT NULL, + is_confidential INTEGER NOT NULL, + is_spent INTEGER DEFAULT 0, PRIMARY KEY (txid, vout) ); -CREATE TABLE blinder_keys ( - txid BLOB NOT NULL, - vout INTEGER NOT NULL, - blinding_key BLOB NOT NULL, +CREATE TABLE blinder_keys +( + txid BLOB NOT NULL, + vout INTEGER NOT NULL, + blinding_key BLOB NOT NULL, + PRIMARY KEY (txid, vout), - FOREIGN KEY (txid, vout) REFERENCES utxos(txid, vout) + FOREIGN KEY (txid, vout) REFERENCES utxos (txid, vout) +); + +CREATE TABLE simplicity_contracts +( + script_pubkey BLOB NOT NULL, + taproot_pubkey_gen BLOB NOT NULL, + cmr BLOB NOT NULL, + source BLOB NOT NULL, + arguments BLOB, + PRIMARY KEY (taproot_pubkey_gen) ); -CREATE INDEX idx_utxos_asset_id ON utxos(asset_id); -CREATE INDEX idx_utxos_is_spent ON utxos(is_spent); -CREATE INDEX idx_utxos_script_pubkey ON utxos(script_pubkey); -CREATE INDEX idx_utxos_asset_spent_value ON utxos(asset_id, is_spent, value DESC); +CREATE INDEX idx_utxos_asset_id ON utxos (asset_id); +CREATE INDEX idx_utxos_is_spent ON utxos (is_spent); +CREATE INDEX idx_utxos_script_pubkey ON utxos (script_pubkey); +CREATE INDEX idx_utxos_asset_spent_value ON utxos (asset_id, is_spent, value DESC); + +CREATE INDEX idx_contracts_cmr ON simplicity_contracts (cmr); +CREATE INDEX idx_contracts_script_pubkey ON simplicity_contracts (script_pubkey); diff --git a/crates/coin-store/src/entry.rs b/crates/coin-store/src/entry.rs index e70b6e1..c8c31f6 100644 --- a/crates/coin-store/src/entry.rs +++ b/crates/coin-store/src/entry.rs @@ -1,45 +1,162 @@ +use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::sync::Arc; + +use sha2::{Digest, Sha256}; use simplicityhl::elements::{OutPoint, TxOut, TxOutSecrets}; +use simplicityhl::{Arguments, CompiledProgram}; + +use crate::StoreError; +use crate::executor::UtxoRow; + +#[derive(Debug, Clone)] +pub struct ContractContext { + programs: HashMap<[u8; 32], Arc>, +} + +impl Default for ContractContext { + fn default() -> Self { + Self::new() + } +} + +impl ContractContext { + #[must_use] + pub fn new() -> Self { + Self { + programs: HashMap::new(), + } + } + + pub(crate) fn add_program_from_row(self, row: &UtxoRow) -> Result { + let (Some(source_bytes), Some(args_bytes)) = (&row.source, &row.arguments) else { + return Ok(self); + }; + + let source_str = + String::from_utf8(source_bytes.clone()).map_err(|_| sqlx::Error::Decode("Invalid source UTF-8".into()))?; + + let (arguments, _): (Arguments, usize) = + bincode::serde::decode_from_slice(args_bytes, bincode::config::standard())?; + + self.add_program(source_str, arguments) + } + + pub fn add_program(mut self, source: String, arguments: Arguments) -> Result { + let key = Self::build_key(&source, &arguments)?; + + let program = CompiledProgram::new(source, arguments, false).map_err(StoreError::SimplicityCompilation)?; + + if let Entry::Vacant(v) = self.programs.entry(key) { + v.insert(Arc::new(program)); + } + + Ok(self) + } + + pub(crate) fn get_program_from_row(&self, row: &UtxoRow) -> Result>, StoreError> { + let (Some(source_bytes), Some(args_bytes)) = (&row.source, &row.arguments) else { + return Ok(None); + }; + + let source_str = + String::from_utf8(source_bytes.clone()).map_err(|_| sqlx::Error::Decode("Invalid source UTF-8".into()))?; + + let (arguments, _): (Arguments, usize) = + bincode::serde::decode_from_slice(args_bytes, bincode::config::standard())?; + + self.get_program(&source_str, &arguments) + } + + pub fn get_program( + &self, + source: &str, + arguments: &Arguments, + ) -> Result>, StoreError> { + let key = Self::build_key(source, arguments)?; + + Ok(self.programs.get(&key)) + } + + fn build_key(source: &str, arguments: &Arguments) -> Result<[u8; 32], StoreError> { + let mut hasher = Sha256::new(); + + hasher.update(source.as_bytes()); + hasher.update(bincode::serde::encode_to_vec(arguments, bincode::config::standard())?); + + Ok(hasher.finalize().into()) + } +} #[derive(Debug)] -pub enum UtxoEntry { - Confidential { - outpoint: OutPoint, - txout: TxOut, - secrets: TxOutSecrets, - }, - Explicit { - outpoint: OutPoint, - txout: TxOut, - }, +pub struct UtxoEntry { + outpoint: OutPoint, + txout: TxOut, + secrets: Option, + contract: Option>, } impl UtxoEntry { #[must_use] - pub const fn outpoint(&self) -> &OutPoint { - match self { - Self::Confidential { outpoint, .. } | Self::Explicit { outpoint, .. } => outpoint, + pub const fn new_explicit(outpoint: OutPoint, txout: TxOut) -> Self { + Self { + outpoint, + txout, + secrets: None, + contract: None, } } #[must_use] - pub const fn txout(&self) -> &TxOut { - match self { - Self::Confidential { txout, .. } | Self::Explicit { txout, .. } => txout, + pub const fn new_confidential(outpoint: OutPoint, txout: TxOut, secrets: TxOutSecrets) -> Self { + Self { + outpoint, + txout, + secrets: Some(secrets), + contract: None, } } + #[must_use] + pub fn with_contract(mut self, contract: Arc) -> Self { + self.contract = Some(contract); + self + } + + #[must_use] + pub const fn outpoint(&self) -> &OutPoint { + &self.outpoint + } + + #[must_use] + pub const fn txout(&self) -> &TxOut { + &self.txout + } + #[must_use] pub const fn secrets(&self) -> Option<&TxOutSecrets> { - match self { - Self::Confidential { secrets, .. } => Some(secrets), - Self::Explicit { .. } => None, - } + self.secrets.as_ref() + } + + #[must_use] + pub const fn contract(&self) -> Option<&Arc> { + self.contract.as_ref() + } + + #[must_use] + pub const fn is_confidential(&self) -> bool { + self.secrets.is_some() + } + + #[must_use] + pub const fn is_bound(&self) -> bool { + self.contract.is_some() } } #[derive(Debug)] -pub enum QueryResult { - Found(Vec), - InsufficientValue(Vec), +pub enum UtxoQueryResult { + Found(Vec, ContractContext), + InsufficientValue(Vec, ContractContext), Empty, } diff --git a/crates/coin-store/src/error.rs b/crates/coin-store/src/error.rs index 3a97dd8..cd69ff1 100644 --- a/crates/coin-store/src/error.rs +++ b/crates/coin-store/src/error.rs @@ -1,12 +1,11 @@ -use std::path::PathBuf; - use simplicityhl::elements::secp256k1_zkp::UpstreamError; use simplicityhl::elements::{OutPoint, UnblindError}; +use std::path::PathBuf; #[derive(thiserror::Error, Debug)] pub enum StoreError { #[error("Database already exists: {0}")] - AlreadyExists(PathBuf), + DbAlreadyExists(PathBuf), #[error("Database not found: {0}")] NotFound(PathBuf), @@ -23,21 +22,33 @@ pub enum StoreError { #[error("Missing blinder key for confidential output: {0}")] MissingBlinderKey(OutPoint), - #[error("Encoding error")] + #[error("Missing serialized TxOutWitness for output: {0}")] + MissingSerializedTxOutWitness(OutPoint), + + #[error("Encoding error, err: {0}")] Encoding(#[from] simplicityhl::elements::encode::Error), - #[error("Invalid secret key")] + #[error("Bincode encoding error, err: {0}")] + BincodeEncoding(#[from] bincode::error::EncodeError), + + #[error("Bincode decoding error, err: {0}")] + BincodeDecoding(#[from] bincode::error::DecodeError), + + #[error("Invalid secret key, err: {0}")] InvalidSecretKey(#[from] UpstreamError), - #[error("Unblind error")] + #[error("Unblind error, err: {0}")] Unblind(#[from] UnblindError), - #[error("SQLx error")] + #[error("SQLx error, err: {0}")] Sqlx(#[from] sqlx::Error), - #[error("Migration error")] + #[error("Migration error, err: {0}")] Migration(#[from] sqlx::migrate::MigrateError), #[error("Value overflow during calculation")] ValueOverflow, + + #[error("Simplicity compilation error: {0}")] + SimplicityCompilation(String), } diff --git a/crates/coin-store/src/executor.rs b/crates/coin-store/src/executor.rs new file mode 100644 index 0000000..6316bd7 --- /dev/null +++ b/crates/coin-store/src/executor.rs @@ -0,0 +1,597 @@ +use std::sync::Arc; + +use crate::entry::{ContractContext, UtxoEntry}; +use crate::{Store, StoreError, UtxoFilter, UtxoQueryResult}; + +use futures::future::try_join_all; + +use contracts::sdk::taproot_pubkey_gen::TaprootPubkeyGen; + +use simplicityhl::elements::encode; +use simplicityhl::elements::hashes::Hash; +use simplicityhl::elements::secp256k1_zkp::{self as secp256k1, SecretKey}; +use simplicityhl::elements::{AssetId, OutPoint, TxOut, TxOutWitness, Txid}; +use simplicityhl::{Arguments, CompiledProgram}; + +use sqlx::{QueryBuilder, Sqlite}; + +#[async_trait::async_trait] +pub trait UtxoStore { + async fn insert( + &self, + outpoint: OutPoint, + txout: TxOut, + blinder_key: Option<[u8; crate::store::BLINDING_KEY_LEN]>, + ) -> Result<(), StoreError>; + + async fn mark_as_spent(&self, prev_outpoint: OutPoint) -> Result<(), StoreError>; + + async fn query_utxos(&self, filters: &[UtxoFilter]) -> Result, StoreError>; + + async fn add_contract( + &self, + source: &str, + arguments: Arguments, + taproot_pubkey_gen: TaprootPubkeyGen, + ) -> Result<(), StoreError>; +} + +#[async_trait::async_trait] +impl UtxoStore for Store { + async fn insert( + &self, + outpoint: OutPoint, + txout: TxOut, + blinder_key: Option<[u8; crate::store::BLINDING_KEY_LEN]>, + ) -> Result<(), StoreError> { + let txid: &[u8] = outpoint.txid.as_ref(); + let vout = i64::from(outpoint.vout); + + let existing: bool = self.does_outpoint_exist(txid, vout).await?; + + if existing { + return Err(StoreError::UtxoAlreadyExists(outpoint)); + } + + let tx: sqlx::Transaction<'_, Sqlite> = self.pool.begin().await?; + + self.internal_utxo_insert(tx, outpoint, txout, blinder_key).await + } + + 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); + + let existing: bool = self.does_outpoint_exist(prev_txid, prev_vout).await?; + + if !existing { + return Err(StoreError::UtxoNotFound(prev_outpoint)); + } + + let mut tx = self.pool.begin().await?; + + sqlx::query("UPDATE utxos SET is_spent = 1 WHERE txid = ? AND vout = ?") + .bind(prev_txid) + .bind(prev_vout) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) + } + + async fn query_utxos(&self, filters: &[UtxoFilter]) -> Result, StoreError> { + let futures: Vec<_> = filters.iter().map(|f| self.query_all_filter_utxos(f)).collect(); + + try_join_all(futures).await + } + + async fn add_contract( + &self, + source: &str, + arguments: Arguments, + taproot_pubkey_gen: TaprootPubkeyGen, + ) -> Result<(), StoreError> { + let compiled_program = + CompiledProgram::new(source, arguments.clone(), false).map_err(StoreError::SimplicityCompilation)?; + let cmr = compiled_program.commit().cmr(); + + let script_pubkey = taproot_pubkey_gen.address.script_pubkey(); + let taproot_gen_str = taproot_pubkey_gen.to_string(); + let arguments_bytes = bincode::serde::encode_to_vec(&arguments, bincode::config::standard())?; + + sqlx::query( + "INSERT INTO simplicity_contracts (script_pubkey, taproot_pubkey_gen, cmr, source, arguments) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(script_pubkey.as_bytes()) + .bind(taproot_gen_str) + .bind(cmr.as_ref()) + .bind(source.as_bytes()) + .bind(arguments_bytes) + .execute(&self.pool) + .await?; + + Ok(()) + } +} + +impl Store { + #[inline] + fn downcast_satoshi_type(value: u64) -> i64 { + i64::try_from(value).expect("UTXO values never exceed i64 max (9.2e18 vs max BTC supply ~2.1e15 sats)") + } + + fn unblind_or_explicit( + outpoint: &OutPoint, + txout: &TxOut, + blinder_key: Option<[u8; crate::store::BLINDING_KEY_LEN]>, + ) -> Result<(AssetId, i64, bool), StoreError> { + if let (Some(asset), Some(sats_value)) = (txout.asset.explicit(), txout.value.explicit()) { + return Ok((asset, Self::downcast_satoshi_type(sats_value), false)); + } + + let Some(key) = blinder_key else { + return Err(StoreError::MissingBlinderKey(*outpoint)); + }; + + let secret_key = SecretKey::from_slice(&key)?; + let secrets = txout.unblind(secp256k1::SECP256K1, secret_key)?; + + Ok((secrets.asset, Self::downcast_satoshi_type(secrets.value), true)) + } + + async fn internal_utxo_insert( + &self, + mut tx: sqlx::Transaction<'_, Sqlite>, + outpoint: OutPoint, + txout: TxOut, + blinder_key: Option<[u8; crate::store::BLINDING_KEY_LEN]>, + ) -> Result<(), StoreError> { + let (asset_id, value, is_confidential) = Self::unblind_or_explicit(&outpoint, &txout, blinder_key)?; + + let txid: &[u8] = outpoint.txid.as_ref(); + let vout = i64::from(outpoint.vout); + + sqlx::query( + "INSERT INTO utxos (txid, vout, script_pubkey, asset_id, value, serialized, serialized_witness, is_confidential) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(txid) + .bind(vout) + .bind(txout.script_pubkey.as_bytes()) + .bind(asset_id.into_inner().0.as_slice()) + .bind(value) + .bind(encode::serialize(&txout)) + .bind(encode::serialize(&txout.witness)) + .bind(i64::from(is_confidential)) + .execute(&mut *tx) + .await?; + + if let Some(key) = blinder_key { + sqlx::query("INSERT INTO blinder_keys (txid, vout, blinding_key) VALUES (?, ?, ?)") + .bind(txid) + .bind(vout) + .bind(key.as_slice()) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) + } + + async fn does_outpoint_exist(&self, tx_id: &[u8], vout: i64) -> Result { + let query_result: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM utxos WHERE txid = ? AND vout = ?") + .bind(tx_id) + .bind(vout) + .fetch_optional(&self.pool) + .await?; + + if query_result == Some((1,)) { + Ok(true) + } else { + Ok(false) + } + } +} + +impl Store { + async fn fetch_utxo_rows( + &self, + filter: &UtxoFilter, + limit: Option, + offset: Option, + ) -> Result<(Vec, ContractContext), StoreError> { + let needs_contract_join = filter.is_contract_join(); + + let mut builder: QueryBuilder = if needs_contract_join { + QueryBuilder::new( + "SELECT u.txid, u.vout, u.serialized, u.serialized_witness, u.is_confidential, u.value, + b.blinding_key, c.source, c.arguments + FROM utxos u + LEFT JOIN blinder_keys b ON u.txid = b.txid AND u.vout = b.vout + INNER JOIN simplicity_contracts c ON u.script_pubkey = c.script_pubkey + WHERE 1=1", + ) + } else { + QueryBuilder::new( + "SELECT u.txid, u.vout, u.serialized, u.serialized_witness, u.is_confidential, u.value, + b.blinding_key, NULL as source, NULL as arguments + FROM utxos u + LEFT JOIN blinder_keys b ON u.txid = b.txid AND u.vout = b.vout + WHERE 1=1", + ) + }; + + if !filter.include_spent { + builder.push(" AND u.is_spent = 0"); + } + + if let Some(ref asset_id) = filter.asset_id { + builder.push(" AND u.asset_id = "); + builder.push_bind(asset_id.into_inner().0.to_vec()); + } + + if let Some(ref script) = filter.script_pubkey { + builder.push(" AND u.script_pubkey = "); + builder.push_bind(script.as_bytes().to_vec()); + } + + if let Some(ref cmr) = filter.cmr { + builder.push(" AND c.cmr = "); + builder.push_bind(cmr.as_ref()); + } + + if let Some(ref tpg) = filter.taproot_pubkey_gen { + builder.push(" AND c.taproot_pubkey_gen = "); + builder.push_bind(tpg.to_string()); + } + + if let Some(ref source) = filter.source { + builder.push(" AND c.source = "); + builder.push_bind(source.as_bytes().to_vec()); + } + + builder.push(" ORDER BY u.value DESC"); + + if let Some(limit) = limit { + builder.push(" LIMIT "); + builder.push_bind(limit); + } + + if let Some(offset) = offset { + builder.push(" OFFSET "); + builder.push_bind(offset); + } + + let rows: Vec = builder.build_query_as().fetch_all(&self.pool).await?; + + let mut context = ContractContext::new(); + + for row in &rows { + context = context.add_program_from_row(row)?; + } + + Ok((rows, context)) + } + + async fn query_all_filter_utxos(&self, filter: &UtxoFilter) -> Result { + let (rows, context): (Vec, ContractContext) = self.fetch_utxo_rows(filter, filter.limit, None).await?; + + if rows.is_empty() { + return Ok(UtxoQueryResult::Empty); + } + + let mut entries = Vec::with_capacity(rows.len()); + let mut total_value: u64 = 0; + + for row in rows { + total_value = total_value.saturating_add(row.value); + entries.push(row.into_entry(&context)?); + } + + if filter.required_value.is_some_and(|required| total_value < required) { + return Ok(UtxoQueryResult::InsufficientValue(entries, context)); + } + + Ok(UtxoQueryResult::Found(entries, context)) + } +} + +#[derive(sqlx::FromRow)] +pub struct UtxoRow { + txid: Vec, + vout: u32, + serialized: Vec, + serialized_witness: Option>, + is_confidential: i64, + value: u64, + blinding_key: Option>, + pub source: Option>, + pub arguments: Option>, +} + +impl UtxoRow { + fn into_entry(self, context: &ContractContext) -> Result { + let contract = context.get_program_from_row(&self)?; + + let txid_array: [u8; Txid::LEN] = self + .txid + .try_into() + .map_err(|_| sqlx::Error::Decode("Invalid txid length".into()))?; + + let txid = Txid::from_byte_array(txid_array); + let outpoint = OutPoint::new(txid, self.vout); + let mut txout: TxOut = encode::deserialize(&self.serialized)?; + + if self.is_confidential != 1 { + let entry = UtxoEntry::new_explicit(outpoint, txout); + + return Ok(if let Some(c) = contract { + entry.with_contract(Arc::clone(c)) + } else { + entry + }); + } + + let key_bytes: [u8; crate::store::BLINDING_KEY_LEN] = self + .blinding_key + .ok_or_else(|| sqlx::Error::Decode("Missing blinding key for confidential output".into()))? + .try_into() + .map_err(|_| sqlx::Error::Decode("Invalid blinding key length".into()))?; + + let serialized_witness = self + .serialized_witness + .as_ref() + .ok_or(StoreError::MissingSerializedTxOutWitness(outpoint))?; + + let deserialized_witness: TxOutWitness = encode::deserialize(serialized_witness)?; + txout.witness = deserialized_witness; + + let secret_key = SecretKey::from_slice(&key_bytes)?; + let secrets = txout.unblind(secp256k1::SECP256K1, secret_key)?; + + let entry = UtxoEntry::new_confidential(outpoint, txout, secrets); + + Ok(if let Some(c) = contract { + entry.with_contract(Arc::clone(c)) + } else { + entry + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::fs; + + use simplicityhl::elements::confidential::{Asset, Nonce, Value}; + + use simplicityhl::elements::{AssetId, Script, TxOutWitness}; + + fn make_explicit_txout(asset_id: AssetId, value: u64) -> TxOut { + TxOut { + asset: Asset::Explicit(asset_id), + value: Value::Explicit(value), + nonce: Nonce::Null, + script_pubkey: Script::new(), + witness: TxOutWitness::default(), + } + } + + fn test_asset_id() -> AssetId { + AssetId::from_slice(&[1; 32]).unwrap() + } + + #[tokio::test] + async fn test_insert_explicit_utxo() { + let path = "/tmp/test_coin_store_insert.db"; + let _ = fs::remove_file(path); + + let store = Store::create(path).await.unwrap(); + + let outpoint = OutPoint::new(Txid::from_byte_array([1; Txid::LEN]), 0); + let txout = make_explicit_txout(test_asset_id(), 1000); + + store.insert(outpoint, txout, None).await.unwrap(); + + let result = store + .insert(outpoint, make_explicit_txout(test_asset_id(), 500), None) + .await; + assert!(matches!(result, Err(StoreError::UtxoAlreadyExists(_)))); + + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn test_query_by_asset() { + let path = "/tmp/test_coin_store_query_asset.db"; + let _ = fs::remove_file(path); + + let store = Store::create(path).await.unwrap(); + + let asset1 = AssetId::from_slice(&[1; 32]).unwrap(); + let asset2 = AssetId::from_slice(&[2; 32]).unwrap(); + + store + .insert( + OutPoint::new(Txid::from_byte_array([1; Txid::LEN]), 0), + make_explicit_txout(asset1, 1000), + None, + ) + .await + .unwrap(); + + store + .insert( + OutPoint::new(Txid::from_byte_array([2; Txid::LEN]), 0), + make_explicit_txout(asset2, 2000), + None, + ) + .await + .unwrap(); + + let filter = UtxoFilter::new().asset_id(asset1); + let results = store.query_utxos(&[filter]).await.unwrap(); + + assert_eq!(results.len(), 1); + match &results[0] { + UtxoQueryResult::Found(entries, _) => { + assert_eq!(entries.len(), 1); + } + _ => panic!("Expected Found result"), + } + + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn test_query_required_value() { + let path = "/tmp/test_coin_store_query_value.db"; + let _ = fs::remove_file(path); + + let store = Store::create(path).await.unwrap(); + + let asset = test_asset_id(); + + store + .insert( + OutPoint::new(Txid::from_byte_array([1; Txid::LEN]), 0), + make_explicit_txout(asset, 500), + None, + ) + .await + .unwrap(); + + store + .insert( + OutPoint::new(Txid::from_byte_array([2; Txid::LEN]), 0), + make_explicit_txout(asset, 300), + None, + ) + .await + .unwrap(); + + let filter = UtxoFilter::new().asset_id(asset).required_value(700); + let results = store.query_utxos(&[filter]).await.unwrap(); + + match &results[0] { + UtxoQueryResult::Found(entries, _) => { + assert_eq!(entries.len(), 2); + } + _ => panic!("Expected Found result"), + } + + let filter = UtxoFilter::new().asset_id(asset).required_value(1000); + let results = store.query_utxos(&[filter]).await.unwrap(); + + match &results[0] { + UtxoQueryResult::InsufficientValue(entries, _) => { + assert_eq!(entries.len(), 2); + } + _ => panic!("Expected InsufficientValue result"), + } + + let filter = UtxoFilter::new().asset_id(asset).required_value(700).limit(1); + let results = store.query_utxos(&[filter]).await.unwrap(); + + match &results[0] { + UtxoQueryResult::InsufficientValue(entries, _) => { + assert_eq!(entries.len(), 1); + } + _ => panic!("Expected InsufficientValue result"), + } + + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn test_mark_as_spent() { + let path = "/tmp/test_coin_store_spent.db"; + let _ = fs::remove_file(path); + + let store = Store::create(path).await.unwrap(); + + let asset = test_asset_id(); + let outpoint1 = OutPoint::new(Txid::from_byte_array([1; Txid::LEN]), 0); + + store + .insert(outpoint1, make_explicit_txout(asset, 1000), None) + .await + .unwrap(); + + let filter = UtxoFilter::new().asset_id(asset); + let results = store.query_utxos(std::slice::from_ref(&filter)).await.unwrap(); + assert!(matches!(&results[0], UtxoQueryResult::Found(e, _) if e.len() == 1)); + + store.mark_as_spent(outpoint1).await.unwrap(); + + let results = store.query_utxos(std::slice::from_ref(&filter)).await.unwrap(); + match &results[0] { + UtxoQueryResult::Empty => {} + _ => panic!("Expected non-Empty result"), + } + + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn test_query_empty() { + let path = "/tmp/test_coin_store_empty.db"; + let _ = fs::remove_file(path); + + let store = Store::create(path).await.unwrap(); + + let filter = UtxoFilter::new().asset_id(test_asset_id()); + let results = store.query_utxos(&[filter]).await.unwrap(); + + assert!(matches!(&results[0], UtxoQueryResult::Empty)); + + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn test_multi_filter_query() { + let path = "/tmp/test_coin_store_multi_filter.db"; + let _ = fs::remove_file(path); + + let store = Store::create(path).await.unwrap(); + + let asset1 = AssetId::from_slice(&[1; 32]).unwrap(); + let asset2 = AssetId::from_slice(&[2; 32]).unwrap(); + + store + .insert( + OutPoint::new(Txid::from_byte_array([1; Txid::LEN]), 0), + make_explicit_txout(asset1, 1000), + None, + ) + .await + .unwrap(); + + store + .insert( + OutPoint::new(Txid::from_byte_array([2; Txid::LEN]), 0), + make_explicit_txout(asset2, 2000), + None, + ) + .await + .unwrap(); + + let filter1 = UtxoFilter::new().asset_id(asset1); + let filter2 = UtxoFilter::new().asset_id(asset2); + + let results = store.query_utxos(&[filter1, filter2]).await.unwrap(); + + assert_eq!(results.len(), 2); + assert!(matches!(&results[0], UtxoQueryResult::Found(e, _) if e.len() == 1)); + assert!(matches!(&results[1], UtxoQueryResult::Found(e, _) if e.len() == 1)); + + let _ = fs::remove_file(path); + } +} diff --git a/crates/coin-store/src/filter.rs b/crates/coin-store/src/filter.rs index ee394a3..010a362 100644 --- a/crates/coin-store/src/filter.rs +++ b/crates/coin-store/src/filter.rs @@ -1,15 +1,22 @@ -use simplicityhl::elements::{AssetId, Script}; +use contracts::sdk::taproot_pubkey_gen::TaprootPubkeyGen; +use simplicityhl::{ + elements::{AssetId, Script}, + simplicity::Cmr, +}; #[derive(Clone, Default)] -pub struct Filter { - pub(crate) asset_id: Option, - pub(crate) script_pubkey: Option