diff --git a/Cargo.lock b/Cargo.lock index a4cad15f13..2ddd19578b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3214,6 +3214,7 @@ dependencies = [ name = "test-bitcoincore-rpc" version = "0.0.1" dependencies = [ + "base64 0.21.6", "bitcoin", "hex", "jsonrpc-core", diff --git a/src/decimal.rs b/src/decimal.rs index 035ff98613..70449bdab3 100644 --- a/src/decimal.rs +++ b/src/decimal.rs @@ -1,7 +1,7 @@ use super::*; #[derive(Debug, PartialEq, Copy, Clone)] -pub(crate) struct Decimal { +pub struct Decimal { value: u128, scale: u8, } @@ -24,6 +24,30 @@ impl Decimal { } } +impl Display for Decimal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let magnitude = 10u128.pow(self.scale.into()); + + let integer = self.value / magnitude; + let mut fraction = self.value % magnitude; + + write!(f, "{integer}")?; + + if fraction > 0 { + let mut width = self.scale.into(); + + while fraction % 10 == 0 { + fraction /= 10; + width -= 1; + } + + write!(f, ".{fraction:0>width$}", width = width)?; + } + + Ok(()) + } +} + impl FromStr for Decimal { type Err = Error; @@ -39,20 +63,15 @@ impl FromStr for Decimal { integer.parse::()? }; - let decimal = if decimal.is_empty() { - 0 + let (decimal, scale) = if decimal.is_empty() { + (0, 0) } else { - decimal.parse::()? + let trailing_zeros = decimal.chars().rev().take_while(|c| *c == '0').count(); + let significant_digits = decimal.chars().count() - trailing_zeros; + let decimal = decimal.parse::()? / 10u128.pow(u32::try_from(trailing_zeros).unwrap()); + (decimal, u8::try_from(significant_digits).unwrap()) }; - let scale = s - .trim_end_matches('0') - .chars() - .skip_while(|c| *c != '.') - .skip(1) - .count() - .try_into()?; - Ok(Self { value: integer * 10u128.pow(u32::from(scale)) + decimal, scale, @@ -66,6 +85,24 @@ impl FromStr for Decimal { } } +impl Serialize for Decimal { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Decimal { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(DeserializeFromStr::deserialize(deserializer)?.0) + } +} + #[cfg(test)] mod tests { use super::*; @@ -99,6 +136,7 @@ mod tests { case("1.11", 111, 2); case("1.", 1, 0); case(".1", 1, 1); + case("1.10", 11, 1); } #[test] @@ -149,4 +187,65 @@ mod tests { case("123.456", 3, 123456); case("123.456", 6, 123456000); } + + #[test] + fn to_string() { + #[track_caller] + fn case(decimal: Decimal, string: &str) { + assert_eq!(decimal.to_string(), string); + assert_eq!(decimal, string.parse::().unwrap()); + } + + case(Decimal { value: 1, scale: 0 }, "1"); + case(Decimal { value: 1, scale: 1 }, "0.1"); + case( + Decimal { + value: 101, + scale: 2, + }, + "1.01", + ); + case( + Decimal { + value: 1234, + scale: 6, + }, + "0.001234", + ); + case( + Decimal { + value: 12, + scale: 0, + }, + "12", + ); + case( + Decimal { + value: 12, + scale: 1, + }, + "1.2", + ); + case( + Decimal { + value: 12, + scale: 2, + }, + "0.12", + ); + case( + Decimal { + value: 123456, + scale: 3, + }, + "123.456", + ); + case( + Decimal { + value: 123456789, + scale: 6, + }, + "123.456789", + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 25be972a42..5ad0805eae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,7 +23,6 @@ use { epoch::Epoch, height::Height, inscriptions::{media, teleburn, Charm, Media, ParsedEnvelope}, - outgoing::Outgoing, representation::Representation, runes::{Etching, Pile, SpacedRune}, subcommand::{Subcommand, SubcommandResult}, @@ -127,7 +126,7 @@ pub mod index; mod inscriptions; mod object; mod options; -mod outgoing; +pub mod outgoing; pub mod rarity; mod representation; pub mod runes; diff --git a/src/outgoing.rs b/src/outgoing.rs index 21dcd83c01..e5fabadecc 100644 --- a/src/outgoing.rs +++ b/src/outgoing.rs @@ -1,13 +1,24 @@ use super::*; #[derive(Debug, PartialEq, Clone)] -pub(crate) enum Outgoing { +pub enum Outgoing { Amount(Amount), InscriptionId(InscriptionId), SatPoint(SatPoint), Rune { decimal: Decimal, rune: SpacedRune }, } +impl Display for Outgoing { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Amount(amount) => write!(f, "{}", amount.to_string().to_lowercase()), + Self::InscriptionId(inscription_id) => inscription_id.fmt(f), + Self::SatPoint(satpoint) => satpoint.fmt(f), + Self::Rune { decimal, rune } => write!(f, "{decimal} {rune}"), + } + } +} + impl FromStr for Outgoing { type Err = Error; @@ -69,6 +80,24 @@ impl FromStr for Outgoing { } } +impl Serialize for Outgoing { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Outgoing { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(DeserializeFromStr::deserialize(deserializer)?.0) + } +} + #[cfg(test)] mod tests { use super::*; @@ -153,4 +182,95 @@ mod tests { assert!("0".parse::().is_err()); } + + #[test] + fn roundtrip() { + #[track_caller] + fn case(s: &str, outgoing: Outgoing) { + assert_eq!(s.parse::().unwrap(), outgoing); + assert_eq!(s, outgoing.to_string()); + } + + case( + "0000000000000000000000000000000000000000000000000000000000000000i0", + Outgoing::InscriptionId( + "0000000000000000000000000000000000000000000000000000000000000000i0" + .parse() + .unwrap(), + ), + ); + + case( + "0000000000000000000000000000000000000000000000000000000000000000:0:0", + Outgoing::SatPoint( + "0000000000000000000000000000000000000000000000000000000000000000:0:0" + .parse() + .unwrap(), + ), + ); + + case("0 btc", Outgoing::Amount("0 btc".parse().unwrap())); + case("1.2 btc", Outgoing::Amount("1.2 btc".parse().unwrap())); + + case( + "0 XY•Z", + Outgoing::Rune { + rune: "XY•Z".parse().unwrap(), + decimal: "0".parse().unwrap(), + }, + ); + + case( + "1.1 XYZ", + Outgoing::Rune { + rune: "XYZ".parse().unwrap(), + decimal: "1.1".parse().unwrap(), + }, + ); + } + + #[test] + fn serde() { + #[track_caller] + fn case(s: &str, j: &str, o: Outgoing) { + assert_eq!(s.parse::().unwrap(), o); + assert_eq!(serde_json::to_string(&o).unwrap(), j); + assert_eq!(serde_json::from_str::(j).unwrap(), o); + } + + case( + "0000000000000000000000000000000000000000000000000000000000000000i0", + "\"0000000000000000000000000000000000000000000000000000000000000000i0\"", + Outgoing::InscriptionId( + "0000000000000000000000000000000000000000000000000000000000000000i0" + .parse() + .unwrap(), + ), + ); + + case( + "0000000000000000000000000000000000000000000000000000000000000000:0:0", + "\"0000000000000000000000000000000000000000000000000000000000000000:0:0\"", + Outgoing::SatPoint( + "0000000000000000000000000000000000000000000000000000000000000000:0:0" + .parse() + .unwrap(), + ), + ); + + case( + "3 btc", + "\"3 btc\"", + Outgoing::Amount(Amount::from_sat(3 * COIN_VALUE)), + ); + + case( + "6.66 HELL.MONEY", + "\"6.66 HELL•MONEY\"", + Outgoing::Rune { + rune: "HELL•MONEY".parse().unwrap(), + decimal: "6.66".parse().unwrap(), + }, + ); + } } diff --git a/src/subcommand/wallet/send.rs b/src/subcommand/wallet/send.rs index ed25292b93..d752a34be0 100644 --- a/src/subcommand/wallet/send.rs +++ b/src/subcommand/wallet/send.rs @@ -1,21 +1,31 @@ -use {super::*, crate::wallet::transaction_builder::Target}; +use { + super::*, + crate::{outgoing::Outgoing, wallet::transaction_builder::Target}, + base64::Engine, + bitcoin::psbt::Psbt, +}; #[derive(Debug, Parser)] pub(crate) struct Send { - address: Address, - outgoing: Outgoing, + #[arg(long, help = "Don't sign or broadcast transaction")] + pub(crate) dry_run: bool, #[arg(long, help = "Use fee rate of sats/vB")] fee_rate: FeeRate, #[arg( long, - help = "Target amount of postage to include with sent inscriptions. Default `10000sat`" + help = "Target amount of postage to include with sent inscriptions [default: 10000 sat]" )] pub(crate) postage: Option, + address: Address, + outgoing: Outgoing, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct Output { - pub transaction: Txid, + pub txid: Txid, + pub psbt: String, + pub outgoing: Outgoing, + pub fee: u64, } impl Send { @@ -25,93 +35,83 @@ impl Send { .clone() .require_network(wallet.chain().network())?; - let unspent_outputs = wallet.get_unspent_outputs()?; - - let locked_outputs = wallet.get_locked_outputs()?; - - let inscriptions = wallet.get_inscriptions()?; - - let runic_outputs = wallet.get_runic_outputs()?; - - let bitcoin_client = wallet.bitcoin_client()?; - - let satpoint = match self.outgoing { + let unsigned_transaction = match self.outgoing { Outgoing::Amount(amount) => { - Self::lock_non_cardinal_outputs( - &bitcoin_client, - &inscriptions, - &runic_outputs, - unspent_outputs, - )?; - let transaction = Self::send_amount(&wallet, amount, address, self.fee_rate)?; - return Ok(Some(Box::new(Output { transaction }))); - } - Outgoing::InscriptionId(id) => wallet.get_inscription_satpoint(id)?, - Outgoing::Rune { decimal, rune } => { - let transaction = Self::send_runes( - address, - &bitcoin_client, - decimal, - self.fee_rate, - inscriptions, - rune, - runic_outputs, - unspent_outputs, - &wallet, - )?; - return Ok(Some(Box::new(Output { transaction }))); - } - Outgoing::SatPoint(satpoint) => { - for inscription_satpoint in inscriptions.keys() { - if satpoint == *inscription_satpoint { - bail!("inscriptions must be sent by inscription ID"); - } - } - - ensure!( - !runic_outputs.contains(&satpoint.outpoint), - "runic outpoints may not be sent by satpoint" - ); - - satpoint + Self::create_unsigned_send_amount_transaction(&wallet, address, amount, self.fee_rate)? } + Outgoing::Rune { decimal, rune } => Self::create_unsigned_send_runes_transaction( + &wallet, + address, + rune, + decimal, + self.fee_rate, + )?, + Outgoing::InscriptionId(id) => Self::create_unsigned_send_satpoint_transaction( + &wallet, + address, + wallet.get_inscription_satpoint(id)?, + self.postage, + self.fee_rate, + true, + )?, + Outgoing::SatPoint(satpoint) => Self::create_unsigned_send_satpoint_transaction( + &wallet, + address, + satpoint, + self.postage, + self.fee_rate, + false, + )?, }; - let change = [wallet.get_change_address()?, wallet.get_change_address()?]; + let bitcoin_client = wallet.bitcoin_client()?; + let unspent_outputs = wallet.get_unspent_outputs()?; - let postage = if let Some(postage) = self.postage { - Target::ExactPostage(postage) + let txid = if self.dry_run { + unsigned_transaction.txid() } else { - Target::Postage - }; + let signed_tx = bitcoin_client + .sign_raw_transaction_with_wallet(&unsigned_transaction.clone(), None, None)? + .hex; - let unsigned_transaction = TransactionBuilder::new( - satpoint, - inscriptions, - unspent_outputs, - locked_outputs, - runic_outputs, - address.clone(), - change, - self.fee_rate, - postage, - ) - .build_transaction()?; - - let signed_tx = bitcoin_client - .sign_raw_transaction_with_wallet(&unsigned_transaction, None, None)? - .hex; - - let txid = bitcoin_client.send_raw_transaction(&signed_tx)?; + bitcoin_client.send_raw_transaction(&signed_tx)? + }; - Ok(Some(Box::new(Output { transaction: txid }))) + let psbt = bitcoin_client + .wallet_process_psbt( + &base64::engine::general_purpose::STANDARD + .encode(Psbt::from_unsigned_tx(unsigned_transaction.clone())?.serialize()), + Some(false), + None, + None, + )? + .psbt; + + Ok(Some(Box::new(Output { + txid, + psbt, + outgoing: self.outgoing, + fee: unsigned_transaction + .input + .iter() + .map(|txin| unspent_outputs.get(&txin.previous_output).unwrap().to_sat()) + .sum::() + .checked_sub( + unsigned_transaction + .output + .iter() + .map(|txout| txout.value) + .sum::(), + ) + .unwrap(), + }))) } fn lock_non_cardinal_outputs( bitcoin_client: &Client, inscriptions: &BTreeMap, runic_outputs: &BTreeSet, - unspent_outputs: BTreeMap, + unspent_outputs: &BTreeMap, ) -> Result { let all_inscription_outputs = inscriptions .keys() @@ -132,50 +132,110 @@ impl Send { Ok(()) } - fn send_amount( + fn create_unsigned_send_amount_transaction( wallet: &Wallet, + destination: Address, amount: Amount, - address: Address, fee_rate: FeeRate, - ) -> Result { - Ok(wallet.bitcoin_client()?.call( - "sendtoaddress", - &[ - address.to_string().into(), // 1. address - amount.to_btc().into(), // 2. amount - serde_json::Value::Null, // 3. comment - serde_json::Value::Null, // 4. comment_to - serde_json::Value::Null, // 5. subtractfeefromamount - serde_json::Value::Null, // 6. replaceable - serde_json::Value::Null, // 7. conf_target - serde_json::Value::Null, // 8. estimate_mode - serde_json::Value::Null, // 9. avoid_reuse - fee_rate.n().into(), // 10. fee_rate - ], - )?) + ) -> Result { + let client = wallet.bitcoin_client()?; + let unspent_outputs = wallet.get_unspent_outputs()?; + let inscriptions = wallet.get_inscriptions()?; + let runic_outputs = wallet.get_runic_outputs()?; + + Self::lock_non_cardinal_outputs(&client, &inscriptions, &runic_outputs, &unspent_outputs)?; + + let unfunded_transaction = Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { + script_pubkey: destination.script_pubkey(), + value: amount.to_sat(), + }], + }; + + let unsigned_transaction = consensus::encode::deserialize(&fund_raw_transaction( + &client, + fee_rate, + &unfunded_transaction, + )?)?; + + Ok(unsigned_transaction) } - fn send_runes( - address: Address, - bitcoin_client: &Client, - decimal: Decimal, + fn create_unsigned_send_satpoint_transaction( + wallet: &Wallet, + destination: Address, + satpoint: SatPoint, + postage: Option, fee_rate: FeeRate, - inscriptions: BTreeMap, - spaced_rune: SpacedRune, - runic_outputs: BTreeSet, - unspent_outputs: BTreeMap, + sending_inscription: bool, + ) -> Result { + let unspent_outputs = wallet.get_unspent_outputs()?; + let locked_outputs = wallet.get_locked_outputs()?; + let inscriptions = wallet.get_inscriptions()?; + let runic_outputs = wallet.get_runic_outputs()?; + + if !sending_inscription { + for inscription_satpoint in inscriptions.keys() { + if satpoint == *inscription_satpoint { + bail!("inscriptions must be sent by inscription ID"); + } + } + } + + ensure!( + !runic_outputs.contains(&satpoint.outpoint), + "runic outpoints may not be sent by satpoint" + ); + + let change = [wallet.get_change_address()?, wallet.get_change_address()?]; + + let postage = if let Some(postage) = postage { + Target::ExactPostage(postage) + } else { + Target::Postage + }; + + Ok( + TransactionBuilder::new( + satpoint, + inscriptions, + unspent_outputs.clone(), + locked_outputs, + runic_outputs, + destination.clone(), + change, + fee_rate, + postage, + ) + .build_transaction()?, + ) + } + + fn create_unsigned_send_runes_transaction( wallet: &Wallet, - ) -> Result { + destination: Address, + spaced_rune: SpacedRune, + decimal: Decimal, + fee_rate: FeeRate, + ) -> Result { ensure!( wallet.has_rune_index()?, "sending runes with `ord send` requires index created with `--index-runes` flag", ); + let unspent_outputs = wallet.get_unspent_outputs()?; + let inscriptions = wallet.get_inscriptions()?; + let runic_outputs = wallet.get_runic_outputs()?; + let bitcoin_client = wallet.bitcoin_client()?; + Self::lock_non_cardinal_outputs( - bitcoin_client, + &bitcoin_client, &inscriptions, &runic_outputs, - unspent_outputs, + &unspent_outputs, )?; let (id, entry, _parent) = wallet @@ -251,19 +311,15 @@ impl Send { value: TARGET_POSTAGE.to_sat(), }, TxOut { - script_pubkey: address.script_pubkey(), + script_pubkey: destination.script_pubkey(), value: TARGET_POSTAGE.to_sat(), }, ], }; let unsigned_transaction = - fund_raw_transaction(bitcoin_client, fee_rate, &unfunded_transaction)?; - - let signed_transaction = bitcoin_client - .sign_raw_transaction_with_wallet(&unsigned_transaction, None, None)? - .hex; + fund_raw_transaction(&bitcoin_client, fee_rate, &unfunded_transaction)?; - Ok(bitcoin_client.send_raw_transaction(&signed_transaction)?) + Ok(consensus::encode::deserialize(&unsigned_transaction)?) } } diff --git a/test-bitcoincore-rpc/Cargo.toml b/test-bitcoincore-rpc/Cargo.toml index d20b7d4aa7..59170c9ba0 100644 --- a/test-bitcoincore-rpc/Cargo.toml +++ b/test-bitcoincore-rpc/Cargo.toml @@ -9,6 +9,7 @@ repository = "https://github.com/ordinals/ord" [dependencies] bitcoin = { version = "0.30.0", features = ["serde", "rand"] } +base64 = "0.21.0" hex = "0.4.3" jsonrpc-core = "18.0.0" jsonrpc-derive = "18.0.0" diff --git a/test-bitcoincore-rpc/src/api.rs b/test-bitcoincore-rpc/src/api.rs index 8472da4aa2..a7763c8d4e 100644 --- a/test-bitcoincore-rpc/src/api.rs +++ b/test-bitcoincore-rpc/src/api.rs @@ -176,4 +176,13 @@ pub trait Api { #[rpc(name = "listwalletdir")] fn list_wallet_dir(&self) -> Result; + + #[rpc(name = "walletprocesspsbt")] + fn wallet_process_psbt( + &self, + psbt: String, + sign: Option, + sighash_type: Option<()>, + bip32derivs: Option, + ) -> Result; } diff --git a/test-bitcoincore-rpc/src/lib.rs b/test-bitcoincore-rpc/src/lib.rs index 05e33af726..87dd9df29e 100644 --- a/test-bitcoincore-rpc/src/lib.rs +++ b/test-bitcoincore-rpc/src/lib.rs @@ -24,7 +24,7 @@ use { GetTransactionResultDetailCategory, GetTxOutResult, GetWalletInfoResult, ImportDescriptors, ImportMultiResult, ListDescriptorsResult, ListTransactionResult, ListUnspentResultEntry, ListWalletDirItem, ListWalletDirResult, LoadWalletResult, SignRawTransactionInput, - SignRawTransactionResult, Timestamp, WalletTxInfo, + SignRawTransactionResult, Timestamp, WalletProcessPsbtResult, WalletTxInfo, }, jsonrpc_core::{IoHandler, Value}, jsonrpc_http_server::{CloseHandle, ServerBuilder}, @@ -136,13 +136,6 @@ pub struct TransactionTemplate<'a> { pub outputs: usize, } -#[derive(Clone, Debug, PartialEq)] -pub struct Sent { - pub amount: f64, - pub address: Address, - pub locked: Vec, -} - #[derive(Serialize, Deserialize)] pub struct JsonOutPoint { txid: bitcoin::Txid, @@ -259,10 +252,6 @@ impl Handle { self.state().descriptors.push(desc); } - pub fn sent(&self) -> Vec { - self.state().sent.clone() - } - pub fn lock(&self, output: OutPoint) { self.state().locked.insert(output); } @@ -288,6 +277,10 @@ impl Handle { pub fn cookie_file(&self) -> PathBuf { self.tempdir.path().join(".cookie") } + + pub fn get_locked(&self) -> BTreeSet { + self.state().get_locked() + } } impl Drop for Handle { diff --git a/test-bitcoincore-rpc/src/server.rs b/test-bitcoincore-rpc/src/server.rs index f65237f49a..c2236bab1b 100644 --- a/test-bitcoincore-rpc/src/server.rs +++ b/test-bitcoincore-rpc/src/server.rs @@ -1,7 +1,9 @@ use { super::*, + base64::Engine, bitcoin::{ consensus::Decodable, + psbt::Psbt, secp256k1::{rand, KeyPair, Secp256k1, XOnlyPublicKey}, Witness, }, @@ -388,6 +390,7 @@ impl Api for Server { fn send_raw_transaction(&self, tx: String) -> Result { let tx: Transaction = deserialize(&hex::decode(tx).unwrap()).unwrap(); + self.state.lock().unwrap().mempool.push(tx.clone()); Ok(tx.txid().to_string()) @@ -461,12 +464,6 @@ impl Api for Server { state.mempool.push(transaction); - state.sent.push(Sent { - address: address.assume_checked(), - amount, - locked, - }); - Ok(txid) } @@ -786,4 +783,42 @@ impl Api for Server { .collect(), }) } + + fn wallet_process_psbt( + &self, + psbt: String, + sign: Option, + sighash_type: Option<()>, + bip32derivs: Option, + ) -> Result { + // we only call this function in `ord wallet send --dry-run` in which case + // we don't want to sign the PSBT, so we assert that sign is false. + assert_eq!(sign, Some(false)); + assert!(sighash_type.is_none()); + assert!(bip32derivs.is_none()); + + let mut psbt = Psbt::deserialize( + &base64::engine::general_purpose::STANDARD + .decode(psbt) + .unwrap(), + ) + .unwrap(); + + for (i, txin) in psbt.unsigned_tx.input.iter().enumerate() { + psbt.inputs[i].witness_utxo = Some( + self + .state() + .transactions + .get(&txin.previous_output.txid) + .unwrap() + .output[txin.previous_output.vout as usize] + .clone(), + ); + } + + Ok(WalletProcessPsbtResult { + psbt: base64::engine::general_purpose::STANDARD.encode(psbt.serialize()), + complete: false, + }) + } } diff --git a/test-bitcoincore-rpc/src/state.rs b/test-bitcoincore-rpc/src/state.rs index fc962056d7..ab98f4bf66 100644 --- a/test-bitcoincore-rpc/src/state.rs +++ b/test-bitcoincore-rpc/src/state.rs @@ -11,7 +11,6 @@ pub(crate) struct State { pub(crate) mempool: Vec, pub(crate) network: Network, pub(crate) nonce: u32, - pub(crate) sent: Vec, pub(crate) transactions: BTreeMap, pub(crate) utxos: BTreeMap, pub(crate) version: usize, @@ -38,7 +37,6 @@ impl State { mempool: Vec::new(), network, nonce: 0, - sent: Vec::new(), transactions: BTreeMap::new(), utxos: BTreeMap::new(), version, @@ -207,4 +205,8 @@ impl State { 0 } + + pub(crate) fn get_locked(&self) -> BTreeSet { + self.locked.clone() + } } diff --git a/tests/lib.rs b/tests/lib.rs index 1abb3d8738..0dd06460ec 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -12,6 +12,7 @@ use { executable_path::executable_path, ord::{ chain::Chain, + outgoing::Outgoing, rarity::Rarity, subcommand::runes::RuneInfo, templates::{ @@ -38,7 +39,7 @@ use { time::Duration, }, tempfile::TempDir, - test_bitcoincore_rpc::{Sent, TransactionTemplate}, + test_bitcoincore_rpc::TransactionTemplate, }; macro_rules! assert_regex_match { diff --git a/tests/server.rs b/tests/server.rs index cca4cc99b6..9bcaa3a786 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -186,7 +186,7 @@ fn inscription_page_after_send() { .ord_rpc_server(&ord_rpc_server) .stdout_regex(".*") .run_and_deserialize_output::() - .transaction; + .txid; bitcoin_rpc_server.mine_blocks(1); diff --git a/tests/wallet/inscriptions.rs b/tests/wallet/inscriptions.rs index 89dc79f833..9c10ed1924 100644 --- a/tests/wallet/inscriptions.rs +++ b/tests/wallet/inscriptions.rs @@ -43,7 +43,7 @@ fn inscriptions() { .expected_exit_code(0) .stdout_regex(".*") .run_and_deserialize_output::() - .transaction; + .txid; bitcoin_rpc_server.mine_blocks(1); diff --git a/tests/wallet/send.rs b/tests/wallet/send.rs index 293b6b6c88..7308431897 100644 --- a/tests/wallet/send.rs +++ b/tests/wallet/send.rs @@ -1,5 +1,7 @@ use { super::*, + base64::Engine, + bitcoin::psbt::Psbt, ord::subcommand::wallet::{balance, create, send}, std::collections::BTreeMap, }; @@ -27,11 +29,11 @@ fn inscriptions_can_be_sent() { .run_and_deserialize_output::(); let txid = bitcoin_rpc_server.mempool()[0].txid(); - assert_eq!(txid, output.transaction); + assert_eq!(txid, output.txid); bitcoin_rpc_server.mine_blocks(1); - let send_txid = output.transaction; + let send_txid = output.txid; ord_rpc_server.assert_response_regex( format!("/inscription/{inscription}"), @@ -94,7 +96,7 @@ fn send_inscribed_sat() { bitcoin_rpc_server.mine_blocks(1); - let send_txid = output.transaction; + let send_txid = output.txid; ord_rpc_server.assert_response_regex( format!("/inscription/{inscription}"), @@ -164,7 +166,7 @@ fn send_on_mainnnet_works_with_wallet_named_ord() { .ord_rpc_server(&ord_rpc_server) .run_and_deserialize_output::(); - assert_eq!(bitcoin_rpc_server.mempool()[0].txid(), output.transaction); + assert_eq!(bitcoin_rpc_server.mempool()[0].txid(), output.txid); } #[test] @@ -390,13 +392,14 @@ fn send_btc_with_fee_rate() { bitcoin_rpc_server.mine_blocks(1); CommandBuilder::new( - "wallet send --fee-rate 13.3 bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4 1btc", + "wallet send --fee-rate 13.3 bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4 2btc", ) .bitcoin_rpc_server(&bitcoin_rpc_server) .ord_rpc_server(&ord_rpc_server) .run_and_deserialize_output::(); let tx = &bitcoin_rpc_server.mempool()[0]; + let mut fee = 0; for input in &tx.input { fee += bitcoin_rpc_server @@ -404,6 +407,7 @@ fn send_btc_with_fee_rate() { .unwrap() .to_sat(); } + for output in &tx.output { fee -= output.value; } @@ -413,16 +417,14 @@ fn send_btc_with_fee_rate() { assert!(f64::abs(fee_rate - 13.3) < 0.1); assert_eq!( - bitcoin_rpc_server.sent(), - &[Sent { - amount: 1.0, - address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" - .parse::>() - .unwrap() - .assume_checked(), - locked: Vec::new(), - }] + Address::from_script(&tx.output[0].script_pubkey, Network::Bitcoin).unwrap(), + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" + .parse::>() + .unwrap() + .assume_checked() ); + + assert_eq!(tx.output[0].value, 2 * COIN_VALUE); } #[test] @@ -442,20 +444,10 @@ fn send_btc_locks_inscriptions() { .ord_rpc_server(&ord_rpc_server) .run_and_deserialize_output::(); - assert_eq!( - bitcoin_rpc_server.sent(), - &[Sent { - amount: 1.0, - address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" - .parse::>() - .unwrap() - .assume_checked(), - locked: vec![OutPoint { - txid: reveal, - vout: 0, - }] - }] - ) + assert!(bitcoin_rpc_server.get_locked().contains(&OutPoint { + txid: reveal, + vout: 0, + })) } #[test] @@ -704,7 +696,7 @@ fn sending_rune_works() { Rune(RUNE), vec![( OutPoint { - txid: output.transaction, + txid: output.txid, vout: 2 }, 1000 @@ -752,7 +744,7 @@ fn sending_spaced_rune_works() { Rune(RUNE), vec![( OutPoint { - txid: output.transaction, + txid: output.txid, vout: 2 }, 1000 @@ -816,14 +808,14 @@ fn sending_rune_with_divisibility_works() { vec![ ( OutPoint { - txid: output.transaction, + txid: output.txid, vout: 1 }, 899 ), ( OutPoint { - txid: output.transaction, + txid: output.txid, vout: 2 }, 101 @@ -874,14 +866,14 @@ fn sending_rune_leaves_unspent_runes_in_wallet() { vec![ ( OutPoint { - txid: output.transaction, + txid: output.txid, vout: 1 }, 250 ), ( OutPoint { - txid: output.transaction, + txid: output.txid, vout: 2 }, 750 @@ -897,7 +889,7 @@ fn sending_rune_leaves_unspent_runes_in_wallet() { let tx = bitcoin_rpc_server.tx(3, 1); - assert_eq!(tx.txid(), output.transaction); + assert_eq!(tx.txid(), output.txid); let address = Address::from_script(&tx.output[1].script_pubkey, Network::Regtest).unwrap(); @@ -943,14 +935,14 @@ fn sending_rune_creates_transaction_with_expected_runestone() { vec![ ( OutPoint { - txid: output.transaction, + txid: output.txid, vout: 1 }, 250 ), ( OutPoint { - txid: output.transaction, + txid: output.txid, vout: 2 }, 750 @@ -966,7 +958,7 @@ fn sending_rune_creates_transaction_with_expected_runestone() { let tx = bitcoin_rpc_server.tx(3, 1); - assert_eq!(tx.txid(), output.transaction); + assert_eq!(tx.txid(), output.txid); assert_eq!( Runestone::from_transaction(&tx).unwrap(), @@ -1090,3 +1082,40 @@ fn sending_rune_does_not_send_inscription() { .stderr_regex("error:.*") .run_and_extract_stdout(); } + +#[test] +fn send_dry_run() { + let bitcoin_rpc_server = test_bitcoincore_rpc::spawn(); + + let ord_rpc_server = TestServer::spawn_with_server_args(&bitcoin_rpc_server, &[], &[]); + + create_wallet(&bitcoin_rpc_server, &ord_rpc_server); + + bitcoin_rpc_server.mine_blocks(1); + + let (inscription, _) = inscribe(&bitcoin_rpc_server, &ord_rpc_server); + + bitcoin_rpc_server.mine_blocks(1); + + let output = CommandBuilder::new(format!( + "wallet send --fee-rate 1 bc1qcqgs2pps4u4yedfyl5pysdjjncs8et5utseepv {inscription} --dry-run", + )) + .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) + .run_and_deserialize_output::(); + + assert!(bitcoin_rpc_server.mempool().is_empty()); + assert_eq!( + Psbt::deserialize( + &base64::engine::general_purpose::STANDARD + .decode(output.psbt) + .unwrap() + ) + .unwrap() + .fee() + .unwrap() + .to_sat(), + output.fee + ); + assert_eq!(output.outgoing, Outgoing::InscriptionId(inscription)); +}