diff --git a/Cargo.lock b/Cargo.lock index ca1b491..54fead4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,18 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -188,39 +200,36 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bdk_chain" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "163b064557cee078e8ee5dd2c88944204506f7b2b1524f78e8fcba38c346da7b" +version = "0.21.0" +source = "git+https://github.com/buffrr/bdk.git?rev=43bca8643dec6fdda99e4a29bf88709729af349e#43bca8643dec6fdda99e4a29bf88709729af349e" dependencies = [ + "bdk_core", "bitcoin", "miniscript", + "rusqlite", "serde", ] [[package]] -name = "bdk_file_store" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180f7ef84b5da0e748f7884becb364f4d06d12734ec46b149b9f02600afdf011" +name = "bdk_core" +version = "0.4.0" +source = "git+https://github.com/buffrr/bdk.git?rev=43bca8643dec6fdda99e4a29bf88709729af349e#43bca8643dec6fdda99e4a29bf88709729af349e" dependencies = [ - "bdk_chain", - "bincode 1.3.3", + "bitcoin", + "hashbrown", "serde", ] [[package]] name = "bdk_wallet" -version = "1.0.0-alpha.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2926afdbfc54ebf7df2caa51af5be4435b90c01c6fbe5578b51b7c2c0a264bd9" +version = "1.0.0-beta.6" +source = "git+https://github.com/buffrr/bdk.git?rev=43bca8643dec6fdda99e4a29bf88709729af349e#43bca8643dec6fdda99e4a29bf88709729af349e" dependencies = [ "bdk_chain", "bip39", "bitcoin", - "getrandom", - "js-sys", "miniscript", - "rand", + "rand_core", "serde", "serde_json", ] @@ -240,15 +249,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bincode" version = "2.0.0-rc.3" @@ -705,19 +705,31 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", "windows-sys 0.52.0", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" -version = "2.1.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "filetime" @@ -902,9 +914,22 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.3" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "serde", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] [[package]] name = "heck" @@ -1320,9 +1345,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.153" +version = "0.2.168" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" [[package]] name = "libredox" @@ -1334,6 +1359,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -1653,7 +1689,7 @@ dependencies = [ name = "protocol" version = "0.0.5" dependencies = [ - "bincode 2.0.0-rc.3", + "bincode", "bitcoin", "log", "rand", @@ -1870,6 +1906,20 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.4.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -1890,9 +1940,9 @@ checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" [[package]] name = "rustix" -version = "0.38.34" +version = "0.38.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" dependencies = [ "bitflags 2.4.2", "errno", @@ -2215,7 +2265,7 @@ dependencies = [ "anyhow", "assert_cmd", "base64 0.22.1", - "bincode 2.0.0-rc.3", + "bincode", "clap", "ctrlc", "directories", @@ -2242,7 +2292,7 @@ name = "spacedb" version = "0.0.2" source = "git+https://github.com/spacesprotocol/spacedb?tag=0.0.2#74eec1903552eef475fc73beb66c8bce9b2cbd06" dependencies = [ - "bincode 2.0.0-rc.3", + "bincode", "hex", "libc", "sha2 0.10.6", @@ -2296,12 +2346,13 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.10.1" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" dependencies = [ "cfg-if", "fastrand", + "once_cell", "rustix", "windows-sys 0.52.0", ] @@ -2624,6 +2675,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" @@ -2650,10 +2707,9 @@ name = "wallet" version = "0.0.5" dependencies = [ "anyhow", - "bdk_file_store", "bdk_wallet", "bech32", - "bincode 2.0.0-rc.3", + "bincode", "bitcoin", "ctrlc", "hex", @@ -2663,6 +2719,7 @@ dependencies = [ "secp256k1", "serde", "serde_json", + "tempfile", ] [[package]] @@ -2954,6 +3011,26 @@ dependencies = [ "rustix", ] +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zeroize" version = "1.7.0" diff --git a/node/src/bin/spaced.rs b/node/src/bin/spaced.rs index 2cd2251..0f46f6d 100644 --- a/node/src/bin/spaced.rs +++ b/node/src/bin/spaced.rs @@ -5,7 +5,7 @@ use env_logger::Env; use log::error; use spaced::{ config::{safe_exit, Args}, - rpc::{AsyncChainState, LoadedWallet, RpcServerImpl, WalletManager}, + rpc::{AsyncChainState, RpcServerImpl, WalletLoadRequest, WalletManager}, source::{BitcoinBlockSource, BitcoinRpc}, store, sync::Spaced, @@ -53,7 +53,7 @@ impl Composer { } } - async fn setup_rpc_wallet(&mut self, spaced: &Spaced, rx: mpsc::Receiver) { + async fn setup_rpc_wallet(&mut self, spaced: &Spaced, rx: mpsc::Receiver) { let wallet_service = RpcWallet::service( spaced.network, spaced.rpc.clone(), diff --git a/node/src/checker.rs b/node/src/checker.rs index 7ca62bd..b1ed567 100644 --- a/node/src/checker.rs +++ b/node/src/checker.rs @@ -1,10 +1,14 @@ -use std::collections::{BTreeMap}; +use std::collections::BTreeMap; + use anyhow::anyhow; -use protocol::bitcoin::{OutPoint, Transaction}; -use protocol::hasher::{KeyHasher, SpaceKey}; -use protocol::prepare::{DataSource, TxContext}; -use protocol::{Covenant, RevokeReason, SpaceOut}; -use protocol::validate::{TxChangeSet, UpdateKind, Validator}; +use protocol::{ + bitcoin::{OutPoint, Transaction}, + hasher::{KeyHasher, SpaceKey}, + prepare::{DataSource, TxContext}, + validate::{TxChangeSet, UpdateKind, Validator}, + Covenant, RevokeReason, SpaceOut, +}; + use crate::store::{LiveSnapshot, Sha256}; pub struct TxChecker<'a> { @@ -22,7 +26,11 @@ impl<'a> TxChecker<'a> { } } - pub fn apply_package(&mut self, height: u32, txs: Vec) -> anyhow::Result>> { + pub fn apply_package( + &mut self, + height: u32, + txs: Vec, + ) -> anyhow::Result>> { let mut sets = Vec::with_capacity(txs.len()); for tx in txs { sets.push(self.apply_tx(height, &tx)?); @@ -30,7 +38,11 @@ impl<'a> TxChecker<'a> { Ok(sets) } - pub fn check_apply_tx(&mut self, height: u32, tx: &Transaction) -> anyhow::Result> { + pub fn check_apply_tx( + &mut self, + height: u32, + tx: &Transaction, + ) -> anyhow::Result> { let changeset = self.apply_tx(height, tx)?; if let Some(changeset) = changeset.as_ref() { Self::check(&changeset)?; @@ -38,12 +50,15 @@ impl<'a> TxChecker<'a> { Ok(changeset) } - pub fn apply_tx(&mut self, height: u32, tx: &Transaction) -> anyhow::Result> { - let ctx = - match { TxContext::from_tx::(self, tx)? } { - None => return Ok(None), - Some(ctx) => ctx, - }; + pub fn apply_tx( + &mut self, + height: u32, + tx: &Transaction, + ) -> anyhow::Result> { + let ctx = match { TxContext::from_tx::(self, tx)? } { + None => return Ok(None), + Some(ctx) => ctx, + }; let validator = Validator::new(); let changeset = validator.process(height, tx, ctx); let changeset2 = changeset.clone(); @@ -60,16 +75,23 @@ impl<'a> TxChecker<'a> { }; if create.space.is_some() { let space = SpaceKey::from(Sha256::hash( - create.space.as_ref().expect("space").name.as_ref()) - ); + create.space.as_ref().expect("space").name.as_ref(), + )); self.spaces.insert(space, Some(outpoint)); } self.spaceouts.insert(outpoint, Some(create)); } for update in changeset.updates { - let space = SpaceKey::from( - Sha256::hash(update.output.spaceout.space.as_ref() - .expect("space").name.as_ref())); + let space = SpaceKey::from(Sha256::hash( + update + .output + .spaceout + .space + .as_ref() + .expect("space") + .name + .as_ref(), + )); match update.kind { UpdateKind::Revoke(_) => { self.spaces.insert(space, None); @@ -78,7 +100,8 @@ impl<'a> TxChecker<'a> { _ => { let outpoint = update.output.outpoint(); self.spaces.insert(space, Some(outpoint)); - self.spaceouts.insert(outpoint, Some(update.output.spaceout)); + self.spaceouts + .insert(outpoint, Some(update.output.spaceout)); } } } @@ -86,7 +109,11 @@ impl<'a> TxChecker<'a> { } pub fn check(changset: &TxChangeSet) -> anyhow::Result<()> { - if changset.spends.iter().any(|spend| spend.script_error.is_some()) { + if changset + .spends + .iter() + .any(|spend| spend.script_error.is_some()) + { return Err(anyhow!("tx-check: transaction not broadcasted as it may have an open that will be rejected")); } for create in changset.creates.iter() { @@ -117,17 +144,20 @@ impl<'a> TxChecker<'a> { } impl DataSource for TxChecker<'_> { - fn get_space_outpoint(&mut self, space_hash: &SpaceKey) -> protocol::errors::Result> { + fn get_space_outpoint( + &mut self, + space_hash: &SpaceKey, + ) -> protocol::errors::Result> { match self.spaces.get(space_hash) { None => self.original.get_space_outpoint(space_hash.into()), - Some(res) => Ok(res.clone()) + Some(res) => Ok(res.clone()), } } fn get_spaceout(&mut self, outpoint: &OutPoint) -> protocol::errors::Result> { match self.spaceouts.get(outpoint) { None => self.original.get_spaceout(outpoint), - Some(space_out) => Ok(space_out.clone()) + Some(space_out) => Ok(space_out.clone()), } } } diff --git a/node/src/lib.rs b/node/src/lib.rs index 17d6421..69b0b86 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -4,6 +4,9 @@ extern crate core; pub extern crate jsonrpsee; pub extern crate log; +use std::time::{Duration, Instant}; + +mod checker; pub mod config; pub mod node; pub mod rpc; @@ -11,4 +14,19 @@ pub mod source; pub mod store; pub mod sync; pub mod wallets; -mod checker; + +fn std_wait(mut predicate: F, wait: Duration) +where + F: FnMut() -> bool, +{ + let start = Instant::now(); + loop { + if predicate() { + break; + } + if start.elapsed() >= wait { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } +} diff --git a/node/src/node.rs b/node/src/node.rs index 9347e4f..789fcc7 100644 --- a/node/src/node.rs +++ b/node/src/node.rs @@ -6,7 +6,7 @@ use std::{error::Error, fmt}; use anyhow::{anyhow, Result}; use bincode::{Decode, Encode}; use protocol::{ - bitcoin::{Amount, Block, BlockHash, OutPoint}, + bitcoin::{Amount, Block, BlockHash, OutPoint, Txid}, constants::{ChainAnchor, ROLLOUT_BATCH_SIZE, ROLLOUT_BLOCK_INTERVAL}, hasher::{BidKey, KeyHasher, OutpointKey, SpaceKey}, prepare::TxContext, @@ -25,6 +25,7 @@ pub trait BlockSource { fn get_block_hash(&self, height: u32) -> Result; fn get_block(&self, hash: &BlockHash) -> Result; fn get_median_time(&self) -> Result; + fn in_mempool(&self, txid: &Txid, height: u32) -> Result; fn get_block_count(&self) -> Result; fn get_best_chain(&self) -> Result; } diff --git a/node/src/rpc.rs b/node/src/rpc.rs index be8fdfa..449be50 100644 --- a/node/src/rpc.rs +++ b/node/src/rpc.rs @@ -15,34 +15,40 @@ use bdk::{ }; use jsonrpsee::{core::async_trait, proc_macros::rpc, server::Server, types::ErrorObjectOwned}; use log::info; -use protocol::{bitcoin, bitcoin::{ - bip32::Xpriv, - Network::{Regtest, Testnet}, - OutPoint, -}, constants::ChainAnchor, hasher::{BaseHash, KeyHasher, SpaceKey}, prepare::DataSource, slabel::SLabel, FullSpaceOut, SpaceOut}; +use protocol::{ + bitcoin, + bitcoin::{ + bip32::Xpriv, + Network::{Regtest, Testnet}, + OutPoint, + }, + constants::ChainAnchor, + hasher::{BaseHash, KeyHasher, SpaceKey}, + prepare::DataSource, + slabel::SLabel, + validate::TxChangeSet, + FullSpaceOut, SpaceOut, +}; use serde::{Deserialize, Serialize}; use tokio::{ select, sync::{broadcast, mpsc, oneshot, RwLock}, task::JoinSet, }; -use protocol::validate::TxChangeSet; -use wallet::{ - bdk_wallet as bdk, bdk_wallet::template::Bip86, bitcoin::hashes::Hash, export::WalletExport, - DoubleUtxo, SpacesWallet, WalletConfig, WalletDescriptors, WalletInfo, -}; +use wallet::{bdk_wallet as bdk, bdk_wallet::template::Bip86, bitcoin::hashes::Hash, export::WalletExport, Balance, DoubleUtxo, WalletConfig, WalletDescriptors, WalletInfo, WalletOutput}; use crate::{ + checker::TxChecker, config::ExtendedNetwork, node::{BlockMeta, TxEntry}, source::BitcoinRpc, store::{ChainState, LiveSnapshot, RolloutEntry, Sha256}, wallets::{ - AddressKind, Balance, RpcWallet, TxInfo, TxResponse, WalletCommand, WalletOutput, + AddressKind, RpcWallet, TxInfo, TxResponse, WalletCommand, WalletResponse, }, }; -use crate::checker::TxChecker; +use crate::wallets::ListSpacesResponse; pub(crate) type Responder = oneshot::Sender; @@ -117,7 +123,10 @@ pub trait Rpc { async fn get_spaceout(&self, outpoint: OutPoint) -> Result, ErrorObjectOwned>; #[method(name = "checkpackage")] - async fn check_package(&self, txs: Vec) -> Result>, ErrorObjectOwned>; + async fn check_package( + &self, + txs: Vec, + ) -> Result>, ErrorObjectOwned>; #[method(name = "estimatebid")] async fn estimate_bid(&self, target: usize) -> Result; @@ -190,7 +199,7 @@ pub trait Rpc { #[method(name = "walletlistspaces")] async fn wallet_list_spaces(&self, wallet: &str) - -> Result, ErrorObjectOwned>; + -> Result; #[method(name = "walletlistunspent")] async fn wallet_list_unspent( @@ -289,29 +298,20 @@ pub struct WalletManager { pub data_dir: PathBuf, pub network: ExtendedNetwork, pub rpc: BitcoinRpc, - pub wallet_loader: mpsc::Sender, + pub wallet_loader: mpsc::Sender, pub wallets: Arc>>, } -pub struct LoadedWallet { +pub struct WalletLoadRequest { pub(crate) rx: mpsc::Receiver, - pub(crate) wallet: SpacesWallet, + pub(crate) config: WalletConfig, + pub(crate) export: WalletExport, } const RPC_WALLET_NOT_LOADED: i32 = -18; -impl LoadedWallet { - fn new(wallet: SpacesWallet, rx: mpsc::Receiver) -> Self { - Self { rx, wallet } - } -} - impl WalletManager { - pub async fn import_wallet( - &self, - client: &reqwest::Client, - wallet: WalletExport, - ) -> anyhow::Result<()> { + pub async fn import_wallet(&self, wallet: WalletExport) -> anyhow::Result<()> { let wallet_path = self.data_dir.join(&wallet.label); if wallet_path.exists() { return Err(anyhow!(format!( @@ -325,7 +325,7 @@ impl WalletManager { let mut file = fs::File::create(wallet_export_path)?; file.write_all(wallet.to_string().as_bytes())?; - self.load_wallet(client, &wallet.label).await?; + self.load_wallet(&wallet.label).await?; Ok(()) } @@ -346,7 +346,7 @@ impl WalletManager { let start_block = self.get_wallet_start_block(client).await?; self.setup_new_wallet(name.to_string(), mnemonic.to_string(), start_block)?; - self.load_wallet(client, name).await?; + self.load_wallet(name).await?; Ok(()) } @@ -379,7 +379,9 @@ impl WalletManager { let xpriv = Self::descriptor_from_mnemonic(network, &mnemonic.to_string())?; let (external, internal) = Self::default_descriptors(xpriv); - let tmp = bdk::wallet::Wallet::new_or_load(external, internal, None, network)?; + let tmp = bdk::Wallet::create(external, internal) + .network(network) + .create_wallet_no_persist()?; let export = WalletExport::export_wallet(&tmp, &name, start_block.height).map_err(|e| anyhow!(e))?; @@ -412,7 +414,7 @@ impl WalletManager { } ExtendedNetwork::Signet => { genesis_hash = Some( - bdk::bitcoin::constants::genesis_block(Network::Signet) + bitcoin::constants::genesis_block(Network::Signet) .header .block_hash(), ); @@ -424,7 +426,7 @@ impl WalletManager { (network, genesis_hash) } - pub async fn load_wallet(&self, client: &reqwest::Client, name: &str) -> anyhow::Result<()> { + pub async fn load_wallet(&self, name: &str) -> anyhow::Result<()> { let wallet_dir = self.data_dir.join(name); if !wallet_dir.exists() { return Err(anyhow!("Wallet does not exist")); @@ -435,7 +437,7 @@ impl WalletManager { let (network, genesis_hash) = self.fallback_network(); let export: WalletExport = serde_json::from_reader(file)?; - let mut wallet = SpacesWallet::new(WalletConfig { + let wallet_config = WalletConfig { start_block: export.blockheight, data_dir: wallet_dir, name: name.to_string(), @@ -447,26 +449,22 @@ impl WalletManager { .change_descriptor() .expect("expected a change descriptor"), }, - })?; - - let wallet_tip = wallet.spaces.local_chain().tip().height(); - - if wallet_tip < export.blockheight { - let block_id = self.get_block_hash(client, export.blockheight).await?; - wallet.spaces.insert_checkpoint(block_id)?; - wallet.commit()?; - } + }; let (rpc_wallet, rpc_wallet_rx) = RpcWallet::new(); - let loaded_wallet = LoadedWallet::new(wallet, rpc_wallet_rx); + let request = WalletLoadRequest { + rx: rpc_wallet_rx, + config: wallet_config, + export, + }; - self.wallet_loader.send(loaded_wallet).await?; + self.wallet_loader.send(request).await?; let mut wallets = self.wallets.write().await; wallets.insert(name.to_string(), rpc_wallet); Ok(()) } - async fn get_block_hash( + pub async fn get_block_hash( &self, client: &reqwest::Client, height: u32, @@ -621,7 +619,10 @@ impl RpcServer for RpcServerImpl { Ok(spaceout) } - async fn check_package(&self, txs: Vec) -> Result>, ErrorObjectOwned> { + async fn check_package( + &self, + txs: Vec, + ) -> Result>, ErrorObjectOwned> { let spaceout = self .store .check_package(txs) @@ -672,7 +673,7 @@ impl RpcServer for RpcServerImpl { async fn wallet_load(&self, name: &str) -> Result<(), ErrorObjectOwned> { self.wallet_manager - .load_wallet(&self.client, name) + .load_wallet(name) .await .map_err(|error| { ErrorObjectOwned::owned(RPC_WALLET_NOT_LOADED, error.to_string(), None::) @@ -681,7 +682,7 @@ impl RpcServer for RpcServerImpl { async fn wallet_import(&self, content: WalletExport) -> Result<(), ErrorObjectOwned> { self.wallet_manager - .import_wallet(&self.client, content) + .import_wallet(content) .await .map_err(|error| { ErrorObjectOwned::owned(RPC_WALLET_NOT_LOADED, error.to_string(), None::) @@ -744,7 +745,7 @@ impl RpcServer for RpcServerImpl { wallet: &str, txid: Txid, fee_rate: FeeRate, - skip_tx_check: bool + skip_tx_check: bool, ) -> Result, ErrorObjectOwned> { self.wallet(&wallet) .await? @@ -782,7 +783,7 @@ impl RpcServer for RpcServerImpl { async fn wallet_list_spaces( &self, wallet: &str, - ) -> Result, ErrorObjectOwned> { + ) -> Result { self.wallet(&wallet) .await? .send_list_spaces() @@ -850,7 +851,6 @@ impl AsyncChainState { Ok(None) } - async fn get_indexed_block( index: &mut Option, block_hash: &BlockHash, @@ -899,7 +899,7 @@ impl AsyncChainState { cmd: ChainStateCommand, ) { match cmd { - ChainStateCommand::CheckPackage { txs : raw_txs, resp } => { + ChainStateCommand::CheckPackage { txs: raw_txs, resp } => { let mut txs = Vec::with_capacity(raw_txs.len()); for raw_tx in raw_txs { let tx = bitcoin::consensus::encode::deserialize_hex(&raw_tx); @@ -912,9 +912,9 @@ impl AsyncChainState { let tip = chain_state.tip.read().expect("read meta").clone(); let mut emulator = TxChecker::new(chain_state); - let result = emulator.apply_package(tip.height+1, txs); + let result = emulator.apply_package(tip.height + 1, txs); let _ = resp.send(result); - }, + } ChainStateCommand::GetTip { resp } => { let tip = chain_state.tip.read().expect("read meta").clone(); _ = resp.send(Ok(tip)) @@ -1010,7 +1010,10 @@ impl AsyncChainState { resp_rx.await? } - pub async fn check_package(&self, txs: Vec) -> anyhow::Result>> { + pub async fn check_package( + &self, + txs: Vec, + ) -> anyhow::Result>> { let (resp, resp_rx) = oneshot::channel(); self.sender .send(ChainStateCommand::CheckPackage { txs, resp }) diff --git a/node/src/source.rs b/node/src/source.rs index 716f8f3..363f56a 100644 --- a/node/src/source.rs +++ b/node/src/source.rs @@ -12,15 +12,17 @@ use std::{ use base64::Engine; use bitcoin::{Block, BlockHash, Txid}; use hex::FromHexError; -use log::{error, info}; -use reqwest::StatusCode; +use log::{error}; use protocol::constants::ChainAnchor; +use reqwest::StatusCode; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::Value; use threadpool::ThreadPool; use tokio::time::Instant; -use wallet::{bdk_wallet::chain::ConfirmationTime, bitcoin, bitcoin::Transaction}; +use wallet::{bitcoin, bitcoin::Transaction}; use crate::node::BlockSource; +use crate::std_wait; const BITCOIN_RPC_IN_WARMUP: i32 = -28; // Client still warming up const BITCOIN_RPC_CLIENT_NOT_CONNECTED: i32 = -9; // Bitcoin is not connected @@ -43,6 +45,7 @@ pub struct BlockFetcher { } pub enum BlockEvent { + Tip(ChainAnchor), Block(ChainAnchor, Block), Error(BlockFetchError), } @@ -170,8 +173,7 @@ impl BitcoinRpc { let params = serde_json::json!([]); self.make_request("getblockchaininfo", params) } - - pub fn get_mempool_entry(&self, txid: Txid) -> BitcoinRpcRequest { + pub fn get_mempool_entry(&self, txid: &Txid) -> BitcoinRpcRequest { let params = serde_json::json!([txid]); self.make_request("getmempoolentry", params) @@ -190,7 +192,10 @@ impl BitcoinRpc { client: &reqwest::Client, request: &BitcoinRpcRequest, ) -> Result { - self.send_request(client, request).await?.error_for_rpc().await + self.send_request(client, request) + .await? + .error_for_rpc() + .await } pub fn send_json_blocking( @@ -206,7 +211,7 @@ impl BitcoinRpc { &self, client: &reqwest::blocking::Client, tx: &Transaction, - ) -> Result { + ) -> Result { let txid: String = self.send_json_blocking(client, &self.send_raw_transaction(tx))?; const MAX_RETRIES: usize = 10; @@ -219,7 +224,7 @@ impl BitcoinRpc { match res { Ok(mem) => { if let Some(time) = mem.get("time").and_then(|t| t.as_u64()) { - return Ok(ConfirmationTime::Unconfirmed { last_seen: time }); + return Ok(time); } } Err(e) => last_error = Some(e), @@ -236,8 +241,8 @@ impl BitcoinRpc { client: &reqwest::Client, request: &BitcoinRpcRequest, ) -> Result { - let mut delay = Duration::from_millis(1000); - let max_retries = 10; + let mut delay = Duration::from_millis(100); + let max_retries = 5; let mut last_error = None; for attempt in 0..max_retries { @@ -246,7 +251,12 @@ impl BitcoinRpc { builder = builder.header("Authorization", format!("Basic {}", auth)); } - match builder.json(&request.body).send().await.map_err(BitcoinRpcError::from) { + match builder + .json(&request.body) + .send() + .await + .map_err(BitcoinRpcError::from) + { Ok(res) => return Self::clean_rpc_response(res).await, Err(e) if e.is_temporary() && attempt < max_retries - 1 => { error!("Rpc: {} - retrying in {:?}...", e, delay); @@ -266,8 +276,8 @@ impl BitcoinRpc { client: &reqwest::blocking::Client, request: &BitcoinRpcRequest, ) -> Result { - let mut delay = Duration::from_millis(1000); - let max_retries = 10; + let mut delay = Duration::from_millis(100); + let max_retries = 5; let mut last_error = None; for attempt in 0..max_retries { @@ -276,7 +286,11 @@ impl BitcoinRpc { builder = builder.header("Authorization", format!("Basic {}", auth)); } - match builder.json(&request.body).send().map_err(BitcoinRpcError::from) { + match builder + .json(&request.body) + .send() + .map_err(BitcoinRpcError::from) + { Ok(res) => return Self::clean_rpc_response_blocking(res), Err(e) if e.is_temporary() && attempt < max_retries - 1 => { error!("Rpc: {} - retrying in {:?}...", e, delay); @@ -291,7 +305,9 @@ impl BitcoinRpc { Err(last_error.expect("an error")) } - pub async fn clean_rpc_response(res: reqwest::Response) -> Result { + pub async fn clean_rpc_response( + res: reqwest::Response, + ) -> Result { let status = res.status(); if status.is_success() { return Ok(res); @@ -305,7 +321,9 @@ impl BitcoinRpc { Err(Self::parse_error_bytes(status, response_bytes.as_ref())) } - pub fn clean_rpc_response_blocking(res: reqwest::blocking::Response) -> Result { + pub fn clean_rpc_response_blocking( + res: reqwest::blocking::Response, + ) -> Result { let status = res.status(); if status.is_success() { return Ok(res); @@ -406,6 +424,12 @@ impl BlockFetcher { Ok(Some(tip)) } + pub fn restart(&self, checkpoint: ChainAnchor, drain_receiver: &Receiver) { + self.stop(); + while drain_receiver.try_recv().is_ok() {} + self.start(checkpoint) + } + pub fn start(&self, mut checkpoint: ChainAnchor) { self.stop(); @@ -420,7 +444,6 @@ impl BlockFetcher { loop { if current_task.load(Ordering::SeqCst) != job_id { - info!("Shutting down block fetcher"); return; } if last_check.elapsed() < Duration::from_secs(1) { @@ -433,7 +456,9 @@ impl BlockFetcher { Ok(t) => t, Err(e) => { _ = task_sender.send(BlockEvent::Error(e)); - return; + std_wait(|| current_task.load(Ordering::SeqCst) != job_id, + Duration::from_secs(1)); + continue; } }; @@ -452,12 +477,20 @@ impl BlockFetcher { Ok(new_tip) => { checkpoint = new_tip; } + Err(e) if matches!(e, BlockFetchError::RpcError(_)) => { + _ = task_sender.send(BlockEvent::Error(e)); + std_wait(|| current_task.load(Ordering::SeqCst) != job_id, + Duration::from_secs(1)); + continue; + } Err(e) => { _ = task_sender.send(BlockEvent::Error(e)); current_task.fetch_add(1, Ordering::SeqCst); return; } } + } else { + _ = task_sender.send(BlockEvent::Tip(checkpoint)); } } }); @@ -805,6 +838,31 @@ impl BlockSource for BitcoinBlockSource { )) } + fn in_mempool(&self, txid: &Txid, height: u32) -> Result { + let result: Result = self + .rpc + .send_json_blocking(&self.client, &self.rpc.get_mempool_entry(txid)); + + match result { + Ok(_) => Ok(true), + Err(error) => match error { + BitcoinRpcError::Rpc(rpc) => { + let current_count = self.get_block_count()?; + if current_count != height as u64 { + // Report it as still in mempool since height changed during the check + return Ok(true); + } + + if rpc.code == -5 && rpc.message.contains("not in mempool") { + return Ok(false); + } + Err(BitcoinRpcError::Rpc(rpc)) + } + _ => Err(error), + }, + } + } + fn get_block_count(&self) -> Result { Ok(self .rpc diff --git a/node/src/store.rs b/node/src/store.rs index f8b73c3..1d28947 100644 --- a/node/src/store.rs +++ b/node/src/store.rs @@ -8,7 +8,7 @@ use std::{ sync::{Arc, RwLock}, }; -use anyhow::Result; +use anyhow::{Context, Result}; use bincode::{config, Decode, Encode}; use jsonrpsee::core::Serialize; use protocol::{ @@ -25,6 +25,7 @@ use spacedb::{ tx::{KeyIterator, ReadTransaction, WriteTransaction}, Configuration, Hash, NodeHasher, Sha256Hasher, }; +use protocol::bitcoin::BlockHash; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RolloutEntry { @@ -53,12 +54,12 @@ pub struct LiveSnapshot { db: SpaceDb, pub tip: Arc>, staged: Arc>, - snapshot: (u32, ReadTx), + snapshot: (BlockHash, ReadTx), } pub struct Staged { /// Block height of latest snapshot - snapshot_version: u32, + snapshot_version: BlockHash, /// Stores changes until committed memory: WriteMemory, } @@ -101,7 +102,7 @@ impl Store { snapshot.metadata().try_into()? }; - let version = anchor.height; + let version = anchor.hash; let live = LiveSnapshot { db: self.0.clone(), tip: Arc::new(RwLock::new(anchor)), @@ -198,7 +199,7 @@ impl LiveSnapshot { } pub fn restore(&self, checkpoint: ChainAnchor) { - let snapshot_version = checkpoint.height; + let snapshot_version = checkpoint.hash; let mut meta_lock = self.tip.write().expect("write lock"); *meta_lock = checkpoint; @@ -234,8 +235,8 @@ impl LiveSnapshot { Some(value) => { let (decoded, _): (T, _) = bincode::decode_from_slice(&value, config::standard()) .map_err(|e| { - spacedb::Error::IO(io::Error::new(ErrorKind::Other, e.to_string())) - })?; + spacedb::Error::IO(io::Error::new(ErrorKind::Other, e.to_string())) + })?; Ok(Some(decoded)) } None => Ok(None), @@ -264,14 +265,14 @@ impl LiveSnapshot { .insert(key, Some(value)); } - fn update_snapshot(&mut self, version: u32) -> Result<()> { + fn update_snapshot(&mut self, version: BlockHash) -> Result<()> { if self.snapshot.0 != version { - self.snapshot.1 = self.db.begin_read()?; + self.snapshot.1 = self.db.begin_read().context("could not read snapshot")?; let anchor: ChainAnchor = self.snapshot.1.metadata().try_into().map_err(|_| { std::io::Error::new(std::io::ErrorKind::Other, "could not parse metdata") })?; - assert_eq!(version, anchor.height, "inconsistent db state"); + assert_eq!(version, anchor.hash, "inconsistent db state"); self.snapshot.0 = version; } Ok(()) @@ -301,7 +302,7 @@ impl LiveSnapshot { let changes = mem::replace( &mut *staged, Staged { - snapshot_version: metadata.height, + snapshot_version: metadata.hash, memory: BTreeMap::new(), }, ); @@ -430,7 +431,7 @@ impl DataSource for LiveSnapshot { ) -> protocol::errors::Result> { let result: Option = self .get(*space_hash) - .map_err(|err| protocol::errors::Error::IO(err.to_string()))?; + .map_err(|err| protocol::errors::Error::IO(format!("getspaceoutpoint: {}", err.to_string())))?; Ok(result.map(|out| out.into())) } @@ -438,7 +439,7 @@ impl DataSource for LiveSnapshot { let h = OutpointKey::from_outpoint::(*outpoint); let result = self .get(h) - .map_err(|err| protocol::errors::Error::IO(err.to_string()))?; + .map_err(|err| protocol::errors::Error::IO(format!("getspaceout: {}", err.to_string())))?; Ok(result) } } @@ -502,8 +503,8 @@ impl Iterator for KeyRolloutIterator { struct MergingIterator where - I1: Iterator>, - I2: Iterator>, + I1: Iterator>, + I2: Iterator>, { iter1: std::iter::Peekable, iter2: std::iter::Peekable, @@ -511,8 +512,8 @@ where impl MergingIterator where - I1: Iterator>, - I2: Iterator>, + I1: Iterator>, + I2: Iterator>, { fn new(iter1: I1, iter2: I2) -> Self { MergingIterator { @@ -524,8 +525,8 @@ where impl Iterator for MergingIterator where - I1: Iterator>, - I2: Iterator>, + I1: Iterator>, + I2: Iterator>, { type Item = Result<(BidKey, SpaceKey)>; diff --git a/node/src/sync.rs b/node/src/sync.rs index 6f47500..b4145d2 100644 --- a/node/src/sync.rs +++ b/node/src/sync.rs @@ -1,20 +1,15 @@ use std::{net::SocketAddr, path::PathBuf, time::Duration}; use anyhow::{anyhow, Context}; -use log::info; +use log::{info, warn}; use protocol::{ bitcoin::{hashes::Hash, Block, BlockHash}, constants::ChainAnchor, hasher::BaseHash, }; use tokio::sync::broadcast; - -use crate::{ - config::ExtendedNetwork, - node::{BlockMeta, BlockSource, Node}, - source::{BitcoinBlockSource, BitcoinRpc, BlockEvent, BlockFetchError, BlockFetcher}, - store::LiveStore, -}; +use crate::{config::ExtendedNetwork, node::{BlockMeta, BlockSource, Node}, source::{BitcoinBlockSource, BitcoinRpc, BlockEvent, BlockFetchError, BlockFetcher}, std_wait, store::LiveStore}; +use crate::source::BitcoinRpcError; // https://internals.rust-lang.org/t/nicer-static-assertions/15986 macro_rules! const_assert { @@ -165,16 +160,30 @@ impl Spaced { } match receiver.try_recv() { Ok(event) => match event { + BlockEvent::Tip(_) => {} BlockEvent::Block(id, block) => { self.handle_block(&mut node, id, block)?; info!("block={} height={}", id.hash, id.height); } BlockEvent::Error(e) if matches!(e, BlockFetchError::BlockMismatch) => { - self.restore(&source)?; + if let Err(e) = self.restore(&source) { + if e.downcast_ref::().is_none() { + return Err(e); + } + warn!("Restore: {} - retrying in 1s", e); + std_wait(|| shutdown_signal.try_recv().is_ok(), Duration::from_secs(1)); + } + // Even if we couldn't restore just attempt to re-sync + let new_tip = self.chain.state.tip.read().expect("read").clone(); + fetcher.restart(new_tip, &receiver); + } + BlockEvent::Error(e) => { + warn!("Fetcher: {} - retrying in 1s", e); + std_wait(|| shutdown_signal.try_recv().is_ok(), Duration::from_secs(1)); + // Even if we couldn't restore just attempt to re-sync let new_tip = self.chain.state.tip.read().expect("read").clone(); - fetcher.start(new_tip); + fetcher.restart(new_tip, &receiver); } - BlockEvent::Error(e) => return Err(e.into()), }, Err(e) if matches!(e, std::sync::mpsc::TryRecvError::Empty) => { std::thread::sleep(Duration::from_millis(10)); diff --git a/node/src/wallets.rs b/node/src/wallets.rs index 5e9fed9..a7791ca 100644 --- a/node/src/wallets.rs +++ b/node/src/wallets.rs @@ -1,62 +1,49 @@ -use std::{collections::BTreeMap, str::FromStr, time::Duration}; - -use anyhow::anyhow; +use std::{ + collections::BTreeMap, + str::FromStr, + time::{Duration}, +}; +use anyhow::{anyhow}; use clap::ValueEnum; use futures::{stream::FuturesUnordered, StreamExt}; use log::{info, warn}; -use protocol::{ - bitcoin::Txid, - constants::ChainAnchor, - hasher::{KeyHasher, SpaceKey}, - prepare::DataSource, - script::SpaceScript, - slabel::SLabel, - FullSpaceOut, Space, -}; +use protocol::{bitcoin::Txid, constants::ChainAnchor, hasher::{KeyHasher, SpaceKey}, script::SpaceScript, slabel::SLabel, FullSpaceOut, SpaceOut}; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::{ select, sync::{broadcast, mpsc, mpsc::Receiver, oneshot}, }; -use wallet::{ - address::SpaceAddress, - bdk_wallet, - bdk_wallet::{ - chain::{local_chain::CheckPoint, BlockId}, - wallet::tx_builder::TxOrdering, - KeychainKind, LocalOutput, - }, - bitcoin, - bitcoin::{Address, Amount, FeeRate, OutPoint}, - builder::{ - CoinTransfer, SelectionOutput, SpaceTransfer, SpacesAwareCoinSelection, TransactionTag, - TransferRequest, - }, - DoubleUtxo, SpacesWallet, WalletInfo, -}; -use wallet::bdk_wallet::chain::ConfirmationTime; -use crate::{ - config::ExtendedNetwork, - node::BlockSource, - rpc::{LoadedWallet, RpcWalletRequest, RpcWalletTxBuilder}, - source::{ - BitcoinBlockSource, BitcoinRpc, BitcoinRpcError, BlockEvent, BlockFetchError, BlockFetcher, - }, - store::{ChainState, LiveSnapshot, Sha256}, -}; -use crate::checker::TxChecker; +use tokio::time::Instant; +use wallet::{address::SpaceAddress, bdk_wallet::{ + chain::{local_chain::CheckPoint, BlockId}, + KeychainKind, +}, bitcoin, bitcoin::{Address, Amount, FeeRate, OutPoint}, builder::{CoinTransfer, SpaceTransfer, SpacesAwareCoinSelection}, tx_event::{TxRecord, TxEvent, TxEventKind}, Balance, DoubleUtxo, SpacesWallet, WalletInfo, WalletOutput}; +use crate::{checker::TxChecker, config::ExtendedNetwork, node::BlockSource, rpc::{RpcWalletRequest, RpcWalletTxBuilder, WalletLoadRequest}, source::{ + BitcoinBlockSource, BitcoinRpc, BitcoinRpcError, BlockEvent, BlockFetchError, BlockFetcher, +}, std_wait, store::{ChainState, LiveSnapshot, Sha256}}; + +const MEMPOOL_CHECK_INTERVAL: Duration = Duration::from_millis( + if cfg!(debug_assertions) { 500 } else { 10_000 } +); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TxResponse { #[serde(skip_serializing_if = "Option::is_none")] pub error: Option>, pub txid: Txid, - pub tags: Vec, + pub events: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListSpacesResponse { + pub winning: Vec, + pub outbid: Vec, + pub owned: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TxInfo { pub txid: Txid, @@ -64,6 +51,7 @@ pub struct TxInfo { pub sent: Amount, pub received: Amount, pub fee: Option, + pub events: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -71,14 +59,6 @@ pub struct WalletResponse { pub result: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WalletOutput { - #[serde(flatten)] - pub output: LocalOutput, - pub space: Option, - pub is_spaceout: bool, -} - pub enum WalletCommand { GetInfo { resp: crate::rpc::Responder>, @@ -103,7 +83,7 @@ pub enum WalletCommand { resp: crate::rpc::Responder>>, }, ListSpaces { - resp: crate::rpc::Responder>>, + resp: crate::rpc::Responder>, }, ListBidouts { resp: crate::rpc::Responder>>, @@ -128,24 +108,19 @@ pub enum AddressKind { Space, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Balance { - pub balance: Amount, - pub details: BalanceDetails, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BalanceDetails { - #[serde(flatten)] - pub balance: bdk_wallet::wallet::Balance, - pub dust: Amount, -} - #[derive(Clone)] pub struct RpcWallet { pub sender: mpsc::Sender, } +pub struct MempoolChecker<'a>(&'a BitcoinBlockSource); + +impl wallet::Mempool for MempoolChecker<'_> { + fn in_mempool(&self, txid: &Txid, height: u32) -> anyhow::Result { + Ok(self.0.in_mempool(txid, height)?) + } +} + impl RpcWallet { pub fn new() -> (Self, Receiver) { let (sender, receiver) = mpsc::channel(10); @@ -170,29 +145,6 @@ impl RpcWallet { None } - fn get_balance(state: &mut LiveSnapshot, wallet: &mut SpacesWallet) -> anyhow::Result { - let unspent = Self::list_unspent(wallet, state)?; - let balance = wallet.spaces.balance(); - - let details = BalanceDetails { - balance, - dust: unspent - .into_iter() - .filter(|output| - // confirmed or trusted pending only - (output.output.confirmation_time.is_confirmed() || output.output.keychain == KeychainKind::Internal) && - (output.space.is_some() || output.output.txout.value <= SpacesAwareCoinSelection::DUST_THRESHOLD) - ) - .map(|output| output.output.txout.value) - .sum(), - }; - - Ok(Balance { - balance: (details.balance.confirmed + details.balance.trusted_pending) - details.dust, - details, - }) - } - fn handle_fee_bump( source: &BitcoinBlockSource, state: &mut LiveSnapshot, @@ -201,79 +153,72 @@ impl RpcWallet { skip_tx_check: bool, fee_rate: FeeRate, ) -> anyhow::Result> { - let coin_selection = Self::get_spaces_coin_selection( - wallet, state, - false, /* generally bdk won't use unconfirmed for replacements anyways */ - )?; - let previous_tx_lock_time = match wallet.spaces.get_tx(txid) { - None => return Err(anyhow::anyhow!("No wallet tx {} found", txid)), - Some(tx) => tx.tx_node.lock_time - }; - - let mut builder = wallet - .spaces - .build_fee_bump(txid)? - .coin_selection(coin_selection); - - builder - .enable_rbf() - .ordering(TxOrdering::Untouched) - .nlocktime(previous_tx_lock_time) - .fee_rate(fee_rate); + let unspendables = wallet.list_spaces_outpoints(state)?; + let tx_events = wallet.get_tx_events(txid)?; + let builder = + wallet.build_fee_bump(unspendables, txid, fee_rate)?; let psbt = builder.finish()?; - let tx = wallet.sign(psbt, None)?; + let replacement = wallet.sign(psbt, None)?; if !skip_tx_check { - let tip = wallet.spaces.local_chain().tip().height(); + let tip = wallet.local_chain().tip().height(); let mut checker = TxChecker::new(state); - checker.check_apply_tx(tip+1, &tx)?; + checker.check_apply_tx(tip + 1, &replacement)?; } - let new_txid = tx.compute_txid(); - let confirmation = source.rpc.broadcast_tx(&source.client, &tx)?; - wallet.insert_tx(tx, confirmation)?; + let new_txid = replacement.compute_txid(); + let last_seen = source.rpc.broadcast_tx(&source.client, &replacement)?; + + let mut tx_record = TxRecord::new_with_events(replacement, tx_events); + tx_record.add_fee_bump(); + + let new_events = tx_record.events.clone(); + + // Incrementing last_seen by 1 ensures eviction of older tx + // in cases with same-second/last seen replacement. + wallet.apply_unconfirmed_tx_record(tx_record, last_seen+1)?; wallet.commit()?; Ok(vec![TxResponse { txid: new_txid, - tags: vec![TransactionTag::FeeBump], + events: new_events, error: None, raw: None, }]) } fn handle_force_spend_output( - source: &BitcoinBlockSource, - state: &mut LiveSnapshot, - wallet: &mut SpacesWallet, - output: OutPoint, - fee_rate: FeeRate, + _source: &BitcoinBlockSource, + _state: &mut LiveSnapshot, + _wallet: &mut SpacesWallet, + _output: OutPoint, + _fee_rate: FeeRate, ) -> anyhow::Result { - let coin_selection = Self::get_spaces_coin_selection(wallet, state, true)?; - let addre = wallet.spaces.next_unused_address(KeychainKind::External); - let mut builder = wallet.spaces.build_tx().coin_selection(coin_selection); - - builder.ordering(TxOrdering::Untouched); - builder.fee_rate(fee_rate); - builder.enable_rbf(); - builder.add_utxo(output)?; - builder.add_recipient(addre.script_pubkey(), Amount::from_sat(5000)); - - let psbt = builder.finish()?; - let tx = wallet.sign(psbt, None)?; - - let txid = tx.compute_txid(); - let confirmation = source.rpc.broadcast_tx(&source.client, &tx)?; - wallet.insert_tx(tx, confirmation)?; - wallet.commit()?; - - Ok(TxResponse { - txid, - tags: vec![TransactionTag::ForceSpendTestOnly], - error: None, - raw: None, - }) + todo!("") + // let coin_selection = Self::get_spaces_coin_selection(wallet, state, true)?; + // let addre = wallet.spaces.next_unused_address(KeychainKind::External); + // let mut builder = wallet.spaces.build_tx().coin_selection(coin_selection); + // + // builder.ordering(TxOrdering::Untouched); + // builder.fee_rate(fee_rate); + // builder.add_utxo(output)?; + // builder.add_recipient(addre.script_pubkey(), Amount::from_sat(5000)); + // + // let psbt = builder.finish()?; + // let tx = wallet.sign(psbt, None)?; + // + // let txid = tx.compute_txid(); + // let last_seen = source.rpc.broadcast_tx(&source.client, &tx)?; + // wallet.apply_unconfirmed_tx(tx, last_seen); + // wallet.commit()?; + // + // Ok(TxResponse { + // txid, + // events: vec![], + // error: None, + // raw: None, + // }) } fn wallet_handle_commands( @@ -282,10 +227,15 @@ impl RpcWallet { mut state: &mut LiveSnapshot, wallet: &mut SpacesWallet, command: WalletCommand, + synced: bool, ) -> anyhow::Result<()> { match command { WalletCommand::GetInfo { resp } => _ = resp.send(Ok(wallet.get_info())), WalletCommand::BatchTx { request, resp } => { + if !synced && !request.force { + _ = resp.send(Err(anyhow::anyhow!("Wallet is syncing"))); + return Ok(()); + } let batch_result = Self::batch_tx(network, &source, wallet, &mut state, request); _ = resp.send(batch_result); } @@ -295,7 +245,18 @@ impl RpcWallet { skip_tx_check, resp, } => { - let result = Self::handle_fee_bump(source, &mut state, wallet, txid, skip_tx_check, fee_rate); + if !synced { + _ = resp.send(Err(anyhow::anyhow!("Wallet is syncing"))); + return Ok(()); + } + let result = Self::handle_fee_bump( + source, + &mut state, + wallet, + txid, + skip_tx_check, + fee_rate, + ); _ = resp.send(result); } WalletCommand::ForceSpendOutput { @@ -310,7 +271,6 @@ impl RpcWallet { WalletCommand::GetNewAddress { kind, resp } => { let address = match kind { AddressKind::Coin => wallet - .spaces .next_unused_address(KeychainKind::External) .address .to_string(), @@ -319,33 +279,26 @@ impl RpcWallet { _ = resp.send(Ok(address)); } WalletCommand::ListUnspent { resp } => { - _ = resp.send(Self::list_unspent(wallet, state)); + _ = resp.send(wallet.list_unspent_with_details(state)); } WalletCommand::ListTransactions { count, skip, resp } => { let transactions = Self::list_transactions(wallet, count, skip); _ = resp.send(transactions); } WalletCommand::ListSpaces { resp } => { - let result = Self::list_unspent(wallet, state); - match result { - Ok(unspent) => { - _ = resp.send(Ok(unspent - .into_iter() - .filter(|s| s.space.is_some()) - .collect())); - } - Err(error) => { - _ = resp.send(Err(error)); - } - } + let result = Self::list_spaces(wallet, state); + _ = resp.send(result); } WalletCommand::ListBidouts { resp } => { - let sel = Self::get_spaces_coin_selection(wallet, state, false)?; - let result = wallet.list_bidouts(&sel); + let result = wallet.list_bidouts(false); _ = resp.send(result); } WalletCommand::GetBalance { resp } => { - let balance = Self::get_balance(state, wallet); + if !synced { + _ = resp.send(Err(anyhow::anyhow!("Wallet is syncing"))); + return Ok(()); + } + let balance = wallet.balance(); _ = resp.send(balance); } WalletCommand::UnloadWallet => { @@ -355,6 +308,30 @@ impl RpcWallet { Ok(()) } + /// Returns true if Bitcoin, protocol, and wallet tips match. + fn all_synced(bitcoin: &BitcoinBlockSource, protocol: &mut LiveSnapshot, wallet: &SpacesWallet) -> Option { + let bitcoin_tip = match bitcoin.get_best_chain() { + Ok(tip) => tip, + Err(e) => { + warn!("Sync check failed: {}", e); + return None; + } + }; + let wallet_tip = wallet.local_chain().tip(); + let protocol_tip = match protocol.tip.read() { + Ok(tip) => tip.clone(), + Err(e) => { + warn!("Failed to read protocol tip: {}", e); + return None; + } + }; + if protocol_tip.hash == wallet_tip.hash() && protocol_tip.hash == bitcoin_tip.hash { + Some(bitcoin_tip) + } else { + None + } + } + fn wallet_sync( network: ExtendedNetwork, source: BitcoinBlockSource, @@ -367,7 +344,7 @@ impl RpcWallet { let (fetcher, receiver) = BlockFetcher::new(source.clone(), num_workers); let mut wallet_tip = { - let tip = wallet.spaces.local_chain().tip(); + let tip = wallet.local_chain().tip(); ChainAnchor { height: tip.height(), hash: tip.hash(), @@ -375,43 +352,32 @@ impl RpcWallet { }; fetcher.start(wallet_tip); - + let mut synced_at_least_once = false; + let mut last_mempool_check = Instant::now(); loop { if shutdown.try_recv().is_ok() { info!("Shutting down wallet sync"); break; } if let Ok(command) = commands.try_recv() { - Self::wallet_handle_commands(network, &source, &mut state, &mut wallet, command)?; + let synced = Self::all_synced(&source, &mut state, &wallet).is_some(); + Self::wallet_handle_commands(network, &source, &mut state, &mut wallet, command, synced)?; } if let Ok(event) = receiver.try_recv() { match event { + BlockEvent::Tip(_) => { + synced_at_least_once = true; + } BlockEvent::Block(id, block) => { wallet.apply_block_connected_to( id.height, &block, - wallet::bdk_wallet::chain::BlockId { + BlockId { height: wallet_tip.height, hash: wallet_tip.hash, }, )?; - // Temporary fix for https://github.com/bitcoindevkit/bdk/issues/1740 - for tx in block.txdata { - for input in tx.input.iter() { - if wallet.watch_bid_spends.contains(&input.previous_output) { - if wallet.spaces.get_tx(tx.compute_txid()).is_some() { - break; - } - wallet.insert_tx(tx, ConfirmationTime::Confirmed { - height: id.height, - time: block.header.time as _, - })?; - break; - } - } - } - wallet_tip.height = id.height; wallet_tip.hash = id.hash; @@ -421,31 +387,52 @@ impl RpcWallet { } BlockEvent::Error(e) if matches!(e, BlockFetchError::BlockMismatch) => { let mut checkpoint_in_chain = None; - let best_chain = source.get_best_chain()?; - for cp in wallet.spaces.local_chain().iter_checkpoints() { - if cp.height() > best_chain.height { + let best_chain = match source.get_best_chain() { + Ok(best) => best, + Err(error) => { + warn!("Wallet error: {}", error); + fetcher.restart(wallet_tip, &receiver); continue; } + }; - let hash = source.get_block_hash(cp.height())?; + for cp in wallet.local_chain().iter_checkpoints() { + if cp.height() > best_chain.height { + continue; + } + let hash = match source.get_block_hash(cp.height()) { + Ok(hash) => hash, + Err(err) => { + warn!("Wallet error: {}", err); + fetcher.restart(wallet_tip, &receiver); + continue; + } + }; if cp.height() != 0 && hash == cp.hash() { checkpoint_in_chain = Some(cp); break; } } - let restore_point = match checkpoint_in_chain { None => { // We couldn't find a restore point warn!("Rebuilding wallet `{}`", wallet.config.name); let birthday = wallet.config.start_block; - let hash = source.get_block_hash(birthday)?; + let hash = match source.get_block_hash(birthday) { + Ok(hash) => hash, + Err(error) => { + warn!("Wallet error: {}", error); + fetcher.restart(wallet_tip, &receiver); + continue; + } + }; + let cp = CheckPoint::new(BlockId { height: birthday, hash, }); wallet = wallet.rebuild()?; - wallet.spaces.insert_checkpoint(cp.block_id())?; + wallet.insert_checkpoint(cp.block_id())?; cp } Some(cp) => cp, @@ -460,15 +447,32 @@ impl RpcWallet { wallet_tip.hash, wallet_tip.height ); - fetcher.start(wallet_tip); + fetcher.restart(wallet_tip, &receiver); + } + BlockEvent::Error(e) => { + warn!("Fetcher: {} - retrying in 1s", e); + std_wait(|| shutdown.try_recv().is_ok(), Duration::from_secs(1)); + fetcher.restart(wallet_tip, &receiver); } - BlockEvent::Error(e) => return Err(e.into()), } continue; } - // TODO: update wallet mempool + if synced_at_least_once && last_mempool_check.elapsed() > MEMPOOL_CHECK_INTERVAL { + if let Some(common_tip) = Self::all_synced(&source, &mut state, &wallet) { + let mem = MempoolChecker(&source); + match wallet.update_unconfirmed_bids(mem, common_tip.height, &mut state) { + Ok(txids) => for txid in txids { + info!("Dropped {} - no longer in the mempool", txid); + } + Err(err) => + warn!("Could not check for unconfirmed bids in mempool: {}", err), + } + last_mempool_check = Instant::now(); + } + } + std::thread::sleep(Duration::from_millis(10)); } @@ -476,31 +480,65 @@ impl RpcWallet { Ok(()) } - fn get_spaces_coin_selection( + fn list_spaces( wallet: &mut SpacesWallet, - state: &mut LiveSnapshot, - confirmed_only: bool, - ) -> anyhow::Result { - // Filters out all "spaceouts" with value higher than DUST threshold && all spaceouts representing - // spaces. - // - // Note: This exclusion only applies to confirmed spaceouts; unconfirmed ones are not excluded - // as we cannot easily detect them for now. - // In practice, this should be fine since Spaces coin selection skips dust by default, - // so explicitly excluding spaceouts may be redundant. - let excluded = Self::list_unspent(wallet, state)? - .into_iter() - .filter(|out| out.space.is_some() || - (out.is_spaceout && out.output.txout.value <= SpacesAwareCoinSelection::DUST_THRESHOLD) - ) - .map(|out| SelectionOutput { - outpoint: out.output.outpoint, - is_space: out.space.is_some(), - is_spaceout: out.is_spaceout, - }) - .collect::>(); + state: &mut LiveSnapshot + ) -> anyhow::Result { + let unspent = wallet.list_unspent_with_details(state)?; + let recent_events = wallet.list_recent_events()?; + + let mut res = ListSpacesResponse { + winning: vec![], + outbid: vec![], + owned: vec![], + }; + for (txid, event) in recent_events { + if unspent.iter() + .any(|out| out.space.as_ref() + .is_some_and(|s| &s.name.to_string() == event.space.as_ref().unwrap())) { + continue; + } + let name = SLabel::from_str(event.space.as_ref().unwrap()).expect("valid space name"); + let spacehash = SpaceKey::from(Sha256::hash(name.as_ref())); + let space = state.get_space_info(&spacehash)?; + if let Some(space) = space { + let tx = wallet.get_tx(txid); + if tx.is_none() { + res.outbid.push(space); + continue; + } + + let foreign_input = match event.foreign_input { + None => continue, + Some(outpoint) => outpoint + }; + if foreign_input != space.outpoint() { + res.outbid.push(space); + } + } + } + + for wallet_output in unspent.into_iter().filter(|output| output.space.is_some()) { + let entry = FullSpaceOut { + txid: wallet_output.output.outpoint.txid, + spaceout: SpaceOut { + n: wallet_output.output.outpoint.vout as _, + space: wallet_output.space, + script_pubkey: wallet_output.output.txout.script_pubkey, + value: wallet_output.output.txout.value, + }, + }; + + let space = entry.spaceout.space.as_ref().expect("space"); + if matches!(space.covenant, protocol::Covenant::Bid { .. }) { + res.winning.push(entry); + } else if matches!(space.covenant, protocol::Covenant::Transfer { .. }) { + res.owned.push(entry); + } + } + - Ok(SpacesAwareCoinSelection::new(excluded, confirmed_only)) + Ok(res) } fn list_transactions( @@ -508,10 +546,10 @@ impl RpcWallet { count: usize, skip: usize, ) -> anyhow::Result> { - let mut transactions: Vec<_> = wallet.spaces.transactions().collect(); + let mut transactions: Vec<_> = wallet.transactions().collect(); transactions.sort(); - Ok(transactions + let mut txs: Vec<_> = transactions .iter() .rev() .skip(skip) @@ -520,40 +558,36 @@ impl RpcWallet { let tx = ctx.tx_node.tx.clone(); let txid = ctx.tx_node.txid.clone(); let confirmed = ctx.chain_position.is_confirmed(); - let (sent, received) = wallet.spaces.sent_and_received(&tx); - let fee = wallet.spaces.calculate_fee(&tx).ok(); + let (sent, received) = wallet.sent_and_received(&tx); + let fee = wallet.calculate_fee(&tx).ok(); TxInfo { txid, confirmed, sent, received, fee, + events: vec![], } }) - .collect()) - } - - fn list_unspent( - wallet: &mut SpacesWallet, - store: &mut LiveSnapshot, - ) -> anyhow::Result> { - let mut wallet_outputs = Vec::new(); - - for output in wallet.spaces.list_unspent() { - let mut details = WalletOutput { - output, - space: None, - is_spaceout: false, + .collect(); + + // TODO: use a single query? + for tx in txs.iter_mut() { + tx.events = { + let conn = wallet.connection.transaction()?; + let mut events = TxEvent::all(&conn, tx.txid).expect("tx event"); + for event in events.iter_mut() { + match event.kind { + TxEventKind::Commit => { + event.details = None + } + _ => {} + } + } + events }; - - let result = store.get_spaceout(&details.output.outpoint)?; - if let Some(spaceout) = result { - details.is_spaceout = true; - details.space = spaceout.space; - } - wallet_outputs.push(details) } - Ok(wallet_outputs) + Ok(txs) } fn resolve( @@ -596,7 +630,6 @@ impl RpcWallet { fn replaces_unconfirmed_bid(wallet: &SpacesWallet, bid_spaceout: &FullSpaceOut) -> bool { let outpoint = bid_spaceout.outpoint(); wallet - .spaces .transactions() .filter(|tx| !tx.chain_position.is_confirmed()) .any(|tx| { @@ -614,8 +647,7 @@ impl RpcWallet { store: &mut LiveSnapshot, tx: RpcWalletTxBuilder, ) -> anyhow::Result { - let tip_height = wallet.spaces.local_chain().tip().height(); - + let tip_height = wallet.local_chain().tip().height(); if let Some(dust) = tx.dust { if dust > SpacesAwareCoinSelection::DUST_THRESHOLD { @@ -652,14 +684,14 @@ impl RpcWallet { RpcWalletRequest::SendCoins(params) => { let recipient = match Self::resolve(network, store, ¶ms.to, false)? { None => { - return Err(anyhow!("sendcoins: could not resolve '{}'", params.to)) + return Err(anyhow!("send: could not resolve '{}'", params.to)) } Some(r) => r, }; - builder = builder.add_transfer(TransferRequest::Coin(CoinTransfer { + builder = builder.add_send(CoinTransfer { amount: params.amount, recipient: recipient.clone(), - })); + }); } RpcWalletRequest::Transfer(params) => { let spaces: Vec<_> = params @@ -679,22 +711,28 @@ impl RpcWallet { for space in spaces { let spacehash = SpaceKey::from(Sha256::hash(space.as_ref())); match store.get_space_info(&spacehash)? { - None => return Err(anyhow!("sendspaces: you don't own `{}`", space)), + None => return Err(anyhow!("transfer: you don't own `{}`", space)), Some(full) if full.spaceout.space.is_none() || !full.spaceout.space.as_ref().unwrap().is_owned() || !wallet - .spaces - .is_mine(full.spaceout.script_pubkey.as_script()) => + .is_mine(full.spaceout.script_pubkey.clone()) => { - return Err(anyhow!("sendspaces: you don't own `{}`", space)); + return Err(anyhow!("transfer: you don't own `{}`", space)); } + + Some(full) if wallet.get_utxo(full.outpoint()).is_none() => { + return Err(anyhow!( + "transfer '{}': wallet already has a pending tx for this space", + space + )); + } + Some(full) => { - builder = - builder.add_transfer(TransferRequest::Space(SpaceTransfer { - space: full, - recipient: recipient.clone(), - })); + builder = builder.add_transfer(SpaceTransfer { + space: full, + recipient: recipient.clone(), + }); } }; } @@ -735,16 +773,23 @@ impl RpcWallet { return Err(anyhow!("register '{}': space does not exist", params.name)); } let utxo = spaceout.unwrap(); - if !wallet.spaces.is_mine(&utxo.spaceout.script_pubkey) { + if !wallet.is_mine(utxo.spaceout.script_pubkey.clone()) { return Err(anyhow!( "register '{}': you don't own this space", params.name )); } + if wallet.get_utxo(utxo.outpoint()).is_none() { + return Err(anyhow!( + "register '{}': wallet already has a pending tx for this space", + params.name + )); + } + if !tx.force { let claim_height = utxo.spaceout.space.as_ref().unwrap().claim_height(); - let tip_height = wallet.spaces.local_chain().tip().height(); + let tip_height = wallet.local_chain().tip().height(); if claim_height.is_none() { return Err(anyhow!( @@ -785,15 +830,23 @@ impl RpcWallet { let spacehash = SpaceKey::from(Sha256::hash(name.as_ref())); let spaceout = store.get_space_info(&spacehash)?; if spaceout.is_none() { - return Err(anyhow!("execute on '{}': space does not exist", space)); + return Err(anyhow!("script '{}': space does not exist", space)); } let spaceout = spaceout.unwrap(); - if !wallet.spaces.is_mine(&spaceout.spaceout.script_pubkey) { + if !wallet.is_mine(spaceout.spaceout.script_pubkey.clone()) { return Err(anyhow!( - "execute on '{}': you don't own this space", + "script '{}': you don't own this space", space )); } + + if wallet.get_utxo(spaceout.outpoint()).is_none() { + return Err(anyhow!( + "script '{}': wallet already has a pending tx for this space", + space + )); + } + let address = wallet.next_unused_space_address(); spaces.push(SpaceTransfer { space: spaceout, @@ -807,16 +860,15 @@ impl RpcWallet { } } + let unspendables = wallet.list_spaces_outpoints(store)?; let median_time = source.get_median_time()?; - let coin_selection = Self::get_spaces_coin_selection(wallet, store, bid_replacement)?; let mut checker = TxChecker::new(store); if !tx.skip_tx_check { let mut unconfirmed: Vec<_> = wallet - .spaces .transactions() - .filter(|x| - !x.chain_position.is_confirmed()).collect(); + .filter(|x| !x.chain_position.is_confirmed()) + .collect(); unconfirmed.sort(); // no tx checks for unconfirmed as they're already broadcasted, // but we need to build on their state still @@ -825,29 +877,29 @@ impl RpcWallet { } } - let mut tx_iter = builder.build_iter(tx.dust, median_time, wallet, coin_selection)?; + let mut tx_iter = builder.build_iter(tx.dust, median_time, wallet, unspendables, bid_replacement)?; let mut result_set = Vec::new(); while let Some(tx_result) = tx_iter.next() { - let tagged = tx_result?; + let tx_record = tx_result?; - let is_bid = tagged.tags.iter().any(|tag| *tag == TransactionTag::Bid); + let is_bid = tx_record.events.iter().any(|tag| tag.kind == TxEventKind::Bid); result_set.push(TxResponse { - txid: tagged.tx.compute_txid(), - tags: tagged.tags, + txid: tx_record.tx.compute_txid(), + events: tx_record.events.clone(), error: None, raw: None, }); if !tx.skip_tx_check { - checker.check_apply_tx(tip_height + 1, &tagged.tx)?; + checker.check_apply_tx(tip_height + 1, &tx_record.tx)?; } - let raw = bitcoin::consensus::encode::serialize_hex(&tagged.tx); - let result = source.rpc.broadcast_tx(&source.client, &tagged.tx); + let raw = bitcoin::consensus::encode::serialize_hex(&tx_record.tx); + let result = source.rpc.broadcast_tx(&source.client, &tx_record.tx); match result { - Ok(confirmation) => { - tx_iter.wallet.insert_tx(tagged.tx, confirmation)?; + Ok(last_seen) => { + tx_iter.wallet.apply_unconfirmed_tx_record(tx_record, last_seen)?; tx_iter.wallet.commit()?; } Err(e) => { @@ -892,11 +944,30 @@ impl RpcWallet { Ok(WalletResponse { result: result_set }) } + pub fn load_wallet( + src: &BitcoinBlockSource, + request: &WalletLoadRequest, + ) -> anyhow::Result { + let mut wallet = SpacesWallet::new(request.config.clone())?; + let wallet_tip = wallet.local_chain().tip().height(); + + if wallet_tip < request.export.blockheight { + let hash = src.get_block_hash(request.export.blockheight)?; + wallet.insert_checkpoint(BlockId { + height: request.export.blockheight, + hash, + })?; + wallet.commit()?; + } + + Ok(wallet) + } + pub async fn service( network: ExtendedNetwork, rpc: BitcoinRpc, store: LiveSnapshot, - mut channel: Receiver, + mut channel: Receiver, shutdown: broadcast::Sender<()>, num_workers: usize, ) -> anyhow::Result<()> { @@ -911,9 +982,7 @@ impl RpcWallet { } wallet = channel.recv() => { if let Some( loaded ) = wallet { - let wallet_name = loaded.wallet.name().to_string(); - info!("Loaded wallet: {}", wallet_name); - + let wallet_name = loaded.export.label.clone(); let wallet_chain = store.clone(); let rpc = rpc.clone(); let wallet_shutdown = shutdown.subscribe(); @@ -921,15 +990,23 @@ impl RpcWallet { std::thread::spawn(move || { let source = BitcoinBlockSource::new(rpc); - _ = tx.send(Self::wallet_sync( - network, - source, - wallet_chain, - loaded.wallet, - loaded.rx, - wallet_shutdown, - num_workers - )); + let wallet = Self::load_wallet(&source, &loaded); + match wallet { + Ok(wallet) => { + _ = tx.send(Self::wallet_sync( + network, + source, + wallet_chain, + wallet, + loaded.rx, + wallet_shutdown, + num_workers + )); + } + Err(err) => { + _ = tx.send(Err(err)); + } + } }); wallet_results.push(named_future(wallet_name, rx)); } @@ -1021,7 +1098,7 @@ impl RpcWallet { resp_rx.await? } - pub async fn send_list_spaces(&self) -> anyhow::Result> { + pub async fn send_list_spaces(&self) -> anyhow::Result { let (resp, resp_rx) = oneshot::channel(); self.sender.send(WalletCommand::ListSpaces { resp }).await?; resp_rx.await? @@ -1077,7 +1154,7 @@ fn fee_rate_from_message(message: &str) -> Option { async fn named_future( name: String, - rx: tokio::sync::oneshot::Receiver, -) -> (String, Result) { + rx: oneshot::Receiver, +) -> (String, Result) { (name, rx.await) } diff --git a/node/tests/fetcher_tests.rs b/node/tests/fetcher_tests.rs index 4c508e7..fbb041f 100644 --- a/node/tests/fetcher_tests.rs +++ b/node/tests/fetcher_tests.rs @@ -37,6 +37,7 @@ fn test_block_fetching_from_bitcoin_rpc() -> Result<()> { panic!("Test timed out after {:?}", timeout); } match receiver.try_recv() { + Ok(BlockEvent::Tip(_)) => {} Ok(BlockEvent::Block(id, _)) => { height += 1; if id.height == GENERATED_BLOCKS as u32 { diff --git a/node/tests/integration_tests.rs b/node/tests/integration_tests.rs index bbd876c..3d12343 100644 --- a/node/tests/integration_tests.rs +++ b/node/tests/integration_tests.rs @@ -1,13 +1,15 @@ -use std::path::{PathBuf}; -use std::str::FromStr; -use protocol::bitcoin::{Amount, FeeRate}; -use protocol::constants::RENEWAL_INTERVAL; -use protocol::{Covenant}; -use protocol::script::SpaceScript; -use spaced::rpc::{BidParams, ExecuteParams, OpenParams, RegisterParams, RpcClient, RpcWalletRequest, RpcWalletTxBuilder, TransferSpacesParams}; -use spaced::wallets::{AddressKind, WalletResponse}; -use testutil::{TestRig}; +use std::{path::PathBuf, str::FromStr}; +use protocol::{bitcoin::{Amount, FeeRate}, constants::RENEWAL_INTERVAL, script::SpaceScript, Covenant}; +use spaced::{ + rpc::{ + BidParams, ExecuteParams, OpenParams, RegisterParams, RpcClient, RpcWalletRequest, + RpcWalletTxBuilder, TransferSpacesParams, + }, + wallets::{AddressKind, WalletResponse}, +}; +use testutil::TestRig; use wallet::export::WalletExport; +use wallet::tx_event::TxEventKind; const ALICE: &str = "wallet_99"; const BOB: &str = "wallet_98"; @@ -16,16 +18,22 @@ const EVE: &str = "wallet_93"; const TEST_SPACE: &str = "@example123"; const TEST_INITIAL_BID: u64 = 5000; - /// alice opens [TEST_SPACE] for auction async fn it_should_open_a_space_for_auction(rig: &TestRig) -> anyhow::Result<()> { rig.wait_until_wallet_synced(ALICE).await?; - let response = wallet_do(rig, ALICE, vec![ - RpcWalletRequest::Open(OpenParams { + let response = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Open(OpenParams { name: TEST_SPACE.to_string(), amount: TEST_INITIAL_BID, - }), - ], false).await.expect("send request"); + })], + false, + ) + .await + .expect("send request"); + + println!("{}", serde_json::to_string_pretty(&response).unwrap()); for tx_res in &response.result { assert!(tx_res.error.is_none(), "expect no errors for simple open"); @@ -40,12 +48,21 @@ async fn it_should_open_a_space_for_auction(rig: &TestRig) -> anyhow::Result<()> let space = fullspaceout.spaceout.space.expect("a space"); match space.covenant { - Covenant::Bid { total_burned, burn_increment, claim_height, .. } => { + Covenant::Bid { + total_burned, + burn_increment, + claim_height, + .. + } => { assert!(claim_height.is_none(), "none for pre-auctions"); assert_eq!(total_burned, burn_increment, "equal for initial bid"); - assert_eq!(total_burned, Amount::from_sat(TEST_INITIAL_BID), "must be equal to opened bid"); + assert_eq!( + total_burned, + Amount::from_sat(TEST_INITIAL_BID), + "must be equal to opened bid" + ); } - _ => panic!("expected a bid covenant") + _ => panic!("expected a bid covenant"), } Ok(()) @@ -62,13 +79,16 @@ async fn it_should_allow_outbidding(rig: &TestRig) -> anyhow::Result<()> { let alices_balance = rig.spaced.client.wallet_get_balance(ALICE).await?; let result = wallet_do( - rig, BOB, - vec![ - RpcWalletRequest::Bid(BidParams { - name: TEST_SPACE.to_string(), - amount: TEST_INITIAL_BID + 1, - }), - ], false).await.expect("send request"); + rig, + BOB, + vec![RpcWalletRequest::Bid(BidParams { + name: TEST_SPACE.to_string(), + amount: TEST_INITIAL_BID + 1, + })], + false, + ) + .await + .expect("send request"); println!("{}", serde_json::to_string_pretty(&result).unwrap()); rig.mine_blocks(1, None).await?; @@ -80,29 +100,75 @@ async fn it_should_allow_outbidding(rig: &TestRig) -> anyhow::Result<()> { let alice_spaces_updated = rig.spaced.client.wallet_list_spaces(ALICE).await?; let alices_balance_updated = rig.spaced.client.wallet_get_balance(ALICE).await?; - assert_eq!(alices_spaces.len() - 1, alice_spaces_updated.len(), "alice must have one less space"); - assert_eq!(bobs_spaces.len() + 1, bob_spaces_updated.len(), "bob must have a new space"); - assert_eq!(alices_balance_updated.balance, alices_balance.balance + - Amount::from_sat(TEST_INITIAL_BID + 662), "alice must be refunded this exact amount"); + assert_eq!( + alices_spaces.winning.len() - 1, + alice_spaces_updated.winning.len(), + "alice must have one less space" + ); + // assert_eq!( + // alices_spaces.outbid.len() + 1, + // alice_spaces_updated.outbid.len(), + // "alice must have one less space" + // ); + assert_eq!( + bobs_spaces.winning.len() + 1, + bob_spaces_updated.winning.len(), + "bob must have a new space" + ); + assert_eq!( + alices_balance_updated.balance, + alices_balance.balance + Amount::from_sat(TEST_INITIAL_BID + 662), + "alice must be refunded this exact amount" + ); let fullspaceout = rig.spaced.client.get_space(TEST_SPACE).await?; let fullspaceout = fullspaceout.expect("a fullspace out"); let space = fullspaceout.spaceout.space.expect("a space"); match space.covenant { - Covenant::Bid { total_burned, burn_increment, claim_height, .. } => { + Covenant::Bid { + total_burned, + burn_increment, + claim_height, + .. + } => { assert!(claim_height.is_none(), "none for pre-auctions"); - assert_eq!(total_burned, Amount::from_sat(TEST_INITIAL_BID + 1), "total burned"); - assert_eq!(burn_increment, Amount::from_sat(1), "burn increment only 1 sat"); + assert_eq!( + total_burned, + Amount::from_sat(TEST_INITIAL_BID + 1), + "total burned" + ); + assert_eq!( + burn_increment, + Amount::from_sat(1), + "burn increment only 1 sat" + ); } - _ => panic!("expected a bid covenant") + _ => panic!("expected a bid covenant"), } Ok(()) } + +async fn it_should_insert_txout_for_bids(rig: &TestRig) -> anyhow::Result<()> { + rig.wait_until_synced().await?; + rig.wait_until_wallet_synced(BOB).await?; + + let tx = rig.spaced.client + .wallet_list_transactions(BOB, 10, 0).await?.iter() + .filter(|tx| tx.events.iter().any(|event| event.kind == TxEventKind::Bid)) + .next() + .expect("a bid").clone(); + + assert!(tx.fee.is_some(), "must be able to calculate fees"); + Ok(()) +} + /// Eve makes an invalid bid with a burn increment of 0 only refunding Bob's money -async fn it_should_only_accept_forced_zero_value_bid_increments_and_revoke(rig: &TestRig) -> anyhow::Result<()> { +async fn it_should_only_accept_forced_zero_value_bid_increments_and_revoke( + rig: &TestRig, +) -> anyhow::Result<()> { // Bob outbids alice rig.wait_until_wallet_synced(BOB).await?; rig.wait_until_wallet_synced(EVE).await?; @@ -110,60 +176,77 @@ async fn it_should_only_accept_forced_zero_value_bid_increments_and_revoke(rig: let bob_spaces = rig.spaced.client.wallet_list_spaces(BOB).await?; let bob_balance = rig.spaced.client.wallet_get_balance(BOB).await?; - let fullspaceout = rig.spaced.client.get_space(TEST_SPACE).await?.expect("exists"); + let fullspaceout = rig + .spaced + .client + .get_space(TEST_SPACE) + .await? + .expect("exists"); let space = fullspaceout.spaceout.space.expect("a space"); let last_bid = match space.covenant { Covenant::Bid { total_burned, .. } => total_burned, - _ => panic!("expected a bid") + _ => panic!("expected a bid"), }; - assert!(wallet_do( - rig, EVE, - vec![ - RpcWalletRequest::Bid(BidParams { + assert!( + wallet_do( + rig, + EVE, + vec![RpcWalletRequest::Bid(BidParams { name: TEST_SPACE.to_string(), amount: last_bid.to_sat(), - }), - ], - false).await.is_err(), "shouldn't be able to bid with same value unless forced"); + }), ], + false + ) + .await + .is_err(), + "shouldn't be able to bid with same value unless forced" + ); // force only - assert!(rig.spaced.client.wallet_send_request( - EVE, - RpcWalletTxBuilder { - bidouts: None, - requests: vec![ - RpcWalletRequest::Bid(BidParams { - name: TEST_SPACE.to_string(), - amount: last_bid.to_sat(), - }), - ], - fee_rate: Some(FeeRate::from_sat_per_vb(1).expect("fee")), - dust: None, - force: true, - confirmed_only: false, - skip_tx_check: false, - }, - ).await.is_err(), "should require skip tx check"); + assert!( + rig.spaced + .client + .wallet_send_request( + EVE, + RpcWalletTxBuilder { + bidouts: None, + requests: vec![RpcWalletRequest::Bid(BidParams { + name: TEST_SPACE.to_string(), + amount: last_bid.to_sat(), + }), ], + fee_rate: Some(FeeRate::from_sat_per_vb(1).expect("fee")), + dust: None, + force: true, + confirmed_only: false, + skip_tx_check: false, + }, + ) + .await + .is_err(), + "should require skip tx check" + ); // force & skip tx check - let result = rig.spaced.client.wallet_send_request( - EVE, - RpcWalletTxBuilder { - bidouts: None, - requests: vec![ - RpcWalletRequest::Bid(BidParams { + let result = rig + .spaced + .client + .wallet_send_request( + EVE, + RpcWalletTxBuilder { + bidouts: None, + requests: vec![RpcWalletRequest::Bid(BidParams { name: TEST_SPACE.to_string(), amount: last_bid.to_sat(), - }), - ], - fee_rate: Some(FeeRate::from_sat_per_vb(1).expect("fee")), - dust: None, - force: true, - confirmed_only: false, - skip_tx_check: true, - }, - ).await?; + })], + fee_rate: Some(FeeRate::from_sat_per_vb(1).expect("fee")), + dust: None, + force: true, + confirmed_only: false, + skip_tx_check: true, + }, + ) + .await?; println!("{}", serde_json::to_string_pretty(&result).unwrap()); rig.mine_blocks(1, None).await?; @@ -175,10 +258,21 @@ async fn it_should_only_accept_forced_zero_value_bid_increments_and_revoke(rig: let bob_balance_updated = rig.spaced.client.wallet_get_balance(BOB).await?; let eve_spaces_updated = rig.spaced.client.wallet_list_spaces(EVE).await?; - assert_eq!(bob_spaces.len() - 1, bob_spaces_updated.len(), "bob must have one less space"); - assert_eq!(bob_balance_updated.balance, bob_balance.balance + - Amount::from_sat(last_bid.to_sat() + 662), "alice must be refunded this exact amount"); - assert_eq!(eve_spaces_updated.len(), eve_spaces.len(), "eve must have the same number of spaces"); + assert_eq!( + bob_spaces.winning.len() - 1, + bob_spaces_updated.winning.len(), + "bob must have one less space" + ); + assert_eq!( + bob_balance_updated.balance, + bob_balance.balance + Amount::from_sat(last_bid.to_sat() + 662), + "alice must be refunded this exact amount" + ); + assert_eq!( + eve_spaces_updated.winning.len(), + eve_spaces.winning.len(), + "eve must have the same number of spaces" + ); let fullspaceout = rig.spaced.client.get_space(TEST_SPACE).await?; assert!(fullspaceout.is_none(), "must be revoked"); @@ -188,25 +282,40 @@ async fn it_should_only_accept_forced_zero_value_bid_increments_and_revoke(rig: async fn it_should_allow_claim_on_or_after_claim_height(rig: &TestRig) -> anyhow::Result<()> { let wallet = EVE; let claimable_space = "@test9880"; - let space = rig.spaced.client.get_space(claimable_space).await? + let space = rig + .spaced + .client + .get_space(claimable_space) + .await? .expect(claimable_space); let space = space.spaceout.space.expect(claimable_space); let current_height = rig.get_block_count().await?; let claim_height = space.claim_height().expect("height") as u64; - rig.mine_blocks((claim_height - current_height) as _, None).await?; + rig.mine_blocks((claim_height - current_height) as _, None) + .await?; - assert_eq!(claim_height, rig.get_block_count().await?, "heights must match"); + assert_eq!( + claim_height, + rig.get_block_count().await?, + "heights must match" + ); + rig.wait_until_synced().await?; rig.wait_until_wallet_synced(wallet).await?; let all_spaces = rig.spaced.client.wallet_list_spaces(wallet).await?; - let result = wallet_do(rig, wallet, vec![ - RpcWalletRequest::Register(RegisterParams { + let result = wallet_do( + rig, + wallet, + vec![RpcWalletRequest::Register(RegisterParams { name: claimable_space.to_string(), to: None, - }), - ], false).await.expect("send request"); + })], + false, + ) + .await + .expect("send request"); println!("{}", serde_json::to_string_pretty(&result).unwrap()); rig.mine_blocks(1, None).await?; @@ -215,9 +324,13 @@ async fn it_should_allow_claim_on_or_after_claim_height(rig: &TestRig) -> anyhow rig.wait_until_wallet_synced(wallet).await?; let all_spaces_2 = rig.spaced.client.wallet_list_spaces(wallet).await?; - assert_eq!(all_spaces.len(), all_spaces_2.len(), "must be equal"); + assert_eq!(all_spaces.owned.len() + 1, all_spaces_2.owned.len(), "must be equal"); - let space = rig.spaced.client.get_space(claimable_space).await? + let space = rig + .spaced + .client + .get_space(claimable_space) + .await? .expect(claimable_space); let space = space.spaceout.space.expect(claimable_space); @@ -228,26 +341,35 @@ async fn it_should_allow_claim_on_or_after_claim_height(rig: &TestRig) -> anyhow Ok(()) } -async fn it_should_allow_batch_transfers_refreshing_expire_height(rig: &TestRig) -> anyhow::Result<()> { +async fn it_should_allow_batch_transfers_refreshing_expire_height( + rig: &TestRig, +) -> anyhow::Result<()> { rig.wait_until_wallet_synced(ALICE).await?; rig.wait_until_synced().await?; let all_spaces = rig.spaced.client.wallet_list_spaces(ALICE).await?; - let registered_spaces: Vec<_> = all_spaces.iter().filter_map(|s| { - let space = s.space.as_ref().expect("space"); - match space.covenant { - Covenant::Transfer { .. } => Some(space.name.to_string()), - _ => None, - } - }).collect(); - - let space_address = rig.spaced.client.wallet_get_new_address(ALICE, AddressKind::Space).await?; + let registered_spaces: Vec<_> = all_spaces + .owned + .iter() + .map(|out| out.spaceout.space.as_ref().expect("space").name.to_string()) + .collect(); + + let space_address = rig + .spaced + .client + .wallet_get_new_address(ALICE, AddressKind::Space) + .await?; - let result = wallet_do(rig, ALICE, vec![ - RpcWalletRequest::Transfer(TransferSpacesParams { + let result = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Transfer(TransferSpacesParams { spaces: registered_spaces.clone(), to: space_address, - }), - ], false).await.expect("send request"); + })], + false, + ) + .await + .expect("send request"); println!("{}", serde_json::to_string_pretty(&result).unwrap()); @@ -258,21 +380,26 @@ async fn it_should_allow_batch_transfers_refreshing_expire_height(rig: &TestRig) rig.wait_until_wallet_synced(ALICE).await?; let all_spaces_2 = rig.spaced.client.wallet_list_spaces(ALICE).await?; - assert_eq!(all_spaces.len(), all_spaces_2.len(), "must be equal"); + assert_eq!(all_spaces.owned.len(), all_spaces_2.owned.len(), "must be equal"); - let mut count = 0; - all_spaces_2.iter().for_each(|s| { - let space = s.space.as_ref().expect("space"); + let _ = all_spaces_2.owned.iter().for_each(|s| { + let space = s.spaceout.space.as_ref().expect("space"); match space.covenant { Covenant::Transfer { expire_height, .. } => { - count += 1; - assert_eq!(expire_height, expected_expire_height, "must refresh expire height"); + assert_eq!( + expire_height, expected_expire_height, + "must refresh expire height" + ); } _ => {} } }); - assert_eq!(count, registered_spaces.len(), "must keep the exact number of registered spaces"); - assert_eq!(all_spaces.len(), all_spaces_2.len(), "shouldn't change number of held spaces"); + + assert_eq!( + all_spaces.winning.len(), + all_spaces_2.winning.len(), + "shouldn't change number of held spaces" + ); Ok(()) } @@ -281,20 +408,26 @@ async fn it_should_allow_applying_script_in_batch(rig: &TestRig) -> anyhow::Resu rig.wait_until_wallet_synced(ALICE).await?; rig.wait_until_synced().await?; let all_spaces = rig.spaced.client.wallet_list_spaces(ALICE).await?; - let registered_spaces: Vec<_> = all_spaces.iter().filter_map(|s| { - let space = s.space.as_ref().expect("space"); - match space.covenant { - Covenant::Transfer { .. } => Some(space.name.to_string()), - _ => None, - } - }).collect(); + let registered_spaces: Vec<_> = all_spaces.owned.iter().map(|out| + out.spaceout.space.as_ref().expect("space").name.to_string()).collect(); - let result = wallet_do(rig, ALICE, vec![ - RpcWalletRequest::Execute(ExecuteParams { - context: registered_spaces.clone(), - space_script: SpaceScript::create_set_fallback(&[0xDE, 0xAD, 0xBE, 0xEF]), - }), - ], false).await.expect("send request"); + let result = wallet_do( + rig, + ALICE, + vec![ + // TODO: transfer then execute causes stack to be outdated + // RpcWalletRequest::Transfer(TransferSpacesParams { + // spaces: registered_spaces.clone(), + // to: addr, + // }), + RpcWalletRequest::Execute(ExecuteParams { + context: registered_spaces.clone(), + space_script: SpaceScript::create_set_fallback(&[0xDE, 0xAD, 0xBE, 0xEF]), + })], + false, + ) + .await + .expect("send request"); println!("{}", serde_json::to_string_pretty(&result).unwrap()); @@ -305,168 +438,564 @@ async fn it_should_allow_applying_script_in_batch(rig: &TestRig) -> anyhow::Resu rig.wait_until_wallet_synced(ALICE).await?; let all_spaces_2 = rig.spaced.client.wallet_list_spaces(ALICE).await?; - assert_eq!(all_spaces.len(), all_spaces_2.len(), "must be equal"); + assert_eq!(all_spaces.owned.len(), all_spaces_2.owned.len(), "must be equal"); + assert_eq!(all_spaces.winning.len(), all_spaces_2.winning.len(), "must be equal"); - let mut count = 0; - all_spaces_2.iter().for_each(|s| { - let space = s.space.as_ref().expect("space"); + all_spaces_2.owned.iter().for_each(|s| { + let space = s.spaceout.space.as_ref().expect("space"); match &space.covenant { - Covenant::Transfer { expire_height, data } => { - count += 1; - assert_eq!(*expire_height, expected_expire_height, "must refresh expire height"); + Covenant::Transfer { + expire_height, + data, + } => { + assert_eq!( + *expire_height, expected_expire_height, + "must refresh expire height" + ); assert!(data.is_some(), "must be data set"); - assert_eq!(data.clone().unwrap().to_vec(), vec![0xDE, 0xAD, 0xBE, 0xEF], "must set correct data"); + assert_eq!( + data.clone().unwrap().to_vec(), + vec![0xDE, 0xAD, 0xBE, 0xEF], + "must set correct data" + ); } _ => {} } }); - assert_eq!(count, registered_spaces.len(), "must keep the exact number of registered spaces"); - assert_eq!(all_spaces.len(), all_spaces_2.len(), "shouldn't change number of held spaces"); - Ok(()) } - - // Alice places an unconfirmed bid on @test2. // Bob attempts to replace it but fails due to a lack of confirmed bid & funding utxos. // Eve, with confirmed bid outputs/funds, successfully replaces the bid. async fn it_should_replace_mempool_bids(rig: &TestRig) -> anyhow::Result<()> { - // create some confirmed bid outs for Eve - rig.spaced.client.wallet_send_request( - EVE, - RpcWalletTxBuilder { - bidouts: Some(2), - requests: vec![], - fee_rate: Some(FeeRate::from_sat_per_vb(2).expect("fee")), - dust: None, - force: false, - confirmed_only: false, - skip_tx_check: false, - }, - ).await?; + rig.wait_until_wallet_synced(ALICE).await?; + rig.wait_until_wallet_synced(BOB).await?; + rig.wait_until_wallet_synced(EVE).await?; + + // make sure Bob runs out of confirmed bidouts + let bob_bidout_count = rig.spaced.client.wallet_list_bidouts(BOB) + .await.expect("get bidouts").len(); + for i in 0..bob_bidout_count { + wallet_do( + rig, + BOB, + vec![RpcWalletRequest::Bid(BidParams { + name: format!("@test{}", i + 100), + amount: 200, + })], + false, + ) + .await.expect("bob makes a bid"); + } + + // create some confirmed bid outs for Alice and Eve + rig.spaced + .client + .wallet_send_request( + EVE, + RpcWalletTxBuilder { + bidouts: Some(2), + requests: vec![], + fee_rate: Some(FeeRate::from_sat_per_vb(2).expect("fee")), + dust: None, + force: false, + confirmed_only: false, + skip_tx_check: false, + }, + ) + .await.expect("send request"); + rig.spaced + .client + .wallet_send_request( + ALICE, + RpcWalletTxBuilder { + bidouts: Some(2), + requests: vec![], + fee_rate: Some(FeeRate::from_sat_per_vb(2).expect("fee")), + dust: None, + force: false, + confirmed_only: false, + skip_tx_check: false, + }, + ) + .await.expect("send request"); rig.mine_blocks(1, None).await?; + rig.wait_until_synced().await?; rig.wait_until_wallet_synced(ALICE).await?; rig.wait_until_wallet_synced(BOB).await?; rig.wait_until_wallet_synced(EVE).await?; - let response = wallet_do(rig, ALICE, vec![ - RpcWalletRequest::Bid(BidParams { + let response = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Bid(BidParams { name: "@test2".to_string(), amount: 1000, - })], false).await?; + })], + false, + ) + .await.expect("send request"); let response = serde_json::to_string_pretty(&response).unwrap(); - println!("{}", response); + println!("Alice bid on @test2 (unconf): {}", response); - let response = wallet_do(rig, BOB, vec![ - RpcWalletRequest::Bid(BidParams { + rig.wait_until_wallet_synced(BOB).await?; + let response = wallet_do( + rig, + BOB, + vec![RpcWalletRequest::Bid(BidParams { name: "@test2".to_string(), amount: 1000, - })], false).await?; + })], + false, + ) + .await.expect("send request"); let response = serde_json::to_string_pretty(&response).unwrap(); - println!("{}", response); - - assert!(response.contains("hint"), "should have a hint about replacement errors"); - - let replacement = rig.spaced.client.wallet_send_request( - BOB, - RpcWalletTxBuilder { - bidouts: None, - requests: vec![ - RpcWalletRequest::Bid(BidParams { + println!("Bob bid on @test (unconf) {}", response); + + assert!( + response.contains("hint"), + "should have a hint about replacement errors" + ); + + let replacement = rig + .spaced + .client + .wallet_send_request( + BOB, + RpcWalletTxBuilder { + bidouts: None, + requests: vec![RpcWalletRequest::Bid(BidParams { name: "@test2".to_string(), amount: 1000, })], - fee_rate: Some(FeeRate::from_sat_per_vb(2).expect("fee")), - dust: None, - force: false, - confirmed_only: false, - skip_tx_check: false, - }, - ).await?; + fee_rate: Some(FeeRate::from_sat_per_vb(2).expect("fee")), + dust: None, + force: false, + confirmed_only: false, + skip_tx_check: false, + }, + ) + .await.expect("send request"); + let response = serde_json::to_string_pretty(&replacement).unwrap(); println!("{}", response); - assert!(response.contains("hint"), "should have a hint about confirmed only"); - assert!(response.contains("replacement-adds-unconfirmed"), "expected a replacement-adds-unconfirmed in the message"); + assert!( + response.contains("hint"), + "should have a hint about confirmed only" + ); + assert!( + response.contains("replacement-adds-unconfirmed"), + "expected a replacement-adds-unconfirmed in the message" + ); // now let Eve try a replacement since she has confirmed outputs - let replacement = rig.spaced.client.wallet_send_request( - EVE, - RpcWalletTxBuilder { - bidouts: None, - requests: vec![ - RpcWalletRequest::Bid(BidParams { + let replacement = rig + .spaced + .client + .wallet_send_request( + EVE, + RpcWalletTxBuilder { + bidouts: None, + requests: vec![RpcWalletRequest::Bid(BidParams { name: "@test2".to_string(), amount: 1000, })], - fee_rate: Some(FeeRate::from_sat_per_vb(2).expect("fee")), - dust: None, - force: false, - confirmed_only: false, - skip_tx_check: false, - }, - ).await?; + fee_rate: Some(FeeRate::from_sat_per_vb(2).expect("fee")), + dust: None, + force: false, + confirmed_only: false, + skip_tx_check: false, + }, + ) + .await.expect("send request"); + + let eve_replacement_txid = replacement.result.iter().filter_map(|tx| { + if tx.events.iter().any(|event| event.kind == TxEventKind::Bid) { + Some(tx.txid) + } else { + None + } + }).next().expect("should have eve replacement txid"); let response = serde_json::to_string_pretty(&replacement).unwrap(); - println!("{}", response); + println!("Eve's replacement: {}", response); for tx_res in replacement.result { - assert!(tx_res.error.is_none(), "Eve should have no problem replacing") + assert!( + tx_res.error.is_none(), + "Eve should have no problem replacing" + ) + } + + // Wait until wallet checks mempool + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + + // Wallet must undo double spent tx. + let txs = rig + .spaced + .client + .wallet_list_transactions(ALICE, 1000, 0) + .await.expect("list transactions"); + let unconfirmed: Vec<_> = txs.iter().filter(|tx| !tx.confirmed).collect(); + for tx in &unconfirmed { + println!("Alice's unconfiremd: {}", tx.txid); } + assert_eq!( + unconfirmed.len(), + 0, + "there should be no stuck unconfirmed transactions" + ); - // Alice won't be able to build off other transactions from the double spent bid - // even when Eve bid gets confirmed. Wallet must remove double spent tx. + // Now Eve's tx is confirmed. Alice wallet must filter it out as irrelevant rig.mine_blocks(1, None).await?; rig.wait_until_wallet_synced(ALICE).await?; - let txs = rig.spaced.client.wallet_list_transactions( - ALICE, - 1000, 0 - ).await?; - let unconfirmed : Vec<_> = txs.iter().filter(|tx| !tx.confirmed).collect(); - assert_eq!(unconfirmed.len(), 0, "there should be no stuck unconfirmed transactions"); + + let txs = rig + .spaced + .client + .wallet_list_transactions(ALICE, 1000, 0) + .await.expect("list transactions"); + + assert!( + txs.iter().all(|tx| tx.txid != eve_replacement_txid), + "Eve's tx shouldn't be listed in Alice's wallet" + ); + + rig.wait_until_wallet_synced(EVE).await?; + let eve_txs = rig + .spaced + .client + .wallet_list_transactions(EVE, 1000, 0) + .await.expect("list transactions"); + + assert!( + eve_txs.iter().any(|tx| tx.txid == eve_replacement_txid && tx.confirmed), + "Eve's tx should be confirmed" + ); + + let space = rig.spaced.client.get_space("@test2").await.expect("space") + .expect("space exists"); + + println!("Space: {}", serde_json::to_string_pretty(&space).unwrap()); Ok(()) } async fn it_should_maintain_locktime_when_fee_bumping(rig: &TestRig) -> anyhow::Result<()> { rig.wait_until_wallet_synced(ALICE).await?; - let response = rig.spaced.client.wallet_send_request( - ALICE, - RpcWalletTxBuilder { - bidouts: Some(2), - requests: vec![], - fee_rate: Some(FeeRate::from_sat_per_vb(1).expect("fee")), - dust: None, - force: false, - confirmed_only: false, - skip_tx_check: false - }, - ).await?; - - println!("{}", serde_json::to_string_pretty(&response).unwrap()); + let response = rig + .spaced + .client + .wallet_send_request( + ALICE, + RpcWalletTxBuilder { + bidouts: Some(2), + requests: vec![], + fee_rate: Some(FeeRate::from_sat_per_vb(1).expect("fee")), + dust: None, + force: false, + confirmed_only: false, + skip_tx_check: false, + }, + ) + .await?; + + println!("bumping fee: {}", serde_json::to_string_pretty(&response).unwrap()); let txid = response.result[0].txid; - for tx_res in response.result{ + for tx_res in response.result { assert!(tx_res.error.is_none(), "should not be error"); } let tx = rig.get_raw_transaction(&txid).await?; - let bump = rig.spaced.client.wallet_bump_fee( - ALICE, txid, FeeRate::from_sat_per_vb(4).expect("fee"), false - ).await?; + let bump = rig + .spaced + .client + .wallet_bump_fee( + ALICE, + txid, + FeeRate::from_sat_per_vb(4).expect("fee"), + false, + ) + .await?; + + println!("after fee bump: {}", serde_json::to_string_pretty(&bump).unwrap()); assert_eq!(bump.len(), 1, "should only be 1 tx"); assert!(bump[0].error.is_none(), "should be no errors"); let replacement = rig.get_raw_transaction(&bump[0].txid).await?; - assert_eq!(tx.lock_time, replacement.lock_time, "locktimes must not change"); + assert_eq!( + tx.lock_time, replacement.lock_time, + "locktimes must not change" + ); + Ok(()) +} + +async fn it_should_not_allow_register_or_transfer_to_same_space_multiple_times(rig: &TestRig) -> anyhow::Result<()> { + rig.wait_until_wallet_synced(BOB).await.expect("synced"); + rig.wait_until_wallet_synced(ALICE).await.expect("synced"); + + // known from data set + let awaiting_claim = "@test9962".to_string(); + let response = wallet_do( + rig, + BOB, + vec![RpcWalletRequest::Register(RegisterParams { + name: awaiting_claim.clone(), + to: None, + })], + false, + ).await.expect("send request"); + + println!("{}", serde_json::to_string_pretty(&response).unwrap()); + assert!(wallet_do( + rig, + BOB, + vec![RpcWalletRequest::Register(RegisterParams { + name: awaiting_claim.clone(), + to: None, + })], + false, + ).await.is_err(), "should not allow register to same space multiple times"); + + + // Try transfer multiple times + let bob_address = rig + .spaced + .client + .wallet_get_new_address(BOB, AddressKind::Space) + .await?; + + let transfer = "@test9995".to_string(); + let response = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Transfer(TransferSpacesParams { + spaces: vec![transfer.clone()], + to: bob_address.clone(), + })], + false, + ).await.expect("send request"); + + println!("Transfer {}: {}", transfer, serde_json::to_string_pretty(&response).unwrap()); + wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Transfer(TransferSpacesParams { + spaces: vec![transfer], + to: bob_address, + })], + false, + ).await.expect_err("there's already a transfer submitted"); + + let setdata = "@test9996".to_string(); + let response = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Execute(ExecuteParams { + context: vec![setdata.clone()], + space_script: SpaceScript::create_set_fallback(&[0xAA, 0xAA]), + })], + false, + ).await.expect("send request"); + + println!("Update sent {}", serde_json::to_string_pretty(&response).unwrap()); + wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Execute(ExecuteParams { + context: vec![setdata], + space_script: SpaceScript::create_set_fallback(&[0xDE, 0xAD]), + })], + false, + ).await.expect_err("there's already an update submitted"); + + rig.mine_blocks(1, None).await.expect("mine"); + rig.wait_until_synced().await.expect("synced"); + + let space = rig.spaced.client.get_space("@test9996") + .await.expect("space").expect("spaceout exists") + .spaceout.space.expect("space exists"); + + match space.covenant { + Covenant::Transfer { data, .. } => { + assert!(data.is_some(), "data must be set"); + assert_eq!(data.unwrap().as_slice(), [0xAAu8, 0xAA].as_slice(), "data not correct"); + } + _ => panic!("expected transfer covenant"), + } + Ok(()) +} + +async fn it_can_batch_txs(rig: &TestRig) -> anyhow::Result<()> { + rig.wait_until_wallet_synced(ALICE).await.expect("synced"); + let bob_address = rig + .spaced + .client + .wallet_get_new_address(BOB, AddressKind::Space) + .await?; + let res = wallet_do( + rig, + ALICE, + vec![ + RpcWalletRequest::Transfer(TransferSpacesParams { + spaces: vec!["@test9996".to_string()], + to: bob_address, + }), + RpcWalletRequest::Bid(BidParams { + name: "@test100".to_string(), + amount: 201, + }), + RpcWalletRequest::Open(OpenParams { + name: "@batch2".to_string(), + amount: 1000, + }), + RpcWalletRequest::Open(OpenParams { + name: "@batch1".to_string(), + amount: 1000, + }), + RpcWalletRequest::Execute(ExecuteParams { + context: vec![ + "@test10000".to_string(), + "@test9999".to_string(), + "@test9998".to_string() + ], + // space_script: SpaceScript::create_set_fallback(&[0xEE, 0xEE, 0x22, 0x22]), + space_script: SpaceScript::create_set_fallback(&[0xEE, 0xEE, 0x22, 0x22]), + }), + ], + false, + ).await.expect("send request"); + + println!("batch request: {}", serde_json::to_string_pretty(&res).unwrap()); + assert!(res.result.iter().all(|tx| tx.error.is_none()), "batching should work"); + assert_eq!(res.result.len(), 5, "expected 4 transactions"); + + rig.mine_blocks(1, None).await.expect("mine"); + rig.wait_until_wallet_synced(ALICE).await.expect("synced"); + rig.wait_until_wallet_synced(BOB).await.expect("synced"); + + let bob_spaces = rig.spaced.client.wallet_list_spaces(BOB).await.expect("bob spaces"); + assert!(bob_spaces.owned.iter().find(|output| + output.spaceout.space.as_ref().is_some_and(|s| s.name.to_string() == "@test9996")).is_some(), + "expected bob to own the space name" + ); + + let alice_spaces = rig.spaced.client.wallet_list_spaces(ALICE).await.expect("alice spaces"); + let batch1 = alice_spaces.winning.iter().find(|output| + output.spaceout.space.as_ref().is_some_and(|s| s.name.to_string() == "@batch1")) + .expect("exists").spaceout.space.clone().expect("space exists"); + + match batch1.covenant { + Covenant::Bid { total_burned, .. } => { + assert_eq!(total_burned.to_sat(), 1000, "incorrect burn value") + } + _ => panic!("must be a bid") + } + let batch2 = alice_spaces.winning.iter().find(|output| + output.spaceout.space.as_ref().is_some_and(|s| s.name.to_string() == "@batch2")) + .expect("exists").spaceout.space.clone().expect("space exists"); + match batch2.covenant { + Covenant::Bid { total_burned, .. } => { + assert_eq!(total_burned.to_sat(), 1000, "incorrect burn value") + } + _ => panic!("must be a bid") + } + + for space in vec![ + "@test10000".to_string(), + "@test9999".to_string(), + "@test9998".to_string() + ] { + let space = alice_spaces.owned.iter().find(|output| + output.spaceout.space.as_ref().is_some_and(|s| s.name.to_string() == space)) + .expect("exists").spaceout.space.clone().expect("space exists"); + + match space.covenant { + Covenant::Transfer { data, .. } => { + assert_eq!( + data.clone().unwrap().to_vec(), + vec![0xEE, 0xEE, 0x22, 0x22], + "must set correct data" + ); + } + _ => panic!("must be a transfer") + } + } + + Ok(()) +} + + +async fn it_can_use_reserved_op_codes(rig: &TestRig) -> anyhow::Result<()> { + rig.wait_until_wallet_synced(ALICE).await.expect("synced"); + let alice_spaces = vec![ + "@test10000".to_string(), + "@test9999".to_string(), + "@test9998".to_string() + ]; + + let res = rig + .spaced + .client + .wallet_send_request( + ALICE, + RpcWalletTxBuilder { + bidouts: None, + requests: vec![ + RpcWalletRequest::Execute(ExecuteParams { + context: alice_spaces.clone(), + space_script: SpaceScript::create_reserve() + }), + ], + fee_rate: Some(FeeRate::from_sat_per_vb(1).expect("fee")), + dust: None, + force: true, + confirmed_only: false, + skip_tx_check: true, + }, + ) + .await.expect("response"); + + assert!(res.result.iter().all(|tx| tx.error.is_none()), "reserve should work"); + assert_eq!(res.result.len(), 2, "expected 2 transactions"); + + rig.mine_blocks(1, None).await.expect("mine"); + rig.wait_until_wallet_synced(ALICE).await.expect("synced"); + + for space in alice_spaces { + let space = rig.spaced.client.get_space(&space) + .await.expect("space").expect("space exists") + .spaceout.space.expect("space exists"); + + assert!(matches!(space.covenant, Covenant::Reserved), "expected a reserved space"); + } + + Ok(()) +} + + +async fn it_should_handle_reorgs(rig: &TestRig) -> anyhow::Result<()> { + rig.wait_until_wallet_synced(ALICE).await.expect("synced"); + const NAME: &str = "hello_world"; + rig.spaced.client.wallet_create(NAME).await.expect("wallet"); + rig.mine_blocks(2, None).await.expect("mine blocks"); + rig.wait_until_wallet_synced(NAME).await.expect("synced"); + + // reorg 20 blocks + rig.reorg(20).await.expect("reorg"); + rig.wait_until_wallet_synced(NAME).await.expect("synced"); + + rig.wait_until_wallet_synced(ALICE).await.expect("synced"); Ok(()) } @@ -483,31 +1012,47 @@ async fn run_auction_tests() -> anyhow::Result<()> { load_wallet(&rig, wallets_path.clone(), BOB).await?; load_wallet(&rig, wallets_path, EVE).await?; - it_should_open_a_space_for_auction(&rig).await?; - it_should_allow_outbidding(&rig).await?; - it_should_only_accept_forced_zero_value_bid_increments_and_revoke(&rig).await?; - it_should_allow_claim_on_or_after_claim_height(&rig).await?; - it_should_allow_batch_transfers_refreshing_expire_height(&rig).await?; - it_should_allow_applying_script_in_batch(&rig).await?; - it_should_replace_mempool_bids(&rig).await?; - it_should_maintain_locktime_when_fee_bumping(&rig).await?; - + it_should_open_a_space_for_auction(&rig).await.expect("should open auction"); + it_should_allow_outbidding(&rig).await.expect("should allow outbidding"); + it_should_insert_txout_for_bids(&rig).await.expect("should insert txout"); + it_should_only_accept_forced_zero_value_bid_increments_and_revoke(&rig).await.expect("should only revoke a bid"); + it_should_allow_claim_on_or_after_claim_height(&rig).await.expect("should allow claim on or above height"); + it_should_allow_batch_transfers_refreshing_expire_height(&rig).await.expect("should allow batch transfers refresh expire height"); + it_should_allow_applying_script_in_batch(&rig).await.expect("should allow batch applying script"); + it_should_replace_mempool_bids(&rig).await.expect("should replace mempool bids"); + it_should_maintain_locktime_when_fee_bumping(&rig).await.expect("should maintain locktime"); + it_should_not_allow_register_or_transfer_to_same_space_multiple_times(&rig).await + .expect("should not allow register/transfer multiple times"); + it_can_batch_txs(&rig).await.expect("bump fee"); + it_can_use_reserved_op_codes(&rig).await.expect("should use reserved opcodes"); + + // keep reorgs last as it can drop some txs from mempool and mess up wallet state + it_should_handle_reorgs(&rig).await.expect("should make wallet"); Ok(()) } -async fn wallet_do(rig: &TestRig, wallet: &str, requests: Vec, force: bool) -> anyhow::Result { - let res = rig.spaced.client.wallet_send_request( - wallet, - RpcWalletTxBuilder { - bidouts: None, - requests, - fee_rate: Some(FeeRate::from_sat_per_vb(1).expect("fee")), - dust: None, - force, - confirmed_only: false, - skip_tx_check: false, - }, - ).await?; +async fn wallet_do( + rig: &TestRig, + wallet: &str, + requests: Vec, + force: bool, +) -> anyhow::Result { + let res = rig + .spaced + .client + .wallet_send_request( + wallet, + RpcWalletTxBuilder { + bidouts: None, + requests, + fee_rate: Some(FeeRate::from_sat_per_vb(1).expect("fee")), + dust: None, + force, + confirmed_only: false, + skip_tx_check: false, + }, + ) + .await?; Ok(res) } diff --git a/testutil/build.rs b/testutil/build.rs index 2fe6dc7..01a8e8b 100644 --- a/testutil/build.rs +++ b/testutil/build.rs @@ -1,12 +1,12 @@ -use std::env; -use std::fs; -use std::path::Path; -use std::io; +use std::{env, fs, io, path::Path}; fn main() -> io::Result<()> { let zip_path = Path::new("testdata/regtest.zip"); if !zip_path.exists() { - return Err(io::Error::new(io::ErrorKind::NotFound, "no regtest testdata found in testdata/regtest.zip")); + return Err(io::Error::new( + io::ErrorKind::NotFound, + "no regtest testdata found in testdata/regtest.zip", + )); } let out_dir = env::var("OUT_DIR").unwrap(); let target_dir = Path::new(&out_dir).join("regtest_unpacked"); diff --git a/testutil/src/lib.rs b/testutil/src/lib.rs index 6f4e94b..ffb7a58 100644 --- a/testutil/src/lib.rs +++ b/testutil/src/lib.rs @@ -1,8 +1,13 @@ pub extern crate bitcoind; pub mod spaced; -use std::{fs, io, sync::Arc, time::Duration}; -use std::path::{Path, PathBuf}; +use std::{ + fs, io, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; +use std::collections::HashMap; use ::spaced::{ jsonrpsee::tokio, node::protocol::{ @@ -19,15 +24,15 @@ use ::spaced::{ use anyhow::Result; use bitcoind::{ anyhow, - anyhow::anyhow, + anyhow::{anyhow, Context}, bitcoincore_rpc::{ bitcoincore_rpc_json::{GetBlockTemplateModes, GetBlockTemplateRules}, RpcApi, }, + tempfile::{tempdir, TempDir}, BitcoinD, }; -use bitcoind::anyhow::Context; -use bitcoind::tempfile::{tempdir, TempDir}; +use bitcoind::bitcoincore_rpc::json; use crate::spaced::SpaceD; // Path to the pre-created regtest testdata in build.rs @@ -47,8 +52,8 @@ pub struct TestRig { impl TestRig { pub async fn new_with_regtest_preset() -> Result { - let original_test_data = bitcoin_regtest_data_path() - .context("could not get unpacked regtest testdata")?; + let original_test_data = + bitcoin_regtest_data_path().context("could not get unpacked regtest testdata")?; let test_data = tempdir()?; copy_dir_all(original_test_data, test_data.path())?; @@ -66,8 +71,11 @@ impl TestRig { } pub async fn testdata_wallets_path(&self) -> PathBuf { - self.test_data.as_ref().expect("created with regtest preset") - .path().join("wallets") + self.test_data + .as_ref() + .expect("created with regtest preset") + .path() + .join("wallets") } pub async fn new() -> Result { @@ -84,7 +92,10 @@ impl TestRig { Self::new_with_bitcoin_conf(conf, None).await } - pub async fn new_with_bitcoin_conf(conf: bitcoind::Conf<'static>, test_data: Option) -> Result { + pub async fn new_with_bitcoin_conf( + conf: bitcoind::Conf<'static>, + test_data: Option, + ) -> Result { let view_stdout = conf.view_stdout; let bitcoind = tokio::task::spawn_blocking(move || BitcoinD::from_downloaded_with_conf(&conf)) @@ -150,6 +161,7 @@ impl TestRig { /// Waits until named wallet tip == bitcoind tip pub async fn wait_until_wallet_synced(&self, wallet_name: &str) -> anyhow::Result<()> { + self.wait_until_synced().await?; loop { let c = self.bitcoind.clone(); let count = tokio::task::spawn_blocking(move || c.client.get_block_count()) @@ -204,8 +216,8 @@ impl TestRig { &[], ) }) - .await - .expect("handle")?; + .await + .expect("handle")?; let txdata = vec![Transaction { version: transaction::Version::ONE, @@ -327,6 +339,25 @@ impl TestRig { ) } + pub async fn get_raw_mempool(&self) -> Result> { + let c = self.bitcoind.clone(); + Ok( + tokio::task::spawn_blocking(move || c.client.get_raw_mempool_verbose()) + .await + .expect("handle")?, + ) + } + + pub async fn get_block(&self, hash: &BlockHash) -> Result { + let c = self.bitcoind.clone(); + let hash = hash.clone(); + Ok( + tokio::task::spawn_blocking(move || c.client.get_block(&hash)) + .await + .expect("handle")?, + ) + } + /// Reorg a number of blocks of a given size `count`. /// Refer to [`SpaceD::mine_empty_block`] for more information. /// @@ -376,8 +407,8 @@ impl TestRig { c.client .send_to_address(&addr, amount, None, None, None, None, None, None) }) - .await - .expect("handle")?; + .await + .expect("handle")?; Ok(txid) } } diff --git a/testutil/src/spaced.rs b/testutil/src/spaced.rs index fe87efb..35da50c 100644 --- a/testutil/src/spaced.rs +++ b/testutil/src/spaced.rs @@ -1,5 +1,9 @@ -use std::{net::Ipv4Addr, process::Child, time::Duration}; -use std::process::Stdio; +use std::{ + net::Ipv4Addr, + process::{Child, Stdio}, + time::Duration, +}; + use anyhow::Result; use assert_cmd::cargo::CommandCargoExt; use bitcoind::{anyhow, anyhow::anyhow, get_available_port, tempfile::tempdir}; diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 1e5cfd4..63a94cf 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -5,8 +5,8 @@ edition = "2021" [dependencies] bitcoin = { version = "0.32.2", features = ["base64", "serde"] } -bdk_wallet = { version = "=1.0.0-alpha.13", features = ["keys-bip39"] } -bdk_file_store = "0.13.0" +# bdk version 1.0.0-beta.6 + hard coded patch for double spend fix from PR https://github.com/bitcoindevkit/bdk/pull/1765 +bdk_wallet = { git = "https://github.com/buffrr/bdk.git", rev= "43bca8643dec6fdda99e4a29bf88709729af349e", features = ["keys-bip39", "rusqlite"] } secp256k1 = "0.29.0" anyhow = "1.0.80" bech32 = "0.11.0" @@ -17,4 +17,8 @@ jsonrpc = "0.18.0" protocol = { path = "../protocol", features = ["std"], version = "*" } ctrlc = "3.4.4" hex = "0.4.3" -log = "0.4.21" \ No newline at end of file +log = "0.4.21" + + +[dev-dependencies] +tempfile = "3.14.0" \ No newline at end of file diff --git a/wallet/src/builder.rs b/wallet/src/builder.rs index 3967010..1a6d18e 100644 --- a/wallet/src/builder.rs +++ b/wallet/src/builder.rs @@ -8,18 +8,18 @@ use std::{ use anyhow::{anyhow, Context}; use bdk_wallet::{ - wallet::{ - coin_selection::{ - CoinSelectionAlgorithm, CoinSelectionResult, DefaultCoinSelectionAlgorithm, Error, - }, - error::CreateTxError, - tx_builder::TxOrdering, + coin_selection::{ + CoinSelectionAlgorithm, CoinSelectionResult, DefaultCoinSelectionAlgorithm, + InsufficientFunds, }, + error::CreateTxError, + tx_builder::TxOrdering, KeychainKind, TxBuilder, Utxo, WeightedUtxo, }; use bitcoin::{ - absolute::LockTime, psbt, psbt::Input, script, script::PushBytesBuf, Address, Amount, FeeRate, - Network, OutPoint, Psbt, Script, ScriptBuf, Sequence, Transaction, TxOut, Txid, Witness, + absolute::LockTime, key::rand::RngCore, psbt::Input, script, script::PushBytesBuf, Address, + Amount, FeeRate, Network, OutPoint, Psbt, Script, ScriptBuf, Sequence, Transaction, TxOut, + Txid, Weight, Witness, }; use protocol::{ bitcoin::absolute::Height, @@ -27,9 +27,11 @@ use protocol::{ script::SpaceScript, Covenant, FullSpaceOut, Space, }; -use serde::{Deserialize, Serialize}; - -use crate::{address::SpaceAddress, DoubleUtxo, FullTxOut, SpaceScriptSigningInfo, SpacesWallet}; +use crate::{ + address::SpaceAddress, + DoubleUtxo, FullTxOut, SpaceScriptSigningInfo, SpacesWallet, +}; +use crate::tx_event::TxRecord; #[derive(Debug, Clone)] pub struct Builder { @@ -57,7 +59,8 @@ pub struct BuilderIterator<'a> { pub wallet: &'a mut SpacesWallet, force: bool, median_time: u64, - coin_selection: SpacesAwareCoinSelection, + confirmed_only: bool, + unspendables: Vec } pub enum BuilderStack { @@ -77,30 +80,34 @@ pub enum StackRequest { Open(OpenRequest), Bid(BidRequest), Register(RegisterRequest), - Transfer(TransferRequest), + Transfer(SpaceTransfer), + Send(CoinTransfer), Execute(ExecuteRequest), } pub enum StackOp { Prepare(CreateParams), - Open(OpenParams), + Open(OpenRevealParams), Bid(BidRequest), Execute(ExecuteParams), } +#[derive(Clone)] pub struct SpaceScriptRevealParams { signing: SpaceScriptSigningInfo, commitment: FullTxOut, } -pub struct OpenParams { - reveals: Vec, - amount: Amount, +#[derive(Clone)] +pub struct OpenRevealParams { + space: String, + initial_bid: Amount, + script: SpaceScriptRevealParams, } pub struct ExecuteParams { - reveal: SpaceScriptRevealParams, context: Vec, + reveal: SpaceScriptRevealParams, } #[derive(Debug, Clone)] @@ -109,12 +116,6 @@ pub struct RegisterRequest { pub to: Option, } -#[derive(Debug, Clone)] -pub enum TransferRequest { - Space(SpaceTransfer), - Coin(CoinTransfer), -} - #[derive(Debug, Clone)] pub struct SpaceTransfer { pub space: FullSpaceOut, @@ -136,8 +137,9 @@ pub struct ExecuteRequest { pub struct CreateParams { opens: Vec, executes: Vec, - transfers: Vec, - auction_outputs: Option, + transfers: Vec, + sends: Vec, + bidouts: Option, } #[derive(Clone, Debug)] @@ -169,7 +171,14 @@ trait TxBuilderSpacesUtils<'a, Cs: CoinSelectionAlgorithm> { signing: SpaceScriptSigningInfo, ) -> anyhow::Result<&mut Self>; - fn add_transfer(&mut self, request: TransferRequest) -> anyhow::Result<&mut Self>; + fn add_transfer(&mut self, request: SpaceTransfer) -> anyhow::Result<&mut Self>; + + fn add_send(&mut self, request: CoinTransfer) -> anyhow::Result<&mut Self>; +} + +fn tap_key_spend_weight() -> Weight { + let tap_key_spend_weight: u64 = 66; + Weight::from_vb(tap_key_spend_weight).expect("valid weight") } impl<'a, Cs: CoinSelectionAlgorithm> TxBuilderSpacesUtils<'a, Cs> for TxBuilder<'a, Cs> { @@ -179,12 +188,11 @@ impl<'a, Cs: CoinSelectionAlgorithm> TxBuilderSpacesUtils<'a, Cs> for TxBuilder< Some(out) => out, }; - let tap_key_spend_weight = 66; self.version(BID_PSBT_TX_VERSION.0); self.add_foreign_utxo_with_sequence( info.outpoint(), input, - tap_key_spend_weight, + tap_key_spend_weight(), BID_PSBT_INPUT_SEQUENCE, )?; self.add_recipient(txout.script_pubkey, txout.value); @@ -218,7 +226,7 @@ impl<'a, Cs: CoinSelectionAlgorithm> TxBuilderSpacesUtils<'a, Cs> for TxBuilder< }, }; - let mut spend_input = psbt::Input { + let mut spend_input = Input { witness_utxo: Some(placeholder.spend.txout.clone()), final_script_witness: Some(Witness::default()), final_script_sig: Some(ScriptBuf::new()), @@ -233,14 +241,14 @@ impl<'a, Cs: CoinSelectionAlgorithm> TxBuilderSpacesUtils<'a, Cs> for TxBuilder< placeholder.auction.outpoint.vout as u8, &offer, )?) - .expect("compressed psbt script bytes"); + .expect("compressed psbt script bytes"); let carrier = ScriptBuf::new_op_return(&compressed_psbt); self.add_foreign_utxo_with_sequence( placeholder.spend.outpoint, spend_input, - 66, + tap_key_spend_weight(), Sequence::ENABLE_RBF_NO_LOCKTIME, )?; self.add_recipient(carrier, burn_amount); @@ -277,74 +285,55 @@ impl<'a, Cs: CoinSelectionAlgorithm> TxBuilderSpacesUtils<'a, Cs> for TxBuilder< Ok(self) } - fn add_transfer(&mut self, request: TransferRequest) -> anyhow::Result<&mut Self> { - match request { - TransferRequest::Space(request) => { - let output_value = space_dust( - request - .space - .spaceout - .script_pubkey - .minimal_non_dust() - .mul(2), - ); + fn add_transfer(&mut self, request: SpaceTransfer) -> anyhow::Result<&mut Self> { + let output_value = space_dust( + request + .space + .spaceout + .script_pubkey + .minimal_non_dust() + .mul(2), + ); - let mut spend_input = psbt::Input { - witness_utxo: Some(TxOut { - value: request.space.spaceout.value, - script_pubkey: request.space.spaceout.script_pubkey, - }), - final_script_witness: Some(Witness::default()), - final_script_sig: Some(ScriptBuf::new()), - proprietary: BTreeMap::new(), - ..Default::default() - }; - spend_input - .proprietary - .insert(SpacesWallet::spaces_signer("tbs"), Vec::new()); - self.add_foreign_utxo( - OutPoint { - txid: request.space.txid, - vout: request.space.spaceout.n as u32, - }, - spend_input, - 66, - )?; - - self.add_recipient( - request.recipient.script_pubkey(), - // TODO: another reason we need to keep more metadata - // we use a special dust value here so that list auction - // outputs won't accidentally auction off this output - output_value, - ); - } - TransferRequest::Coin(request) => { - self.add_recipient(request.recipient.script_pubkey(), request.amount); - } - } + self.add_utxo(OutPoint { + txid: request.space.txid, + vout: request.space.spaceout.n as u32, + })?; + + self.add_recipient( + request.recipient.script_pubkey(), + // TODO: another reason we need to keep more metadata + // we use a special dust value here so that list auction + // outputs won't accidentally auction off this output + output_value, + ); + + Ok(self) + } + fn add_send(&mut self, request: CoinTransfer) -> anyhow::Result<&mut Self> { + self.add_recipient(request.recipient.script_pubkey(), request.amount); Ok(self) } } impl Builder { fn prepare_all( - coin_selection: SpacesAwareCoinSelection, median_time: u64, w: &mut SpacesWallet, auction_outputs: Option, reveals: Option<&Vec>, space_transfers: Vec, coin_transfers: Vec, + unspendables: Vec, fee_rate: FeeRate, dust: Option, + confirmed_only: bool, ) -> anyhow::Result<(Transaction, Vec)> { - let coin_selection_confirmed_only = coin_selection.confirmed_only; let mut vout: u32 = 0; let mut tap_outputs = Vec::new(); let change_address = w - .spaces + .internal .next_unused_address(KeychainKind::Internal) .script_pubkey(); @@ -355,7 +344,7 @@ impl Builder { // one is spent in an auction. // pointer/spend output let addr1 = w - .spaces + .internal .next_unused_address(KeychainKind::Internal) .script_pubkey(); let dust = match dust { @@ -366,16 +355,34 @@ impl Builder { let magic_dust = magic_dust(dust); placeholder_outputs.push((addr1, connector_dust)); - let addr2 = w.spaces.next_unused_address(KeychainKind::External); + let addr2 = w.internal.next_unused_address(KeychainKind::External); placeholder_outputs.push((addr2.script_pubkey(), magic_dust)); } } let commit_psbt = { - let mut builder = w.spaces.build_tx().coin_selection(coin_selection); + let mut builder = w.build_tx(unspendables, confirmed_only)?; builder.nlocktime(magic_lock_time(median_time)); - builder.ordering(TxOrdering::Untouched); + // handle transfers + if !space_transfers.is_empty() { + // Must be an odd number of outputs so that + // transfers align correctly + // TODO: use the actual change output instead of creating this + if vout % 2 == 0 { + let dust = match dust { + None => change_address.minimal_non_dust().mul(2), + Some(dust) => dust, + }; + builder.add_recipient(change_address, dust); + vout += 1; + } + for transfer in space_transfers { + builder.add_transfer(transfer)?; + vout += 1; + } + } + for (addr, amount) in placeholder_outputs { builder.add_recipient(addr, amount); vout += 1; @@ -397,31 +404,16 @@ impl Builder { if !coin_transfers.is_empty() { for coin in coin_transfers { - builder.add_transfer(TransferRequest::Coin(coin))?; + builder.add_send(coin)?; vout += 1; } } - // handle transfers - if !space_transfers.is_empty() { - // Must be an odd number of outputs so that - // transfers align correctly - // TODO: use the actual change output instead of creating this - if vout % 2 == 0 { - let dust = match dust { - None => change_address.minimal_non_dust().mul(2), - Some(dust) => dust, - }; - builder.add_recipient(change_address, dust); - } - for transfer in space_transfers { - builder.add_transfer(TransferRequest::Space(transfer))?; - } - } - builder.enable_rbf().fee_rate(fee_rate); + + builder.fee_rate(fee_rate); let r = builder.finish().map_err(|e| match e { - CreateTxError::CoinSelection(e) if coin_selection_confirmed_only => { + CreateTxError::CoinSelection(e) if confirmed_only => { anyhow!("{} (replacements use confirmed balance only)", e) } _ => { @@ -445,26 +437,8 @@ impl Builder { } } -pub struct TaggedTransaction { - pub tx: Transaction, - pub tags: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "kebab-case")] -pub enum TransactionTag { - FeeBump, - Bidouts, - Commitment, - Transfers, - Open, - Bid, - Script, - ForceSpendTestOnly, -} - impl Iterator for BuilderIterator<'_> { - type Item = anyhow::Result; + type Item = anyhow::Result; fn next(&mut self) -> Option { let op = match self.stack.pop() { @@ -473,29 +447,19 @@ impl Iterator for BuilderIterator<'_> { }; match op { + // A Prepare tx could bundle all P2TR commitments for open and other scripts + // it can also bundle all transfers, and it can create bid outputs StackOp::Prepare(params) => { - let mut tags = Vec::new(); - if !params.transfers.is_empty() { - tags.push(TransactionTag::Transfers); - } - if params.auction_outputs.is_some() { - tags.push(TransactionTag::Bidouts); - } - if !params.opens.is_empty() || !params.executes.is_empty() { - tags.push(TransactionTag::Commitment); - } - let mut reveals = Vec::with_capacity(params.opens.len() + params.executes.len()); - let mut amounts = Vec::with_capacity(params.opens.len()); - for req in params.opens { + // Push all open script commitments + for req in params.opens.iter() { let tap = Builder::create_open_tap_data(self.wallet.config.network, &req.name) .context("could not initialize tap data for name"); if tap.is_err() { return Some(Err(tap.unwrap_err())); } reveals.push(tap.unwrap()); - amounts.push(req.initial_amount); } let mut contexts = Vec::with_capacity(params.executes.len()); @@ -509,66 +473,76 @@ impl Iterator for BuilderIterator<'_> { contexts.push(execute.context); } - let prep = Builder::prepare_all( - self.coin_selection.clone(), + let (tx, commitments) = match Builder::prepare_all( self.median_time, self.wallet, - params.auction_outputs, + params.bidouts.clone(), Some(&reveals), - params - .transfers - .iter() - .filter_map(|req| match req { - TransferRequest::Space(transfer) => Some(transfer.clone()), - _ => None, - }) - .collect(), - params - .transfers - .iter() - .filter_map(|req| match req { - TransferRequest::Coin(transfer) => Some(transfer.clone()), - _ => None, - }) - .collect(), + params.transfers.clone(), + params.sends.clone(), + self.unspendables.clone(), self.fee_rate, self.dust, - ); - if prep.is_err() { - return Some(Err(prep.unwrap_err())); - } + self.confirmed_only, + + ) { + Ok(prep) => prep, + Err(err) => return Some(Err(err)), + }; - let (tx, commitments) = prep.unwrap(); + let mut detailed_tx = TxRecord::new(tx); + if let Some(count) = params.bidouts { + detailed_tx.add_bidout(count as _); + } let mut reveals_iter = reveals.into_iter(); let mut commitments_iter = commitments.into_iter(); let open_reveals = reveals_iter .by_ref() - .take(amounts.len()) + .take(params.opens.len()) .collect::>() .into_iter(); let open_commitments = commitments_iter .by_ref() - .take(amounts.len()) + .take(params.opens.len()) .collect::>() .into_iter(); - for ((signing, commitment), amount) in - open_reveals.zip(open_commitments).zip(amounts) + for ((signing, commitment), open) in + open_reveals.zip(open_commitments).zip(params.opens) { - self.stack.push(StackOp::Open(OpenParams { - reveals: vec![SpaceScriptRevealParams { + detailed_tx.add_commitment( + open.name.clone(), + commitment.txout.script_pubkey.clone(), + signing.to_vec(), + ); + self.stack.push(StackOp::Open(OpenRevealParams { + space: open.name, + initial_bid: open.initial_amount, + script: SpaceScriptRevealParams { signing: signing.clone(), commitment, - }], - amount, - })) + }, + })); } - for ((signing, commitment), context) in - reveals_iter.zip(commitments_iter).zip(contexts) - { + for ((signing, commitment), context) in reveals_iter.zip(commitments_iter).zip(contexts) { + // script applies to every space in context + for transfer in context.iter() { + detailed_tx.add_commitment( + transfer + .space + .spaceout + .space + .as_ref() + .expect("space") + .name + .to_string(), + commitment.txout.script_pubkey.clone(), + signing.to_vec(), + ); + } self.stack.push(StackOp::Execute(ExecuteParams { reveal: SpaceScriptRevealParams { signing, @@ -578,46 +552,75 @@ impl Iterator for BuilderIterator<'_> { })) } - Some(Ok(TaggedTransaction { tx, tags })) + if !params.transfers.is_empty() { + // TODO: resolved address recipient + for transfer in ¶ms.transfers { + detailed_tx.add_transfer( + transfer + .space + .spaceout + .space + .as_ref() + .expect("space") + .name + .to_string(), + transfer.recipient.script_pubkey(), + ); + } + } + Some(Ok(detailed_tx)) } StackOp::Open(params) => { let tx = Builder::open_tx( - self.coin_selection.clone(), self.wallet, - params, + params.clone(), self.fee_rate, + self.unspendables.clone(), + self.confirmed_only, self.force, ); - Some(tx.map(|tx| TaggedTransaction { - tx, - tags: vec![TransactionTag::Open], + Some(tx.map(|tx| { + let mut detailed = TxRecord::new(tx); + detailed.add_open(params.space, params.initial_bid); + detailed })) } StackOp::Execute(params) => { - let tx = Builder::execute_tx( - self.coin_selection.clone(), + let spaces = params.context.clone(); + let res = Builder::execute_tx( self.wallet, params, self.fee_rate, + self.unspendables.clone(), + self.confirmed_only, self.force, ); - Some(tx.map(|tx| TaggedTransaction { - tx, - tags: vec![TransactionTag::Script], + + Some(res.map(|(tx, reveal_input_index)| { + let mut detailed = TxRecord::new(tx); + for space in spaces { + detailed.add_execute( + space.space.spaceout.space.expect("space").name.to_string(), + reveal_input_index, + ); + } + detailed })) } StackOp::Bid(bid) => { let tx = Builder::bid_tx( - self.coin_selection.clone(), self.wallet, - bid.space, + bid.space.clone(), bid.amount, self.fee_rate, + self.unspendables.clone(), + self.confirmed_only, self.force, ); - Some(tx.map(|tx| TaggedTransaction { - tx, - tags: vec![TransactionTag::Bid], + Some(tx.map(|tx| { + let mut detailed = TxRecord::new(tx); + detailed.add_bid(self.wallet, &bid.space, bid.amount); + detailed })) } } @@ -669,11 +672,16 @@ impl Builder { self } - pub fn add_transfer(mut self, request: TransferRequest) -> Self { + pub fn add_transfer(mut self, request: SpaceTransfer) -> Self { self.requests.push(StackRequest::Transfer(request)); self } + pub fn add_send(mut self, request: CoinTransfer) -> Self { + self.requests.push(StackRequest::Send(request)); + self + } + pub fn add_execute( mut self, spaces: Vec, @@ -691,7 +699,8 @@ impl Builder { dust: Option, median_time: u64, wallet: &mut SpacesWallet, - coin_selection: SpacesAwareCoinSelection, + unspendables: Vec, + confirmed_only: bool, ) -> anyhow::Result { let fee_rate = self .fee_rate @@ -714,30 +723,30 @@ impl Builder { _ => counts, }); - let required_auction_outputs = open_count + bid_count as u8; - let available = if required_auction_outputs > 0 { - let mut selection = coin_selection.clone(); - selection.confirmed_only = false; - wallet.list_bidouts(&selection)? + let required_bidouts = open_count + bid_count as u8; + let available = if required_bidouts > 0 { + wallet.list_bidouts(false)? } else { Vec::new() }; + // Always create a few more bidouts for future transactions + const EXTRA_BIDOUTS : u8 = 2; // check how many bid outputs we need to create let auction_outputs = match self.bidouts { None => { - if required_auction_outputs > available.len() as u8 { - Some(required_auction_outputs - available.len() as u8) + if required_bidouts > available.len() as u8 { + Some((required_bidouts - available.len() as u8) + EXTRA_BIDOUTS) } else { None } } Some(count) => { - if required_auction_outputs > available.len() as u8 + count { + if required_bidouts > available.len() as u8 + count { return Err(anyhow!( - "number of required placeholders {} \ + "number of required bidouts {} \ exceeds currently available {} + requested {}", - required_auction_outputs, + required_bidouts, available.len(), count )); @@ -751,6 +760,7 @@ impl Builder { let mut opens = Vec::new(); let mut bids = Vec::new(); let mut transfers = Vec::new(); + let mut sends = Vec::new(); let mut executes = Vec::new(); for req in self.requests { match req { @@ -761,11 +771,12 @@ impl Builder { None => wallet.next_unused_space_address(), Some(address) => address, }; - transfers.push(TransferRequest::Space(SpaceTransfer { + transfers.push(SpaceTransfer { space: params.space, recipient: to.0, - })) + }) } + StackRequest::Send(send) => sends.push(send), StackRequest::Transfer(params) => transfers.push(params), StackRequest::Execute(params) => executes.push(params), } @@ -785,7 +796,8 @@ impl Builder { opens, executes, transfers, - auction_outputs, + sends: vec![], + bidouts: auction_outputs, })); } @@ -795,28 +807,27 @@ impl Builder { fee_rate, wallet, force: self.force, + unspendables, + confirmed_only, median_time, - coin_selection, }) } fn bid_tx( - coin_selection: SpacesAwareCoinSelection, w: &mut SpacesWallet, prev: FullSpaceOut, bid: Amount, fee_rate: FeeRate, + unspendables: Vec, + confirmed_only: bool, force: bool, ) -> anyhow::Result { - w.watch_bid_spend(prev.outpoint()); - - let (offer, placeholder) = w.new_bid_psbt(bid, &coin_selection)?; + let (offer, placeholder) = w.new_bid_psbt(bid, confirmed_only)?; let bid_psbt = { - let mut builder = w.spaces.build_tx().coin_selection(coin_selection); + let mut builder = w.build_tx(unspendables, confirmed_only)?; builder - .ordering(TxOrdering::Untouched) .nlocktime(LockTime::Blocks(Height::ZERO)) - .enable_rbf_with_sequence(BID_PSBT_INPUT_SEQUENCE) + .set_exact_sequence(BID_PSBT_INPUT_SEQUENCE) .add_bid( Some(prev.spaceout.space.as_ref().unwrap()), offer, @@ -834,30 +845,32 @@ impl Builder { } fn open_tx( - coin_selection: SpacesAwareCoinSelection, w: &mut SpacesWallet, - params: OpenParams, + params: OpenRevealParams, fee_rate: FeeRate, + unspendables: Vec, + confirmed_only: bool, force: bool, ) -> anyhow::Result { - let (offer, placeholder) = w.new_bid_psbt(params.amount, &coin_selection)?; + let (offer, placeholder) = w.new_bid_psbt(params.initial_bid, confirmed_only)?; let mut extra_prevouts = BTreeMap::new(); let open_psbt = { - let mut builder = w.spaces.build_tx().coin_selection(coin_selection); + let mut builder = w.build_tx(unspendables, confirmed_only)?; builder.ordering(TxOrdering::Untouched).add_bid( None, offer, placeholder, - params.amount, + params.initial_bid, force, )?; - for reveal in params.reveals { - builder.add_reveal(reveal.commitment.clone(), reveal.signing)?; - extra_prevouts.insert(reveal.commitment.outpoint, reveal.commitment.txout); - } + builder.add_reveal(params.script.commitment.clone(), params.script.signing)?; + extra_prevouts.insert( + params.script.commitment.outpoint, + params.script.commitment.txout, + ); - builder.enable_rbf().fee_rate(fee_rate); + builder.fee_rate(fee_rate); builder.finish()? }; @@ -866,22 +879,23 @@ impl Builder { } fn execute_tx( - coin_selection: SpacesAwareCoinSelection, w: &mut SpacesWallet, params: ExecuteParams, fee_rate: FeeRate, + unspendables: Vec, + confirmed_only: bool, _force: bool, - ) -> anyhow::Result { + ) -> anyhow::Result<(Transaction, usize)> { let mut extra_prevouts = BTreeMap::new(); + let reveal_input_index; let reveal_psbt = { let change_address = w - .spaces + .internal .next_unused_address(KeychainKind::Internal) .script_pubkey(); - let mut builder = w.spaces.build_tx().coin_selection(coin_selection); + let mut builder = w.build_tx(unspendables, confirmed_only)?; builder - .ordering(TxOrdering::Untouched) // Added first to keep an odd number of outputs before adding transfers .add_recipient(change_address, Amount::from_sat(1000)); @@ -890,20 +904,21 @@ impl Builder { params.reveal.commitment.txout.clone(), ); + let input_count = params.context.len(); for transfer in params.context { - builder.add_transfer(TransferRequest::Space(transfer))?; + builder.add_transfer(transfer)?; } + reveal_input_index = input_count + 1; builder // add reveal last to not disrupt space inputs order .add_reveal(params.reveal.commitment, params.reveal.signing)? - .enable_rbf() .fee_rate(fee_rate); builder.finish()? }; let signed = w.sign(reveal_psbt, Some(extra_prevouts))?; - Ok(signed) + Ok((signed, reveal_input_index)) } fn create_open_tap_data( @@ -932,7 +947,7 @@ pub struct SelectionOutput { pub struct SpacesAwareCoinSelection { pub default_algorithm: DefaultCoinSelectionAlgorithm, // Exclude outputs - pub exclude_outputs: Vec, + pub exclude_outputs: Vec, // Whether to use confirmed only outputs // to fund the transaction pub confirmed_only: bool, @@ -942,7 +957,7 @@ impl SpacesAwareCoinSelection { // Will skip any outputs with value less than the dust threshold // to avoid accidentally spending space outputs pub const DUST_THRESHOLD: Amount = Amount::from_sat(1200); - pub fn new(excluded: Vec, confirmed_only: bool) -> Self { + pub fn new(excluded: Vec, confirmed_only: bool) -> Self { Self { default_algorithm: DefaultCoinSelectionAlgorithm::default(), exclude_outputs: excluded, @@ -952,14 +967,15 @@ impl SpacesAwareCoinSelection { } impl CoinSelectionAlgorithm for SpacesAwareCoinSelection { - fn coin_select( + fn coin_select( &self, required_utxos: Vec, mut optional_utxos: Vec, fee_rate: FeeRate, - target_amount: u64, + target_amount: Amount, drain_script: &Script, - ) -> Result { + rand: &mut R, + ) -> Result { let required = required_utxos .iter() .map(|w| w.utxo.clone()) @@ -970,7 +986,7 @@ impl CoinSelectionAlgorithm for SpacesAwareCoinSelection { if self.confirmed_only { match &weighted_utxo.utxo { Utxo::Local(local) => { - if !local.confirmation_time.is_confirmed() { + if !local.chain_position.is_confirmed() { return false; } } @@ -980,9 +996,9 @@ impl CoinSelectionAlgorithm for SpacesAwareCoinSelection { weighted_utxo.utxo.txout().value > SpacesAwareCoinSelection::DUST_THRESHOLD && !self - .exclude_outputs - .iter() - .any(|o| o.outpoint == weighted_utxo.utxo.outpoint()) + .exclude_outputs + .iter() + .any(|o| o == &weighted_utxo.utxo.outpoint()) }); let mut result = self.default_algorithm.coin_select( @@ -991,6 +1007,7 @@ impl CoinSelectionAlgorithm for SpacesAwareCoinSelection { fee_rate, target_amount, drain_script, + rand, )?; let mut optional = Vec::with_capacity(result.selected.len() - required.len()); diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index ccb9f4a..c41063a 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -1,63 +1,59 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap}, fmt::Debug, fs, path::PathBuf, - time::{Duration, SystemTime}, }; -use std::collections::HashSet; use anyhow::{anyhow, Context}; -use bdk_wallet::{ - chain::{BlockId, ConfirmationTime}, - wallet::{ - coin_selection::{CoinSelectionAlgorithm, CoinSelectionResult, Error, Excess}, - tx_builder::TxOrdering, - ChangeSet, InsertTxError, - }, - KeychainKind, LocalOutput, SignOptions, WeightedUtxo, -}; +use bdk_wallet::{chain, chain::BlockId, coin_selection::{CoinSelectionAlgorithm, CoinSelectionResult, Excess, InsufficientFunds}, rusqlite::Connection, tx_builder::TxOrdering, AddressInfo, KeychainKind, LocalOutput, PersistedWallet, SignOptions, TxBuilder, Update, Wallet, WalletTx, WeightedUtxo}; +use bdk_wallet::chain::{ChainPosition, Indexer}; +use bdk_wallet::chain::local_chain::{CannotConnectError, LocalChain}; +use bdk_wallet::chain::tx_graph::CalculateFeeError; use bincode::config; -use bitcoin::{ - absolute::{Height, LockTime}, - psbt::raw::ProprietaryKey, - script, - sighash::{Prevouts, SighashCache}, - taproot, - taproot::LeafVersion, - Amount, Block, BlockHash, FeeRate, Network, OutPoint, Psbt, Sequence, TapLeafHash, - TapSighashType, Transaction, TxOut, Witness, -}; -use protocol::{ - bitcoin::{ - constants::genesis_block, - key::{rand, UntweakedKeypair}, - opcodes, - taproot::{ControlBlock, TaprootBuilder}, - Address, ScriptBuf, XOnlyPublicKey, - }, - prepare::TrackableOutput, -}; +use bitcoin::{absolute::{Height, LockTime}, key::rand::RngCore, psbt::raw::ProprietaryKey, script, sighash::{Prevouts, SighashCache}, taproot, taproot::LeafVersion, Amount, Block, BlockHash, FeeRate, Network, OutPoint, Psbt, Sequence, TapLeafHash, TapSighashType, Transaction, TxOut, Txid, Weight, Witness}; +use protocol::{bitcoin::{ + constants::genesis_block, + key::{rand, UntweakedKeypair}, + opcodes, + taproot::{ControlBlock, TaprootBuilder}, + Address, ScriptBuf, XOnlyPublicKey, +}, prepare::{is_magic_lock_time, TrackableOutput}, Space}; use serde::{ser::SerializeSeq, Deserialize, Deserializer, Serialize, Serializer}; -use protocol::prepare::is_magic_lock_time; +use protocol::prepare::DataSource; use crate::{ address::SpaceAddress, builder::{is_connector_dust, is_space_dust, SpacesAwareCoinSelection}, + tx_event::TxEvent, }; +use crate::tx_event::{TxEventKind, TxRecord}; pub extern crate bdk_wallet; pub extern crate bitcoin; +extern crate core; pub mod address; pub mod builder; pub mod export; - -const WALLET_SPACE_MAGIC: &[u8; 12] = b"WALLET_SPACE"; +mod rusqlite_impl; +pub mod tx_event; pub struct SpacesWallet { pub config: WalletConfig, - pub spaces: bdk_wallet::wallet::Wallet, - pub spaces_db: bdk_file_store::Store, - pub watch_bid_spends: HashSet, + internal: PersistedWallet, + pub connection: Connection, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Balance { + pub balance: Amount, + pub details: BalanceDetails, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BalanceDetails { + #[serde(flatten)] + pub balance: bdk_wallet::Balance, + pub dust: Amount, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -92,12 +88,21 @@ pub struct DoubleUtxo { pub confirmed: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletOutput { + #[serde(flatten)] + pub output: LocalOutput, + pub space: Option, + pub is_spaceout: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct FullTxOut { pub outpoint: OutPoint, pub(crate) txout: TxOut, } +#[derive(Clone, Debug)] pub struct WalletConfig { pub name: String, pub data_dir: PathBuf, @@ -107,60 +112,298 @@ pub struct WalletConfig { pub space_descriptors: WalletDescriptors, } +#[derive(Clone, Debug)] pub struct WalletDescriptors { pub external: String, pub internal: String, } +pub trait Mempool { + fn in_mempool(&self, txid: &Txid, height: u32) -> anyhow::Result; +} + impl SpacesWallet { pub fn name(&self) -> &str { &self.config.name } + pub fn init_sqlite_tables(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<()> { + TxEvent::init_sqlite_tables(db_tx)?; + Ok(()) + } + pub fn new(config: WalletConfig) -> anyhow::Result { if !config.data_dir.exists() { fs::create_dir_all(config.data_dir.clone())?; } - let spaces_path = config.data_dir.join("spaces.db"); - let mut spaces_db = - bdk_file_store::Store::::open_or_create_new(WALLET_SPACE_MAGIC, spaces_path) - .context("create store for spaces")?; + let wallet_path = config.data_dir.join("wallet.db"); + use bdk_wallet::rusqlite::Connection; + + let mut conn = Connection::open(wallet_path)?; let genesis_hash = match config.genesis_hash { None => genesis_block(config.network).block_hash(), Some(hash) => hash, }; - let spaces_changeset = spaces_db.aggregate_changesets()?; - let spaces_wallet = bdk_wallet::wallet::Wallet::new_or_load_with_genesis_hash( - &config.space_descriptors.external, - &config.space_descriptors.internal, - spaces_changeset, - config.network, - genesis_hash, - )?; + let spaces_wallet = if let Some(wallet) = + Wallet::load() + .check_network(config.network) + .descriptor(KeychainKind::External, Some(config.space_descriptors.external.clone())) + .descriptor(KeychainKind::Internal, Some(config.space_descriptors.internal.clone())) + .lookahead(50) + .extract_keys() + .load_wallet(&mut conn).context("could not load wallet")? { + wallet + } else { + Wallet::create( + config.space_descriptors.external.clone(), + config.space_descriptors.internal.clone(), + ) + .lookahead(50) + .network(config.network) + .genesis_hash(genesis_hash) + .create_wallet(&mut conn).context("could not create wallet")? + }; + + + let tx = conn.transaction().context("could not create wallet db transaction")?; + Self::init_sqlite_tables(&tx).context("could not initialize wallet db tables")?; + tx.commit().context("could not commit wallet db transaction")?; let wallet = Self { config, - spaces: spaces_wallet, - spaces_db, - watch_bid_spends: HashSet::new(), + internal: spaces_wallet, + connection: conn, }; - - wallet.clear_unused_signing_info(); Ok(wallet) } - pub fn watch_bid_spend(&mut self, outpoint: OutPoint) { - self.watch_bid_spends.insert(outpoint); + pub fn balance(&mut self) -> anyhow::Result { + let unspent = self.list_unspent(); + let balance = self.internal.balance(); + let details = BalanceDetails { + balance, + dust: unspent + .filter(|output| + // confirmed or trusted pending only + (output.chain_position.is_confirmed() || output.keychain == KeychainKind::Internal) && + (output.txout.value <= SpacesAwareCoinSelection::DUST_THRESHOLD) + ) + .map(|output| output.txout.value) + .sum(), + }; + Ok(Balance { + balance: (details.balance.confirmed + details.balance.trusted_pending) - details.dust, + details, + }) + } + + pub fn get_tx(&mut self, txid: Txid) -> Option { + self.internal.get_tx(txid) + } + + pub fn get_utxo(&mut self, outpoint: OutPoint) -> Option { + self.internal.get_utxo(outpoint) + } + + pub fn next_unused_address(&mut self, keychain_kind: KeychainKind) -> AddressInfo { + self.internal.next_unused_address(keychain_kind) + } + + pub fn local_chain(&self) -> &LocalChain { + self.internal.local_chain() + } + + pub fn insert_checkpoint(&mut self, checkpoint: BlockId) -> Result<(), CannotConnectError> { + let mut cp = self.internal.latest_checkpoint(); + cp = cp.insert(checkpoint); + self.internal + .apply_update(Update { + chain: Some(cp), + ..Default::default() + }) + } + + pub fn transactions(&self) -> impl Iterator + '_ { + self.internal.transactions().filter(|tx| !is_revert_tx(tx) && + self.internal.spk_index().is_tx_relevant(&tx.tx_node)) + } + + pub fn sent_and_received(&self, tx: &Transaction) -> (Amount, Amount) { + self.internal.sent_and_received(tx) + } + + pub fn calculate_fee(&self, tx: &Transaction) -> Result { + self.internal.calculate_fee(tx) + } + + pub fn build_tx(&mut self, unspendables: Vec, confirmed_only: bool) + -> anyhow::Result> { + self.create_builder(unspendables, None, confirmed_only) + } + + pub fn list_spaces_outpoints(&self, src: &mut impl DataSource) -> anyhow::Result> { + let mut outs = Vec::new(); + for unspent in self.list_unspent() { + if src.get_spaceout(&unspent.outpoint)?.and_then(|out| out.space).is_some() { + outs.push(unspent.outpoint); + } + } + Ok(outs) + } + + pub fn build_fee_bump(&mut self, unspendables: Vec, txid: Txid, fee_rate: FeeRate) -> anyhow::Result> { + let events = self.get_tx_events(txid)?; + for event in events { + match event.kind { + TxEventKind::Bid => { + match self.get_tx(txid) { + Some(tx) => if !tx.chain_position.is_confirmed() { + return Err(anyhow!("Bid with a higher fee on `{}` to replace this tx", event.space.expect("space"))) + } + _ => continue, + } + } + _ => {} + } + } + + self.create_builder(unspendables, Some((txid, fee_rate)), false) + } + + fn create_builder( + &mut self, + unspendables: Vec, + replace: Option<(Txid, FeeRate)>, + confirmed_only: bool, + ) -> anyhow::Result> { + let selection = SpacesAwareCoinSelection::new( + unspendables, confirmed_only + ); + + let mut builder = match replace { + None => { + self.internal.build_tx().coin_selection(selection) + } + Some((txid, fee_rate)) => { + let previous_tx_lock_time = match self.get_tx(txid) { + None => return Err(anyhow::anyhow!("No wallet tx {} found", txid)), + Some(tx) => tx.tx_node.lock_time, + }; + let mut builder = self.internal.build_fee_bump(txid)? + .coin_selection(selection); + builder + .nlocktime(previous_tx_lock_time) + .fee_rate(fee_rate); + builder + } + }; + + builder.ordering(TxOrdering::Untouched); + Ok(builder) + } + + pub fn is_mine(&self, script: ScriptBuf) -> bool { + self.internal.is_mine(script) + } + + pub fn list_unspent(&self) -> impl Iterator + '_ { + self.internal.list_unspent() + } + + pub fn list_output(&self) -> impl Iterator + '_ { + self.internal.list_output() + } + + pub fn list_recent_events(&mut self) -> anyhow::Result> { + let db_tx = self.connection.transaction().context("no db transaction")?; + TxEvent::get_latest_events(&db_tx).context("could not read latest events") + } + + pub fn list_unspent_with_details(&mut self, store: &mut impl DataSource) -> anyhow::Result> { + let mut wallet_outputs = Vec::new(); + for output in self.internal.list_unspent() { + let mut details = WalletOutput { + output, + space: None, + is_spaceout: false, + }; + let result = store.get_spaceout(&details.output.outpoint)?; + if let Some(spaceout) = result { + details.is_spaceout = true; + details.space = spaceout.space; + } + wallet_outputs.push(details) + } + Ok(wallet_outputs) + } + + /// Checks the mempool for dropped bid transactions and reverts them in the wallet’s Tx graph, + /// reclaiming any "stuck" funds. This is necessary because continuously scanning the entire + /// mainnet mempool would be resource-intensive to fetch from Bitcoin Core RPC. + pub fn update_unconfirmed_bids(&mut self, mem: impl Mempool, height: u32, data_source: &mut impl DataSource) -> anyhow::Result> { + let unconfirmed_bids = self.unconfirmed_bids()?; + let mut revert_txs = Vec::new(); + for (bid, outpoint) in unconfirmed_bids { + let in_mempool = mem.in_mempool(&bid.tx_node.txid, height)?; + if in_mempool { + continue; + } + // bid dropped from mempool perhaps it was confirmed spending outpoint? + if data_source.get_spaceout(&outpoint).context("could not fetch spaceout from db")?.is_none() { + continue; + } + if let Some((revert, seen)) = revert_unconfirmed_bid_tx(&bid, outpoint) { + revert_txs.push((bid.tx_node.txid, revert, seen)); + } + } + + let mut txids = Vec::with_capacity(revert_txs.len()); + for (original, revert_tx, last_seen) in revert_txs { + txids.push(original); + self.apply_unconfirmed_tx(revert_tx, last_seen); + } + Ok(txids) + } + + /// Returns all unconfirmed bid transactions in the wallet + /// and any foreign outputs they're spending. + /// + /// This is used to monitor bid txs in the mempool + /// to check if they have been replaced. + pub fn unconfirmed_bids(&mut self) -> anyhow::Result> { + let txids: Vec<_> = { + let unconfirmed: Vec<_> = self.transactions() + .filter(|x| !x.chain_position.is_confirmed()).collect(); + unconfirmed.iter().map(|x| x.tx_node.txid).collect() + }; + let bid_txids = { + let db_tx = self.connection.transaction()?; + TxEvent::filter_bids(&db_tx, txids)? + }; + let bid_txs: Vec<_> = self.transactions() + .filter(|tx| !tx.chain_position.is_confirmed()) + .filter_map(|tx| { + bid_txids.iter().find(|(bid_txid, _)| *bid_txid == tx.tx_node.txid).map(|(_, bid_outpoint)| { + (tx, *bid_outpoint) + }) + }).collect(); + Ok(bid_txs) + } + + pub fn get_tx_events(&mut self, txid: Txid) -> anyhow::Result> { + let db_tx = self.connection.transaction() + .context("could not get wallet db transaction")?; + let result = TxEvent::all(&db_tx, txid) + .context("could not get wallet db tx events")?; + Ok(result) } pub fn rebuild(self) -> anyhow::Result { let config = self.config; - drop(self.spaces_db); - fs::remove_file(config.data_dir.join("spaces.db"))?; - fs::remove_file(config.data_dir.join("coins.db"))?; + fs::remove_file(config.data_dir.join("wallet.db"))?; Ok(SpacesWallet::new(config)?) } @@ -169,7 +412,7 @@ impl SpacesWallet { descriptors.push(DescriptorInfo { descriptor: self - .spaces + .internal .public_descriptor(KeychainKind::External) .to_string(), internal: false, @@ -177,7 +420,7 @@ impl SpacesWallet { }); descriptors.push(DescriptorInfo { descriptor: self - .spaces + .internal .public_descriptor(KeychainKind::Internal) .to_string(), internal: true, @@ -187,13 +430,13 @@ impl SpacesWallet { WalletInfo { label: self.config.name.clone(), start_block: self.config.start_block, - tip: self.spaces.local_chain().tip().height(), + tip: self.internal.local_chain().tip().height(), descriptors, } } pub fn next_unused_space_address(&mut self) -> SpaceAddress { - let info = self.spaces.next_unused_address(KeychainKind::External); + let info = self.internal.next_unused_address(KeychainKind::External); SpaceAddress(info.address) } @@ -203,34 +446,51 @@ impl SpacesWallet { block: &Block, block_id: BlockId, ) -> anyhow::Result<()> { - self.spaces + self.internal .apply_block_connected_to(&block, height, block_id)?; - Ok(()) } - pub fn insert_tx( - &mut self, - tx: Transaction, - position: ConfirmationTime, - ) -> Result { - self.spaces.insert_tx(tx.clone(), position) + pub fn apply_unconfirmed_tx(&mut self, tx: Transaction, seen: u64) { + self.internal.apply_unconfirmed_txs(vec![(tx, seen)]); } - pub fn commit(&mut self) -> anyhow::Result<()> { - if let Some(changeset) = self.spaces.take_staged() { - self.spaces_db.append_changeset(&changeset)?; + pub fn apply_unconfirmed_tx_record(&mut self, tx_record: TxRecord, seen: u64) -> anyhow::Result<()> { + let txid = tx_record.tx.compute_txid(); + self.apply_unconfirmed_tx(tx_record.tx, seen); + + // Insert txouts for foreign inputs to be able to calculate fees + for (outpoint, txout) in tx_record.txouts { + self.internal.insert_txout(outpoint, txout); + } + + let db_tx = self.connection.transaction() + .context("could not create wallet db transaction")?; + for event in tx_record.events { + TxEvent::insert( + &db_tx, + txid, + event.kind, + event.space, + event.foreign_input, + event.details, + ).context("could not insert tx event into wallet db")?; } + db_tx.commit().context("could not commit tx events to wallet db")?; + Ok(()) + } + pub fn commit(&mut self) -> anyhow::Result<()> { + self.internal.persist(&mut self.connection)?; Ok(()) } /// List outputs that can be safely auctioned off pub fn list_bidouts( &mut self, - selection: &SpacesAwareCoinSelection, + confirmed_only: bool, ) -> anyhow::Result> { - let mut unspent: Vec = self.spaces.list_unspent().collect(); + let mut unspent: Vec = self.list_unspent().collect(); let mut not_auctioned = vec![]; if unspent.is_empty() { @@ -272,22 +532,15 @@ impl SpacesWallet { && is_connector_dust(utxo1.txout.value) && !is_space_dust(utxo2.txout.value) && utxo2.txout.is_magic_output() - - // Exclude any outputs that we know to be spaces - && !selection.exclude_outputs.iter() - .any(|sel| - sel.is_space && - (sel.outpoint == utxo1.outpoint || sel.outpoint == utxo2.outpoint) - ) // Check if confirmed only are required - && (!selection.confirmed_only || utxo1.confirmation_time.is_confirmed()) + && (!confirmed_only || utxo1.chain_position.is_confirmed()) { // While it's possible to create outputs within space transactions // that don't use a special locktime, for now it's safer to require // explicitly trackable outputs. - let locktime = match self.spaces.get_tx(utxo2.outpoint.txid) { + let locktime = match self.internal.get_tx(utxo2.outpoint.txid) { None => continue, - Some(tx) => tx.tx_node.lock_time + Some(tx) => tx.tx_node.lock_time, }; if !is_magic_lock_time(&locktime) { continue; @@ -302,7 +555,7 @@ impl SpacesWallet { outpoint: utxo2.outpoint, txout: utxo2.txout.clone(), }, - confirmed: utxo1.confirmation_time.is_confirmed(), + confirmed: utxo1.chain_position.is_confirmed(), }); } } @@ -313,11 +566,11 @@ impl SpacesWallet { pub fn new_bid_psbt( &mut self, total_burned: Amount, - selection: &SpacesAwareCoinSelection, + confirmed_only: bool, ) -> anyhow::Result<(Psbt, DoubleUtxo)> { - let all: Vec<_> = self.list_bidouts(selection)?; + let all: Vec<_> = self.list_bidouts(confirmed_only)?; - let msg = if selection.confirmed_only { + let msg = if confirmed_only { "The wallet already has an unconfirmed bid for this space in the mempool, but no \ confirmed bid utxos are available to replace it with a different amount." } else { @@ -338,7 +591,7 @@ impl SpacesWallet { let mut bid_psbt = { let mut builder = self - .spaces + .internal .build_tx() .coin_selection(RequiredUtxosOnlyCoinSelectionAlgorithm); @@ -347,7 +600,7 @@ impl SpacesWallet { .allow_dust(true) .ordering(TxOrdering::Untouched) .nlocktime(LockTime::Blocks(Height::ZERO)) - .enable_rbf_with_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME) + .set_exact_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME) .manually_selected_only() .sighash(TapSighashType::SinglePlusAnyoneCanPay.into()) .add_utxo(placeholder.auction.outpoint)? @@ -358,7 +611,7 @@ impl SpacesWallet { builder.finish()? }; - let finalized = self.spaces.sign( + let finalized = self.internal.sign( &mut bid_psbt, SignOptions { allow_all_sighashes: true, @@ -418,14 +671,10 @@ impl SpacesWallet { } if input.final_script_witness.is_none() && input.witness_utxo.is_some() { - if self.spaces.is_mine( - input - .witness_utxo - .as_ref() - .unwrap() - .script_pubkey - .as_script(), - ) { + if self + .internal + .is_mine(input.witness_utxo.as_ref().unwrap().script_pubkey.clone()) + { input .proprietary .insert(Self::spaces_signer("tbs"), Vec::new()); @@ -433,12 +682,14 @@ impl SpacesWallet { continue; } + let previous_output = psbt.unsigned_tx.input[input_index].previous_output; let signing_info = - self.get_signing_info(&input.witness_utxo.as_ref().unwrap().script_pubkey); + self.get_signing_info(previous_output, &input.witness_utxo.as_ref().unwrap().script_pubkey) + .context("could not retrieve signing info for script")?; if let Some(info) = signing_info { input .proprietary - .insert(Self::spaces_signer("reveal_signing_info"), info); + .insert(Self::spaces_signer("reveal_signing_info"), info.to_vec()); input.final_script_witness = Some(Witness::default()); } } @@ -451,7 +702,7 @@ impl SpacesWallet { input.final_script_sig = None; } } - if !self.spaces.sign(&mut psbt, SignOptions::default())? { + if !self.internal.sign(&mut psbt, SignOptions::default())? { return Err(anyhow!("could not finalize psbt using spaces signer")); } @@ -463,10 +714,6 @@ impl SpacesWallet { let raw = input.proprietary.get(&reveal_key).expect("signing info"); let signing_info = SpaceScriptSigningInfo::from_slice(raw.as_slice()) .context("expected reveal signing info")?; - - let script = input.witness_utxo.as_ref().unwrap().script_pubkey.clone(); - self.save_signing_info(script, raw.clone())?; - reveals.insert(idx as u32, signing_info); } } @@ -485,7 +732,7 @@ impl SpacesWallet { continue; } - let space_utxo = self.spaces.get_utxo(input.previous_output); + let space_utxo = self.internal.get_utxo(input.previous_output); if let Some(space_utxo) = space_utxo { prevouts.push(space_utxo.txout); continue; @@ -528,46 +775,12 @@ impl SpacesWallet { Ok(tx) } - fn get_signing_info(&self, script: &ScriptBuf) -> Option> { - let script_info_dir = self.config.data_dir.join("script_solutions"); - let filename = hex::encode(script.as_bytes()); - let file_path = script_info_dir.join(filename); - fs::read(file_path).ok() - } - - fn save_signing_info(&self, script: ScriptBuf, raw: Vec) -> anyhow::Result<()> { - let script_info_dir = self.config.data_dir.join("script_solutions"); - fs::create_dir_all(&script_info_dir).context("could not create script_info directory")?; - let filename = hex::encode(script.as_bytes()); - let file_path = script_info_dir.join(filename); - fs::write(file_path, raw)?; - Ok(()) - } - - fn clear_unused_signing_info(&self) { - let script_info_dir = self.config.data_dir.join("script_solutions"); - let one_week_ago = SystemTime::now() - Duration::from_secs(7 * 24 * 60 * 60); - - let entries = match fs::read_dir(&script_info_dir) { - Ok(entries) => entries, - Err(_) => return, - }; - - for entry in entries.flatten() { - let metadata = match entry.metadata() { - Ok(metadata) => metadata, - Err(_) => continue, - }; - - let modified_time = match metadata.modified() { - Ok(time) => time, - Err(_) => continue, - }; - - if modified_time < one_week_ago { - let _ = fs::remove_file(entry.path()); - } - } + fn get_signing_info(&mut self, previous_output: OutPoint, script: &ScriptBuf) + -> anyhow::Result> { + let db_tx = self.connection.transaction() + .context("couldn't create db transaction")?; + let info = TxEvent::get_signing_info(&db_tx, previous_output.txid, script)?; + Ok(info) } } @@ -575,27 +788,59 @@ impl SpacesWallet { pub struct RequiredUtxosOnlyCoinSelectionAlgorithm; impl CoinSelectionAlgorithm for RequiredUtxosOnlyCoinSelectionAlgorithm { - fn coin_select( + fn coin_select( &self, required_utxos: Vec, _optional_utxos: Vec, _fee_rate: FeeRate, - _target_amount: u64, + _target_amount: Amount, _drain_script: &bitcoin::Script, - ) -> Result { + _rand: &mut R, + ) -> Result { let utxos = required_utxos.iter().map(|w| w.utxo.clone()).collect(); Ok(CoinSelectionResult { selected: utxos, - fee_amount: 0, + fee_amount: Amount::from_sat(0), excess: Excess::NoChange { - dust_threshold: 0, - remaining_amount: 0, - change_fee: 0, + dust_threshold: Amount::from_sat(0), + remaining_amount: Amount::from_sat(0), + change_fee: Amount::from_sat(0), }, }) } } +/// Creates a dummy revert transaction double spending the foreign input +/// to be applied to the wallet's tx graph +fn revert_unconfirmed_bid_tx(bid: &WalletTx, foreign_outpoint: OutPoint) -> Option<(Transaction, u64)> { + let foreign_input = bid.tx_node.input.iter() + .find(|input| input.previous_output == foreign_outpoint)?.clone(); + + let op_return_output = bid.tx_node.output.first()?.clone(); + if !op_return_output.script_pubkey.is_op_return() { + return None; + } + let revert_tx = Transaction { + version: bid.tx_node.version, + lock_time: bid.tx_node.lock_time, + input: vec![foreign_input], + output: vec![op_return_output], + }; + let revert_tx_last_seen = match bid.chain_position { + ChainPosition::Confirmed { .. } => panic!("must be unconfirmed"), + ChainPosition::Unconfirmed { last_seen } => + last_seen.map(|last_seen| last_seen + 1), + }; + Some((revert_tx, revert_tx_last_seen.unwrap_or(1))) +} + +fn is_revert_tx(tx: &WalletTx) -> bool { + !tx.chain_position.is_confirmed() && + tx.tx_node.input.len() == 1 && + tx.tx_node.output.len() == 1 && + tx.tx_node.output[0].script_pubkey.is_op_return() +} + impl SpaceScriptSigningInfo { fn new(network: Network, nop_script: script::Builder) -> anyhow::Result { let secp256k1 = bitcoin::secp256k1::Secp256k1::new(); @@ -625,13 +870,18 @@ impl SpaceScriptSigningInfo { }) } - pub fn satisfaction_weight(&self) -> usize { - // 1-byte varint(control_block) - 1 + self.control_block.size() + - // 1-byte varint(script) - 1 + self.script.len() + - // 1-byte varint(sig+sighash) + - 1 + 65 + pub fn satisfaction_weight(&self) -> Weight { + Weight::from_vb( + ( + // 1-byte varint(control_block) + 1 + self.control_block.size() + + // 1-byte varint(script) + 1 + self.script.len() + + // 1-byte varint(sig+sighash) + + 1 + 65 + ) as _, + ) + .expect("valid weight") } pub(crate) fn to_vec(&self) -> Vec { diff --git a/wallet/src/rusqlite_impl.rs b/wallet/src/rusqlite_impl.rs new file mode 100644 index 0000000..66749c4 --- /dev/null +++ b/wallet/src/rusqlite_impl.rs @@ -0,0 +1,119 @@ +use std::str::FromStr; + +// Simple schema migration adapted from bdk to include +// additional metadata stored alongside bdk's tables +// https://github.com/bitcoindevkit/bdk/blob/bcff89d51d0e1d91058e4430eda8cc57fb7f0f08/crates/chain/src/rusqlite_impl.rs#L55 +use bdk_wallet::rusqlite; +use bdk_wallet::rusqlite::{ + named_params, + types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef}, + OptionalExtension, ToSql, Transaction, +}; + +use crate::*; + +/// Table name for schemas. +pub const SCHEMAS_TABLE_NAME: &str = "spaces_schemas"; +pub struct Impl(pub T); + +/// Initialize the schema table. +fn init_schemas_table(db_tx: &Transaction) -> rusqlite::Result<()> { + let sql = format!("CREATE TABLE IF NOT EXISTS {}( name TEXT PRIMARY KEY NOT NULL, version INTEGER NOT NULL ) STRICT", SCHEMAS_TABLE_NAME); + db_tx.execute(&sql, ())?; + Ok(()) +} + +/// Get schema version of `schema_name`. +fn schema_version(db_tx: &Transaction, schema_name: &str) -> rusqlite::Result> { + let sql = format!( + "SELECT version FROM {} WHERE name=:name", + SCHEMAS_TABLE_NAME + ); + db_tx + .query_row(&sql, named_params! { ":name": schema_name }, |row| { + row.get::<_, u32>("version") + }) + .optional() +} + +/// Set the `schema_version` of `schema_name`. +fn set_schema_version( + db_tx: &Transaction, + schema_name: &str, + schema_version: u32, +) -> rusqlite::Result<()> { + let sql = format!( + "REPLACE INTO {}(name, version) VALUES(:name, :version)", + SCHEMAS_TABLE_NAME, + ); + db_tx.execute( + &sql, + named_params! { ":name": schema_name, ":version": schema_version }, + )?; + Ok(()) +} + +/// Runs logic that initializes/migrates the table schemas. +pub fn migrate_schema( + db_tx: &Transaction, + schema_name: &str, + versioned_scripts: &[&[&str]], +) -> rusqlite::Result<()> { + init_schemas_table(db_tx)?; + let current_version = schema_version(db_tx, schema_name)?; + let exec_from = current_version.map_or(0_usize, |v| v as usize + 1); + let scripts_to_exec = versioned_scripts.iter().enumerate().skip(exec_from); + for (version, &script) in scripts_to_exec { + set_schema_version(db_tx, schema_name, version as u32)?; + for statement in script { + db_tx.execute(statement, ())?; + } + } + Ok(()) +} + +impl FromSql for Impl { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + bitcoin::OutPoint::from_str(value.as_str()?) + .map(Self) + .map_err(from_sql_error) + } +} + +impl ToSql for Impl { + fn to_sql(&self) -> rusqlite::Result> { + Ok(self.0.to_string().into()) + } +} + +impl FromSql for Impl { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + Txid::from_str(value.as_str()?) + .map(Self) + .map_err(from_sql_error) + } +} + +impl ToSql for Impl { + fn to_sql(&self) -> rusqlite::Result> { + Ok(self.0.to_string().into()) + } +} + +impl FromSql for Impl { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + serde_json::Value::from_str(value.as_str()?) + .map(Self) + .map_err(from_sql_error) + } +} + +impl ToSql for Impl { + fn to_sql(&self) -> rusqlite::Result> { + Ok(self.0.to_string().into()) + } +} + +fn from_sql_error(err: E) -> FromSqlError { + FromSqlError::Other(Box::new(err)) +} diff --git a/wallet/src/tx_event.rs b/wallet/src/tx_event.rs new file mode 100644 index 0000000..8b55629 --- /dev/null +++ b/wallet/src/tx_event.rs @@ -0,0 +1,546 @@ +use std::{fmt, fmt::Display, str::FromStr}; +use bdk_wallet::{ + chain, rusqlite, + rusqlite::{ + types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef}, + ToSql, + }, +}; +use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid}; +use serde::{Deserialize, Serialize}; +use protocol::{Covenant, FullSpaceOut}; +use crate::rusqlite_impl::{migrate_schema, Impl}; +use crate::{SpaceScriptSigningInfo, SpacesWallet}; + +#[derive(Clone, Debug)] +pub struct TxRecord { + pub tx: Transaction, + pub events: Vec, + pub txouts: Vec<(OutPoint, TxOut)> +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TxEvent { + #[serde(rename = "type")] + pub kind: TxEventKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub space: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub foreign_input: Option, + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub details: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BidEventDetails { + pub bid_current: Amount, + pub bid_previous: Amount, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TransferEventDetails { + pub to: ScriptBuf, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BidoutEventDetails { + pub count: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SendEventDetails { + #[serde(skip_serializing_if = "Option::is_none")] + pub to_space: Option, + pub resolved_address: ScriptBuf, + pub amount: Amount, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct OpenEventDetails { + pub bid_initial: Amount, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CommitEventDetails { + pub commit_script_pubkey: protocol::Bytes, + /// [SpaceScriptSigningInfo] in raw format + pub commit_signing_info: protocol::Bytes, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ExecuteEventDetails { + // Space script input index + pub n: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TxEventKind { + Commit, + Bidout, + Open, + Script, + Bid, + Register, + Transfer, + Send, + FeeBump, +} + +impl TxEvent { + pub const TX_EVENTS_TABLE_NAME: &'static str = "spaces_tx_events"; + pub const TX_EVENTS_SCHEMA_NAME: &'static str = "spaces_tx_events_schema"; + + pub fn init_sqlite_tables(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<()> { + let schema_v0: &[&str] = &[&format!( + "CREATE TABLE {} ( \ + id INTEGER PRIMARY KEY AUTOINCREMENT, \ + txid TEXT NOT NULL, \ + type TEXT NOT NULL, \ + space TEXT, \ + foreign_input TEXT, \ + details TEXT, \ + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + ) STRICT;", + Self::TX_EVENTS_TABLE_NAME, + )]; + + migrate_schema(db_tx, Self::TX_EVENTS_SCHEMA_NAME, &[schema_v0]) + } + + pub fn all(db_tx: &rusqlite::Transaction, txid: Txid) -> rusqlite::Result> { + let stmt = db_tx.prepare(&format!( + "SELECT type, space, foreign_input, details + FROM {} WHERE txid = ?1", + Self::TX_EVENTS_TABLE_NAME, + ))?; + Self::from_sqlite_statement(stmt, [Impl(txid)]) + } + + pub fn bids(db_tx: &rusqlite::Transaction, space: String) -> rusqlite::Result> { + let stmt = db_tx.prepare(&format!( + "SELECT type, space, foreign_input, details + FROM {} WHERE type = 'bid' AND space = ?1", + Self::TX_EVENTS_TABLE_NAME, + ))?; + Self::from_sqlite_statement(stmt, [space]) + } + + pub fn filter_bids( + db_tx: &rusqlite::Transaction, + txids: Vec, + ) -> rusqlite::Result> { + if txids.is_empty() { + return Ok(Vec::new()); + } + let query_placeholders = txids.iter().map(|_| "?").collect::>().join(","); + + let mut stmt = db_tx.prepare(&format!( + "SELECT txid, foreign_input + FROM {} + WHERE foreign_input IS NOT NULL AND type = 'bid' AND txid IN ({})", + Self::TX_EVENTS_TABLE_NAME, + query_placeholders + ))?; + + let rows = stmt.query_map( + rusqlite::params_from_iter(txids.into_iter().map(|t| Impl(t))), + |row| { + let txid: Impl = row.get(0)?; + let foreign_input: Option> = row.get(1)?; + Ok((txid, foreign_input.map(|x| x.0).unwrap())) + }, + )?; + let mut results = Vec::new(); + for row in rows { + if let Ok((txid, outpoint)) = row { + results.push((txid.0, outpoint)); + } + } + Ok(results) + } + + pub fn all_bid_txs(db_tx: &rusqlite::Transaction, txid: Txid) -> rusqlite::Result> { + let stmt = db_tx.prepare(&format!( + "SELECT type, space, foreign_input, details + FROM {} WHERE type = 'bid' AND txid = ?1", + Self::TX_EVENTS_TABLE_NAME, + ))?; + let results: Vec = Self::from_sqlite_statement(stmt, [Impl(txid)])?; + Ok(results.get(0).cloned()) + } + + pub fn get_signing_info(db_tx: &rusqlite::Transaction, txid: Txid, script_pubkey: &ScriptBuf) -> rusqlite::Result> { + let stmt = db_tx.prepare(&format!( + "SELECT type, space, foreign_input, details + FROM {} WHERE type = 'commit' AND txid = ?1", + Self::TX_EVENTS_TABLE_NAME, + ))?; + + let results: Vec = Self::from_sqlite_statement(stmt, [Impl(txid)]) + .expect("could not retrieve signing details from sqlite"); + for result in results { + let details = result.details.expect("signing details in tx event"); + let details: CommitEventDetails = serde_json::from_value(details) + .expect("signing details"); + if details.commit_script_pubkey.as_slice() == script_pubkey.as_bytes() { + let raw = details.commit_signing_info.to_vec(); + let info = + SpaceScriptSigningInfo::from_slice(raw.as_slice()).expect("valid signing info"); + return Ok(Some(info)) + } + } + Ok(None) + } + + /// Retrieve all spaces the wallet has bid on in the last 2 weeks + pub fn get_latest_events( + db_tx: &rusqlite::Transaction, + ) -> rusqlite::Result> { + let query = format!( + "SELECT txid, type, space, foreign_input, details + FROM {table} + WHERE id IN ( + SELECT MAX(id) + FROM {table} + WHERE type IN ('bid', 'open') + AND created_at >= strftime('%s', 'now', '-14 days') + GROUP BY space + ) + ORDER BY id DESC", + table = Self::TX_EVENTS_TABLE_NAME, + ); + + let mut stmt = db_tx.prepare(&query)?; + + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, Impl>("txid")?.0, + TxEvent { + kind: row.get("type")?, + space: row.get("space")?, + foreign_input: row.get::<_, Option>>("foreign_input")?.map(|x| x.0), + details: row.get::<_, Option>>("details")?.map(|x| x.0), + }, + )) + })?; + + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + + Ok(results) + } + + fn from_sqlite_statement( + mut stmt: rusqlite::Statement, + params: P, + ) -> rusqlite::Result> { + let row_iter = stmt.query_map(params, |row| { + Ok(( + row.get::<_, TxEventKind>("type")?, + row.get::<_, Option>("space")?, + row.get::<_, Option>>("foreign_input")?, + row.get::<_, Option>>("details")?, + )) + })?; + + let mut events = Vec::new(); + for row in row_iter { + let (event_type, space, foreign_input, details) = row?; + events.push(TxEvent { + kind: event_type, + space, + foreign_input: foreign_input.map(|x| x.0), + details: details.map(|x| x.0), + }) + } + + Ok(events) + } + + pub fn insert( + db_tx: &rusqlite::Transaction, + txid: Txid, + kind: TxEventKind, + space: Option, + foreign_input: Option, + details: Option, + ) -> rusqlite::Result { + let query = format!( + "INSERT INTO {} (txid, type, space, foreign_input, details) + VALUES (?1, ?2, ?3, ?4, ?5)", + Self::TX_EVENTS_TABLE_NAME, + ); + + db_tx.execute( + &query, + rusqlite::params![ + txid.to_string(), + kind, + space, + foreign_input.map(|b| b.to_string()), + details.map(|d| d.to_string()) + ], + )?; + + Ok(db_tx.last_insert_rowid() as usize) + } +} + + +impl TxRecord { + pub fn new(tx: Transaction) -> Self { + Self::new_with_events(tx, vec![]) + } + + pub fn new_with_events(tx: Transaction, events: Vec) -> Self { + Self { + events, + tx, + txouts: vec![], + } + } + + pub fn add_fee_bump(&mut self) { + self.events.push(TxEvent { + kind: TxEventKind::FeeBump, + space: None, + foreign_input: None, + details: None, + }); + } + + pub fn add_transfer(&mut self, space: String, to: ScriptBuf) { + self.events.push(TxEvent { + kind: TxEventKind::Transfer, + space: Some(space), + foreign_input: None, + details: Some(serde_json::to_value(TransferEventDetails { to }).expect("json value")), + }); + } + + pub fn add_bidout(&mut self, count: usize) { + self.events.push(TxEvent { + kind: TxEventKind::Bidout, + space: None, + foreign_input: None, + details: Some(serde_json::to_value(BidoutEventDetails { count }).expect("json value")), + }); + } + + pub fn add_send( + &mut self, + amount: Amount, + to_space: Option, + resolved_address: ScriptBuf, + ) { + self.events.push(TxEvent { + kind: TxEventKind::Send, + space: None, + foreign_input: None, + details: Some( + serde_json::to_value(SendEventDetails { + to_space, + resolved_address, + amount, + }) + .expect("json value"), + ), + }); + } + + // Should be added for every space affected by this commitment + pub fn add_commitment( + &mut self, + space: String, + reveal_address: ScriptBuf, + signing_info: Vec, + ) { + self.events.push(TxEvent { + kind: TxEventKind::Commit, + space: Some(space), + foreign_input: None, + details: Some( + serde_json::to_value(CommitEventDetails { + commit_script_pubkey: protocol::Bytes::new(reveal_address.to_bytes()), + commit_signing_info: protocol::Bytes::new(signing_info), + }) + .expect("json value"), + ), + }); + } + + pub fn add_open(&mut self, space: String, initial_bid: Amount) { + self.events.push(TxEvent { + kind: TxEventKind::Open, + space: Some(space), + foreign_input: None, + details: Some( + serde_json::to_value(OpenEventDetails { + bid_initial: initial_bid, + }) + .expect("json value"), + ), + }); + } + + // Should be added for each space affected + pub fn add_execute(&mut self, space: String, reveal_input_index: usize) { + self.events.push(TxEvent { + kind: TxEventKind::Script, + space: Some(space), + foreign_input: None, + details: Some( + serde_json::to_value(ExecuteEventDetails { + n: reveal_input_index, + }) + .expect("json value"), + ), + }); + } + + pub fn add_bid(&mut self, wallet: &mut SpacesWallet, previous: &FullSpaceOut, amount: Amount) { + let space = previous.spaceout.space.as_ref().expect("space not found"); + let previous_bid = match space.covenant { + Covenant::Bid { total_burned, .. } => total_burned, + _ => panic!("expected a bid"), + }; + let foreign_input = match wallet.is_mine(previous.spaceout.script_pubkey.clone()) { + false => Some(previous.outpoint()), + true => None + }; + + if foreign_input.is_some() { + self.txouts.push((previous.outpoint(), TxOut { + value: previous.spaceout.value, + script_pubkey: previous.spaceout.script_pubkey.clone(), + })) + } + + self.events.push(TxEvent { + kind: TxEventKind::Bid, + space: Some(space.name.to_string()), + foreign_input, + details: Some( + serde_json::to_value(BidEventDetails { + bid_current: amount, + bid_previous: previous_bid, + }) + .expect("json value"), + ), + }); + } +} + + +#[cfg(test)] +mod tests { + use bitcoin::{hashes::Hash, Txid}; + use serde_json::json; + use tempfile::tempdir; + + use crate::{tx_event::TxEventKind, *}; + + #[test] + fn test_tx_event() -> anyhow::Result<()> { + // Create a temporary directory + let tmp_dir = tempdir()?; + let db_path = tmp_dir.path().join("test.db"); + + // Initialize SQLite connection + let mut conn = Connection::open(&db_path)?; + let tx = conn.transaction()?; + + // Initialize the table + TxEvent::init_sqlite_tables(&tx)?; + + // Insert a sample transaction event + let txid = Txid::all_zeros(); + let kind = TxEventKind::Bid; + let space = Some("test_space".to_string()); + let details = Some(json!({"amount": 1000, "currency": "USD"})); + + TxEvent::insert(&tx, txid, kind, space.clone(), None, details.clone())?; + + // Commit the transaction + tx.commit()?; + + // Re-open the connection to verify the insertion + let mut conn = Connection::open(&db_path)?; + let tx = conn.transaction()?; + + // Query the inserted event + let inserted_events = TxEvent::all(&tx, txid)?; + + assert_eq!(inserted_events.len(), 1); + let event = &inserted_events[0]; + assert_eq!(event.space, space); + assert_eq!(event.details, details); + + let mut conn = Connection::open(&db_path)?; + let tx = conn.transaction()?; + + let spaces = TxEvent::get_latest_events(&tx)?; + assert_eq!(spaces.len(), 1); + assert_eq!(spaces[0].1.space.as_ref().expect("space"), "test_space"); + + let bids = TxEvent::bids(&tx, "test_space".to_string())?; + assert_eq!(bids.len(), 1); + assert!(matches!(bids[0].kind, TxEventKind::Bid)); + + Ok(()) + } +} + +impl Display for TxEventKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + TxEventKind::Commit => "commit", + TxEventKind::Bidout => "bidout", + TxEventKind::Open => "open", + TxEventKind::Bid => "bid", + TxEventKind::Register => "register", + TxEventKind::Transfer => "transfer", + TxEventKind::Send => "send", + TxEventKind::Script => "script", + TxEventKind::FeeBump => "fee-bump" + }) + } +} + +impl FromStr for TxEventKind { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "commit" => Ok(TxEventKind::Commit), + "bidout" => Ok(TxEventKind::Bidout), + "open" => Ok(TxEventKind::Open), + "bid" => Ok(TxEventKind::Bid), + "register" => Ok(TxEventKind::Register), + "transfer" => Ok(TxEventKind::Transfer), + "send" => Ok(TxEventKind::Send), + "script" => Ok(TxEventKind::Script), + "fee-bump" => Ok(TxEventKind::FeeBump), + _ => Err("invalid event kind"), + } + } +} + +impl FromSql for TxEventKind { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + value + .as_str() + .map_err(|_| FromSqlError::InvalidType)? + .parse() + .map_err(|_| FromSqlError::InvalidType) + } +} + +impl ToSql for TxEventKind { + fn to_sql(&self) -> rusqlite::Result> { + Ok(ToSqlOutput::from(self.to_string())) + } +}