From 28a23627efa038eebec6d2fcaa21e01b0ce09472 Mon Sep 17 00:00:00 2001 From: liam <31192478+terror@users.noreply.github.com> Date: Fri, 12 Aug 2022 17:43:53 -0400 Subject: [PATCH] Add `ord wallet send` (#305) --- src/main.rs | 11 +- src/purse.rs | 123 ++++++++++++++++++++++ src/subcommand/wallet.rs | 51 +--------- src/subcommand/wallet/balance.rs | 2 +- src/subcommand/wallet/fund.rs | 8 +- src/subcommand/wallet/init.rs | 49 +-------- src/subcommand/wallet/send.rs | 46 +++++++++ src/subcommand/wallet/utxos.rs | 2 +- tests/wallet.rs | 169 +++++++++++++++++++++++++++++++ 9 files changed, 357 insertions(+), 104 deletions(-) create mode 100644 src/purse.rs create mode 100644 src/subcommand/wallet/send.rs diff --git a/src/main.rs b/src/main.rs index 0432638e37..352e7a45e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use { crate::{ arguments::Arguments, bytes::Bytes, epoch::Epoch, height::Height, index::Index, nft::Nft, - options::Options, ordinal::Ordinal, sat_point::SatPoint, subcommand::Subcommand, + options::Options, ordinal::Ordinal, purse::Purse, sat_point::SatPoint, subcommand::Subcommand, }, anyhow::{anyhow, bail, Context, Error}, axum::{ @@ -12,13 +12,13 @@ use { axum_server::Handle, bdk::{ blockchain::rpc::{Auth, RpcBlockchain, RpcConfig}, - blockchain::ConfigurableBlockchain, + blockchain::{Blockchain, ConfigurableBlockchain}, database::SqliteDatabase, keys::bip39::{Language, Mnemonic}, template::Bip84, - wallet::AddressIndex::LastUnused, + wallet::{signer::SignOptions, AddressIndex::LastUnused}, wallet::{wallet_name_from_descriptor, SyncOptions}, - KeychainKind, + KeychainKind, LocalUtxo, }, bitcoin::{ blockdata::constants::COIN_VALUE, @@ -32,7 +32,7 @@ use { KeyPair, Secp256k1, XOnlyPublicKey, }, util::key::PrivateKey, - Block, Network, OutPoint, Transaction, Txid, + Address, Block, Network, OutPoint, Transaction, Txid, }, chrono::{DateTime, NaiveDateTime, Utc}, clap::Parser, @@ -75,6 +75,7 @@ mod index; mod nft; mod options; mod ordinal; +mod purse; mod sat_point; mod subcommand; diff --git a/src/purse.rs b/src/purse.rs new file mode 100644 index 0000000000..34cf9cd8ad --- /dev/null +++ b/src/purse.rs @@ -0,0 +1,123 @@ +use super::*; + +#[derive(Debug)] +pub(crate) struct Purse { + pub(crate) wallet: bdk::wallet::Wallet, + pub(crate) blockchain: RpcBlockchain, +} + +impl Purse { + pub(crate) fn init(options: &Options) -> Result { + let path = data_dir() + .ok_or_else(|| anyhow!("Failed to retrieve data dir"))? + .join("ord"); + + if path.exists() { + return Err(anyhow!("Wallet already exists.")); + } + + fs::create_dir_all(&path)?; + + let seed = Mnemonic::generate_in_with(&mut rand::thread_rng(), Language::English, 12)?; + + fs::write(path.join("entropy"), seed.to_entropy())?; + + let wallet = bdk::wallet::Wallet::new( + Bip84((seed.clone(), None), KeychainKind::External), + None, + options.network, + SqliteDatabase::new( + path + .join("wallet.sqlite") + .to_str() + .ok_or_else(|| anyhow!("Failed to convert path to str"))? + .to_string(), + ), + )?; + + wallet.sync( + &RpcBlockchain::from_config(&RpcConfig { + url: options.rpc_url(), + auth: Auth::Cookie { + file: options.cookie_file()?, + }, + network: options.network, + wallet_name: wallet_name_from_descriptor( + Bip84((seed, None), KeychainKind::External), + None, + options.network, + &Secp256k1::new(), + )?, + skip_blocks: None, + })?, + SyncOptions::default(), + )?; + + eprintln!("Wallet initialized."); + + Ok(()) + } + + pub(crate) fn load(options: &Options) -> Result { + let path = data_dir() + .ok_or_else(|| anyhow!("Failed to retrieve data dir"))? + .join("ord"); + + if !path.exists() { + return Err(anyhow!("Wallet doesn't exist.")); + } + + let key = ( + Mnemonic::from_entropy(&fs::read(path.join("entropy"))?)?, + None, + ); + + let wallet = bdk::wallet::Wallet::new( + Bip84(key.clone(), KeychainKind::External), + None, + options.network, + SqliteDatabase::new( + path + .join("wallet.sqlite") + .to_str() + .ok_or_else(|| anyhow!("Failed to convert path to str"))? + .to_string(), + ), + )?; + + let blockchain = RpcBlockchain::from_config(&RpcConfig { + url: options.rpc_url(), + auth: Auth::Cookie { + file: options.cookie_file()?, + }, + network: options.network, + wallet_name: wallet_name_from_descriptor( + Bip84(key, KeychainKind::External), + None, + options.network, + &Secp256k1::new(), + )?, + skip_blocks: None, + })?; + + wallet.sync(&blockchain, SyncOptions::default())?; + + Ok(Self { wallet, blockchain }) + } + + pub(crate) fn find(&self, options: &Options, ordinal: Ordinal) -> Result { + let index = Index::index(options)?; + + for utxo in self.wallet.list_unspent()? { + if let Some(ranges) = index.list(utxo.outpoint)? { + for (start, end) in ranges { + if ordinal.0 >= start && ordinal.0 < end { + return Ok(utxo); + } + } + } + } + + bail!("No utxo contains {}˚.", ordinal); + } +} diff --git a/src/subcommand/wallet.rs b/src/subcommand/wallet.rs index 6804e25211..65ebb570df 100644 --- a/src/subcommand/wallet.rs +++ b/src/subcommand/wallet.rs @@ -3,61 +3,15 @@ use super::*; mod balance; mod fund; mod init; +mod send; mod utxos; -fn get_wallet(options: Options) -> Result> { - let path = data_dir() - .ok_or_else(|| anyhow!("Failed to retrieve data dir"))? - .join("ord"); - - if !path.exists() { - return Err(anyhow!("Wallet doesn't exist.")); - } - - let key = ( - Mnemonic::from_entropy(&fs::read(path.join("entropy"))?)?, - None, - ); - - let wallet = bdk::wallet::Wallet::new( - Bip84(key.clone(), KeychainKind::External), - None, - options.network, - SqliteDatabase::new( - path - .join("wallet.sqlite") - .to_str() - .ok_or_else(|| anyhow!("Failed to convert path to str"))? - .to_string(), - ), - )?; - - wallet.sync( - &RpcBlockchain::from_config(&RpcConfig { - url: options.rpc_url(), - auth: Auth::Cookie { - file: options.cookie_file()?, - }, - network: options.network, - wallet_name: wallet_name_from_descriptor( - Bip84(key, KeychainKind::External), - None, - options.network, - &Secp256k1::new(), - )?, - skip_blocks: None, - })?, - SyncOptions::default(), - )?; - - Ok(wallet) -} - #[derive(Debug, Parser)] pub(crate) enum Wallet { Balance, Fund, Init, + Send(send::Send), Utxos, } @@ -67,6 +21,7 @@ impl Wallet { Self::Balance => balance::run(options), Self::Fund => fund::run(options), Self::Init => init::run(options), + Self::Send(send) => send.run(options), Self::Utxos => utxos::run(options), } } diff --git a/src/subcommand/wallet/balance.rs b/src/subcommand/wallet/balance.rs index dc0553aca4..0014576812 100644 --- a/src/subcommand/wallet/balance.rs +++ b/src/subcommand/wallet/balance.rs @@ -1,6 +1,6 @@ use super::*; pub(crate) fn run(options: Options) -> Result { - println!("{}", get_wallet(options)?.get_balance()?); + println!("{}", Purse::load(&options)?.wallet.get_balance()?); Ok(()) } diff --git a/src/subcommand/wallet/fund.rs b/src/subcommand/wallet/fund.rs index 597f256060..6bda859af2 100644 --- a/src/subcommand/wallet/fund.rs +++ b/src/subcommand/wallet/fund.rs @@ -1,6 +1,12 @@ use super::*; pub(crate) fn run(options: Options) -> Result { - println!("{}", get_wallet(options)?.get_address(LastUnused)?.address); + println!( + "{}", + Purse::load(&options)? + .wallet + .get_address(LastUnused)? + .address + ); Ok(()) } diff --git a/src/subcommand/wallet/init.rs b/src/subcommand/wallet/init.rs index 7f05d6f5b6..ba6d501780 100644 --- a/src/subcommand/wallet/init.rs +++ b/src/subcommand/wallet/init.rs @@ -1,52 +1,5 @@ use super::*; pub(crate) fn run(options: Options) -> Result { - let path = data_dir() - .ok_or_else(|| anyhow!("Failed to retrieve data dir"))? - .join("ord"); - - if path.exists() { - return Err(anyhow!("Wallet already exists.")); - } - - fs::create_dir_all(&path)?; - - let seed = Mnemonic::generate_in_with(&mut rand::thread_rng(), Language::English, 12)?; - - fs::write(path.join("entropy"), seed.to_entropy())?; - - let wallet = bdk::wallet::Wallet::new( - Bip84((seed.clone(), None), KeychainKind::External), - None, - options.network, - SqliteDatabase::new( - path - .join("wallet.sqlite") - .to_str() - .ok_or_else(|| anyhow!("Failed to convert path to str"))? - .to_string(), - ), - )?; - - wallet.sync( - &RpcBlockchain::from_config(&RpcConfig { - url: options.rpc_url(), - auth: Auth::Cookie { - file: options.cookie_file()?, - }, - network: options.network, - wallet_name: wallet_name_from_descriptor( - Bip84((seed, None), KeychainKind::External), - None, - options.network, - &Secp256k1::new(), - )?, - skip_blocks: None, - })?, - SyncOptions::default(), - )?; - - eprintln!("Wallet initialized."); - - Ok(()) + Purse::init(&options) } diff --git a/src/subcommand/wallet/send.rs b/src/subcommand/wallet/send.rs new file mode 100644 index 0000000000..3b87854cb4 --- /dev/null +++ b/src/subcommand/wallet/send.rs @@ -0,0 +1,46 @@ +use super::*; + +#[derive(Debug, Parser)] +pub(crate) struct Send { + #[clap(long)] + address: Address, + #[clap(long)] + ordinal: Ordinal, +} + +impl Send { + pub(crate) fn run(self, options: Options) -> Result { + let wallet = Purse::load(&options)?; + + let utxo = wallet.find(&options, self.ordinal)?; + + let (mut psbt, _details) = { + let mut builder = wallet.wallet.build_tx(); + + builder + .manually_selected_only() + .fee_absolute(0) + .add_utxo(utxo.outpoint)? + .add_recipient(self.address.script_pubkey(), utxo.txout.value); + + builder.finish()? + }; + + if !wallet.wallet.sign(&mut psbt, SignOptions::default())? { + bail!("Failed to sign transaction."); + } + + let tx = psbt.extract_tx(); + + wallet.blockchain.broadcast(&tx)?; + + println!( + "Sent ordinal {} to address {}: {}", + self.ordinal.0, + self.address, + tx.txid() + ); + + Ok(()) + } +} diff --git a/src/subcommand/wallet/utxos.rs b/src/subcommand/wallet/utxos.rs index badbe6eec6..eafb88b382 100644 --- a/src/subcommand/wallet/utxos.rs +++ b/src/subcommand/wallet/utxos.rs @@ -1,7 +1,7 @@ use super::*; pub(crate) fn run(options: Options) -> Result { - for utxo in get_wallet(options)?.list_unspent()? { + for utxo in Purse::load(&options)?.wallet.list_unspent()? { println!( "{}:{} {}", utxo.outpoint.txid, utxo.outpoint.vout, utxo.txout.value diff --git a/tests/wallet.rs b/tests/wallet.rs index bea1d48f65..21b9973f3a 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -184,3 +184,172 @@ fn balance() { .expected_stdout("5000000000\n") .run() } + +#[test] +fn send_owned_ordinal() { + let state = Test::new() + .command("--network regtest wallet init") + .expected_status(0) + .expected_stderr("Wallet initialized.\n") + .output() + .state; + + let output = Test::with_state(state) + .command("--network regtest wallet fund") + .stdout_regex("^bcrt1.*\n") + .output(); + + let from_address = Address::from_str( + output + .stdout + .strip_suffix('\n') + .ok_or("Failed to strip suffix") + .unwrap(), + ) + .unwrap(); + + output + .state + .client + .generate_to_address(101, &from_address) + .unwrap(); + + let mut output = Test::with_state(output.state) + .command("--network regtest wallet utxos") + .expected_status(0) + .stdout_regex("[[:xdigit:]]{64}:[[:digit:]] 5000000000\n") + .output(); + + output.state.request( + &format!( + "api/list/{}", + output + .stdout + .split(' ') + .collect::>() + .first() + .unwrap() + ), + 200, + "[[5000000000, 10000000000]]", + ); + + let wallet = Wallet::new( + Bip84( + ( + Mnemonic::parse("book fit fly ketchup also elevator scout mind edit fatal where rookie") + .unwrap(), + None, + ), + KeychainKind::External, + ), + None, + Network::Regtest, + MemoryDatabase::new(), + ) + .unwrap(); + + let to_address = wallet.get_address(AddressIndex::LastUnused).unwrap(); + + let state = Test::with_state(output.state) + .command(&format!( + "--network regtest wallet send --address {to_address} --ordinal 5000000001", + )) + .expected_status(0) + .stdout_regex(format!( + "Sent ordinal 5000000001 to address {to_address}: {}\n", + "[[:xdigit:]]{64}", + )) + .output() + .state; + + wallet + .sync(&state.blockchain, SyncOptions::default()) + .unwrap(); + + state.client.generate_to_address(1, &from_address).unwrap(); + + Test::with_state(state) + .command(&format!( + "--network regtest list {}", + wallet.list_unspent().unwrap().first().unwrap().outpoint + )) + .expected_status(0) + .expected_stdout("[5000000000,10000000000)\n") + .run() +} + +#[test] +fn send_foreign_ordinal() { + let state = Test::new() + .command("--network regtest wallet init") + .expected_status(0) + .expected_stderr("Wallet initialized.\n") + .output() + .state; + + let output = Test::with_state(state) + .command("--network regtest wallet fund") + .stdout_regex("^bcrt1.*\n") + .output(); + + let from_address = Address::from_str( + output + .stdout + .strip_suffix('\n') + .ok_or("Failed to strip suffix") + .unwrap(), + ) + .unwrap(); + + output + .state + .client + .generate_to_address(101, &from_address) + .unwrap(); + + let mut output = Test::with_state(output.state) + .command("--network regtest wallet utxos") + .expected_status(0) + .stdout_regex("[[:xdigit:]]{64}:[[:digit:]] 5000000000\n") + .output(); + + output.state.request( + &format!( + "api/list/{}", + output + .stdout + .split(' ') + .collect::>() + .first() + .unwrap() + ), + 200, + "[[5000000000, 10000000000]]", + ); + + let wallet = Wallet::new( + Bip84( + ( + Mnemonic::parse("book fit fly ketchup also elevator scout mind edit fatal where rookie") + .unwrap(), + None, + ), + KeychainKind::External, + ), + None, + Network::Regtest, + MemoryDatabase::new(), + ) + .unwrap(); + + let to_address = wallet.get_address(AddressIndex::LastUnused).unwrap(); + + Test::with_state(output.state) + .command(&format!( + "--network regtest wallet send --address {to_address} --ordinal 4999999999", + )) + .expected_status(1) + .expected_stderr("error: No utxo contains 4999999999˚.\n") + .run() +}