diff --git a/Cargo.lock b/Cargo.lock index fb24737494..25206396fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2949,6 +2949,7 @@ name = "test-bitcoincore-rpc" version = "0.0.0" dependencies = [ "bitcoin", + "bitcoincore-rpc", "bitcoincore-rpc-json", "hex", "jsonrpc-core", diff --git a/src/subcommand.rs b/src/subcommand.rs index b1c3f9f3c7..9865bae3f9 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -10,6 +10,7 @@ mod range; mod server; mod supply; mod traits; +mod wallet; #[derive(Debug, Parser)] pub(crate) enum Subcommand { @@ -23,6 +24,8 @@ pub(crate) enum Subcommand { Server(server::Server), Supply, Traits(traits::Traits), + #[clap(subcommand)] + Wallet(wallet::Wallet), } impl Subcommand { @@ -43,6 +46,7 @@ impl Subcommand { } Self::Supply => supply::run(), Self::Traits(traits) => traits.run(), + Self::Wallet(wallet) => wallet.run(options), } } } diff --git a/src/subcommand/wallet.rs b/src/subcommand/wallet.rs new file mode 100644 index 0000000000..e575fb304c --- /dev/null +++ b/src/subcommand/wallet.rs @@ -0,0 +1,19 @@ +use super::*; + +mod identify; + +#[derive(Debug, Parser)] +pub(crate) enum Wallet { + Identify, +} + +impl Wallet { + pub(crate) fn run(self, options: Options) -> Result<()> { + match self { + Self::Identify => identify::run(options), + } + } +} + +#[cfg(test)] +mod tests {} diff --git a/src/subcommand/wallet/identify.rs b/src/subcommand/wallet/identify.rs new file mode 100644 index 0000000000..a6ece41616 --- /dev/null +++ b/src/subcommand/wallet/identify.rs @@ -0,0 +1,141 @@ +use { + super::*, + bitcoincore_rpc::{Auth, Client, RpcApi}, +}; + +pub(crate) fn run(options: Options) -> Result { + let index = Index::open(&options)?; + index.index()?; + + let cookie_file = options.cookie_file()?; + let rpc_url = options.rpc_url(); + log::info!( + "Connecting to Bitcoin Core RPC server at {rpc_url} using credentials from `{}`", + cookie_file.display() + ); + let client = Client::new(&rpc_url, Auth::CookieFile(cookie_file)) + .context("Failed to connect to Bitcoin Core RPC at {rpc_url}")?; + + let unspent = client.list_unspent(None, None, None, None, None)?; + + let mut utxos = Vec::new(); + for utxo in unspent { + let output = OutPoint::new(utxo.txid, utxo.vout); + match index.list(output)? { + Some(List::Unspent(ordinal_ranges)) => { + utxos.push((output, ordinal_ranges)); + } + Some(List::Spent) => { + bail!("Output {output} in wallet but is spent according to index") + } + None => bail!("Ordinals index has not seen {output}"), + } + } + + for (ordinal, output, offset, rarity) in identify(utxos) { + println!("{ordinal}\t{output}\t{offset}\t{rarity}"); + } + + Ok(()) +} + +fn identify(utxos: Vec<(OutPoint, Vec<(u64, u64)>)>) -> Vec<(Ordinal, OutPoint, u64, Rarity)> { + utxos + .into_iter() + .flat_map(|(outpoint, ordinal_ranges)| { + let mut offset = 0; + ordinal_ranges.into_iter().filter_map(move |(start, end)| { + let ordinal = Ordinal(start); + let rarity = ordinal.rarity(); + let start_offset = offset; + offset += end - start; + if rarity > Rarity::Common { + Some((ordinal, outpoint, start_offset, rarity)) + } else { + None + } + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identify_no_rare_ordinals() { + let utxos = vec![( + OutPoint::null(), + vec![(51 * COIN_VALUE, 100 * COIN_VALUE), (1234, 5678)], + )]; + assert_eq!(identify(utxos), vec![]) + } + + #[test] + fn identify_one_rare_ordinal() { + let utxos = vec![( + OutPoint::null(), + vec![(10, 80), (50 * COIN_VALUE, 100 * COIN_VALUE)], + )]; + assert_eq!( + identify(utxos), + vec![( + Ordinal(50 * COIN_VALUE), + OutPoint::null(), + 70, + Rarity::Uncommon + )] + ) + } + + #[test] + fn identify_two_rare_ordinals() { + let utxos = vec![( + OutPoint::null(), + vec![(0, 100), (1050000000000000, 1150000000000000)], + )]; + assert_eq!( + identify(utxos), + vec![ + (Ordinal(0), OutPoint::null(), 0, Rarity::Mythic), + ( + Ordinal(1050000000000000), + OutPoint::null(), + 100, + Rarity::Epic + ) + ] + ) + } + + #[test] + fn identify_rare_ordinals_in_different_outpoints() { + let utxos = vec![ + (OutPoint::null(), vec![(50 * COIN_VALUE, 55 * COIN_VALUE)]), + ( + OutPoint::from_str("4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:5") + .unwrap(), + vec![(100 * COIN_VALUE, 111 * COIN_VALUE)], + ), + ]; + assert_eq!( + identify(utxos), + vec![ + ( + Ordinal(50 * COIN_VALUE), + OutPoint::null(), + 0, + Rarity::Uncommon + ), + ( + Ordinal(100 * COIN_VALUE), + OutPoint::from_str("4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:5") + .unwrap(), + 0, + Rarity::Uncommon + ) + ] + ) + } +} diff --git a/test-bitcoincore-rpc/Cargo.toml b/test-bitcoincore-rpc/Cargo.toml index 832c5d1802..1e8a4bc23e 100644 --- a/test-bitcoincore-rpc/Cargo.toml +++ b/test-bitcoincore-rpc/Cargo.toml @@ -9,6 +9,7 @@ repository = "https://github.com/casey/ord" [dependencies] bitcoin = { version = "0.29.1", features = ["serde"] } +bitcoincore-rpc = "0.16.0" bitcoincore-rpc-json = "0.16.0" hex = "0.4.3" jsonrpc-core = "18.0.0" diff --git a/test-bitcoincore-rpc/src/lib.rs b/test-bitcoincore-rpc/src/lib.rs index d27e22ff51..16002667bc 100644 --- a/test-bitcoincore-rpc/src/lib.rs +++ b/test-bitcoincore-rpc/src/lib.rs @@ -1,10 +1,10 @@ use { bitcoin::{ blockdata::constants::COIN_VALUE, blockdata::script, consensus::encode::serialize, - hash_types::BlockHash, hashes::Hash, Block, BlockHeader, Network, OutPoint, PackedLockTime, - Script, Sequence, Transaction, TxIn, TxMerkleNode, TxOut, Txid, Witness, Wtxid, + hash_types::BlockHash, hashes::Hash, Amount, Block, BlockHeader, Network, OutPoint, + PackedLockTime, Script, Sequence, Transaction, TxIn, TxMerkleNode, TxOut, Txid, Witness, Wtxid, }, - bitcoincore_rpc_json::GetRawTransactionResult, + bitcoincore_rpc_json::{GetRawTransactionResult, ListUnspentResultEntry}, jsonrpc_core::{IoHandler, Value}, jsonrpc_http_server::{CloseHandle, ServerBuilder}, std::collections::BTreeMap, @@ -205,6 +205,16 @@ pub trait Api { verbose: bool, blockhash: Option, ) -> Result; + + #[rpc(name = "listunspent")] + fn list_unspent( + &self, + minconf: Option, + maxconf: Option, + address: Option, + include_unsafe: Option, + query_options: Option, + ) -> Result, jsonrpc_core::Error>; } impl Api for Server { @@ -282,6 +292,47 @@ impl Api for Server { } } } + + fn list_unspent( + &self, + minconf: Option, + maxconf: Option, + address: Option, + include_unsafe: Option, + query_options: Option, + ) -> Result, jsonrpc_core::Error> { + assert_eq!(minconf, None, "minconf param not supported"); + assert_eq!(maxconf, None, "maxconf param not supported"); + assert_eq!(address, None, "address param not supported"); + assert_eq!(include_unsafe, None, "include_unsafe param not supported"); + assert_eq!(query_options, None, "query_options param not supported"); + Ok( + self + .state + .lock() + .unwrap() + .transactions + .iter() + .flat_map(|(txid, tx)| { + (0..tx.output.len()).map(|vout| ListUnspentResultEntry { + txid: *txid, + vout: vout as u32, + address: None, + label: None, + redeem_script: None, + witness_script: None, + script_pub_key: Script::new(), + amount: Amount::default(), + confirmations: 0, + spendable: true, + solvable: true, + descriptor: None, + safe: true, + }) + }) + .collect(), + ) + } } pub struct Handle { diff --git a/tests/lib.rs b/tests/lib.rs index a0a7fca2ec..a439f72b51 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -28,3 +28,4 @@ mod server; mod supply; mod traits; mod version; +mod wallet; diff --git a/tests/wallet.rs b/tests/wallet.rs new file mode 100644 index 0000000000..a519c01d56 --- /dev/null +++ b/tests/wallet.rs @@ -0,0 +1,19 @@ +use { + super::*, + bitcoin::{blockdata::constants::COIN_VALUE, OutPoint}, +}; + +#[test] +fn identify() { + let rpc_server = test_bitcoincore_rpc::spawn(); + let second_coinbase = rpc_server.mine_blocks(1)[0].txdata[0].txid(); + + CommandBuilder::new("wallet identify") + .rpc_server(&rpc_server) + .expected_stdout(format!( + "{}\t{}\t0\tuncommon\n", + 50 * COIN_VALUE, + OutPoint::new(second_coinbase, 0) + )) + .run(); +}