diff --git a/Cargo.toml b/Cargo.toml index 4d585f1..bd983cc 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" } -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..ecd7e4d 100644 --- a/crates/cli-client/Cargo.toml +++ b/crates/cli-client/Cargo.toml @@ -14,7 +14,7 @@ categories.workspace = true [[bin]] name = "simplicity-dex" -path = "src/bin/main.rs" +path = "src/main.rs" [dependencies] signer = { path = "../signer" } @@ -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 new file mode 100644 index 0000000..0802264 --- /dev/null +++ b/crates/cli-client/src/cli/basic.rs @@ -0,0 +1,624 @@ +use crate::cli::{BasicCommand, Cli}; +use crate::config::Config; +use crate::error::Error; + +use std::collections::HashMap; + +use coin_store::{UtxoQueryResult, UtxoStore}; + +use simplicityhl::elements::TxOut; +use simplicityhl::elements::hashes::Hash; +use simplicityhl::elements::issuance::ContractHash; +use simplicityhl::elements::pset::serialize::Serialize; +use simplicityhl::elements::pset::{Input, Output, PartiallySignedTransaction}; +use simplicityhl::elements::secp256k1_zkp::{self as secp256k1, Keypair}; +use simplicityhl::simplicity::hex::DisplayHex; +use simplicityhl_core::{LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, finalize_p2pk_transaction}; + +impl Cli { + #[allow(clippy::too_many_lines)] + 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 filter = coin_store::UtxoFilter::new() + .asset_id(*simplicityhl_core::LIQUID_TESTNET_BITCOIN_ASSET) + .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()); + + let results: Vec = + <_ as UtxoStore>::query_utxos(wallet.store(), &[filter]).await?; + + 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(_, _) => { + eprintln!("No single UTXO large enough. Try using 'merge' command first."); + None + } + coin_store::UtxoQueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config("No native UTXO found".to_string()))?; + + let fee_utxo = (*native_entry.outpoint(), native_entry.txout().clone()); + + let pst = contracts::sdk::split_native_any(fee_utxo.clone(), *parts, *fee)?; + + let tx = pst.extract_tx()?; + let utxos = &[fee_utxo.1]; + + 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, + )?; + + match broadcast { + false => { + println!("{}", tx.serialize().to_lower_hex_string()); + } + true => { + cli_helper::explorer::broadcast_tx(&tx).await?; + + println!("Broadcasted: {}", tx.txid()); + + wallet.store().insert_transaction(&tx, HashMap::default()).await?; + } + } + } + BasicCommand::Merge { + asset_id, + count, + fee, + broadcast, + } => { + if *count < 2 { + return Err(Error::Config("Need at least 2 UTXOs to merge".to_string())); + } + + let wallet = self.get_wallet(&config).await?; + let script_pubkey = wallet.signer().p2pk_address(config.address_params())?.script_pubkey(); + + let target_asset = asset_id.unwrap_or(*LIQUID_TESTNET_BITCOIN_ASSET); + let is_native = target_asset == *LIQUID_TESTNET_BITCOIN_ASSET; + + #[allow(clippy::cast_possible_wrap)] + let asset_filter = coin_store::UtxoFilter::new() + .asset_id(target_asset) + .script_pubkey(script_pubkey.clone()) + .limit(*count as i64); + + let results: Vec = + <_ as UtxoStore>::query_utxos(wallet.store(), &[asset_filter]).await?; + + let entries: Vec<_> = results + .into_iter() + .next() + .and_then(|r| match r { + coin_store::UtxoQueryResult::Found(entries, _) => Some(entries), + coin_store::UtxoQueryResult::InsufficientValue(entries, _) => { + eprintln!("Only found {} UTXOs for merge.", entries.len()); + Some(entries) + } + coin_store::UtxoQueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config(format!("No UTXOs found for asset {target_asset}")))?; + + if entries.len() < 2 { + return Err(Error::Config(format!( + "Need at least 2 UTXOs to merge, found {}", + entries.len() + ))); + } + + let total_asset_value: u64 = entries.iter().filter_map(coin_store::UtxoEntry::value).sum(); + let mut pst = PartiallySignedTransaction::new_v2(); + + let mut utxos: Vec = entries + .iter() + .map(|e| { + let mut input = Input::from_prevout(*e.outpoint()); + input.witness_utxo = Some(e.txout().clone()); + pst.add_input(input); + e.txout().clone() + }) + .collect(); + + if is_native { + let output_value = total_asset_value + .checked_sub(*fee) + .ok_or_else(|| Error::Config("Fee exceeds total UTXO value".to_string()))?; + + pst.add_output(Output::new_explicit( + script_pubkey, + output_value, + *LIQUID_TESTNET_BITCOIN_ASSET, + None, + )); + + println!( + "Merging {} native UTXOs ({} sats) -> 1 UTXO ({} sats)", + entries.len(), + total_asset_value, + output_value + ); + } else { + let fee_filter = coin_store::UtxoFilter::new() + .asset_id(*LIQUID_TESTNET_BITCOIN_ASSET) + .script_pubkey(script_pubkey.clone()) + .required_value(*fee); + + let fee_results: Vec = + <_ as UtxoStore>::query_utxos(wallet.store(), &[fee_filter]).await?; + + let fee_entry = fee_results + .into_iter() + .next() + .and_then(|r| match r { + coin_store::UtxoQueryResult::Found(entries, _) => entries.into_iter().next(), + coin_store::UtxoQueryResult::InsufficientValue(entries, _) => { + let available: u64 = entries.iter().filter_map(coin_store::UtxoEntry::value).sum(); + eprintln!( + "Insufficient LBTC for fee: have {available} sats, need {fee} sats. Try using 'merge' command first." + ); + None + } + coin_store::UtxoQueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config(format!("No LBTC UTXO found to pay fee of {fee} sats")))?; + + let Some(fee_input_value) = fee_entry.value() else { + return Err(Error::Config("Unexpected confidential value".to_string())); + }; + + let mut fee_input = Input::from_prevout(*fee_entry.outpoint()); + fee_input.witness_utxo = Some(fee_entry.txout().clone()); + pst.add_input(fee_input); + utxos.push(fee_entry.txout().clone()); + + pst.add_output(Output::new_explicit( + script_pubkey.clone(), + total_asset_value, + target_asset, + None, + )); + + if fee_input_value > *fee { + pst.add_output(Output::new_explicit( + script_pubkey, + fee_input_value - *fee, + *LIQUID_TESTNET_BITCOIN_ASSET, + None, + )); + } + + println!( + "Merging {} UTXOs of asset {} ({} units) -> 1 UTXO", + entries.len(), + target_asset, + total_asset_value + ); + } + + pst.add_output(Output::from_txout(TxOut::new_fee(*fee, *LIQUID_TESTNET_BITCOIN_ASSET))); + + let mut tx = pst.extract_tx()?; + + for (i, _) in utxos.iter().enumerate() { + let signature = + wallet + .signer() + .sign_p2pk(&tx, &utxos, i, config.address_params(), *LIQUID_TESTNET_GENESIS)?; + + tx = finalize_p2pk_transaction( + tx, + &utxos, + &wallet.signer().public_key(), + &signature, + i, + config.address_params(), + *LIQUID_TESTNET_GENESIS, + )?; + } + + match broadcast { + false => { + println!("{}", tx.serialize().to_lower_hex_string()); + } + true => { + cli_helper::explorer::broadcast_tx(&tx).await?; + + println!("Broadcasted: {}", tx.txid()); + + wallet.store().insert_transaction(&tx, HashMap::default()).await?; + } + } + } + BasicCommand::Transfer { + asset_id, + to, + amount, + fee, + broadcast, + } => { + let wallet = self.get_wallet(&config).await?; + let script_pubkey = wallet.signer().p2pk_address(config.address_params())?.script_pubkey(); + + let target_asset = asset_id.unwrap_or(*LIQUID_TESTNET_BITCOIN_ASSET); + let is_native = target_asset == *LIQUID_TESTNET_BITCOIN_ASSET; + + let required_amount = if is_native { *amount + *fee } else { *amount }; + + let asset_filter = coin_store::UtxoFilter::new() + .asset_id(target_asset) + .script_pubkey(script_pubkey.clone()) + .required_value(required_amount); + + let results: Vec = + <_ as UtxoStore>::query_utxos(wallet.store(), &[asset_filter]).await?; + + let entries: Vec<_> = results + .into_iter() + .next() + .and_then(|r| match r { + coin_store::UtxoQueryResult::Found(entries, _) => Some(entries), + coin_store::UtxoQueryResult::InsufficientValue(entries, _) => { + let available: u64 = entries.iter().filter_map(coin_store::UtxoEntry::value).sum(); + eprintln!( + "Insufficient funds: have {available} sats, need {required_amount} sats. Try using 'merge' command first." + ); + None + } + coin_store::UtxoQueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config(format!("No UTXOs found for asset {target_asset}")))?; + + let total_asset_value: u64 = entries.iter().filter_map(coin_store::UtxoEntry::value).sum(); + let mut pst = PartiallySignedTransaction::new_v2(); + + let mut utxos: Vec = entries + .iter() + .map(|e| { + let mut input = Input::from_prevout(*e.outpoint()); + input.witness_utxo = Some(e.txout().clone()); + pst.add_input(input); + e.txout().clone() + }) + .collect(); + + if is_native { + pst.add_output(Output::new_explicit( + to.script_pubkey(), + *amount, + *LIQUID_TESTNET_BITCOIN_ASSET, + None, + )); + + let change = total_asset_value + .checked_sub(*amount + *fee) + .ok_or_else(|| Error::Config("Fee + amount exceeds total UTXO value".to_string()))?; + + if change > 0 { + pst.add_output(Output::new_explicit( + script_pubkey, + change, + *LIQUID_TESTNET_BITCOIN_ASSET, + None, + )); + } + + println!("Transferring {amount} sats LBTC to {to}"); + } else { + let fee_filter = coin_store::UtxoFilter::new() + .asset_id(*LIQUID_TESTNET_BITCOIN_ASSET) + .script_pubkey(script_pubkey.clone()) + .required_value(*fee); + + let fee_results: Vec = + <_ as UtxoStore>::query_utxos(wallet.store(), &[fee_filter]).await?; + + let fee_entry = fee_results + .into_iter() + .next() + .and_then(|r| match r { + coin_store::UtxoQueryResult::Found(entries, _) => entries.into_iter().next(), + coin_store::UtxoQueryResult::InsufficientValue(entries, _) => { + let available: u64 = entries.iter().filter_map(coin_store::UtxoEntry::value).sum(); + eprintln!( + "Insufficient LBTC for fee: have {available} sats, need {fee} sats. Try using 'merge' command first." + ); + None + } + coin_store::UtxoQueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config(format!("No LBTC UTXO found to pay fee of {fee} sats")))?; + + let Some(fee_input_value) = fee_entry.value() else { + return Err(Error::Config("Unexpected confidential value".to_string())); + }; + + let mut fee_input = Input::from_prevout(*fee_entry.outpoint()); + fee_input.witness_utxo = Some(fee_entry.txout().clone()); + pst.add_input(fee_input); + utxos.push(fee_entry.txout().clone()); + + pst.add_output(Output::new_explicit(to.script_pubkey(), *amount, target_asset, None)); + + let asset_change = total_asset_value - *amount; + if asset_change > 0 { + pst.add_output(Output::new_explicit( + script_pubkey.clone(), + asset_change, + target_asset, + None, + )); + } + + if fee_input_value > *fee { + pst.add_output(Output::new_explicit( + script_pubkey, + fee_input_value - *fee, + *LIQUID_TESTNET_BITCOIN_ASSET, + None, + )); + } + + println!("Transferring {amount} units of asset {target_asset} to {to}"); + } + + pst.add_output(Output::from_txout(TxOut::new_fee(*fee, *LIQUID_TESTNET_BITCOIN_ASSET))); + + let mut tx = pst.extract_tx()?; + + for (i, _) in utxos.iter().enumerate() { + let signature = + wallet + .signer() + .sign_p2pk(&tx, &utxos, i, config.address_params(), *LIQUID_TESTNET_GENESIS)?; + + tx = finalize_p2pk_transaction( + tx, + &utxos, + &wallet.signer().public_key(), + &signature, + i, + config.address_params(), + *LIQUID_TESTNET_GENESIS, + )?; + } + + match broadcast { + false => { + println!("{}", tx.serialize().to_lower_hex_string()); + } + true => { + cli_helper::explorer::broadcast_tx(&tx).await?; + + println!("Broadcasted: {}", tx.txid()); + + wallet.store().insert_transaction(&tx, HashMap::default()).await?; + } + } + } + BasicCommand::IssueAsset { amount, fee, broadcast } => { + let wallet = self.get_wallet(&config).await?; + let script_pubkey = wallet.signer().p2pk_address(config.address_params())?.script_pubkey(); + + let fee_filter = coin_store::UtxoFilter::new() + .asset_id(*LIQUID_TESTNET_BITCOIN_ASSET) + .script_pubkey(script_pubkey) + .required_value(*fee); + + let results = <_ as UtxoStore>::query_utxos(wallet.store(), &[fee_filter]).await?; + + let fee_entry = results + .into_iter() + .next() + .and_then(|r| match r { + coin_store::UtxoQueryResult::Found(entries, _) => entries.into_iter().next(), + coin_store::UtxoQueryResult::InsufficientValue(entries, _) => { + let available: u64 = entries.iter().filter_map(coin_store::UtxoEntry::value).sum(); + eprintln!( + "Insufficient LBTC for fee: have {available} sats, need {fee} sats. Try using 'merge' command first." + ); + None + } + coin_store::UtxoQueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config(format!("No LBTC UTXO found to pay fee of {fee} sats")))?; + + let fee_utxo = (*fee_entry.outpoint(), fee_entry.txout().clone()); + + let blinding_keypair = Keypair::new(secp256k1::SECP256K1, &mut secp256k1::rand::thread_rng()); + + let pst = contracts::sdk::issue_asset(&blinding_keypair.public_key(), fee_utxo.clone(), *amount, *fee)?; + + let (asset_id, token_id) = pst.inputs()[0].issuance_ids(); + let asset_entropy_bytes = pst.inputs()[0] + .issuance_asset_entropy + .ok_or_else(|| Error::Config("Missing asset entropy in PST".to_string()))?; + let contract_hash = ContractHash::from_byte_array(asset_entropy_bytes); + let entropy = simplicityhl::elements::issuance::AssetId::generate_asset_entropy( + *fee_entry.outpoint(), + contract_hash, + ); + + let mut tx = pst.extract_tx()?; + let utxos = &[fee_utxo.1]; + + let signature = + wallet + .signer() + .sign_p2pk(&tx, utxos, 0, config.address_params(), *LIQUID_TESTNET_GENESIS)?; + + tx = finalize_p2pk_transaction( + tx, + utxos, + &wallet.signer().public_key(), + &signature, + 0, + config.address_params(), + *LIQUID_TESTNET_GENESIS, + )?; + + println!("Asset ID: {asset_id}"); + println!("Reissuance Token ID: {token_id}"); + println!("Asset Entropy: {}", entropy.to_byte_array().to_lower_hex_string()); + + match broadcast { + false => { + println!("{}", tx.serialize().to_lower_hex_string()); + } + true => { + cli_helper::explorer::broadcast_tx(&tx).await?; + + println!("Broadcasted: {}", tx.txid()); + + let mut blinder_keys = HashMap::new(); + blinder_keys.insert(0, blinding_keypair); + wallet.store().insert_transaction(&tx, blinder_keys).await?; + } + } + } + BasicCommand::ReissueAsset { + asset_id, + amount, + fee, + broadcast, + } => { + let wallet = self.get_wallet(&config).await?; + let script_pubkey = wallet.signer().p2pk_address(config.address_params())?.script_pubkey(); + + let asset_filter = coin_store::UtxoFilter::new() + .asset_id(*asset_id) + .script_pubkey(script_pubkey.clone()) + .include_entropy() + .limit(1); + + let asset_results = <_ as UtxoStore>::query_utxos(wallet.store(), &[asset_filter]).await?; + + let asset_entry = asset_results + .into_iter() + .next() + .and_then(|r| match r { + coin_store::UtxoQueryResult::Found(entries, _) + | coin_store::UtxoQueryResult::InsufficientValue(entries, _) => entries.into_iter().next(), + coin_store::UtxoQueryResult::Empty => None, + }) + .ok_or_else(|| Error::Config(format!("No UTXO found for asset {asset_id}")))?; + + let (_, token_id) = asset_entry.issuance_ids().ok_or_else(|| { + Error::Config(format!( + "No issuance data found for asset {asset_id}. Was this asset issued by this wallet?" + )) + })?; + + let entropy = asset_entry + .entropy() + .0 + .ok_or_else(|| Error::Config("Missing entropy".to_string()))?; + + let token_filter = coin_store::UtxoFilter::new() + .asset_id(token_id) + .script_pubkey(script_pubkey.clone()) + .limit(1); + + let fee_filter = coin_store::UtxoFilter::new() + .asset_id(*LIQUID_TESTNET_BITCOIN_ASSET) + .script_pubkey(script_pubkey) + .required_value(*fee) + .limit(1); + + let results = <_ as UtxoStore>::query_utxos(wallet.store(), &[token_filter, fee_filter]).await?; + + let token_entry = match &results[0] { + UtxoQueryResult::Found(entries, _) => &entries[0], + UtxoQueryResult::InsufficientValue(entries, _) if !entries.is_empty() => &entries[0], + _ => return Err(Error::Config(format!("No reissuance token UTXO found for {token_id}"))), + }; + + let token_secrets = token_entry + .secrets() + .ok_or_else(|| Error::Config("Reissuance token must be confidential".to_string()))?; + + let fee_entry = match &results[1] { + UtxoQueryResult::Found(entries, _) => &entries[0], + UtxoQueryResult::InsufficientValue(entries, _) => { + let available: u64 = entries.iter().filter_map(coin_store::UtxoEntry::value).sum(); + eprintln!( + "Insufficient LBTC for fee: have {available} sats, need {fee} sats. Try using 'merge' command first." + ); + return Err(Error::Config(format!("No LBTC UTXO found to pay fee of {fee} sats"))); + } + UtxoQueryResult::Empty => { + return Err(Error::Config(format!("No LBTC UTXO found to pay fee of {fee} sats"))); + } + }; + + let token_utxo = (*token_entry.outpoint(), token_entry.txout().clone()); + let fee_utxo = (*fee_entry.outpoint(), fee_entry.txout().clone()); + + let blinding_keypair = Keypair::new(secp256k1::SECP256K1, &mut secp256k1::rand::thread_rng()); + + let pst = contracts::sdk::reissue_asset( + &blinding_keypair.public_key(), + token_utxo.clone(), + *token_secrets, + fee_utxo.clone(), + *amount, + *fee, + entropy, + )?; + + let mut tx = pst.extract_tx()?; + let utxos = vec![token_utxo.1, fee_utxo.1]; + + for i in 0..2 { + let signature = + wallet + .signer() + .sign_p2pk(&tx, &utxos, i, config.address_params(), *LIQUID_TESTNET_GENESIS)?; + + tx = finalize_p2pk_transaction( + tx, + &utxos, + &wallet.signer().public_key(), + &signature, + i, + config.address_params(), + *LIQUID_TESTNET_GENESIS, + )?; + } + + println!("Reissuing {amount} units of asset {asset_id}"); + + match broadcast { + false => { + println!("{}", tx.serialize().to_lower_hex_string()); + } + true => { + cli_helper::explorer::broadcast_tx(&tx).await?; + println!("Broadcasted: {}", tx.txid()); + + let mut blinder_keys = HashMap::new(); + blinder_keys.insert(0, blinding_keypair); + wallet.store().insert_transaction(&tx, blinder_keys).await?; + } + } + } + } + + Ok(()) + } +} diff --git a/crates/cli-client/src/cli/commands.rs b/crates/cli-client/src/cli/commands.rs index 50f976f..70ffbb3 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, AssetId, 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 @@ -88,16 +88,26 @@ pub enum HelperCommand { #[arg(long, short = 'b')] blinding_key: Option, }, + + /// Mark a specific output as spent + Spend { + /// Outpoint to mark as spent (txid:vout) + #[arg(long, short = 'o')] + outpoint: OutPoint, + }, } #[derive(Debug, Subcommand)] pub enum BasicCommand { - /// Transfer LBTC to a recipient - TransferNative { + /// Transfer an asset to a recipient + Transfer { + /// Asset ID (defaults to native LBTC if not specified) + #[arg(long)] + asset_id: Option, /// Recipient address #[arg(long)] - to: String, - /// Amount to send in satoshis + to: Address, + /// Amount to send #[arg(long)] amount: u64, /// Fee amount in satoshis @@ -121,17 +131,14 @@ pub enum BasicCommand { broadcast: bool, }, - /// Transfer an asset to a recipient - TransferAsset { - /// Asset ID (hex) - #[arg(long)] - asset: String, - /// Recipient address + /// Merge multiple UTXOs of the same asset into one + Merge { + /// Asset ID to merge (defaults to native LBTC if not specified) #[arg(long)] - to: String, - /// Amount to send + asset_id: Option, + /// Number of UTXOs to merge #[arg(long)] - amount: u64, + count: usize, /// Fee amount in satoshis #[arg(long)] fee: u64, @@ -142,9 +149,6 @@ pub enum BasicCommand { /// Issue a new asset IssueAsset { - /// Asset name (local reference) - #[arg(long)] - name: String, /// Amount to issue #[arg(long)] amount: u64, @@ -156,11 +160,11 @@ pub enum BasicCommand { broadcast: bool, }, - /// Reissue an existing asset + /// Reissue an existing asset using reissuance token ReissueAsset { - /// Asset name (local reference) + /// Asset ID to reissue #[arg(long)] - name: String, + asset_id: AssetId, /// Amount to reissue #[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 new file mode 100644 index 0000000..4b7e19c --- /dev/null +++ b/crates/cli-client/src/cli/helper.rs @@ -0,0 +1,119 @@ +use crate::cli::{Cli, HelperCommand}; +use crate::config::Config; +use crate::error::Error; +use crate::wallet::Wallet; + +use coin_store::UtxoStore; +use simplicityhl::elements::bitcoin::secp256k1; + +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::UtxoFilter::new() + .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()); + 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::UtxoQueryResult::Found(entries, _)) = results.into_iter().next() { + for entry in entries { + let (Some(asset), Some(value)) = (entry.asset(), entry.value()) else { + continue; + }; + + *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::UtxoFilter::new(); + let results = wallet.store().query_utxos(&[filter]).await?; + + if let Some(coin_store::UtxoQueryResult::Found(entries, _)) = results.into_iter().next() { + for entry in &entries { + let (Some(asset), Some(value)) = (entry.asset(), entry.value()) else { + println!( + "{:<76} | {:<64} | {:<25}", + entry.outpoint(), + "Confidential", + "Confidential" + ); + + continue; + }; + + println!("{:<76} | {:<64} | {:<25}", entry.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; 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()))?; + Some(bytes) + } + None => None, + }; + + wallet.store().insert(*outpoint, txout, blinder).await?; + + println!("Imported {outpoint}"); + + Ok(()) + } + HelperCommand::Spend { outpoint } => { + let wallet = self.get_wallet(&config).await?; + + wallet.store().mark_as_spent(*outpoint).await?; + + println!("Marked {outpoint} as spent"); + + Ok(()) + } + } + } +} 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 37d8544..ca20501 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,10 @@ use clap::Parser; use crate::config::{Config, default_config_path}; use crate::error::Error; -use crate::wallet::Wallet; +pub use commands::{BasicCommand, Command, HelperCommand}; +use signer::Signer; -pub use commands::{Command, HelperCommand, MakerCommand, TakerCommand}; +use crate::wallet::Wallet; #[derive(Debug, Parser)] #[command(name = "simplicity-dex")] @@ -30,26 +33,37 @@ 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() .ok_or_else(|| Error::Config("Seed is required. Use --seed or SIMPLICITY_DEX_SEED".to_string()))?; - let bytes = hex::decode(seed_hex).map_err(|e| Error::Config(format!("Invalid seed hex: {e}")))?; + let bytes = hex::decode(seed_hex)?; + + 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 { + let seed = self.parse_seed()?; + let db_path = config.database_path(); - bytes - .try_into() - .map_err(|_| Error::Config("Seed must be exactly 32 bytes (64 hex chars)".to_string())) + 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::Maker { command: _ } => todo!(), - Command::Taker { command: _ } => todo!(), + Command::Basic { command } => self.run_basic(config, command).await, + Command::Maker { .. } => todo!(), + Command::Taker { .. } => todo!(), Command::Helper { command } => self.run_helper(config, command).await, Command::Config => { println!("{config:#?}"); @@ -57,113 +71,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/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/config.rs b/crates/cli-client/src/config.rs index 8352536..e075a26 100644 --- a/crates/cli-client/src/config.rs +++ b/crates/cli-client/src/config.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use std::path::{Path, PathBuf}; use std::time::Duration; diff --git a/crates/cli-client/src/error.rs b/crates/cli-client/src/error.rs index 5f54a4e..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}")] @@ -17,4 +19,19 @@ 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), + + #[error("Hex to array error: {0}")] + HexToArray(#[from] HexToArrayError), } diff --git a/crates/cli-client/src/lib.rs b/crates/cli-client/src/lib.rs deleted file mode 100644 index 4014f60..0000000 --- a/crates/cli-client/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -#![warn(clippy::all, clippy::pedantic)] -#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)] - -pub mod cli; -pub mod config; -pub mod error; -pub mod wallet; diff --git a/crates/cli-client/src/bin/main.rs b/crates/cli-client/src/main.rs similarity index 62% rename from crates/cli-client/src/bin/main.rs rename to crates/cli-client/src/main.rs index b7f6da6..5aeb33d 100644 --- a/crates/cli-client/src/bin/main.rs +++ b/crates/cli-client/src/main.rs @@ -1,14 +1,19 @@ #![warn(clippy::all, clippy::pedantic)] +mod cli; +mod config; +mod error; +mod wallet; + +use crate::cli::Cli; + use clap::Parser; -use cli_client::cli::Cli; #[tokio::main] async fn main() -> anyhow::Result<()> { let _ = dotenvy::dotenv(); - let cli = Cli::parse(); - cli.run().await?; + Cli::parse().run().await?; Ok(()) } diff --git a/crates/cli-client/src/wallet.rs b/crates/cli-client/src/wallet.rs index c32802e..32734f3 100644 --- a/crates/cli-client/src/wallet.rs +++ b/crates/cli-client/src/wallet.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use std::path::Path; use coin_store::Store; @@ -14,7 +16,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 +27,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..1782b31 100644 --- a/crates/coin-store/migrations/001_initial.sql +++ b/crates/coin-store/migrations/001_initial.sql @@ -1,24 +1,60 @@ -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_sources +( + source_hash BLOB NOT NULL, + source BLOB NOT NULL, + + PRIMARY KEY (source_hash) ); -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 TABLE simplicity_contracts +( + script_pubkey BLOB NOT NULL, + taproot_pubkey_gen BLOB NOT NULL, + cmr BLOB NOT NULL, + source_hash BLOB NOT NULL, + arguments BLOB, + + PRIMARY KEY (taproot_pubkey_gen), + FOREIGN KEY (source_hash) REFERENCES simplicity_sources (source_hash) +); + +CREATE TABLE asset_entropy +( + asset_id BLOB NOT NULL, + issuance_is_confidential INTEGER NOT NULL, + entropy BLOB NOT NULL, + + PRIMARY KEY (asset_id) +); + +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 480129f..586062b 100644 --- a/crates/coin-store/src/entry.rs +++ b/crates/coin-store/src/entry.rs @@ -1,43 +1,209 @@ -use simplicityhl::elements::{OutPoint, TxOut, TxOutSecrets}; - -pub enum UtxoEntry { - Confidential { - outpoint: OutPoint, - txout: TxOut, - secrets: TxOutSecrets, - }, - Explicit { - outpoint: OutPoint, - txout: TxOut, - }, +use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::sync::Arc; + +use sha2::{Digest, Sha256}; +use simplicityhl::elements::hashes::sha256; +use simplicityhl::elements::issuance::AssetId as IssuanceAssetId; +use simplicityhl::elements::{AssetId, 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 struct UtxoEntry { + outpoint: OutPoint, + txout: TxOut, + secrets: Option, + contract: Option>, + entropy: Option, + is_confidential: 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, + entropy: None, + is_confidential: 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, + entropy: None, + is_confidential: None, } } + #[must_use] + pub fn with_contract(mut self, contract: Arc) -> Self { + self.contract = Some(contract); + self + } + + #[must_use] + pub const fn with_issuance(mut self, entropy: sha256::Midstate, is_confidential: bool) -> Self { + self.entropy = Some(entropy); + self.is_confidential = Some(is_confidential); + self + } + + #[must_use] + pub const fn outpoint(&self) -> &OutPoint { + &self.outpoint + } + + #[must_use] + pub const fn txout(&self) -> &TxOut { + &self.txout + } + + #[must_use] + pub fn asset(&self) -> Option { + self.secrets + .as_ref() + .map(|s| s.asset) + .or_else(|| self.txout.asset.explicit()) + } + + #[must_use] + pub fn value(&self) -> Option { + self.secrets + .as_ref() + .map(|s| s.value) + .or_else(|| self.txout.value.explicit()) + } + #[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() + } + + #[must_use] + pub fn issuance_ids(&self) -> Option<(AssetId, AssetId)> { + let entropy = self.entropy?; + let is_confidential = self.is_confidential?; + + let asset_id = IssuanceAssetId::from_entropy(entropy); + let token_id = IssuanceAssetId::reissuance_token_from_entropy(entropy, is_confidential); + + Some((asset_id, token_id)) + } + + #[must_use] + pub const fn entropy(&self) -> (Option, Option) { + (self.entropy, self.is_confidential) } } -pub enum QueryResult { - Found(Vec), - InsufficientValue(Vec), +#[derive(Debug)] +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..ac7ae0a 100644 --- a/crates/coin-store/src/error.rs +++ b/crates/coin-store/src/error.rs @@ -1,12 +1,12 @@ -use std::path::PathBuf; - +use simplicityhl::elements::hashes::FromSliceError; 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 +23,39 @@ 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), + + #[error("Hash slice error: {0}")] + HashSlice(#[from] FromSliceError), + + #[error("Invalid asset ID")] + InvalidAssetId, } diff --git a/crates/coin-store/src/executor.rs b/crates/coin-store/src/executor.rs new file mode 100644 index 0000000..7b9163e --- /dev/null +++ b/crates/coin-store/src/executor.rs @@ -0,0 +1,899 @@ +use std::collections::HashMap; +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, sha256}; +use simplicityhl::elements::hex::ToHex; +use simplicityhl::elements::issuance::{AssetId as IssuanceAssetId, ContractHash}; +use simplicityhl::elements::secp256k1_zkp::{self as secp256k1, Keypair, SecretKey, ZERO_TWEAK}; +use simplicityhl::elements::{AssetId, OutPoint, Transaction, TxOut, TxOutWitness, Txid}; +use simplicityhl::{Arguments, CompiledProgram}; + +use sqlx::{QueryBuilder, Sqlite}; + +#[async_trait::async_trait] +pub trait UtxoStore { + type Error: std::error::Error; + + async fn insert( + &self, + outpoint: OutPoint, + txout: TxOut, + blinder_key: Option<[u8; crate::store::BLINDING_KEY_LEN]>, + ) -> Result<(), Self::Error>; + + async fn mark_as_spent(&self, prev_outpoint: OutPoint) -> Result<(), Self::Error>; + + async fn query_utxos(&self, filters: &[UtxoFilter]) -> Result, Self::Error>; + + async fn add_contract( + &self, + source: &str, + arguments: Arguments, + taproot_pubkey_gen: TaprootPubkeyGen, + ) -> Result<(), Self::Error>; + + /// Process a transaction by inserting its outputs and marking inputs as spent. + /// + /// # Arguments + /// * `tx` - The transaction to process + /// * `out_blinder_keys` - Map from output index to keypair for unblinding. + /// Outputs not in the map are attempted as explicit; unblind failures are skipped. + /// + /// Also inserts asset entropy entries for any inputs with new issuances. + async fn insert_transaction( + &self, + tx: &Transaction, + out_blinder_keys: HashMap, + ) -> Result<(), Self::Error>; +} + +#[async_trait::async_trait] +impl UtxoStore for Store { + type Error = StoreError; + + async fn insert( + &self, + outpoint: OutPoint, + txout: TxOut, + blinder_key: Option<[u8; crate::store::BLINDING_KEY_LEN]>, + ) -> Result<(), Self::Error> { + 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<(), Self::Error> { + 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, Self::Error> { + 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<(), Self::Error> { + 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())?; + + let source_hash = sha256::Hash::hash(source.as_bytes()); + let source_hash_bytes: &[u8] = source_hash.as_ref(); + + sqlx::query("INSERT OR IGNORE INTO simplicity_sources (source_hash, source) VALUES (?, ?)") + .bind(source_hash_bytes) + .bind(source.as_bytes()) + .execute(&self.pool) + .await?; + + sqlx::query( + "INSERT INTO simplicity_contracts (script_pubkey, taproot_pubkey_gen, cmr, source_hash, arguments) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(script_pubkey.as_bytes()) + .bind(taproot_gen_str) + .bind(cmr.as_ref()) + .bind(source_hash_bytes) + .bind(arguments_bytes) + .execute(&self.pool) + .await?; + + Ok(()) + } + + async fn insert_transaction( + &self, + tx: &Transaction, + out_blinder_keys: HashMap, + ) -> Result<(), Self::Error> { + let txid = tx.txid(); + let mut db_tx = self.pool.begin().await?; + + for input in &tx.input { + let prev_txid: &[u8] = input.previous_output.txid.as_ref(); + let prev_vout = i64::from(input.previous_output.vout); + + sqlx::query("UPDATE utxos SET is_spent = 1 WHERE txid = ? AND vout = ?") + .bind(prev_txid) + .bind(prev_vout) + .execute(&mut *db_tx) + .await?; + + if input.has_issuance() && input.asset_issuance.asset_blinding_nonce == ZERO_TWEAK { + let contract_hash = ContractHash::from_byte_array(input.asset_issuance.asset_entropy); + let entropy = IssuanceAssetId::generate_asset_entropy(input.previous_output, contract_hash); + let asset_id = IssuanceAssetId::from_entropy(entropy); + let is_confidential = input.asset_issuance.amount.is_confidential(); + + sqlx::query( + "INSERT OR IGNORE INTO asset_entropy (asset_id, issuance_is_confidential, entropy) VALUES (?, ?, ?)", + ) + .bind(asset_id.to_hex()) + .bind(is_confidential) + .bind(entropy.as_ref()) + .execute(&mut *db_tx) + .await?; + } + } + + for (vout, txout) in tx.output.iter().enumerate() { + if txout.is_fee() { + continue; + } + + #[allow(clippy::cast_possible_truncation)] + let outpoint = OutPoint::new(txid, vout as u32); + let blinder_key = out_blinder_keys.get(&vout); + + match blinder_key { + Some(keypair) => { + let key_bytes: [u8; crate::store::BLINDING_KEY_LEN] = keypair.secret_key().secret_bytes(); + + self.internal_utxo_insert_with_tx(&mut db_tx, outpoint, txout.clone(), Some(key_bytes)) + .await?; + } + None => { + if let Err(e) = self + .internal_utxo_insert_with_tx(&mut db_tx, outpoint, txout.clone(), None) + .await + { + match e { + StoreError::MissingBlinderKey(_) | StoreError::Unblind(_) => { + // Skip this output - blinding key was optional + } + _ => return Err(e), + } + } + } + } + } + + db_tx.commit().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> { + self.internal_utxo_insert_with_tx(&mut tx, outpoint, txout, blinder_key) + .await?; + + tx.commit().await?; + + Ok(()) + } + + async fn internal_utxo_insert_with_tx( + &self, + tx: &mut 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.to_hex()) + .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?; + } + + 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 = QueryBuilder::new( + "SELECT u.txid, u.vout, u.serialized, u.serialized_witness, u.is_confidential, u.value, b.blinding_key", + ); + + if needs_contract_join { + builder.push(", s.source, c.arguments"); + } else { + builder.push(", NULL as source, NULL as arguments"); + } + + if filter.include_entropy { + builder.push(", ae.entropy, ae.issuance_is_confidential"); + } else { + builder.push(", NULL as entropy, NULL as issuance_is_confidential"); + } + + builder.push( + " FROM utxos u + LEFT JOIN blinder_keys b ON u.txid = b.txid AND u.vout = b.vout", + ); + + if needs_contract_join { + builder.push(" INNER JOIN simplicity_contracts c ON u.script_pubkey = c.script_pubkey"); + builder.push(" INNER JOIN simplicity_sources s ON c.source_hash = s.source_hash"); + } + + if filter.is_entropy_join() { + builder.push(" LEFT JOIN asset_entropy ae ON u.asset_id = ae.asset_id"); + } + + builder.push(" 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.to_hex()); + } + + 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_hash) = filter.source_hash { + builder.push(" AND c.source_hash = "); + builder.push_bind(source_hash.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>, + pub entropy: Option>, + pub issuance_is_confidential: Option, +} + +impl UtxoRow { + fn into_entry(self, context: &ContractContext) -> Result { + let contract = context.get_program_from_row(&self)?; + + let entropy: Option = self + .entropy + .as_ref() + .map(|e| sha256::Midstate::from_slice(e)) + .transpose()?; + + let issuance_is_confidential: Option = self.issuance_is_confidential.map(|v| v != 0); + + 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 mut entry = UtxoEntry::new_explicit(outpoint, txout); + + if let Some(c) = contract { + entry = entry.with_contract(Arc::clone(c)); + } + if let Some((e, c)) = entropy.zip(issuance_is_confidential) { + entry = entry.with_issuance(e, c); + } + + return Ok(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 mut entry = UtxoEntry::new_confidential(outpoint, txout, secrets); + + if let Some(c) = contract { + entry = entry.with_contract(Arc::clone(c)); + } + if let Some((e, c)) = entropy.zip(issuance_is_confidential) { + entry = entry.with_issuance(e, c); + } + + Ok(entry) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::fs; + + use contracts::bytes32_tr_storage::{ + BYTES32_TR_STORAGE_SOURCE, get_bytes32_tr_compiled_program, taproot_spend_info, unspendable_internal_key, + }; + use contracts::sdk::taproot_pubkey_gen::TaprootPubkeyGen; + use simplicityhl::elements::confidential::{Asset, Nonce, Value}; + use simplicityhl::elements::{AddressParams, AssetId, Script, TxOutWitness}; + use simplicityhl::simplicity::bitcoin::PublicKey; + use simplicityhl::simplicity::bitcoin::key::Parity; + + 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() + } + + fn make_test_taproot_pubkey_gen(state: [u8; 32]) -> TaprootPubkeyGen { + let program = get_bytes32_tr_compiled_program(); + let cmr = program.commit().cmr(); + let spend_info = taproot_spend_info(unspendable_internal_key(), state, cmr); + + let address = simplicityhl::elements::Address::p2tr( + secp256k1::SECP256K1, + spend_info.internal_key(), + spend_info.merkle_root(), + None, + &AddressParams::LIQUID_TESTNET, + ); + + let seed = vec![42u8; 32]; + let xonly = spend_info.internal_key(); + let pubkey = PublicKey::from(xonly.public_key(Parity::Even)); + + TaprootPubkeyGen { seed, pubkey, address } + } + + #[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); + } + + #[tokio::test] + async fn test_add_contract() { + let path = "/tmp/test_coin_store_add_contract.db"; + let _ = fs::remove_file(path); + + let store = Store::create(path).await.unwrap(); + + let tpg1 = make_test_taproot_pubkey_gen([0u8; 32]); + let tpg2 = make_test_taproot_pubkey_gen([1u8; 32]); + let arguments = simplicityhl::Arguments::default(); + + let result = store + .add_contract(BYTES32_TR_STORAGE_SOURCE, arguments.clone(), tpg1) + .await; + assert!(result.is_ok()); + + let result = store.add_contract(BYTES32_TR_STORAGE_SOURCE, arguments, tpg2).await; + assert!(result.is_ok()); + + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn test_query_by_cmr() { + let path = "/tmp/test_coin_store_query_cmr.db"; + let _ = fs::remove_file(path); + + let store = Store::create(path).await.unwrap(); + + let tpg = make_test_taproot_pubkey_gen([0u8; 32]); + let arguments = simplicityhl::Arguments::default(); + let script_pubkey = tpg.address.script_pubkey(); + + store + .add_contract(BYTES32_TR_STORAGE_SOURCE, arguments.clone(), tpg) + .await + .unwrap(); + + let outpoint = OutPoint::new(Txid::from_byte_array([1; Txid::LEN]), 0); + let mut txout = make_explicit_txout(test_asset_id(), 1000); + txout.script_pubkey = script_pubkey; + + store.insert(outpoint, txout, None).await.unwrap(); + + let program = simplicityhl::CompiledProgram::new(BYTES32_TR_STORAGE_SOURCE, arguments, false).unwrap(); + let cmr = program.commit().cmr(); + + let filter = UtxoFilter::new().cmr(cmr); + let results = store.query_utxos(&[filter]).await.unwrap(); + + match &results[0] { + UtxoQueryResult::Found(entries, _) => { + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].value(), Some(1000)); + } + _ => panic!("Expected Found result"), + } + + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn test_query_by_taproot_pubkey_gen() { + let path = "/tmp/test_coin_store_query_tpg.db"; + let _ = fs::remove_file(path); + + let store = Store::create(path).await.unwrap(); + + let tpg = make_test_taproot_pubkey_gen([0u8; 32]); + let arguments = simplicityhl::Arguments::default(); + let script_pubkey = tpg.address.script_pubkey(); + + store + .add_contract(BYTES32_TR_STORAGE_SOURCE, arguments, tpg.clone()) + .await + .unwrap(); + + let outpoint = OutPoint::new(Txid::from_byte_array([2; Txid::LEN]), 0); + let mut txout = make_explicit_txout(test_asset_id(), 2000); + txout.script_pubkey = script_pubkey; + + store.insert(outpoint, txout, None).await.unwrap(); + + let filter = UtxoFilter::new().taproot_pubkey_gen(tpg); + let results = store.query_utxos(&[filter]).await.unwrap(); + + match &results[0] { + UtxoQueryResult::Found(entries, _) => { + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].value(), Some(2000)); + } + _ => panic!("Expected Found result"), + } + + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn test_query_by_source_hash() { + let path = "/tmp/test_coin_store_query_source_hash.db"; + let _ = fs::remove_file(path); + + let store = Store::create(path).await.unwrap(); + + let tpg = make_test_taproot_pubkey_gen([0u8; 32]); + let arguments = simplicityhl::Arguments::default(); + let script_pubkey = tpg.address.script_pubkey(); + + store + .add_contract(BYTES32_TR_STORAGE_SOURCE, arguments, tpg) + .await + .unwrap(); + + let outpoint = OutPoint::new(Txid::from_byte_array([3; Txid::LEN]), 0); + let mut txout = make_explicit_txout(test_asset_id(), 3000); + txout.script_pubkey = script_pubkey; + + store.insert(outpoint, txout, None).await.unwrap(); + + let filter = UtxoFilter::new().source(BYTES32_TR_STORAGE_SOURCE); + let results = store.query_utxos(&[filter]).await.unwrap(); + + match &results[0] { + UtxoQueryResult::Found(entries, _) => { + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].value(), Some(3000)); + } + _ => panic!("Expected Found result"), + } + + let _ = fs::remove_file(path); + } +} diff --git a/crates/coin-store/src/filter.rs b/crates/coin-store/src/filter.rs index ee394a3..d385974 100644 --- a/crates/coin-store/src/filter.rs +++ b/crates/coin-store/src/filter.rs @@ -1,20 +1,34 @@ -use simplicityhl::elements::{AssetId, Script}; +use contracts::sdk::taproot_pubkey_gen::TaprootPubkeyGen; +use simplicityhl::elements::hashes::{Hash, sha256}; +use simplicityhl::{ + elements::{AssetId, Script}, + simplicity::Cmr, +}; #[derive(Clone, Default)] -pub struct Filter { - pub(crate) asset_id: Option, - pub(crate) script_pubkey: Option