diff --git a/Cargo.toml b/Cargo.toml index 32937c6..67715a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,3 +42,10 @@ tokio = { version = "1.48.0", features = ["macros", "test-util", "rt", "rt-multi tracing = { version = "0.1.41" } tracing-appender = { version = "0.2.3" } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } + +lwk_wollet = { version = "0.13.0" } +lwk_signer = { version = "0.13.0" } +lwk_common = { version = "0.13.0" } +lwk_wasm = { version = "0.12.0" } +lwk_test_util = { version = "0.13.0" } +tempfile = { version = "3.23.0" } diff --git a/crates/lwk-utils/Cargo.toml b/crates/lwk-utils/Cargo.toml new file mode 100644 index 0000000..e72a64e --- /dev/null +++ b/crates/lwk-utils/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "lwk-utils" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +readme.workspace = true + +[dependencies] +anyhow = { workspace = true } +config = { workspace = true } +contracts = { workspace = true } +contracts-adapter = { workspace = true } +dotenvy = { workspace = true } +elements = { workspace = true } +global-utils = { workspace = true } +hex = { workspace = true } +nostr = { workspace = true } +proptest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +simplicity-lang = { workspace = true } +simplicityhl = { workspace = true } +simplicityhl-core = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } + +lwk_common = { workspace = true } +lwk_wollet = { workspace = true } +lwk_signer = { workspace = true } + +[dev-dependencies] +lwk_test_util = { workspace = true } +tempfile = { workspace = true } \ No newline at end of file diff --git a/crates/lwk-utils/src/lib.rs b/crates/lwk-utils/src/lib.rs new file mode 100644 index 0000000..cd40856 --- /dev/null +++ b/crates/lwk-utils/src/lib.rs @@ -0,0 +1 @@ +pub mod types; diff --git a/crates/lwk-utils/src/types.rs b/crates/lwk-utils/src/types.rs new file mode 100644 index 0000000..89d3d62 --- /dev/null +++ b/crates/lwk-utils/src/types.rs @@ -0,0 +1,27 @@ +/// A trait that can be used to sign messages and verify signatures. +/// The sdk user can implement this trait to use their own signer. +pub trait SimplicitySigner: Send + Sync { + /// The master xpub encoded as 78 bytes length as defined in bip32 specification. + /// For reference: + fn xpub(&self) -> anyhow::Result>; + + /// The derived xpub encoded as 78 bytes length as defined in bip32 specification. + /// The derivation path is a string represents the shorter notation of the key tree to derive. For example: + /// m/49'/1'/0'/0/0 + /// m/48'/1'/0'/0/0 + /// For reference: + fn derive_xpub(&self, derivation_path: String) -> anyhow::Result>; + + /// Sign an ECDSA message using the private key derived from the given derivation path + fn sign_ecdsa(&self, msg: Vec, derivation_path: String) -> anyhow::Result>; + + /// Sign an ECDSA message using the private key derived from the master key + fn sign_ecdsa_recoverable(&self, msg: Vec) -> anyhow::Result>; + + /// Return the master blinding key for SLIP77: + fn slip77_master_blinding_key(&self) -> anyhow::Result>; + + /// HMAC-SHA256 using the private key derived from the given derivation path + /// This is used to calculate the linking key of lnurl-auth specification: + fn hmac_sha256(&self, msg: Vec, derivation_path: String) -> anyhow::Result>; +} diff --git a/crates/lwk-utils/tests/faucet_contract.rs b/crates/lwk-utils/tests/faucet_contract.rs new file mode 100644 index 0000000..334ea09 --- /dev/null +++ b/crates/lwk-utils/tests/faucet_contract.rs @@ -0,0 +1,234 @@ +use anyhow::anyhow; +use elements::bitcoin::secp256k1; +use elements::hashes::Hash; +use elements::hex::ToHex; +use elements::schnorr::Keypair; +use elements::secp256k1_zkp::rand::thread_rng; +use elements::secp256k1_zkp::{PublicKey, Secp256k1}; +use lwk_common::Signer; +use lwk_wollet::WalletTx; +use lwk_wollet::elements::{Transaction, TxInWitness}; +use serde::Serialize; +use simplicity::elements::confidential::{AssetBlindingFactor, ValueBlindingFactor}; +use simplicity::elements::pset::{Input, Output, PartiallySignedTransaction}; +use simplicity::elements::{AddressParams, AssetId, OutPoint, TxOut, TxOutSecrets}; +use simplicityhl::simplicity::RedeemNode; +use simplicityhl::simplicity::jet::Elements; +use simplicityhl::simplicity::jet::elements::ElementsEnv; +use simplicityhl::str::WitnessName; +use simplicityhl::value::ValueConstructible; +use simplicityhl::{CompiledProgram, Value}; +use simplicityhl_core::{ + RunnerLogLevel, control_block, get_and_verify_env, get_new_asset_entropy, get_p2pk_address, get_p2pk_program, + get_random_seed, run_program, +}; +use std::collections::HashMap; +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub struct TxInfo { + pub outpoint: OutPoint, + pub wallet_tx: WalletTx, +} + +impl TxInfo { + fn obtain_tx_out(&self) -> TxOut { + self.wallet_tx.tx.output[self.outpoint.vout as usize].clone() + } + + #[inline] + pub fn obtain_token_value(&self, asset: &AssetId) -> anyhow::Result { + println!("{:?}, asset: {asset}", self.wallet_tx.balance.get(asset)); + self.wallet_tx + .balance + .get(asset) + .map(|x| *x as u64) + .ok_or_else(|| anyhow::anyhow!("No value in utxo, check it, signed tx values for asset: {asset:?}")) + } +} + +#[expect(clippy::too_many_arguments)] +pub async fn issue_asset( + signer: &Keypair, + blinding_key: PublicKey, + fee_tx_info: TxInfo, + issue_amount: u64, + fee_amount: u64, + address_params: &'static AddressParams, + lbtc_asset: AssetId, + genesis_block_hash: simplicity::elements::BlockHash, +) -> anyhow::Result { + let fee_utxo_tx_out = fee_tx_info.obtain_tx_out(); + println!("fee_tx_out: {:?}", fee_utxo_tx_out); + let total_input_lbtc_value = fee_tx_info.obtain_token_value(&lbtc_asset)?; + + if fee_amount > total_input_lbtc_value { + return Err(anyhow!( + "fee exceeds fee input value, fee_input: {fee_amount}, total_input_fee: {total_input_lbtc_value}" + )); + } + + let asset_entropy = get_random_seed(); + let asset_entropy_to_return = get_new_asset_entropy(&fee_tx_info.outpoint, asset_entropy).to_hex(); + + let mut issuance_tx = Input::from_prevout(fee_tx_info.outpoint); + issuance_tx.witness_utxo = Some(fee_utxo_tx_out.clone()); + issuance_tx.issuance_value_amount = Some(issue_amount); + issuance_tx.issuance_inflation_keys = Some(1); + issuance_tx.issuance_asset_entropy = Some(asset_entropy); + + let (asset_id, reissuance_asset_id) = issuance_tx.issuance_ids(); + + let change_recipient = get_p2pk_address(&signer.x_only_public_key().0, address_params)?; + + let mut inp_txout_sec = std::collections::HashMap::new(); + let mut pst = PartiallySignedTransaction::new_v2(); + + // Issuance token input + { + let issuance_secrets = TxOutSecrets { + asset_bf: AssetBlindingFactor::zero(), + value_bf: ValueBlindingFactor::zero(), + value: total_input_lbtc_value, + asset: lbtc_asset, + }; + + issuance_tx.blinded_issuance = Some(0x00); + pst.add_input(issuance_tx); + + inp_txout_sec.insert(0, issuance_secrets); + } + + // Passing Reissuance token to new tx_out + { + let mut output = Output::new_explicit( + change_recipient.script_pubkey(), + 1, + reissuance_asset_id, + Some(blinding_key.into()), + ); + output.blinder_index = Some(0); + pst.add_output(output); + } + + // Defining the amount of token issuance + pst.add_output(Output::new_explicit( + change_recipient.script_pubkey(), + issue_amount, + asset_id, + None, + )); + + // Change + pst.add_output(Output::new_explicit( + change_recipient.script_pubkey(), + total_input_lbtc_value - fee_amount, + lbtc_asset, + None, + )); + + // Fee + pst.add_output(Output::from_txout(TxOut::new_fee(fee_amount, lbtc_asset))); + + pst.blind_last(&mut thread_rng(), &Secp256k1::new(), &inp_txout_sec)?; + + let tx = finalize_p2pk_transaction( + pst.extract_tx()?, + std::slice::from_ref(&fee_utxo_tx_out.clone()), + signer, + 0, + address_params, + genesis_block_hash, + )?; + + tx.verify_tx_amt_proofs(secp256k1::SECP256K1, &[fee_utxo_tx_out])?; + Ok(pst) +} + +fn get_x_only_pubkey_from_signer(signer: &impl Signer) -> anyhow::Result { + Ok(signer + .xpub() + .map_err(|err| anyhow::anyhow!("xpub forming error, err: {err:?}"))? + .public_key) +} + +pub fn finalize_p2pk_transaction( + mut tx: Transaction, + utxos: &[TxOut], + signer: &Keypair, + input_index: usize, + params: &'static AddressParams, + genesis_hash: lwk_wollet::elements::BlockHash, +) -> anyhow::Result { + let x_only_public_key = signer.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 pruned = execute_p2pk_program(&p2pk_program, signer, &env, RunnerLogLevel::None)?; + + let (simplicity_program_bytes, simplicity_witness_bytes) = pruned.to_vec_with_witness(); + let cmr = pruned.cmr(); + + tx.input[input_index].witness = TxInWitness { + amount_rangeproof: None, + inflation_keys_rangeproof: None, + script_witness: vec![ + simplicity_witness_bytes, + simplicity_program_bytes, + cmr.as_ref().to_vec(), + control_block(cmr, x_only_public_key).serialize(), + ], + pegin_witness: vec![], + }; + + Ok(tx) +} + +pub fn execute_p2pk_program( + compiled_program: &CompiledProgram, + keypair: &Keypair, + env: &ElementsEnv>, + runner_log_level: RunnerLogLevel, +) -> anyhow::Result>> { + let sighash_all = secp256k1::Message::from_digest(env.c_tx_env().sighash_all().to_byte_array()); + + let witness_values = simplicityhl::WitnessValues::from(HashMap::from([( + WitnessName::from_str_unchecked("SIGNATURE"), + Value::byte_array(keypair.sign_schnorr(sighash_all).serialize()), + )])); + + Ok(run_program(compiled_program, witness_values, env, runner_log_level)?.0) +} + +// pub fn fetch_utxo(outpoint: OutPoint) -> anyhow::Result { +// // Check file cache first +// let txid_str = outpoint.txid.to_string(); +// let cache_path = cache_path_for_txid(&txid_str)?; +// if cache_path.exists() { +// let cached_hex = fs::read_to_string(&cache_path)?; +// return extract_utxo(&cached_hex, outpoint.vout as usize); +// } +// +// let url = format!( +// "https://blockstream.info/liquidtestnet/api/tx/{}/hex", +// outpoint.txid +// ); +// +// let client = Client::builder().timeout(Duration::from_secs(10)).build()?; +// +// let tx_hex = client.get(&url).send()?.error_for_status()?.text()?; +// // Persist to cache best-effort +// if let Err(_e) = fs::write(&cache_path, &tx_hex) { +// // Ignore cache write errors +// } +// extract_utxo(&tx_hex, outpoint.vout as usize) +// } diff --git a/crates/lwk-utils/tests/testing_faucet.rs b/crates/lwk-utils/tests/testing_faucet.rs new file mode 100644 index 0000000..6128d8e --- /dev/null +++ b/crates/lwk-utils/tests/testing_faucet.rs @@ -0,0 +1,236 @@ +mod faucet_contract; +mod utils; + +use crate::faucet_contract::{TxInfo, issue_asset}; +use crate::utils::{ + TEST_LOGGER, TestWollet, generate_signer, get_descriptor, test_client_electrum, test_client_esplora, + wait_update_with_txs, +}; +use elements::bitcoin::bip32::DerivationPath; +use lwk_signer::SwSigner; +use lwk_test_util::{TestEnvBuilder, generate_view_key, regtest_policy_asset}; +use lwk_wollet::asyncr::EsploraClient; +use lwk_wollet::blocking::BlockchainBackend; +use lwk_wollet::{ElementsNetwork, NoPersist, Wollet, WolletBuilder, WolletDescriptor}; +use nostr::secp256k1::Secp256k1; +use simplicity::bitcoin::secp256k1::Keypair; +use simplicityhl::elements::{AddressParams, TxOut}; +use simplicityhl_core::{LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, derive_public_blinder_key}; +use std::str::FromStr; + +const DEFAULT_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +#[tokio::test] +async fn test_issue_custom() -> anyhow::Result<()> { + let _ = dotenvy::dotenv(); + let _guard = &*TEST_LOGGER; + let network = ElementsNetwork::LiquidTestnet; + + let sw_signer = SwSigner::new(DEFAULT_MNEMONIC, false)?; + let mut sw_wallet = Wollet::new(network, NoPersist::new(), get_descriptor(&sw_signer).unwrap())?; + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &sw_signer.derive_xprv(&DerivationPath::master())?.private_key); + + let mut esplora_client = { + // let url = match &self.inner { + // lwk_wollet::ElementsNetwork::Liquid => "https://blockstream.info/liquid/api", + // lwk_wollet::ElementsNetwork::LiquidTestnet => { + // "https://blockstream.info/liquidtestnet/api" + // } + // lwk_wollet::ElementsNetwork::ElementsRegtest { policy_asset: _ } => "127.0.0.1:3000", + // }; + EsploraClient::new( + ElementsNetwork::LiquidTestnet, + "https://blockstream.info/liquidtestnet/api/", + ) + // EsploraClient::new(ElementsNetwork::LiquidTestnet, "https://liquid.network/api/") + }; + if let Some(update) = esplora_client.full_scan_to_index(&sw_wallet, 0).await? { + sw_wallet.apply_update(update)?; + } + println!("address 0: {:?}", sw_wallet.address(Some(0))); + println!("assets owned: {:?}", sw_wallet.assets_owned()); + println!("decriptor: {:?}", sw_wallet.wollet_descriptor()); + println!("transactions: {:?}", sw_wallet.transactions()); + println!("balance: {:?}", sw_wallet.balance()); + // + // let pset = issue_asset( + // &keypair, + // derive_public_blinder_key().public_key(), + // outpoint, + // 123456, + // 500, + // &AddressParams::LIQUID_TESTNET, + // LIQUID_TESTNET_BITCOIN_ASSET, + // *LIQUID_TESTNET_GENESIS, + // ) + // .await?; + // + // println!("pset: {:#?}", pset); + + Ok(()) +} + +#[test] +fn test_issue_custom2() -> anyhow::Result<()> { + let _ = dotenvy::dotenv().ok(); + let _guard = &*TEST_LOGGER; + + let secp = Secp256k1::new(); + let env = TestEnvBuilder::from_env().with_electrum().build(); + let client = test_client_electrum(&env.electrum_url()); + + let signer = generate_signer(); + let view_key = generate_view_key(); + let desc = format!("ct({},elwpkh({}/*))", view_key, signer.xpub()); + let mut wallet = TestWollet::new(client, &desc); + let keypair = Keypair::from_secret_key(&secp, &signer.derive_xprv(&DerivationPath::master())?.private_key); + + let address = wallet.wollet.address(Some(0))?; + wallet.fund_btc(&env); + // wallet.fund( + // &env, + // 10_000_000, + // Some(address.address().clone()), + // Some(LIQUID_TESTNET_BITCOIN_ASSET), + // ); + let utxos = wallet.wollet.utxos()?; + println!("Utxos: {:?}", utxos); + let asset_owned = wallet.wollet.assets_owned()?; + println!("asset_owned: {:?}", asset_owned); + let external_utxos = wallet.wollet.explicit_utxos()?; + println!("external_utxos: {:?}", external_utxos); + + // let mut pset = issue_asset( + // &keypair, + // derive_public_blinder_key().public_key(), + // utxos[0].outpoint, + // 123456, + // 500, + // &AddressParams::LIQUID_TESTNET, + // LIQUID_TESTNET_BITCOIN_ASSET, + // *LIQUID_TESTNET_GENESIS, + // ) + // .await?; + + // let mut pset = tokio::runtime::Runtime::new()?.block_on(async { + // issue_asset( + // &keypair, + // derive_public_blinder_key().public_key(), + // utxos[0].outpoint, + // 123456, + // 500, + // &AddressParams::LIQUID_TESTNET, + // LIQUID_TESTNET_BITCOIN_ASSET, + // *LIQUID_TESTNET_GENESIS, + // ) + // .await + // })?; + + // let tx_to_send = wallet.wollet.finalize(&mut pset)?; + // wallet.client.broadcast(&tx_to_send)?; + + wallet.sync(); + + let utxos = wallet.wollet.utxos()?; + tracing::info!("Utxos after: {:?}", utxos); + + Ok(()) +} + +#[tokio::test] +async fn async_test_issue_custom2() -> anyhow::Result<()> { + let _ = dotenvy::dotenv().ok(); + let _guard = &*TEST_LOGGER; + + let secp = Secp256k1::new(); + let env = TestEnvBuilder::from_env().with_esplora().build(); + let mut client = test_client_esplora(&env.esplora_url()); + + let signer = generate_signer(); + let view_key = generate_view_key(); + let regtest_bitcoin_asset = regtest_policy_asset(); + + let descriptor = format!("ct({},elwpkh({}/*))", view_key, signer.xpub()); + let network = ElementsNetwork::default_regtest(); + let descriptor: WolletDescriptor = descriptor.parse()?; + let mut wollet = WolletBuilder::new(network, descriptor).build()?; + let keypair = Keypair::from_secret_key(&secp, &signer.derive_xprv(&DerivationPath::master())?.private_key); + + let update = client.full_scan(&wollet).await?.unwrap(); + wollet.apply_update(update).unwrap(); + + let address = wollet.address(None)?; + let txid = env.elementsd_sendtoaddress(address.address(), 1_000_011, None); + + let update = wait_update_with_txs(&mut client, &wollet).await; + wollet.apply_update(update)?; + let tx = wollet.transaction(&txid)?.unwrap(); + assert!(tx.height.is_none()); + assert!(wollet.tip().timestamp().is_some()); + + env.elementsd_generate(10); + let update = wait_update_with_txs(&mut client, &wollet).await; + wollet.apply_update(update)?; + let tx = wollet.transaction(&txid)?.unwrap(); + + assert!(tx.height.is_some()); + assert!(wollet.tip().timestamp().is_some()); + + let utxos = wollet.utxos()?; + println!("Utxos: {:#?}", utxos); + let asset_owned = wollet.assets_owned()?; + println!("asset_owned: {:?}", asset_owned); + let external_utxos = wollet.explicit_utxos()?; + println!("external_utxos: {:?}", external_utxos); + + let outpoint = utxos[0].outpoint; + let wallet_tx = wollet.transaction(&outpoint.txid)?.unwrap(); + println!("wallet_tx: {:?}", wallet_tx); + println!("signed balance: {:#?}", wallet_tx.balance); + // println!("wallet_tx outs: {:?}", wallet_tx.outputs[0].unwrap().outpoint); + + let mut pset = issue_asset( + &keypair, + derive_public_blinder_key().public_key(), + TxInfo { outpoint, wallet_tx }, + 123456, + 500, + &AddressParams::LIQUID_TESTNET, + regtest_bitcoin_asset, + *LIQUID_TESTNET_GENESIS, + ) + .await?; + + let tx_to_send = wollet.finalize(&mut pset)?; + client.broadcast(&tx_to_send).await?; + + env.elementsd_generate(10); + let update = wait_update_with_txs(&mut client, &wollet).await; + wollet.apply_update(update)?; + + let utxos = wollet.utxos()?; + println!("[after] Utxos: {:?}", utxos); + let asset_owned = wollet.assets_owned()?; + println!("[after] asset_owned: {:?}", asset_owned); + let external_utxos = wollet.explicit_utxos()?; + println!("[after] external_utxos: {:?}", external_utxos); + let wallet_tx = wollet.transaction(&utxos[0].outpoint.txid)?; + println!("[after] wallet_tx: {:?}", wallet_tx.unwrap()); + + Ok(()) +} + +#[tokio::test] +async fn get_addr() -> anyhow::Result<()> { + let sw_signer = SwSigner::new(DEFAULT_MNEMONIC, false)?; + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &sw_signer.derive_xprv(&DerivationPath::master())?.private_key); + + let public_key = keypair.x_only_public_key().0; + let address = simplicityhl_core::get_p2pk_address(&public_key, &AddressParams::LIQUID_TESTNET)?; + println!("X Only Public Key: '{public_key}', P2PK Address: '{address}'"); + + Ok(()) +} diff --git a/crates/lwk-utils/tests/utils.rs b/crates/lwk-utils/tests/utils.rs new file mode 100644 index 0000000..5918ba2 --- /dev/null +++ b/crates/lwk-utils/tests/utils.rs @@ -0,0 +1,646 @@ +use clients::blocking::BlockchainBackend; +use global_utils::logger::{LoggerGuard, init_logger}; +use lwk_common::{Signer, Singlesig, singlesig_desc}; +use lwk_signer::SwSigner; +use lwk_signer::*; +use lwk_test_util::generate_mnemonic; +use lwk_test_util::*; +use lwk_wollet::elements_miniscript::{DescriptorPublicKey, ForEachKey}; +use lwk_wollet::*; +use lwk_wollet::{ElectrumClient, ElectrumUrl, WolletDescriptor}; +use simplicityhl::elements::hashes::Hash; +use simplicityhl::elements::pset::PartiallySignedTransaction; +use simplicityhl::elements::{Address, AssetId, ContractHash, OutPoint, Txid}; +use std::str::FromStr; +use std::sync::LazyLock; +use std::thread; +use std::time::Duration; +use tempfile::TempDir; + +pub static TEST_LOGGER: LazyLock = LazyLock::new(init_logger); + +pub fn get_descriptor(signer: &S) -> Result { + let descriptor_str = singlesig_desc(signer, Singlesig::Wpkh, lwk_common::DescriptorBlindingKey::Slip77) + .map_err(|e| anyhow::anyhow!("Invalid descriptor: {e}"))?; + Ok(descriptor_str.parse()?) +} + +pub fn generate_signer() -> SwSigner { + let mnemonic = generate_mnemonic(); + SwSigner::new(&mnemonic, false).unwrap() +} + +pub fn test_client_electrum(url: &str) -> ElectrumClient { + let url = &url.replace("tcp://", ""); + let tls = false; + let validate_domain = false; + let electrum_url = ElectrumUrl::new(url, tls, validate_domain).unwrap(); + ElectrumClient::new(&electrum_url).unwrap() +} + +pub fn test_client_esplora(url: &str) -> lwk_wollet::asyncr::EsploraClient { + let url = &url.replace("tcp://", ""); + lwk_wollet::asyncr::EsploraClient::new(ElementsNetwork::default_regtest(), &url) +} + +fn sync(wollet: &mut Wollet, client: &mut S) { + let update = client.full_scan(wollet).unwrap(); + if let Some(update) = update { + wollet.apply_update(update).unwrap(); + } +} + +/// Used with Esplora +pub async fn wait_update_with_txs(client: &mut clients::asyncr::EsploraClient, wollet: &Wollet) -> Update { + for _ in 0..50 { + let update = client.full_scan(wollet).await.unwrap(); + if let Some(update) = update { + if !update.only_tip() { + return update; + } + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + panic!("update didn't arrive"); +} + +pub fn wait_for_tx(wollet: &mut Wollet, client: &mut S, txid: &Txid) { + for _ in 0..120 { + sync(wollet, client); + let list = wollet.transactions().unwrap(); + if list.iter().any(|e| &e.txid == txid) { + return; + } + thread::sleep(Duration::from_millis(500)); + } + panic!("Wallet does not have {txid} in its list"); +} + +pub struct TestWollet { + pub wollet: Wollet, + pub client: C, + db_root_dir: TempDir, +} +impl TestWollet { + pub fn new(mut client: C, desc: &str) -> Self { + let db_root_dir = TempDir::new().unwrap(); + + let network = ElementsNetwork::default_regtest(); + let descriptor = add_checksum(desc); + + let desc: WolletDescriptor = descriptor.parse().unwrap(); + let mut wollet = Wollet::with_fs_persist(network, desc, &db_root_dir).unwrap(); + + sync(&mut wollet, &mut client); + + let mut i = 120; + let tip = loop { + assert!(i > 0, "1 minute without updates"); + i -= 1; + let height = client.tip().unwrap().height; + if height >= 101 { + break height; + } else { + thread::sleep(Duration::from_millis(500)); + } + }; + sync(&mut wollet, &mut client); + + assert!(tip >= 101); + + Self { + wollet, + db_root_dir, + client, + } + } + + pub fn tx_builder(&self) -> WolletTxBuilder { + self.wollet.tx_builder() + } + + pub fn db_root_dir(self) -> TempDir { + self.db_root_dir + } + + pub fn policy_asset(&self) -> AssetId { + self.wollet.policy_asset() + } + + pub fn tip(&self) -> Tip { + self.wollet.tip() + } + + pub fn sync(&mut self) { + sync(&mut self.wollet, &mut self.client); + } + + pub fn address(&self) -> Address { + self.wollet.address(None).unwrap().address().clone() + } + + pub fn address_result(&self, last_unused: Option) -> AddressResult { + self.wollet.address(last_unused).unwrap() + } + + /// Wait until tx appears in tx list (max 1 min) + fn wait_for_tx(&mut self, txid: &Txid) { + wait_for_tx(&mut self.wollet, &mut self.client, txid); + } + + /// Wait until the wallet has the transaction, although it might not be in the tx list + /// + /// This might be useful for explicit outputs or blinded outputs that cannot be unblinded. + pub fn wait_for_tx_outside_list(&mut self, txid: &Txid) { + for _ in 0..120 { + sync(&mut self.wollet, &mut self.client); + if self.wollet.transaction(txid).unwrap().is_some() { + return; + } + thread::sleep(Duration::from_millis(500)); + } + panic!("Wallet does not have {txid} in its list"); + } + + /// asset balance in satoshi + pub fn balance(&mut self, asset: &AssetId) -> u64 { + let balance = self.wollet.balance().unwrap(); + *balance.get(asset).unwrap_or(&0u64) + } + + pub fn balance_btc(&mut self) -> u64 { + self.balance(&self.wollet.policy_asset()) + } + + pub fn get_tx(&mut self, txid: &Txid) -> WalletTx { + self.wollet.transaction(txid).unwrap().unwrap() + } + + pub fn fund(&mut self, env: &TestEnv, satoshi: u64, address: Option
, asset: Option) { + let utxos_before = self.wollet.utxos().unwrap().len(); + let balance_before = self.balance(&asset.unwrap_or(self.policy_asset())); + + let address = address.unwrap_or_else(|| self.address()); + let txid = env.elementsd_sendtoaddress(&address, satoshi, asset); + self.wait_for_tx(&txid); + let tx = self.get_tx(&txid); + // We only received, all balances are positive + assert!(tx.balance.values().all(|v| *v > 0)); + assert_eq!(&tx.type_, "incoming"); + let wallet_txid = tx.tx.txid(); + assert_eq!(txid, wallet_txid); + assert_eq!(tx.inputs.iter().filter(|o| o.is_some()).count(), 0); + assert_eq!(tx.outputs.iter().filter(|o| o.is_some()).count(), 1); + + let utxos_after = self.wollet.utxos().unwrap().len(); + let balance_after = self.balance(&asset.unwrap_or(self.policy_asset())); + assert_eq!(utxos_after, utxos_before + 1); + assert_eq!(balance_before + satoshi, balance_after); + } + + pub fn fund_btc(&mut self, env: &TestEnv) { + self.fund(env, 1_000_000, Some(self.address()), None); + } + + pub fn fund_asset(&mut self, env: &TestEnv) -> AssetId { + let satoshi = 10_000; + let asset = env.elementsd_issueasset(satoshi); + self.fund(env, satoshi, Some(self.address()), Some(asset)); + asset + } + + pub fn fund_explicit(&mut self, env: &TestEnv, satoshi: u64, address: Option
, asset: Option) { + let explicit_utxos_before = self.wollet.explicit_utxos().unwrap().len(); + + let address = address.unwrap_or_else(|| self.address()).to_unconfidential(); + let txid = env.elementsd_sendtoaddress(&address, satoshi, asset); + self.wait_for_tx_outside_list(&txid); + + let explicit_utxos_after = self.wollet.explicit_utxos().unwrap().len(); + assert_eq!(explicit_utxos_after, explicit_utxos_before + 1); + } + + /// Send 10_000 satoshi to self with default fee rate. + /// + /// To specify a custom fee rate pass Some in `fee_rate` + /// To specify an external recipient specify the `to` parameter + pub fn send_btc(&mut self, signers: &[&AnySigner], fee_rate: Option, external: Option<(Address, u64)>) { + let balance_before = self.balance_btc(); + + let recipient = external.clone().unwrap_or((self.address(), 10_000)); + + let mut pset = self + .tx_builder() + .add_lbtc_recipient(&recipient.0, recipient.1) + .unwrap() + .fee_rate(fee_rate) + .finish() + .unwrap(); + pset = pset_rt(&pset); + + let details = self.wollet.get_details(&pset).unwrap(); + let fee = details.balance.fee as i64; + assert!(fee > 0); + let balance = match &external { + Some((_a, v)) => -fee - *v as i64, + None => -fee, + }; + assert_eq!(*details.balance.balances.get(&self.policy_asset()).unwrap(), balance); + assert_eq!(n_issuances(&details), 0); + assert_eq!(n_reissuances(&details), 0); + + for signer in signers { + self.sign(signer, &mut pset); + } + assert_fee_rate(compute_fee_rate(&pset), fee_rate); + let txid = self.send(&mut pset); + let balance_after = self.balance_btc(); + assert!(balance_before > balance_after); + let tx = self.get_tx(&txid); + // We only sent, so all balances are negative + assert!(tx.balance.values().all(|v| *v < 0)); + assert_eq!(&tx.type_, "outgoing"); + assert_eq!(tx.fee, fee as u64); + assert!(tx.inputs.iter().filter(|o| o.is_some()).count() > 0); + assert!(tx.outputs.iter().filter(|o| o.is_some()).count() > 0); + + self.wollet.descriptor().descriptor.for_each_key(|k| { + if let DescriptorPublicKey::XPub(x) = k { + if let Some(origin) = &x.origin { + assert_eq!(pset.global.xpub.get(&x.xkey).unwrap(), origin); + } + } + true + }); + } + + /// Send all L-BTC + pub fn send_all_btc(&mut self, signers: &[&AnySigner], fee_rate: Option, address: Address) { + let balance_before = self.balance_btc(); + + let mut pset = self + .tx_builder() + .drain_lbtc_wallet() + .drain_lbtc_to(address) + .fee_rate(fee_rate) + .finish() + .unwrap(); + + let details = self.wollet.get_details(&pset).unwrap(); + let fee = details.balance.fee as i64; + assert!(fee > 0); + assert_eq!( + *details.balance.balances.get(&self.policy_asset()).unwrap(), + -(balance_before as i64) + ); + + for signer in signers { + self.sign(signer, &mut pset); + } + self.send(&mut pset); + let balance_after = self.balance_btc(); + assert_eq!(balance_after, 0); + } + + pub fn send_asset( + &mut self, + signers: &[&AnySigner], + address: &Address, + asset: &AssetId, + fee_rate: Option, + ) -> Txid { + let balance_before = self.balance(asset); + let satoshi: u64 = 10; + let mut pset = self + .tx_builder() + .add_recipient(address, satoshi, *asset) + .unwrap() + .fee_rate(fee_rate) + .finish() + .unwrap(); + + pset = pset_rt(&pset); + + let details = self.wollet.get_details(&pset).unwrap(); + let fee = details.balance.fee as i64; + assert!(fee > 0); + assert_eq!(*details.balance.balances.get(&self.policy_asset()).unwrap(), -fee); + assert_eq!(*details.balance.balances.get(asset).unwrap(), -(satoshi as i64)); + assert_eq!(n_issuances(&details), 0); + assert_eq!(n_reissuances(&details), 0); + + for signer in signers { + self.sign(signer, &mut pset); + } + assert_fee_rate(compute_fee_rate(&pset), fee_rate); + let txid = self.send(&mut pset); + let balance_after = self.balance(asset); + assert!(balance_before > balance_after); + txid + } + + pub fn send_many( + &mut self, + signers: &[&AnySigner], + addr1: &Address, + asset1: &AssetId, + addr2: &Address, + asset2: &AssetId, + fee_rate: Option, + ) { + let balance1_before = self.balance(asset1); + let balance2_before = self.balance(asset2); + let addr1 = addr1.to_string(); + let addr2 = addr2.to_string(); + let ass1 = asset1.to_string(); + let ass2 = asset2.to_string(); + let addressees: Vec = vec![ + UnvalidatedRecipient { + satoshi: 1_000, + address: addr1, + asset: ass1, + }, + UnvalidatedRecipient { + satoshi: 2_000, + address: addr2, + asset: ass2, + }, + ]; + + let mut pset = self + .tx_builder() + .set_unvalidated_recipients(&addressees) + .unwrap() + .fee_rate(fee_rate) + .finish() + .unwrap(); + + pset = pset_rt(&pset); + + let details = self.wollet.get_details(&pset).unwrap(); + let fee = details.balance.fee as i64; + assert!(fee > 0); + // Checking the balance here has a bit too many cases: + // asset1,2 are btc, asset1,2 are equal, addr1,2 belong to the wallet + // Skipping the checks here + assert_eq!(n_issuances(&details), 0); + assert_eq!(n_reissuances(&details), 0); + + for signer in signers { + self.sign(signer, &mut pset); + } + assert_fee_rate(compute_fee_rate(&pset), fee_rate); + self.send(&mut pset); + let balance1_after = self.balance(asset1); + let balance2_after = self.balance(asset2); + assert!(balance1_before > balance1_after); + assert!(balance2_before > balance2_after); + } + + pub fn issueasset( + &mut self, + signers: &[&AnySigner], + satoshi_asset: u64, + satoshi_token: u64, + contract: Option<&str>, + fee_rate: Option, + ) -> (AssetId, AssetId) { + let balance_before = self.balance_btc(); + let contract = contract.map(|c| Contract::from_str(c).unwrap()); + let contract_hash = contract + .as_ref() + .map(|c| c.contract_hash().unwrap()) + .unwrap_or_else(|| ContractHash::from_slice(&[0u8; 32]).expect("static")); + let mut pset = self + .tx_builder() + .issue_asset(satoshi_asset, None, satoshi_token, None, contract) + .unwrap() + .fee_rate(fee_rate) + .finish() + .unwrap(); + pset = pset_rt(&pset); + + let issuance_input = &pset.inputs()[0].clone(); + let (asset, token) = issuance_input.issuance_ids(); + + let details = self.wollet.get_details(&pset).unwrap(); + assert_eq!(n_issuances(&details), 1); + assert_eq!(n_reissuances(&details), 0); + let issuance = &details.issuances[0]; + assert_eq!(asset, issuance.asset().unwrap()); + assert_eq!(token, issuance.token().unwrap()); + assert_eq!(satoshi_asset, issuance.asset_satoshi().unwrap_or(0)); + assert_eq!(satoshi_token, issuance.token_satoshi().unwrap()); + let fee = details.balance.fee as i64; + assert!(fee > 0); + assert_eq!(*details.balance.balances.get(&self.policy_asset()).unwrap(), -fee); + assert_eq!( + *details.balance.balances.get(&asset).unwrap_or(&0), + satoshi_asset as i64 + ); + assert_eq!( + *details.balance.balances.get(&token).unwrap_or(&0), + satoshi_token as i64 + ); + + for signer in signers { + self.sign(signer, &mut pset); + } + assert_fee_rate(compute_fee_rate(&pset), fee_rate); + let txid = self.send(&mut pset); + let tx = self.get_tx(&txid); + assert_eq!(&tx.type_, "issuance"); + + assert_eq!(self.balance(&asset), satoshi_asset); + assert_eq!(self.balance(&token), satoshi_token); + let balance_after = self.balance_btc(); + assert!(balance_before > balance_after); + + let issuance = self.wollet.issuance(&asset).unwrap(); + assert_eq!(issuance.vin, 0); + assert!(!issuance.is_reissuance); + assert_eq!(issuance.asset_amount.unwrap_or(0), satoshi_asset); + assert_eq!(issuance.token_amount.unwrap_or(0), satoshi_token); + + let prevout = OutPoint::new(issuance_input.previous_txid, issuance_input.previous_output_index); + assert_eq!(asset, AssetId::new_issuance(prevout, contract_hash)); + + (asset, token) + } + + pub fn reissueasset(&mut self, signers: &[&AnySigner], satoshi_asset: u64, asset: &AssetId, fee_rate: Option) { + let issuance = self.wollet.issuance(asset).unwrap(); + let balance_btc_before = self.balance_btc(); + let balance_asset_before = self.balance(asset); + let balance_token_before = self.balance(&issuance.token); + let mut pset = self + .tx_builder() + .reissue_asset(*asset, satoshi_asset, None, None) + .unwrap() + .fee_rate(fee_rate) + .finish() + .unwrap(); + pset = pset_rt(&pset); + + let details = self.wollet.get_details(&pset).unwrap(); + assert_eq!(n_issuances(&details), 0); + assert_eq!(n_reissuances(&details), 1); + let reissuance = details.issuances.iter().find(|e| e.is_reissuance()).unwrap(); + assert_eq!(asset, &reissuance.asset().unwrap()); + assert_eq!(issuance.token, reissuance.token().unwrap()); + assert_eq!(satoshi_asset, reissuance.asset_satoshi().unwrap()); + assert!(reissuance.token_satoshi().is_none()); + let fee = details.balance.fee as i64; + assert!(fee > 0); + assert_eq!(*details.balance.balances.get(&self.policy_asset()).unwrap(), -fee); + assert_eq!(*details.balance.balances.get(asset).unwrap(), satoshi_asset as i64); + assert!(!details.balance.balances.contains_key(&issuance.token)); + + for signer in signers { + self.sign(signer, &mut pset); + } + assert_fee_rate(compute_fee_rate(&pset), fee_rate); + let txid = self.send(&mut pset); + let tx = self.get_tx(&txid); + assert_eq!(&tx.type_, "reissuance"); + + assert_eq!(self.balance(asset), balance_asset_before + satoshi_asset); + assert_eq!(self.balance(&issuance.token), balance_token_before); + assert!(self.balance_btc() < balance_btc_before); + + let issuances = self.wollet.issuances().unwrap(); + assert!(issuances.len() > 1); + let reissuance = issuances.iter().find(|e| e.txid == txid).unwrap(); + assert!(reissuance.is_reissuance); + assert_eq!(reissuance.asset_amount, Some(satoshi_asset)); + assert!(reissuance.token_amount.is_none()); + } + + pub fn burnasset(&mut self, signers: &[&AnySigner], satoshi_asset: u64, asset: &AssetId, fee_rate: Option) { + let balance_btc_before = self.balance_btc(); + let balance_asset_before = self.balance(asset); + let mut pset = self + .tx_builder() + .add_burn(satoshi_asset, *asset) + .unwrap() + .fee_rate(fee_rate) + .finish() + .unwrap(); + pset = pset_rt(&pset); + + let details = self.wollet.get_details(&pset).unwrap(); + let fee = details.balance.fee as i64; + assert!(fee > 0); + let btc = self.policy_asset(); + let (expected_asset, expected_btc) = if asset == &btc { + (0, -(fee + satoshi_asset as i64)) + } else { + (-(satoshi_asset as i64), -fee) + }; + assert_eq!(*details.balance.balances.get(&btc).unwrap(), expected_btc); + assert_eq!(*details.balance.balances.get(asset).unwrap_or(&0), expected_asset); + assert_eq!(n_issuances(&details), 0); + assert_eq!(n_reissuances(&details), 0); + + for signer in signers { + self.sign(signer, &mut pset); + } + assert_fee_rate(compute_fee_rate(&pset), fee_rate); + let txid = self.send(&mut pset); + let tx = self.get_tx(&txid); + assert_eq!(&tx.type_, "burn"); + + assert_eq!(self.balance(asset), balance_asset_before - satoshi_asset); + assert!(self.balance_btc() < balance_btc_before); + } + + pub fn sign(&self, signer: &S, pset: &mut PartiallySignedTransaction) { + *pset = pset_rt(pset); + let sigs_added_or_overwritten = signer.sign(pset).unwrap(); + assert!(sigs_added_or_overwritten > 0); + } + + pub fn send(&mut self, pset: &mut PartiallySignedTransaction) -> Txid { + *pset = pset_rt(pset); + // TODO: check we that the tx has some signatures + // check_witnesses_non_empty does not cover the pre-segwit case anymore + // let tx_pre_finalize = pset.extract_tx().unwrap(); + // let err = self.client.broadcast(&tx_pre_finalize).unwrap_err(); + // assert!(matches!(err, lwk_wollet::Error::EmptyWitness)); + let tx = self.wollet.finalize(pset).unwrap(); + let txid = self.client.broadcast(&tx).unwrap(); + self.wait_for_tx(&txid); + txid + } + + pub fn send_outside_list(&mut self, pset: &mut PartiallySignedTransaction) -> Txid { + *pset = pset_rt(pset); + let tx = self.wollet.finalize(pset).unwrap(); + let txid = self.client.broadcast(&tx).unwrap(); + self.wait_for_tx_outside_list(&txid); + txid + } + + pub fn check_persistence(wollet: TestWollet) { + let descriptor = wollet.wollet.descriptor().to_string(); + let expected_updates = wollet.wollet.updates().unwrap(); + let expected = wollet.wollet.balance().unwrap(); + let db_root_dir = wollet.db_root_dir(); + let network = ElementsNetwork::default_regtest(); + + for _ in 0..2 { + let wollet = Wollet::with_fs_persist(network, descriptor.parse().unwrap(), &db_root_dir).unwrap(); + + let balance = wollet.balance().unwrap(); + assert_eq!(expected, balance); + assert_eq!(expected_updates, wollet.updates().unwrap()); + } + } + + pub fn wait_height(&mut self, height: u32) { + for _ in 0..120 { + sync(&mut self.wollet, &mut self.client); + if self.wollet.tip().height() == height { + return; + } + thread::sleep(Duration::from_millis(500)); + } + panic!("Wait for height {height} failed"); + } + + pub fn make_external(&mut self, utxo: &lwk_wollet::WalletTxOut) -> lwk_wollet::ExternalUtxo { + let tx = self.get_tx(&utxo.outpoint.txid).tx; + let txout = tx.output.get(utxo.outpoint.vout as usize).unwrap().clone(); + let tx = if self.wollet.is_segwit() { None } else { Some(tx) }; + lwk_wollet::ExternalUtxo { + outpoint: utxo.outpoint, + txout, + tx, + unblinded: utxo.unblinded, + max_weight_to_satisfy: self.wollet.max_weight_to_satisfy(), + } + } + + #[track_caller] + pub fn assert_spent_unspent(&self, spent: usize, unspent: usize) { + let txos = self.wollet.txos().unwrap(); + let spent_count = txos.iter().filter(|txo| txo.is_spent).count(); + let unspent_count = txos.iter().filter(|txo| !txo.is_spent).count(); + assert_eq!(spent_count, spent, "Wrong number of spent outputs"); + assert_eq!(unspent_count, unspent, "Wrong number of unspent outputs"); + assert_eq!(txos.len(), spent + unspent, "Wrong number of outputs"); + let utxos = self.wollet.utxos().unwrap(); + assert_eq!(utxos.len(), unspent, "Wrong number of unspent outputs"); + assert!(utxos.iter().all(|utxo| !utxo.is_spent)); + let txs = self.wollet.transactions().unwrap(); + let tx_outs_from_tx: Vec<_> = txs + .iter() + .flat_map(|tx| tx.outputs.iter()) + .filter_map(|o| o.as_ref()) + .collect(); + let spent_count_txs = tx_outs_from_tx.iter().filter(|o| o.is_spent).count(); + let unspent_count_txs = tx_outs_from_tx.iter().filter(|o| !o.is_spent).count(); + assert_eq!(spent_count_txs, spent); + assert_eq!(unspent_count_txs, unspent); + } +}