diff --git a/.gitignore b/.gitignore index 26154489a5..d272a24139 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ /target /test-times.txt /tmp +/300-inscriptions diff --git a/addresses.example.txt b/addresses.example.txt new file mode 100644 index 0000000000..8ab45cd653 --- /dev/null +++ b/addresses.example.txt @@ -0,0 +1,12 @@ +tb1p7t5xhyxj2g98eua7xfgrp5ufgwjypqje8q2z2cns79yaly4vv9lq5k4fmh +tb1p2khdgxygwmt4hs2ls9p39w6m9pf3lxfvtmg855h65jqazj0e2hrse387vm +tb1p833fd7mv8kngqf6acue3f8c9k63xwxu23ph3jmt8fjqn2p0d4amqawndj6 +tb1pajkhapfr7qzsv84x4esmp8tt87rgfy2764a2we9zahawp02sn2ask4wwvp +tb1pak3d2nf3n2ezpm3tx52sswz2zzlr75ch9fq2qp8dt8re4a3r2p5shja0ku +tb1pw5fkhj4u9fhs4hednnd2x2tped4ssuwnxax39ndcakce7ayc6erq9679tm +tb1pkeetmfw2khdvlessj7wgk5gvmc4lg46cq4z5m590pkk8r80edrcqjxa82t +tb1pldqf2fmtj6f38z5qmtemlfx3rxmvt7sg3xq00px4yc90fe9adx4qfn6xpf +tb1pyc3ng7l4ry9e9tvhudeq8tlt3zu77uynjm4vx2wppvywvke2pd2scrtgrf +tb1pcxtr98jtam7zpk6lfvth675c43r8eteslgw9809ykucvdusquv3qzygc3s +tb1p44qzt7sxmjhr2rlhxw7y6zl5n4hhwlq35wuthfrkpx2rtqj4uxcqk02x2q +tb1pqswqkj4rp86lats7plu0ypc2u9vphnmackm0cccax8f8dchkq8jsc3u99u diff --git a/chain.png b/chain.png new file mode 100644 index 0000000000..8679ef6ede Binary files /dev/null and b/chain.png differ diff --git a/create-30-testnet.sh b/create-30-testnet.sh new file mode 100755 index 0000000000..db6b19f60e --- /dev/null +++ b/create-30-testnet.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +dir="30-inscriptions-testnet" +mkdir -p $dir + +parentInscriptionId="3995befab6b08427416bf9442d6877f6057780f31cdca37eb600a12bcf5e9345i0" +offsetInscriptionId="f4429c67523c9437f3db69fe0521f10dec4ae2b3bb64e98a1fd97c53c59803cai0" + +for ((i=1; i<=30; i++)); do + filename="${i}.html" + filepath="${dir}/${filename}" + tokenID=$i + + echo '' > "$filepath" + +done + +echo "Test files created!" \ No newline at end of file diff --git a/create-30-with-addresses-testnet.sh b/create-30-with-addresses-testnet.sh new file mode 100755 index 0000000000..0dc9a25f6e --- /dev/null +++ b/create-30-with-addresses-testnet.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +file="$1" # Path to the addresses file + +# Check if the file exists +if [ ! -f "$file" ]; then + echo "Addresses file not found: $file" + exit 1 +fi + +dir="30-inscriptions-testnet" +mkdir -p $dir + +inscriptions_dir="$dir/inscriptions" +mkdir -p $inscriptions_dir + +addresses_dir="$dir/addresses" +mkdir -p $addresses_dir + +# Read the file line by line +while IFS= read -r line; do + ((count++)) + + filename="${count}.address" + filepath="${addresses_dir}/${filename}" + echo "$line" > "$filepath" +done < "$file" + +parentInscriptionId="3995befab6b08427416bf9442d6877f6057780f31cdca37eb600a12bcf5e9345i0" +offsetInscriptionId="f4429c67523c9437f3db69fe0521f10dec4ae2b3bb64e98a1fd97c53c59803cai0" + +for ((i=1; i<=30; i++)); do + filename="${i}.html" + filepath="${inscriptions_dir}/${filename}" + tokenID=$i + + echo '' > "$filepath" + +done + +echo "Test files created!" diff --git a/create-300.sh b/create-300.sh new file mode 100755 index 0000000000..8d6f785372 --- /dev/null +++ b/create-300.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +mkdir -p 300-inscriptions + +parentInscriptionId="2dbdf9ebbec6be793fd16ae9b797c7cf968ab2427166aaf390b90b71778266abi0" +offsetInscriptionId="" + +for ((i=1; i<=300; i++)); do + filename="${i}.html" + filepath="300-inscriptions/${filename}" + tokenID=$i + + echo '' > "$filepath" + +done + +echo "Files created!" diff --git a/inscribe-chain.md b/inscribe-chain.md new file mode 100644 index 0000000000..ddd0a6f893 --- /dev/null +++ b/inscribe-chain.md @@ -0,0 +1,68 @@ +# ord wallet inscribe-chain +> Broadcast a transaction chain to the mempool inscribing 10 ordinals, each containing one special sat extracted from a designated output. + +![](chain.png) + +## Requirements +1. utxo containing special sats must be padded with at least 1,000 normal sats at the beginning +2. if the utxo has more than one range of special sats, start with the last sat of the last range, because everything below that will be trimmed. +3. for each iteration, 10 inscriptions being created in one block, wallet must have 10 additional available utxos with enough sats for each inscription because 1 sat will come from the special utxo and the other 9,999 will come from any other normal utxo available in the wallet. **Important**: each of these prepared utxo's also needs to be large enough to pay the chosen fee rate for the inscription. +4. files to be inscribed must be named as `.`, the inscribing will take place in that order. + +## Example +1. Create the files to be inscribed: + ```bash + mkdir files/ + echo "1" > files/1.txt + echo "2" > files/2.txt + echo "3" > files/3.txt + echo "4" > files/4.txt + echo "5" > files/5.txt + echo "6" > files/6.txt + echo "7" > files/7.txt + echo "8" > files/8.txt + echo "9" > files/9.txt + echo "10" > files/10.txt + echo "11" > files/11.txt + echo "12" > files/12.txt + echo "13" > files/13.txt + echo "14" > files/14.txt + echo "15" > files/15.txt + ``` +2. Get the special utxo + ```bash + ord wallet outputs + ``` + ```json + [ + { + "output": "7f320d87dd2d011ba9a3dbc66c46aed4b0b3a9a0a4d1c93fe3ed97ab280463f5:8", + "amount": 15000 + } + ] + ``` + Lets pick sats from `10_001` to `10_015` as the special ones, so my `satpoint` is: + ``` + 7f320d87dd2d011ba9a3dbc66c46aed4b0b3a9a0a4d1c93fe3ed97ab280463f5:8:10014 + ``` +3. Run the `inscribe-chain` command + > **Note** + > You must have at least 10 additional available utxos, each containing a minimum of 12,000 sats, when running this command. If you don't have enough, use the `ord wallet split` command to break one of your larger utxos into several smaller ones.. + + ```bash + ord wallet inscribe-chain --fee-rate=1.0 --satpoint=7f320d87dd2d011ba9a3dbc66c46aed4b0b3a9a0a4d1c93fe3ed97ab280463f5:8:10014 files/ + ``` + The sats `10015`, `10014`, `10013`, `10012`, `10011`, `10010`, `10009`, `10008`, `10007` and `10006` were extracted from the special utxo and given to the inscribed files `1.json`, `2.json`, ... `10.json` in this order. + +4. Now we have to **wait for the block to be mined** and then run the next command (that was given when the previous completed): + > **Warning** + > If you run the following command while the previous transaction chain is still pending in the mempool, you will be able to inscribe only 2 more inscriptions and will have a commit transaction without the reveal. In other words, it will waste valuable special sats and will require a manual task to fix the transaction chain. + ```bash + ord wallet inscribe-chain --fee-rate 1 --satpoint d3ecd1d753e09d2ca3a69fe1a36a7b829e3c096d44f101d11c5a3f6aef1e757b:0:10004 files/ + ``` + +And this is the full chain: +https://mempool.space/pt/signet/tx/58a7f430fbd5933d7f2dbcb9b2e1b4aaeea3af47a965c1a8917fa05bbf854a45#flow=&vin=0 + +![](mempool.png) +If we navigate through the first outputs we can see all the 10015 to 10001 sats being added to the commit outputs and then to the reveal output. diff --git a/mempool.png b/mempool.png new file mode 100644 index 0000000000..a4c1b2db1c Binary files /dev/null and b/mempool.png differ diff --git a/src/index.rs b/src/index.rs index 32062bd38a..b6d90db0a9 100644 --- a/src/index.rs +++ b/src/index.rs @@ -525,6 +525,21 @@ impl Index { ) } + pub(crate) fn insert_inscription_satpoint( + &self, + inscription_id: InscriptionId, + satpoint: SatPoint, + ) -> Result { + let tx = self.database.begin_write()?; + + tx.open_table(INSCRIPTION_ID_TO_SATPOINT)? + .insert(&inscription_id.store(), &satpoint.store())?; + + tx.commit()?; + + Ok(()) + } + pub(crate) fn get_inscription_satpoint_by_id( &self, inscription_id: InscriptionId, diff --git a/src/index/updater.rs b/src/index/updater.rs index 89906bf049..12b819f14d 100644 --- a/src/index/updater.rs +++ b/src/index/updater.rs @@ -116,10 +116,21 @@ impl<'a> Updater<'a> { &mut outpoint_sender, &mut value_receiver, &mut wtx, - block, + &block, &mut value_cache, )?; + if self.height.checked_sub(1).is_some() { + log::info!( + target: "new_inscription_satpoint", + "{{\"height\":{},\"block_hash\":\"{}\",\"prev_block_hash\":\"{}\",\"tx_count\":{}}}", + &self.height - 1, + &block.header.block_hash(), + &block.header.prev_blockhash, + &block.txdata.len(), + ); + } + if let Some(progress_bar) = &mut progress_bar { progress_bar.inc(1); @@ -336,7 +347,7 @@ impl<'a> Updater<'a> { outpoint_sender: &mut Sender, value_receiver: &mut Receiver, wtx: &mut WriteTransaction, - block: BlockData, + block: &BlockData, value_cache: &mut HashMap, ) -> Result<()> { // If value_receiver still has values something went wrong with the last block @@ -434,6 +445,7 @@ impl<'a> Updater<'a> { block.header.time, value_cache, self.cached_children_by_id, + // &index.client, )?; if self.index_sats { diff --git a/src/index/updater/inscription_updater.rs b/src/index/updater/inscription_updater.rs index d33535f707..97a293fc9a 100644 --- a/src/index/updater/inscription_updater.rs +++ b/src/index/updater/inscription_updater.rs @@ -1,10 +1,12 @@ +use serde_json::Value; use {super::*, std::collections::BTreeSet}; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub(super) struct Flotsam { inscription_id: InscriptionId, offset: u64, origin: Origin, + tx: Option, } // change name to Jetsam or more poetic german word @@ -30,6 +32,7 @@ pub(super) struct InscriptionUpdater<'a, 'db, 'tx> { timestamp: u32, value_cache: &'a mut HashMap, cached_children_by_id: &'a Mutex>>, + // client: &'a Client, } impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { @@ -46,6 +49,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { timestamp: u32, value_cache: &'a mut HashMap, cached_children_by_id: &'a Mutex>>, + // client: &'a Client, ) -> Result { let next_number = number_to_id .iter()? @@ -70,6 +74,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { timestamp, value_cache, cached_children_by_id, + // client, }) } @@ -97,6 +102,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { offset: input_value + old_satpoint.offset, inscription_id, origin: Origin::Old(old_satpoint), + tx: None, }); inscribed_offsets.insert(input_value + old_satpoint.offset); @@ -139,6 +145,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { inscription_id, offset: input_value, origin: Origin::New((0, parent)), + tx: Some(tx.clone()), }); } } @@ -170,12 +177,14 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { inscription_id, offset, origin: Origin::New((_, parent)), + tx, } = flotsam { Flotsam { inscription_id, offset, origin: Origin::New((input_value - total_output_value, parent)), + tx, } } else { flotsam @@ -316,6 +325,48 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { self.satpoint_to_id.insert(&new_satpoint, &inscription_id)?; self.id_to_satpoint.insert(&inscription_id, &new_satpoint)?; + let inscription_id = InscriptionId::load(inscription_id); + let satpoint = SatPoint::load(new_satpoint); + let inscription_entry = self.id_to_entry.get(&inscription_id.store())?.unwrap(); + let inscription_number = InscriptionEntry::load(inscription_entry.value()).number; + + if let Some(tx) = flotsam.tx { + let inscription = Inscription::from_transaction(&tx).unwrap(); + let is_brc_20 = Self::is_brc_20(&self, &inscription); + + // if !Self::is_brc_20(&self, &inscription) { + let content_type = inscription.content_type().unwrap_or(""); + let content_len = inscription.body().map_or(0, |body| body.len()); + + log::info!( + target: "new_inscription_satpoint", + "{},{},{},{},{},{},{}", + self.height, + satpoint, + inscription_id, + inscription_number, + content_type, + content_len, + is_brc_20, + ); + // } + } else { + // let inscription = self + // .get_transaction(inscription_id.txid)? + // .and_then(|tx| Inscription::from_transaction(&tx)).unwrap(); + + // if !Self::is_brc_20(&self, &inscription) { + log::info!( + target: "new_inscription_satpoint", + "{},{},{},{}", + self.height, + satpoint, + inscription_id, + inscription_number, + ); + // } + } + Ok(()) } @@ -327,4 +378,36 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { children.push(inscription_id); } } + + fn valid_json(data: Option<&[u8]>) -> bool { + match data { + Some(bytes) => serde_json::from_slice::(bytes).is_ok(), + None => false, + } + } + + // fn get_transaction(&self, txid: Txid) -> Result> { + // self.client.get_raw_transaction(&txid, None).into_option() + // } + + fn is_brc_20(&self, inscription: &Inscription) -> bool { + let valid_json = Self::valid_json(inscription.body()); + if valid_json { + let json_result: Result = + serde_json::from_slice(&inscription.body().unwrap()); + let json: Value = json_result.unwrap(); + let empty_json = serde_json::Map::new(); + let json_obj = json.as_object().unwrap_or(&empty_json); + if json_obj.contains_key("p") { + let p = json_obj.get("p").unwrap(); + if p.is_string() { + let p_str = p.as_str().unwrap(); + if p_str.to_lowercase() == "brc-20" { + return true; + } + } + } + } + false + } } diff --git a/src/lib.rs b/src/lib.rs index ca7229fe56..37ce5f10c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,8 +54,8 @@ use { env, ffi::OsString, fmt::{self, Display, Formatter}, - fs::{self, File}, - io, + fs::{self, File, OpenOptions}, + io::{self, Write}, net::{TcpListener, ToSocketAddrs}, ops::{Add, AddAssign, Sub}, path::{Path, PathBuf}, @@ -144,7 +144,20 @@ fn timestamp(seconds: u32) -> DateTime { const INTERRUPT_LIMIT: u64 = 5; pub fn main() { - env_logger::init(); + let inscription_satpoint_logs_file = OpenOptions::new() + .write(true) + .append(true) + .create(true) + .open("inscription_satpoint.txt") + .unwrap(); + + env_logger::builder() + .filter(Some("new_inscription_satpoint"), log::LevelFilter::Info) + .target(env_logger::Target::Pipe(Box::new( + inscription_satpoint_logs_file, + ))) + .format(|buf, record| writeln!(buf, "{}", record.args())) + .init(); ctrlc::set_handler(move || { LISTENERS diff --git a/src/subcommand.rs b/src/subcommand.rs index cc1848b252..2767584cac 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -1,6 +1,7 @@ use super::*; pub mod epochs; +pub mod file; pub mod find; mod index; pub mod info; @@ -45,6 +46,8 @@ pub(crate) enum Subcommand { Traits(traits::Traits), #[clap(subcommand, about = "Wallet commands")] Wallet(wallet::Wallet), + #[clap(about = "Wallet commands")] + File(file::File), } impl Subcommand { @@ -67,6 +70,7 @@ impl Subcommand { Self::Supply => supply::run(), Self::Traits(traits) => traits.run(), Self::Wallet(wallet) => wallet.run(options), + Self::File(file) => file.run(options), } } } diff --git a/src/subcommand/file.rs b/src/subcommand/file.rs new file mode 100644 index 0000000000..de59de7956 --- /dev/null +++ b/src/subcommand/file.rs @@ -0,0 +1,24 @@ +use super::*; + +#[derive(Debug, Parser)] +pub(crate) struct File { + #[clap(long)] + pub(crate) tx: Txid, + #[clap()] + pub(crate) filename: String, +} + +impl File { + pub(crate) fn run(&self, options: Options) -> Result { + let client = options.bitcoin_rpc_client_for_wallet_command(false)?; + + let tx = client.get_raw_transaction(&self.tx, None)?; + let inscription = Inscription::from_transaction(&tx).unwrap(); + + let content_bytes = inscription.body().unwrap(); + let mut file = fs::File::create(self.filename.clone())?; + file.write_all(content_bytes)?; + + Ok(()) + } +} diff --git a/src/subcommand/preview.rs b/src/subcommand/preview.rs index 13ba8d1730..d16755a6ea 100644 --- a/src/subcommand/preview.rs +++ b/src/subcommand/preview.rs @@ -87,6 +87,8 @@ impl Preview { no_limit: false, destination: None, parent: None, + commit: None, + keypair: None, }, )), } diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index e0b3ab9382..32084f6e98 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -732,7 +732,8 @@ impl Server { return None; } - let json_result: Result = serde_json::from_slice(&inscription.body().unwrap()); + let json_result: Result = + serde_json::from_slice(&inscription.body().unwrap()); let json: Value = json_result.unwrap(); if !json.as_object().unwrap().contains_key("use_p") { @@ -756,7 +757,10 @@ impl Server { if let Some(url_params_field) = json.get("params") { if let Some(url_params) = url_params_field.as_array() { - let param_strs: Vec<&str> = url_params.into_iter().map(|v| v.as_str().unwrap()).collect(); + let param_strs: Vec<&str> = url_params + .into_iter() + .map(|v| v.as_str().unwrap()) + .collect(); params_str = param_strs.join("&"); } } @@ -768,19 +772,31 @@ impl Server { format!("?{params_str}") } - fn get_content_response_if_child_pointer(inscription: &Inscription) -> Option> { + fn get_content_response_if_child_pointer( + inscription: &Inscription, + ) -> Option> { let parent_url_params = Self::get_parent_url_params_if_child_pointer(inscription); if let Some(url_params) = parent_url_params { - let redirect_uri = format!("/content/{}{}", inscription.get_parent_id().unwrap(), url_params); + let redirect_uri = format!( + "/content/{}{}", + inscription.get_parent_id().unwrap(), + url_params + ); return Some(Ok(Redirect::permanent(&redirect_uri).into_response())); } None } - fn get_preview_response_if_child_pointer(inscription: &Inscription) -> Option> { + fn get_preview_response_if_child_pointer( + inscription: &Inscription, + ) -> Option> { let parent_url_params = Self::get_parent_url_params_if_child_pointer(inscription); if let Some(url_params) = parent_url_params { - let redirect_uri = format!("/preview/{}{}", inscription.get_parent_id().unwrap(), url_params); + let redirect_uri = format!( + "/preview/{}{}", + inscription.get_parent_id().unwrap(), + url_params + ); return Some(Ok(Redirect::permanent(&redirect_uri).into_response())); } None @@ -823,7 +839,11 @@ impl Server { ); headers.insert( header::CONTENT_SECURITY_POLICY, - HeaderValue::from_static("default-src 'unsafe-eval' 'unsafe-inline' data:"), + HeaderValue::from_static("default-src 'self' 'unsafe-eval' 'unsafe-inline' data:"), + ); + headers.append( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src *:*/content/ 'unsafe-eval' 'unsafe-inline' data:"), ); headers.insert( header::CACHE_CONTROL, @@ -1056,9 +1076,7 @@ mod tests { Self::new_server(test_bitcoincore_rpc::spawn(), None, ord_args, server_args) } - fn new_with_bitcoin_rpc_server( - bitcoin_rpc_server: test_bitcoincore_rpc::Handle, - ) -> Self { + fn new_with_bitcoin_rpc_server(bitcoin_rpc_server: test_bitcoincore_rpc::Handle) -> Self { Self::new_server(bitcoin_rpc_server, None, &[], &[]) } @@ -2285,7 +2303,7 @@ mod tests { server.assert_response_csp( format!("/preview/{}", InscriptionId::from(txid)), StatusCode::OK, - "default-src 'unsafe-eval' 'unsafe-inline' data:", + "default-src 'self' 'unsafe-eval' 'unsafe-inline' data:", "hello", ); } @@ -2641,8 +2659,9 @@ mod tests { inscription_with_parent( "application/json", "{\"use_p\":1,\"params\":[\"tokenID=69\"]}", - parent_inscription - ).to_witness() + parent_inscription, + ) + .to_witness(), ], ..Default::default() }); @@ -2657,11 +2676,11 @@ mod tests { server.assert_redirect_permanent( &format!("/preview/{child_inscription}"), - &format!("/preview/{parent_inscription}?tokenID=69") + &format!("/preview/{parent_inscription}?tokenID=69"), ); server.assert_redirect_permanent( &format!("/content/{child_inscription}"), - &format!("/content/{parent_inscription}?tokenID=69") + &format!("/content/{parent_inscription}?tokenID=69"), ); } @@ -2682,11 +2701,8 @@ mod tests { inputs: &[(2, 1, 0), (2, 0, 0)], witnesses: vec![ Witness::new(), - inscription_with_parent( - "application/json", - "{\"use_p\":1}", - parent_inscription - ).to_witness() + inscription_with_parent("application/json", "{\"use_p\":1}", parent_inscription) + .to_witness(), ], ..Default::default() }); @@ -2701,11 +2717,11 @@ mod tests { server.assert_redirect_permanent( &format!("/preview/{child_inscription}"), - &format!("/preview/{parent_inscription}") + &format!("/preview/{parent_inscription}"), ); server.assert_redirect_permanent( &format!("/content/{child_inscription}"), - &format!("/content/{parent_inscription}") + &format!("/content/{parent_inscription}"), ); } @@ -2729,8 +2745,9 @@ mod tests { inscription_with_parent( "application/json", "{\"not_ord_pointer\":1}", - parent_inscription - ).to_witness() + parent_inscription, + ) + .to_witness(), ], ..Default::default() }); @@ -2768,7 +2785,8 @@ mod tests { inputs: &[(2, 1, 0), (2, 0, 0)], witnesses: vec![ Witness::new(), - inscription_with_parent("text/plain;charset=utf-8", "child", parent_inscription).to_witness(), + inscription_with_parent("text/plain;charset=utf-8", "child", parent_inscription) + .to_witness(), ], ..Default::default() }); diff --git a/src/subcommand/wallet.rs b/src/subcommand/wallet.rs index 914805d5af..261d04cb13 100644 --- a/src/subcommand/wallet.rs +++ b/src/subcommand/wallet.rs @@ -18,12 +18,15 @@ pub mod balance; pub mod cardinals; pub mod create; pub(crate) mod inscribe; +pub mod inscribe_chain; +pub mod inscribe_chain_destination_addresses; pub mod inscriptions; pub mod outputs; pub mod receive; mod restore; pub mod sats; pub mod send; +pub mod split; pub(crate) mod transaction_builder; pub mod transactions; @@ -51,6 +54,12 @@ pub(crate) enum Wallet { Outputs, #[clap(about = "List unspent cardinal outputs in wallet")] Cardinals, + #[clap(about = "Inscribe a directory of files on specific sats, 12 at a time using CPFP. Takes as an argument, a path to a directory of files to be inscribed.")] + InscribeChain(inscribe_chain::InscribeChain), + #[clap(about = "Inscribe a directory of files on specific sats, sent to specific destination addresses, 12 at a time using CPFP. Takes as an argument, a path to a directory containing 'addresses/' and 'inscriptions/' subdirs.")] + InscribeChainDestinationAddresses(inscribe_chain_destination_addresses::InscribeChainDestinationAddresses), + #[clap(about = "Split a utxo into multiple utxo's of smaller, equal denominations.")] + Split(split::Split), } impl Wallet { @@ -58,7 +67,9 @@ impl Wallet { match self { Self::Balance => balance::run(options), Self::Create(create) => create.run(options), - Self::Inscribe(inscribe) => inscribe.run(options), + Self::Inscribe(inscribe) => inscribe.run(options).map(|_| Ok(()))?, + Self::InscribeChain(inscribe_chain) => inscribe_chain.run(options), + Self::InscribeChainDestinationAddresses(inscribe_chain_destination_addresses) => inscribe_chain_destination_addresses.run(options), Self::Inscriptions => inscriptions::run(options), Self::Receive => receive::run(options), Self::Restore(restore) => restore.run(options), @@ -67,6 +78,7 @@ impl Wallet { Self::Transactions(transactions) => transactions.run(options), Self::Outputs => outputs::run(options), Self::Cardinals => cardinals::run(options), + Self::Split(split) => split.run(options), } } } diff --git a/src/subcommand/wallet/create.rs b/src/subcommand/wallet/create.rs index a236f8ee80..608844b215 100644 --- a/src/subcommand/wallet/create.rs +++ b/src/subcommand/wallet/create.rs @@ -1,8 +1,15 @@ +use bitcoin::{ + secp256k1::PublicKey, + util::bip32::{self, ExtendedPubKey}, +}; + use super::*; #[derive(Serialize)] struct Output { mnemonic: Mnemonic, + address: Address, + public_key: PublicKey, passphrase: Option, } @@ -22,11 +29,32 @@ impl Create { rand::thread_rng().fill_bytes(&mut entropy); let mnemonic = Mnemonic::from_entropy(&entropy)?; + let seed = mnemonic.to_seed(self.passphrase.clone()); + let secp = Secp256k1::new(); + let root = bip32::ExtendedPrivKey::new_master(options.chain().network(), &seed)?; + + let coin_type = match options.chain().network() { + Network::Bitcoin => 0, + _ => 1, + }; + + let derivation_path = &DerivationPath::from_str(format!("m/86'/{}'/0'", coin_type).as_str())?; + let xprv = root.derive_priv(&secp, derivation_path)?; + let xpub = ExtendedPubKey::from_priv(&secp, &xprv); + let public_key = xpub + .derive_pub(&secp, &DerivationPath::from_str("m/0/0")?)? + .public_key; initialize_wallet(&options, mnemonic.to_seed(self.passphrase.clone()))?; + let address = options + .bitcoin_rpc_client_for_wallet_command(false)? + .get_new_address(None, Some(bitcoincore_rpc::json::AddressType::Bech32m))?; + print_json(Output { mnemonic, + address, + public_key, passphrase: Some(self.passphrase), })?; diff --git a/src/subcommand/wallet/inscribe.rs b/src/subcommand/wallet/inscribe.rs index d0f38dcd4a..9b8979f384 100644 --- a/src/subcommand/wallet/inscribe.rs +++ b/src/subcommand/wallet/inscribe.rs @@ -1,4 +1,5 @@ use bitcoin::SchnorrSig; +use bitcoincore_rpc::RawTx; use { super::*, @@ -21,11 +22,11 @@ use { }; #[derive(Serialize)] -struct Output { - commit: Txid, +pub struct Output { + pub commit: Txid, inscription: InscriptionId, parent: Option, - reveal: Txid, + pub reveal: Txid, fees: u64, } @@ -55,10 +56,21 @@ pub(crate) struct Inscribe { pub(crate) destination: Option
, #[clap(long, help = "Establish parent relationship with .")] pub(crate) parent: Option, + #[clap(long, help = "Use already created commit transaction.")] + pub(crate) commit: Option, + #[clap(long, help = "KeyPair to sign commit transaction with.")] + pub(crate) keypair: Option, } impl Inscribe { - pub(crate) fn run(self, options: Options) -> Result { + pub(crate) fn run(self, options: Options) -> Result { + if self.destination.is_none() { + bail!("--destination is required"); + } + if self.commit.is_some() ^ self.keypair.is_some() { + bail!("--commit and --keypair must be used together"); + } + let index = Index::open(&options)?; index.update()?; @@ -66,14 +78,28 @@ impl Inscribe { let mut utxos = index.get_unspent_outputs(Wallet::load(&options)?)?; + if let Some(satpoint) = &self.satpoint { + if utxos.get(&satpoint.outpoint).is_none() { + let mempool_transaction = client.get_raw_transaction(&satpoint.outpoint.txid, None)?; + let mempool_outpoint_amount = + Amount::from_sat(mempool_transaction.output[satpoint.outpoint.vout as usize].value); + + utxos.insert(satpoint.outpoint, mempool_outpoint_amount); + //println!("Using a satpoint pending in the mempool."); + } + } + let inscriptions = index.get_inscriptions(None)?; let (parent, commit_input_offset) = if let Some(parent_id) = self.parent { if let Some(satpoint) = index.get_inscription_satpoint_by_id(parent_id)? { if !utxos.contains_key(&satpoint.outpoint) { - return Err(anyhow!(format!( - "unrelated parent {parent_id} not accepting mailman's child" // for the germans: "Kuckuckskind" - ))); + let mempool_transaction = client.get_raw_transaction(&satpoint.outpoint.txid, None)?; + let mempool_outpoint_amount = + Amount::from_sat(mempool_transaction.output[satpoint.outpoint.vout as usize].value); + + utxos.insert(satpoint.outpoint, mempool_outpoint_amount); + //println!("Using a parent satpoint pending in the mempool."); } let output = index @@ -103,7 +129,13 @@ impl Inscribe { .map(Ok) .unwrap_or_else(|| get_change_address(&client))?; - let (unsigned_commit_tx, partially_signed_reveal_tx, _recovery_key_pair) = + let broadcasted_commit_tx: Option = if let Some(commit) = self.commit { + Some(client.get_raw_transaction(&commit.txid, None)?) + } else { + None + }; + + let (unsigned_commit_tx, partially_signed_reveal_tx, recovery_key_pair) = Inscribe::create_inscription_transactions( self.satpoint, parent, @@ -116,6 +148,8 @@ impl Inscribe { self.commit_fee_rate.unwrap_or(self.fee_rate), self.fee_rate, self.no_limit, + self.keypair, + broadcasted_commit_tx, )?; utxos.insert( @@ -128,8 +162,12 @@ impl Inscribe { ), ); - let fees = Self::calculate_fee(&unsigned_commit_tx, &utxos) - + Self::calculate_fee(&partially_signed_reveal_tx, &utxos); + let fees = if self.commit.is_some() { + Self::calculate_fee(&partially_signed_reveal_tx, &utxos) + } else { + Self::calculate_fee(&unsigned_commit_tx, &utxos) + + Self::calculate_fee(&partially_signed_reveal_tx, &utxos) + }; if self.dry_run { print_json(Output { @@ -140,20 +178,37 @@ impl Inscribe { fees, })?; - return Ok(()); + let signed_raw_commit_tx = client + .sign_raw_transaction_with_wallet(&unsigned_commit_tx, None, None)? + .hex; + + println!("commit tx hex\n{}\n", hex::encode(signed_raw_commit_tx)); + println!("reveal tx hex\n{}", partially_signed_reveal_tx.raw_hex()); + + return Ok(Output { + commit: unsigned_commit_tx.txid(), + reveal: partially_signed_reveal_tx.txid(), + inscription: partially_signed_reveal_tx.txid().into(), + parent: self.parent, + fees, + }); } - // if !self.no_backup { - // Inscribe::backup_recovery_key(&client, recovery_key_pair, options.chain().network())?; - // } + if !self.no_backup { + Inscribe::backup_recovery_key(&client, recovery_key_pair, options.chain().network())?; + } - let signed_raw_commit_tx = client - .sign_raw_transaction_with_wallet(&unsigned_commit_tx, None, None)? - .hex; + let commit = if let Some(commit) = self.commit { + commit.txid + } else { + let signed_raw_commit_tx = client + .sign_raw_transaction_with_wallet(&unsigned_commit_tx, None, None)? + .hex; - let commit = client - .send_raw_transaction(&signed_raw_commit_tx) - .context("Failed to send commit transaction")?; + client + .send_raw_transaction(&signed_raw_commit_tx) + .context("Failed to send commit transaction")? + }; let reveal = if self.parent.is_some() { let fully_signed_raw_reveal_tx = client @@ -174,6 +229,19 @@ impl Inscribe { index: 0, }; + // update parent satpoint (only needed for inscribe-chain) + if let Some(parent_id) = self.parent { + let parent_new_satpoint = SatPoint { + outpoint: OutPoint { + txid: reveal, + vout: 0, + }, + offset: 0, + }; + + index.insert_inscription_satpoint(parent_id, parent_new_satpoint)?; + } + print_json(Output { commit, reveal, @@ -182,7 +250,13 @@ impl Inscribe { fees, })?; - Ok(()) + Ok(Output { + commit, + reveal, + inscription, + parent: self.parent, + fees, + }) } fn calculate_fee(tx: &Transaction, utxos: &BTreeMap) -> u64 { @@ -206,6 +280,8 @@ impl Inscribe { commit_fee_rate: FeeRate, reveal_fee_rate: FeeRate, no_limit: bool, + keypair: Option, + broadcasted_commit_tx: Option, ) -> Result<(Transaction, Transaction, TweakedKeyPair)> { let satpoint = if let Some(satpoint) = satpoint { satpoint @@ -225,6 +301,7 @@ impl Inscribe { .ok_or_else(|| anyhow!("wallet contains no cardinal utxos"))? }; + /* for (inscribed_satpoint, inscription_id) in &inscriptions { if inscribed_satpoint == &satpoint { return Err(anyhow!("sat at {} already inscribed", satpoint)); @@ -237,11 +314,21 @@ impl Inscribe { )); } } + */ let secp256k1 = Secp256k1::new(); - let key_pair = UntweakedKeyPair::new(&secp256k1, &mut rand::thread_rng()); + + let key_pair = if let Some(keypair) = keypair { + keypair + } else { + UntweakedKeyPair::new(&secp256k1, &mut rand::thread_rng()) + }; + let (public_key, _parity) = XOnlyPublicKey::from_keypair(&key_pair); + println!("key pair:"); + print_json(key_pair)?; + let reveal_script = inscription.append_reveal_script( script::Builder::new() .push_slice(&public_key.serialize()) @@ -307,7 +394,8 @@ impl Inscribe { reveal_fee + TransactionBuilder::TARGET_POSTAGE, )?; - let (vout, output) = unsigned_commit_tx + let commit_tx = broadcasted_commit_tx.unwrap_or(unsigned_commit_tx); + let (vout, output) = commit_tx .output .iter() .enumerate() @@ -315,7 +403,7 @@ impl Inscribe { .expect("should find sat commit/inscription output"); inputs[commit_input_offset] = OutPoint { - txid: unsigned_commit_tx.txid(), + txid: commit_tx.txid(), vout: vout.try_into().unwrap(), }; @@ -407,10 +495,10 @@ impl Inscribe { ); } - Ok((unsigned_commit_tx, reveal_tx, recovery_key_pair)) + Ok((commit_tx, reveal_tx, recovery_key_pair)) } - fn _backup_recovery_key( + fn backup_recovery_key( client: &Client, recovery_key_pair: TweakedKeyPair, network: Network, diff --git a/src/subcommand/wallet/inscribe_chain.rs b/src/subcommand/wallet/inscribe_chain.rs new file mode 100644 index 0000000000..2c6fe86e9b --- /dev/null +++ b/src/subcommand/wallet/inscribe_chain.rs @@ -0,0 +1,139 @@ +use std::fs::{DirBuilder, DirEntry}; + +use super::{inscribe::Inscribe, *}; + +#[derive(Debug, Parser)] +pub(crate) struct InscribeChain { + #[clap(long, help = "Inscribe ")] + pub(crate) satpoint: SatPoint, + #[clap(long, help = "Use fee rate of sats/vB")] + pub(crate) fee_rate: FeeRate, + #[clap( + long, + help = "Use sats/vbyte for commit transaction.\nDefaults to if unset." + )] + pub(crate) commit_fee_rate: Option, + #[clap(help = "Inscribe sat with contents of ")] + pub(crate) dir: PathBuf, + #[clap(long, help = "Do not back up recovery key.")] + pub(crate) no_backup: bool, + #[clap( + long, + help = "Do not check that transactions are equal to or below the MAX_STANDARD_TX_WEIGHT of 400,000 weight units. Transactions over this limit are currently nonstandard and will not be relayed by bitcoind in its default configuration. Do not use this flag unless you understand the implications." + )] + pub(crate) no_limit: bool, + #[clap(long, help = "Send inscription to .")] + pub(crate) destination: Option
, + #[clap(long, help = "Establish parent relationship with .")] + pub(crate) parent: Option, +} + +// maximum of 12 +const INSCRIPTION_PER_BLOCK: usize = 10; + +impl InscribeChain { + pub(crate) fn run(self, options: Options) -> Result { + let mut satpoint = self.satpoint; + + let dir = self.dir.read_dir()?; + let mut files: Vec = dir + .map(|f| f.unwrap()) + .filter(|file| file.path().is_file()) + .collect(); + files.sort_by(|a, b| get_number_from_dir_entry(a).cmp(&get_number_from_dir_entry(b))); + + if files.len() as u64 > satpoint.offset + 1 { + return Err(anyhow!( + "Not enough sats: folder has {} files and output offset is {}", + files.len(), + satpoint.offset + )); + } + + DirBuilder::new() + .create(&self.dir.join("inscribed")) + .unwrap_or_default(); + + for i in 0..(files.len().min(INSCRIPTION_PER_BLOCK)) { + let file = files.get(i).unwrap(); + let file_path = file.path(); + + let inscribe = Inscribe { + dry_run: false, + fee_rate: self.fee_rate, + commit_fee_rate: self.commit_fee_rate, + destination: self.destination.clone(), + file: file_path.clone(), + no_backup: true, + no_limit: self.no_limit, + satpoint: Some(satpoint), + parent: self.parent.clone(), + commit: None, + keypair: None, + }; + + println!("Inscribing {} at {}", file_path.clone().display(), satpoint); + let inscription = inscribe.run(options.clone())?; + + fs::rename( + file_path.clone(), + &self + .dir + .join("inscribed") + .join(file_path.file_name().unwrap()), + )?; + + // update satpoint to inscribe for next iteration + if satpoint.offset >= 1 { + satpoint.offset -= 1; + + satpoint = SatPoint { + outpoint: OutPoint { + txid: inscription.commit, + vout: 0, + }, + offset: satpoint.offset, + }; + }; + } + + println!("\nSuccess!"); + println!( + "{} new inscriptions pending in the mempool.", + files.len().min(INSCRIPTION_PER_BLOCK) + ); + println!("\nTo continue inscribing, wait for the block to be mined and run:"); + println!("{}", self.get_resume_cli_command(satpoint)); + + Ok(()) + } + + fn get_resume_cli_command(&self, updated_satpoint: SatPoint) -> String { + let mut cli = format!( + "ord wallet inscribe-chain --fee-rate {}", + self.fee_rate.fee(10 as usize).to_sat() as f64 / 10.0 + ); + if let Some(parent) = self.parent { + cli.push_str(&format!(" --parent {}", parent)); + } + if let Some(destination) = &self.destination { + cli.push_str(&format!(" --destination {}", destination)); + } + cli.push_str(&format!(" --satpoint {}", updated_satpoint)); + cli.push_str(&format!(" {}", self.dir.display())); + + return cli; + } +} + +fn get_number_from_dir_entry(dir_entry: &DirEntry) -> u64 { + dir_entry + .path() + .file_stem() + .unwrap() + .to_str() + .unwrap() + .parse() + .context("Name the files in the format: ., e.g. 1.json") + .unwrap() +} diff --git a/src/subcommand/wallet/inscribe_chain_destination_addresses.rs b/src/subcommand/wallet/inscribe_chain_destination_addresses.rs new file mode 100644 index 0000000000..e64240c8dc --- /dev/null +++ b/src/subcommand/wallet/inscribe_chain_destination_addresses.rs @@ -0,0 +1,173 @@ +use std::fs::{DirBuilder, DirEntry}; + +use super::{inscribe::Inscribe, *}; + +#[derive(Debug, Parser)] +pub(crate) struct InscribeChainDestinationAddresses { + #[clap(long, help = "Inscribe ")] + pub(crate) satpoint: SatPoint, + #[clap(long, help = "Use fee rate of sats/vB")] + pub(crate) fee_rate: FeeRate, + #[clap( + long, + help = "Use sats/vbyte for commit transaction.\nDefaults to if unset." + )] + pub(crate) commit_fee_rate: Option, + #[clap(help = "Inscribe sats with contents of /inscriptions/ and send to /addresses/")] + pub(crate) dir: PathBuf, + #[clap(long, help = "Do not back up recovery key.")] + pub(crate) no_backup: bool, + #[clap( + long, + help = "Do not check that transactions are equal to or below the MAX_STANDARD_TX_WEIGHT of 400,000 weight units. Transactions over this limit are currently nonstandard and will not be relayed by bitcoind in its default configuration. Do not use this flag unless you understand the implications." + )] + pub(crate) no_limit: bool, + #[clap(long, help = "Establish parent relationship with .")] + pub(crate) parent: Option, +} + +// maximum of 12 +const INSCRIPTION_PER_BLOCK: usize = 12; + +impl InscribeChainDestinationAddresses { + pub(crate) fn run(self, options: Options) -> Result { + let mut satpoint = self.satpoint; + + let inscriptions_path = self.dir.join("inscriptions"); + + if !Path::new(&inscriptions_path).exists() { + return Err(anyhow!("Error: inscriptions/ directory does not exist")); + } + + let addresses_path = self.dir.join("addresses"); + + if !Path::new(&addresses_path).exists() { + return Err(anyhow!("Error: addresses/ directory does not exist")); + } + + let mut inscriptions_files: Vec = fs::read_dir(inscriptions_path) + .unwrap() + .map(|f| f.unwrap()) + .filter(|file| file.path().is_file()) + .collect(); + inscriptions_files.sort_by(|a, b| get_number_from_dir_entry(a).cmp(&get_number_from_dir_entry(b))); + + let mut addresses_files: Vec = fs::read_dir(addresses_path) + .unwrap() + .map(|f| f.unwrap()) + .filter(|file| file.path().is_file()) + .collect(); + addresses_files.sort_by(|a, b| get_number_from_dir_entry(a).cmp(&get_number_from_dir_entry(b))); + + if inscriptions_files.len() != addresses_files.len() { + return Err(anyhow!( + "The number of files in 'inscriptions' and 'addresses' subdirectories is not the same: inscriptions/ has {} files and addresses/ has {} files", + inscriptions_files.len(), + addresses_files.len() + )); + } + + if inscriptions_files.len() as u64 > satpoint.offset + 1 { + return Err(anyhow!( + "Not enough sats: folder has {} files and output offset is {}", + inscriptions_files.len(), + satpoint.offset + )); + } + + DirBuilder::new() + .create(&self.dir.join("inscribed")) + .unwrap_or_default(); + + for i in 0..(inscriptions_files.len().min(INSCRIPTION_PER_BLOCK)) { + let file = inscriptions_files.get(i).unwrap(); + let file_path = file.path(); + let destination_address_file = addresses_files.get(i).unwrap(); + let destination_address_file_path = destination_address_file.path(); + let destination_address_str = fs::read_to_string(destination_address_file_path.clone())?; + let destination_address = Address::from_str(destination_address_str.as_str().trim()).ok(); + + let inscribe = Inscribe { + dry_run: false, + fee_rate: self.fee_rate, + commit_fee_rate: self.commit_fee_rate, + destination: destination_address, + file: file_path.clone(), + no_backup: true, + no_limit: self.no_limit, + satpoint: Some(satpoint), + parent: self.parent.clone(), + commit: None, + keypair: None, + }; + + println!("Inscribing {} at {}, and sending to {}", file_path.clone().display(), satpoint, destination_address_str); + let inscription = inscribe.run(options.clone())?; + + fs::rename( + file_path.clone(), + &self + .dir + .join("inscribed") + .join(file_path.file_name().unwrap()), + )?; + + fs::rename( + destination_address_file_path.clone(), + &self + .dir + .join("inscribed") + .join(destination_address_file_path.file_name().unwrap()), + )?; + + // update satpoint to inscribe for next iteration + if satpoint.offset >= 1 { + satpoint.offset -= 1; + + satpoint = SatPoint { + outpoint: OutPoint { + txid: inscription.commit, + vout: 0, + }, + offset: satpoint.offset, + }; + }; + } + + println!("\nSuccess!"); + println!( + "{} new inscriptions pending in the mempool.", + inscriptions_files.len().min(INSCRIPTION_PER_BLOCK) + ); + println!("\nTo continue inscribing, wait for the block to be mined and run:"); + println!("{}", self.get_resume_cli_command(satpoint)); + + Ok(()) + } + + fn get_resume_cli_command(&self, updated_satpoint: SatPoint) -> String { + let mut cli = format!( + "ord wallet inscribe-chain-destination-addresses --fee-rate {}", + self.fee_rate.fee(10 as usize).to_sat() as f64 / 10.0 + ); + if let Some(parent) = self.parent { + cli.push_str(&format!(" --parent {}", parent)); + } + cli.push_str(&format!(" --satpoint {}", updated_satpoint)); + cli.push_str(&format!(" {}", self.dir.display())); + + return cli; + } +} + +fn get_number_from_dir_entry(dir_entry: &DirEntry) -> u64 { + dir_entry + .path() + .file_stem() + .unwrap() + .to_str() + .unwrap() + .parse() + .context("Name the files in the format: ., e.g. 1.json") + .unwrap() +} diff --git a/src/subcommand/wallet/split.rs b/src/subcommand/wallet/split.rs new file mode 100644 index 0000000000..b5b12fa40f --- /dev/null +++ b/src/subcommand/wallet/split.rs @@ -0,0 +1,77 @@ +use { + super::*, + bitcoin::{PackedLockTime, Witness}, +}; + +#[derive(Debug, Parser)] +pub(crate) struct Split { + #[clap(long)] + pub(crate) fee_rate: FeeRate, + #[clap(long)] + pub(crate) amount: u64, + #[clap(long)] + pub(crate) destination: Address, + #[clap()] + pub(crate) outpoint: OutPoint, +} + +impl Split { + pub(crate) fn run(&self, options: Options) -> Result { + let client = options.bitcoin_rpc_client_for_wallet_command(false)?; + + let output_to_spend = OutPoint { + txid: self.outpoint.txid, + vout: self.outpoint.vout, + }; + + let txin = TxIn { + previous_output: output_to_spend, + script_sig: Script::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }; + + let output_tx = client.get_raw_transaction(&self.outpoint.txid, None)?; + let output_sats = output_tx.output[self.outpoint.vout as usize].value; + let new_outputs_quantity = output_sats / self.amount; + + let to_address = self.destination.clone(); + let output_locking_script = to_address.script_pubkey(); + let mut outputs: Vec = vec![]; + + for _ in 0..new_outputs_quantity { + let txout = TxOut { + script_pubkey: output_locking_script.clone(), + value: self.amount, + }; + + outputs.push(txout); + } + + let mut transaction = Transaction { + version: 1, + lock_time: PackedLockTime::ZERO, + input: vec![txin], + output: outputs, + }; + + let fee = self.fee_rate.fee(transaction.vsize()); + transaction.output[(new_outputs_quantity - 1) as usize].value = transaction.output + [(new_outputs_quantity - 1) as usize] + .value + .checked_sub(fee.to_sat()) + .context(format!( + "fees higher than output amount: {} > {}", + fee, self.amount + ))?; + + let signed_tx = client + .sign_raw_transaction_with_wallet(&transaction, None, None)? + .hex; + let txid = client.send_raw_transaction(&signed_tx)?; + + println!("Transaction: {}", txid); + + Ok(()) + } +}