From c6a87ab012f394d1d627c912582e9948013a5829 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 15 Dec 2023 22:24:35 +0800 Subject: [PATCH] Send runes with `ord wallet send` (#2858) --- justfile | 3 - src/decimal.rs | 150 +++++-- src/decimal_sat.rs | 52 +++ src/envelope.rs | 2 +- src/index.rs | 26 ++ src/lib.rs | 36 +- src/outgoing.rs | 136 ++++-- src/runes.rs | 4 +- src/runes/etching.rs | 12 +- src/runes/runestone.rs | 43 +- src/sat.rs | 2 +- src/subcommand/wallet/etch.rs | 27 +- src/subcommand/wallet/inscribe.rs | 2 - src/subcommand/wallet/send.rs | 153 ++++++- src/subcommand/wallet/transaction_builder.rs | 4 - src/templates/transaction.rs | 5 +- test-bitcoincore-rpc/src/lib.rs | 2 +- test-bitcoincore-rpc/src/server.rs | 36 +- tests/command_builder.rs | 3 + tests/etch.rs | 4 +- tests/lib.rs | 2 +- tests/wallet/inscribe.rs | 2 +- tests/wallet/send.rs | 446 ++++++++++++++++++- 23 files changed, 1003 insertions(+), 149 deletions(-) create mode 100644 src/decimal_sat.rs diff --git a/justfile b/justfile index 9a7793578b..f1ca30f1a7 100644 --- a/justfile +++ b/justfile @@ -17,9 +17,6 @@ fmt: clippy: cargo clippy --all --all-targets -- -D warnings -lclippy: - cargo lclippy --all --all-targets -- -D warnings - deploy branch remote chain domain: ssh root@{{domain}} "mkdir -p deploy \ && apt-get update --yes \ diff --git a/src/decimal.rs b/src/decimal.rs index 481ec38bce..035ff98613 100644 --- a/src/decimal.rs +++ b/src/decimal.rs @@ -1,23 +1,68 @@ use super::*; -#[derive(PartialEq, Debug)] +#[derive(Debug, PartialEq, Copy, Clone)] pub(crate) struct Decimal { - height: Height, - offset: u64, + value: u128, + scale: u8, } -impl From for Decimal { - fn from(sat: Sat) -> Self { - Self { - height: sat.height(), - offset: sat.third(), +impl Decimal { + pub(crate) fn to_amount(self, divisibility: u8) -> Result { + match divisibility.checked_sub(self.scale) { + Some(difference) => Ok( + self + .value + .checked_mul( + 10u128 + .checked_pow(u32::from(difference)) + .context("divisibility out of range")?, + ) + .context("amount out of range")?, + ), + None => bail!("excessive precision"), } } } -impl Display for Decimal { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - write!(f, "{}.{}", self.height, self.offset) +impl FromStr for Decimal { + type Err = Error; + + fn from_str(s: &str) -> Result { + if let Some((integer, decimal)) = s.split_once('.') { + if integer.is_empty() && decimal.is_empty() { + bail!("empty decimal"); + } + + let integer = if integer.is_empty() { + 0 + } else { + integer.parse::()? + }; + + let decimal = if decimal.is_empty() { + 0 + } else { + decimal.parse::()? + }; + + 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, + }) + } else { + Ok(Self { + value: s.parse::()?, + scale: 0, + }) + } } } @@ -26,27 +71,82 @@ mod tests { use super::*; #[test] - fn decimal() { + fn from_str() { + #[track_caller] + fn case(s: &str, value: u128, scale: u8) { + assert_eq!(s.parse::().unwrap(), Decimal { value, scale }); + } + assert_eq!( - Sat(0).decimal(), - Decimal { - height: Height(0), - offset: 0 - } + ".".parse::().unwrap_err().to_string(), + "empty decimal", ); + assert_eq!( - Sat(1).decimal(), - Decimal { - height: Height(0), - offset: 1 - } + "a.b".parse::().unwrap_err().to_string(), + "invalid digit found in string", + ); + + assert_eq!( + " 0.1 ".parse::().unwrap_err().to_string(), + "invalid digit found in string", ); + + case("0", 0, 0); + case("0.00000", 0, 0); + case("1.0", 1, 0); + case("1.1", 11, 1); + case("1.11", 111, 2); + case("1.", 1, 0); + case(".1", 1, 1); + } + + #[test] + fn to_amount() { + #[track_caller] + fn case(s: &str, divisibility: u8, amount: u128) { + assert_eq!( + s.parse::() + .unwrap() + .to_amount(divisibility) + .unwrap(), + amount, + ); + } + + assert_eq!( + Decimal { value: 0, scale: 0 } + .to_amount(255) + .unwrap_err() + .to_string(), + "divisibility out of range" + ); + assert_eq!( - Sat(2099999997689999).decimal(), Decimal { - height: Height(6929999), - offset: 0 + value: u128::MAX, + scale: 0, } + .to_amount(1) + .unwrap_err() + .to_string(), + "amount out of range", ); + + assert_eq!( + Decimal { value: 1, scale: 1 } + .to_amount(0) + .unwrap_err() + .to_string(), + "excessive precision", + ); + + case("1", 0, 1); + case("1.0", 0, 1); + case("1.0", 1, 10); + case("1.2", 1, 12); + case("1.2", 2, 120); + case("123.456", 3, 123456); + case("123.456", 6, 123456000); } } diff --git a/src/decimal_sat.rs b/src/decimal_sat.rs new file mode 100644 index 0000000000..a47dd3af15 --- /dev/null +++ b/src/decimal_sat.rs @@ -0,0 +1,52 @@ +use super::*; + +#[derive(PartialEq, Debug)] +pub(crate) struct DecimalSat { + height: Height, + offset: u64, +} + +impl From for DecimalSat { + fn from(sat: Sat) -> Self { + Self { + height: sat.height(), + offset: sat.third(), + } + } +} + +impl Display for DecimalSat { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "{}.{}", self.height, self.offset) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decimal() { + assert_eq!( + Sat(0).decimal(), + DecimalSat { + height: Height(0), + offset: 0 + } + ); + assert_eq!( + Sat(1).decimal(), + DecimalSat { + height: Height(0), + offset: 1 + } + ); + assert_eq!( + Sat(2099999997689999).decimal(), + DecimalSat { + height: Height(6929999), + offset: 0 + } + ); + } +} diff --git a/src/envelope.rs b/src/envelope.rs index 68415799fb..98352320b7 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -290,7 +290,7 @@ impl RawEnvelope { #[cfg(test)] mod tests { - use {super::*, bitcoin::absolute::LockTime}; + use super::*; fn parse(witnesses: &[Witness]) -> Vec { ParsedEnvelope::from_transaction(&Transaction { diff --git a/src/index.rs b/src/index.rs index db69556d04..67a2c8d7cf 100644 --- a/src/index.rs +++ b/src/index.rs @@ -888,6 +888,32 @@ impl Index { Ok(entries) } + pub(crate) fn get_rune_balance(&self, outpoint: OutPoint, id: RuneId) -> Result { + let rtx = self.database.begin_read()?; + + let outpoint_to_balances = rtx.open_table(OUTPOINT_TO_RUNE_BALANCES)?; + + let Some(balances) = outpoint_to_balances.get(&outpoint.store())? else { + return Ok(0); + }; + + let balances_buffer = balances.value(); + + let mut i = 0; + while i < balances_buffer.len() { + let (balance_id, length) = runes::varint::decode(&balances_buffer[i..]); + i += length; + let (amount, length) = runes::varint::decode(&balances_buffer[i..]); + i += length; + + if RuneId::try_from(balance_id).unwrap() == id { + return Ok(amount); + } + } + + Ok(0) + } + pub(crate) fn get_rune_balances_for_outpoint( &self, outpoint: OutPoint, diff --git a/src/lib.rs b/src/lib.rs index e58b927fc3..7b8921141f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ use { charm::Charm, config::Config, decimal::Decimal, + decimal_sat::DecimalSat, degree::Degree, deserialize_from_str::DeserializeFromStr, envelope::ParsedEnvelope, @@ -28,7 +29,7 @@ use { options::Options, outgoing::Outgoing, representation::Representation, - runes::{Edict, Etching, Pile, SpacedRune}, + runes::{Etching, Pile, SpacedRune}, subcommand::{Subcommand, SubcommandResult}, tally::Tally, }, @@ -36,14 +37,17 @@ use { bip39::Mnemonic, bitcoin::{ address::{Address, NetworkUnchecked}, - blockdata::constants::COIN_VALUE, - blockdata::constants::{DIFFCHANGE_INTERVAL, SUBSIDY_HALVING_INTERVAL}, + blockdata::{ + constants::{COIN_VALUE, DIFFCHANGE_INTERVAL, SUBSIDY_HALVING_INTERVAL}, + locktime::absolute::LockTime, + }, consensus::{self, Decodable, Encodable}, hash_types::BlockHash, hashes::Hash, opcodes, script::{self, Instruction}, Amount, Block, Network, OutPoint, Script, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, + Witness, }, bitcoincore_rpc::{Client, RpcApi}, chain::Chain, @@ -85,7 +89,7 @@ pub use self::{ inscription::Inscription, object::Object, rarity::Rarity, - runes::{Rune, RuneId, Runestone}, + runes::{Edict, Rune, RuneId, Runestone}, sat::Sat, sat_point::SatPoint, subcommand::wallet::transaction_builder::{Target, TransactionBuilder}, @@ -114,6 +118,7 @@ mod chain; mod charm; mod config; mod decimal; +mod decimal_sat; mod degree; mod deserialize_from_str; mod envelope; @@ -149,6 +154,29 @@ static INDEXER: Mutex>> = Mutex::new(Option::None) const TARGET_POSTAGE: Amount = Amount::from_sat(10_000); +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn fund_raw_transaction( + client: &Client, + fee_rate: FeeRate, + unfunded_transaction: &Transaction, +) -> Result> { + Ok( + client + .fund_raw_transaction( + unfunded_transaction, + Some(&bitcoincore_rpc::json::FundRawTransactionOptions { + // NB. This is `fundrawtransaction`'s `feeRate`, which is fee per kvB + // and *not* fee per vB. So, we multiply the fee rate given by the user + // by 1000. + fee_rate: Some(Amount::from_sat((fee_rate.n() * 1000.0).ceil() as u64)), + ..Default::default() + }), + Some(false), + )? + .hex, + ) +} + fn integration_test() -> bool { env::var_os("ORD_INTEGRATION_TEST") .map(|value| value.len() > 0) diff --git a/src/outgoing.rs b/src/outgoing.rs index a8265f042d..21dcd83c01 100644 --- a/src/outgoing.rs +++ b/src/outgoing.rs @@ -5,24 +5,66 @@ pub(crate) enum Outgoing { Amount(Amount), InscriptionId(InscriptionId), SatPoint(SatPoint), + Rune { decimal: Decimal, rune: SpacedRune }, } impl FromStr for Outgoing { type Err = Error; fn from_str(s: &str) -> Result { - Ok(if s.contains(':') { + lazy_static! { + static ref SATPOINT: Regex = Regex::new(r"^[[:xdigit:]]{64}:\d+:\d+$").unwrap(); + static ref INSCRIPTION_ID: Regex = Regex::new(r"^[[:xdigit:]]{64}i\d+$").unwrap(); + static ref AMOUNT: Regex = Regex::new( + r"(?x) + ^ + ( + \d+ + | + \.\d+ + | + \d+\.\d+ + ) + \ * + (bit|btc|cbtc|mbtc|msat|nbtc|pbtc|sat|satoshi|ubtc) + (s)? + $ + " + ) + .unwrap(); + static ref RUNE: Regex = Regex::new( + r"(?x) + ^ + ( + \d+ + | + \.\d+ + | + \d+\.\d+ + ) + \ * + ( + [A-Z•.]+ + ) + $ + " + ) + .unwrap(); + } + + Ok(if SATPOINT.is_match(s) { Self::SatPoint(s.parse()?) - } else if s.len() >= 66 { + } else if INSCRIPTION_ID.is_match(s) { Self::InscriptionId(s.parse()?) - } else if s.contains(' ') { - Self::Amount(s.parse()?) - } else if let Some(i) = s.find(|c: char| c.is_alphabetic()) { - let mut s = s.to_owned(); - s.insert(i, ' '); + } else if AMOUNT.is_match(s) { Self::Amount(s.parse()?) + } else if let Some(captures) = RUNE.captures(s) { + Self::Rune { + decimal: captures[1].parse()?, + rune: captures[2].parse()?, + } } else { - Self::Amount(s.parse()?) + bail!("unrecognized outgoing: {s}"); }) } } @@ -32,37 +74,81 @@ mod tests { use super::*; #[test] - fn parse() { - assert_eq!( - "0000000000000000000000000000000000000000000000000000000000000000i0" - .parse::() - .unwrap(), + fn from_str() { + #[track_caller] + fn case(s: &str, outgoing: Outgoing) { + assert_eq!(s.parse::().unwrap(), outgoing); + } + + case( + "0000000000000000000000000000000000000000000000000000000000000000i0", Outgoing::InscriptionId( "0000000000000000000000000000000000000000000000000000000000000000i0" .parse() - .unwrap() + .unwrap(), ), ); - assert_eq!( - "0000000000000000000000000000000000000000000000000000000000000000:0:0" - .parse::() - .unwrap(), + case( + "0000000000000000000000000000000000000000000000000000000000000000:0:0", Outgoing::SatPoint( "0000000000000000000000000000000000000000000000000000000000000000:0:0" .parse() - .unwrap() + .unwrap(), ), ); - assert_eq!( - "0 sat".parse::().unwrap(), - Outgoing::Amount("0 sat".parse().unwrap()), + case("0 btc", Outgoing::Amount("0 btc".parse().unwrap())); + case("0btc", Outgoing::Amount("0 btc".parse().unwrap())); + case("0.0btc", Outgoing::Amount("0 btc".parse().unwrap())); + case(".0btc", Outgoing::Amount("0 btc".parse().unwrap())); + + case( + "0 XYZ", + Outgoing::Rune { + rune: "XYZ".parse().unwrap(), + decimal: "0".parse().unwrap(), + }, + ); + + case( + "0XYZ", + Outgoing::Rune { + rune: "XYZ".parse().unwrap(), + decimal: "0".parse().unwrap(), + }, + ); + + case( + "0.0XYZ", + Outgoing::Rune { + rune: "XYZ".parse().unwrap(), + decimal: "0.0".parse().unwrap(), + }, + ); + + case( + ".0XYZ", + Outgoing::Rune { + rune: "XYZ".parse().unwrap(), + decimal: ".0".parse().unwrap(), + }, + ); + + case( + "1.1XYZ", + Outgoing::Rune { + rune: "XYZ".parse().unwrap(), + decimal: "1.1".parse().unwrap(), + }, ); - assert_eq!( - "0sat".parse::().unwrap(), - Outgoing::Amount("0 sat".parse().unwrap()), + case( + "1.1X.Y.Z", + Outgoing::Rune { + rune: "X.Y.Z".parse().unwrap(), + decimal: "1.1".parse().unwrap(), + }, ); assert!("0".parse::().is_err()); diff --git a/src/runes.rs b/src/runes.rs index 0e76cef347..fcb6c96bc9 100644 --- a/src/runes.rs +++ b/src/runes.rs @@ -1,8 +1,8 @@ use super::*; -pub use {rune::Rune, rune_id::RuneId, runestone::Runestone, spaced_rune::SpacedRune}; +pub use {edict::Edict, rune::Rune, rune_id::RuneId, runestone::Runestone}; -pub(crate) use {edict::Edict, etching::Etching, pile::Pile}; +pub(crate) use {etching::Etching, pile::Pile, spaced_rune::SpacedRune}; pub const MAX_DIVISIBILITY: u8 = 38; pub(crate) const CLAIM_BIT: u128 = 1 << 48; diff --git a/src/runes/etching.rs b/src/runes/etching.rs index 1021688e9a..86f5d3d065 100644 --- a/src/runes/etching.rs +++ b/src/runes/etching.rs @@ -2,10 +2,10 @@ use super::*; #[derive(Default, Serialize, Debug, PartialEq, Copy, Clone)] pub struct Etching { - pub(crate) divisibility: u8, - pub(crate) limit: Option, - pub(crate) rune: Option, - pub(crate) spacers: u32, - pub(crate) symbol: Option, - pub(crate) term: Option, + pub divisibility: u8, + pub limit: Option, + pub rune: Option, + pub symbol: Option, + pub term: Option, + pub spacers: u32, } diff --git a/src/runes/runestone.rs b/src/runes/runestone.rs index fb0348d582..67064e7516 100644 --- a/src/runes/runestone.rs +++ b/src/runes/runestone.rs @@ -229,10 +229,7 @@ impl Runestone { #[cfg(test)] mod tests { - use { - super::*, - bitcoin::{locktime, script::PushBytes, ScriptBuf, TxOut}, - }; + use {super::*, bitcoin::script::PushBytes}; fn decipher(integers: &[u128]) -> Runestone { let payload = payload(integers); @@ -249,7 +246,7 @@ mod tests { .into_script(), value: 0, }], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }) .unwrap() @@ -275,7 +272,7 @@ mod tests { script_pubkey: ScriptBuf::from_bytes(vec![opcodes::all::OP_PUSHBYTES_4.to_u8()]), value: 0, }], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }), None @@ -288,7 +285,7 @@ mod tests { Runestone::decipher(&Transaction { input: Vec::new(), output: Vec::new(), - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }), Ok(None) @@ -304,7 +301,7 @@ mod tests { script_pubkey: script::Builder::new().push_slice([]).into_script(), value: 0 }], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }), Ok(None) @@ -322,7 +319,7 @@ mod tests { .into_script(), value: 0 }], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }), Ok(None) @@ -341,7 +338,7 @@ mod tests { .into_script(), value: 0 }], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }), Ok(None) @@ -356,7 +353,7 @@ mod tests { script_pubkey: ScriptBuf::from_bytes(vec![opcodes::all::OP_PUSHBYTES_4.to_u8()]), value: 0, }], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }) .unwrap_err(); @@ -378,7 +375,7 @@ mod tests { script_pubkey: ScriptBuf::from_bytes(script_pubkey), value: 0, }], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }) .unwrap_err(); @@ -396,7 +393,7 @@ mod tests { .into_script(), value: 0, }], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }) .unwrap(); @@ -417,7 +414,7 @@ mod tests { .into_script(), value: 0, }], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }) .unwrap() @@ -445,7 +442,7 @@ mod tests { .into_script(), value: 0 }], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }), Ok(Some(Runestone::default())) @@ -480,7 +477,7 @@ mod tests { value: 0, }, ], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }) .unwrap_err(); @@ -988,7 +985,7 @@ mod tests { .into_script(), value: 0 }], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }), Ok(Some(Runestone { @@ -1029,7 +1026,7 @@ mod tests { value: 0 } ], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }), Ok(Some(Runestone { @@ -1069,7 +1066,7 @@ mod tests { value: 0 } ], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }), Ok(Some(Runestone { @@ -1128,9 +1125,11 @@ mod tests { divisibility: MAX_DIVISIBILITY, rune: Some(Rune(0)), symbol: Some('$'), - ..Default::default() + limit: Some(1), + spacers: 1, + term: Some(1), }), - 10, + 16, ); case( @@ -1372,7 +1371,7 @@ mod tests { script_pubkey, value: 0, }], - lock_time: locktime::absolute::LockTime::ZERO, + lock_time: LockTime::ZERO, version: 0, }; diff --git a/src/sat.rs b/src/sat.rs index 5323297285..1d736b7210 100644 --- a/src/sat.rs +++ b/src/sat.rs @@ -49,7 +49,7 @@ impl Sat { self.0 - self.epoch().starting_sat().0 } - pub(crate) fn decimal(self) -> Decimal { + pub(crate) fn decimal(self) -> DecimalSat { self.into() } diff --git a/src/subcommand/wallet/etch.rs b/src/subcommand/wallet/etch.rs index f3711c6491..67ea16c659 100644 --- a/src/subcommand/wallet/etch.rs +++ b/src/subcommand/wallet/etch.rs @@ -1,4 +1,4 @@ -use {super::*, bitcoin::blockdata::locktime::absolute::LockTime}; +use super::*; #[derive(Debug, Parser)] pub(crate) struct Etch { @@ -9,12 +9,12 @@ pub(crate) struct Etch { #[clap(long, help = "Etch rune . May contain `.` or `•`as spacers.")] rune: SpacedRune, #[clap(long, help = "Set supply to .")] - supply: u128, + supply: Decimal, #[clap(long, help = "Set currency symbol to .")] symbol: char, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug)] pub struct Output { pub transaction: Txid, } @@ -70,7 +70,7 @@ impl Etch { term: None, }), edicts: vec![Edict { - amount: self.supply, + amount: self.supply.to_amount(self.divisibility)?, id: 0, output: 1, }], @@ -113,24 +113,13 @@ impl Etch { bail!("failed to lock UTXOs"); } - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let unsigned_transaction = client.fund_raw_transaction( - &unfunded_transaction, - Some(&bitcoincore_rpc::json::FundRawTransactionOptions { - // NB. This is `fundrawtransaction`'s `feeRate`, which is fee per kvB - // and *not* fee per vB. So, we multiply the fee rate given by the user - // by 1000. - fee_rate: Some(Amount::from_sat((self.fee_rate.n() * 1000.0).ceil() as u64)), - ..Default::default() - }), - Some(false), - )?; + let unsigned_transaction = fund_raw_transaction(&client, self.fee_rate, &unfunded_transaction)?; - let signed_tx = client - .sign_raw_transaction_with_wallet(&unsigned_transaction.hex, None, None)? + let signed_transaction = client + .sign_raw_transaction_with_wallet(&unsigned_transaction, None, None)? .hex; - let transaction = client.send_raw_transaction(&signed_tx)?; + let transaction = client.send_raw_transaction(&signed_transaction)?; Ok(Box::new(Output { transaction })) } diff --git a/src/subcommand/wallet/inscribe.rs b/src/subcommand/wallet/inscribe.rs index 43f92e5c98..4dfb93ed9f 100644 --- a/src/subcommand/wallet/inscribe.rs +++ b/src/subcommand/wallet/inscribe.rs @@ -6,13 +6,11 @@ use { blockdata::{opcodes, script}, key::PrivateKey, key::{TapTweak, TweakedKeyPair, TweakedPublicKey, UntweakedKeyPair}, - locktime::absolute::LockTime, policy::MAX_STANDARD_TX_WEIGHT, secp256k1::{self, constants::SCHNORR_SIGNATURE_SIZE, rand, Secp256k1, XOnlyPublicKey}, sighash::{Prevouts, SighashCache, TapSighashType}, taproot::Signature, taproot::{ControlBlock, LeafVersion, TapLeafHash, TaprootBuilder}, - ScriptBuf, Witness, }, bitcoincore_rpc::bitcoincore_rpc_json::{ImportDescriptors, SignRawTransactionInput, Timestamp}, bitcoincore_rpc::Client, diff --git a/src/subcommand/wallet/send.rs b/src/subcommand/wallet/send.rs index d2cf6ee4f7..a41fb99183 100644 --- a/src/subcommand/wallet/send.rs +++ b/src/subcommand/wallet/send.rs @@ -44,6 +44,29 @@ impl Send { index.get_runic_outputs(&unspent_outputs.keys().cloned().collect::>())?; let satpoint = match self.outgoing { + Outgoing::Amount(amount) => { + Self::lock_non_cardinal_outputs(&client, &inscriptions, &runic_outputs, unspent_outputs)?; + let transaction = Self::send_amount(&client, amount, address, self.fee_rate)?; + return Ok(Box::new(Output { transaction })); + } + Outgoing::InscriptionId(id) => index + .get_inscription_satpoint_by_id(id)? + .ok_or_else(|| anyhow!("inscription {id} not found"))?, + Outgoing::Rune { decimal, rune } => { + let transaction = Self::send_runes( + address, + chain, + &client, + decimal, + self.fee_rate, + &index, + inscriptions, + rune, + runic_outputs, + unspent_outputs, + )?; + return Ok(Box::new(Output { transaction })); + } Outgoing::SatPoint(satpoint) => { for inscription_satpoint in inscriptions.keys() { if satpoint == *inscription_satpoint { @@ -58,14 +81,6 @@ impl Send { satpoint } - Outgoing::InscriptionId(id) => index - .get_inscription_satpoint_by_id(id)? - .ok_or_else(|| anyhow!("Inscription {id} not found"))?, - Outgoing::Amount(amount) => { - Self::lock_inscriptions(&client, inscriptions, runic_outputs, unspent_outputs)?; - let txid = Self::send_amount(&client, amount, address, self.fee_rate.n())?; - return Ok(Box::new(Output { transaction: txid })); - } }; let change = [ @@ -101,10 +116,10 @@ impl Send { Ok(Box::new(Output { transaction: txid })) } - fn lock_inscriptions( + fn lock_non_cardinal_outputs( client: &Client, - inscriptions: BTreeMap, - runic_outputs: BTreeSet, + inscriptions: &BTreeMap, + runic_outputs: &BTreeSet, unspent_outputs: BTreeMap, ) -> Result { let all_inscription_outputs = inscriptions @@ -126,7 +141,12 @@ impl Send { Ok(()) } - fn send_amount(client: &Client, amount: Amount, address: Address, fee_rate: f64) -> Result { + fn send_amount( + client: &Client, + amount: Amount, + address: Address, + fee_rate: FeeRate, + ) -> Result { Ok(client.call( "sendtoaddress", &[ @@ -139,8 +159,115 @@ impl Send { serde_json::Value::Null, // 7. conf_target serde_json::Value::Null, // 8. estimate_mode serde_json::Value::Null, // 9. avoid_reuse - fee_rate.into(), // 10. fee_rate + fee_rate.n().into(), // 10. fee_rate ], )?) } + + fn send_runes( + address: Address, + chain: Chain, + client: &Client, + decimal: Decimal, + fee_rate: FeeRate, + index: &Index, + inscriptions: BTreeMap, + spaced_rune: SpacedRune, + runic_outputs: BTreeSet, + unspent_outputs: BTreeMap, + ) -> Result { + ensure!( + index.has_rune_index(), + "sending runes with `ord send` requires index created with `--index-runes` flag", + ); + + Self::lock_non_cardinal_outputs(client, &inscriptions, &runic_outputs, unspent_outputs)?; + + let (id, entry) = index + .rune(spaced_rune.rune)? + .with_context(|| format!("rune `{}` has not been etched", spaced_rune.rune))?; + + let amount = decimal.to_amount(entry.divisibility)?; + + let inscribed_outputs = inscriptions + .keys() + .map(|satpoint| satpoint.outpoint) + .collect::>(); + + let mut input_runes = 0; + let mut input = Vec::new(); + + for output in runic_outputs { + if inscribed_outputs.contains(&output) { + continue; + } + + let balance = index.get_rune_balance(output, id)?; + + if balance > 0 { + input_runes += balance; + input.push(output); + } + + if input_runes >= amount { + break; + } + } + + ensure! { + input_runes >= amount, + "insufficient `{}` balance, only {} in wallet", + spaced_rune, + Pile { + amount: input_runes, + divisibility: entry.divisibility, + symbol: entry.symbol + }, + } + + let runestone = Runestone { + edicts: vec![Edict { + amount, + id: id.into(), + output: 2, + }], + ..Default::default() + }; + + let unfunded_transaction = Transaction { + version: 1, + lock_time: LockTime::ZERO, + input: input + .into_iter() + .map(|previous_output| TxIn { + previous_output, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }) + .collect(), + output: vec![ + TxOut { + script_pubkey: runestone.encipher(), + value: 0, + }, + TxOut { + script_pubkey: get_change_address(client, chain)?.script_pubkey(), + value: TARGET_POSTAGE.to_sat(), + }, + TxOut { + script_pubkey: address.script_pubkey(), + value: TARGET_POSTAGE.to_sat(), + }, + ], + }; + + let unsigned_transaction = fund_raw_transaction(client, fee_rate, &unfunded_transaction)?; + + let signed_transaction = client + .sign_raw_transaction_with_wallet(&unsigned_transaction, None, None)? + .hex; + + Ok(client.send_raw_transaction(&signed_transaction)?) + } } diff --git a/src/subcommand/wallet/transaction_builder.rs b/src/subcommand/wallet/transaction_builder.rs index bd1694e861..a31d277e9a 100644 --- a/src/subcommand/wallet/transaction_builder.rs +++ b/src/subcommand/wallet/transaction_builder.rs @@ -33,10 +33,6 @@ use { super::*, - bitcoin::{ - blockdata::{locktime::absolute::LockTime, witness::Witness}, - Amount, ScriptBuf, - }, std::cmp::{max, min}, }; diff --git a/src/templates/transaction.rs b/src/templates/transaction.rs index c7b15fa5bd..985a5512b2 100644 --- a/src/templates/transaction.rs +++ b/src/templates/transaction.rs @@ -37,10 +37,7 @@ impl PageContent for TransactionHtml { #[cfg(test)] mod tests { - use { - super::*, - bitcoin::{blockdata::script, locktime::absolute::LockTime, TxOut}, - }; + use {super::*, bitcoin::blockdata::script}; #[test] fn html() { diff --git a/test-bitcoincore-rpc/src/lib.rs b/test-bitcoincore-rpc/src/lib.rs index 3d11b8f132..3a3a1560a2 100644 --- a/test-bitcoincore-rpc/src/lib.rs +++ b/test-bitcoincore-rpc/src/lib.rs @@ -274,7 +274,7 @@ impl Handle { self.state().loaded_wallets.clone() } - pub fn get_change_addresses(&self) -> Vec
{ + pub fn change_addresses(&self) -> Vec
{ self.state().change_addresses.clone() } diff --git a/test-bitcoincore-rpc/src/server.rs b/test-bitcoincore-rpc/src/server.rs index 6afa9f00f2..4655939f92 100644 --- a/test-bitcoincore-rpc/src/server.rs +++ b/test-bitcoincore-rpc/src/server.rs @@ -260,25 +260,37 @@ impl Api for Server { .map(|(outpoint, value)| (value, outpoint)) .collect::>(); + let mut input_value = transaction + .input + .iter() + .map(|txin| state.utxos.get(&txin.previous_output).unwrap().to_sat()) + .sum::(); + + let shortfall = output_value.saturating_sub(input_value); + utxos.sort(); utxos.reverse(); - let (input_value, outpoint) = utxos - .iter() - .find(|(value, outpoint)| value.to_sat() >= output_value && !state.locked.contains(outpoint)) - .ok_or_else(Self::not_found)?; - - transaction.input.push(TxIn { - previous_output: *outpoint, - script_sig: ScriptBuf::new(), - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - witness: Witness::default(), - }); + if shortfall > 0 { + let (additional_input_value, outpoint) = utxos + .iter() + .find(|(value, outpoint)| value.to_sat() >= shortfall && !state.locked.contains(outpoint)) + .ok_or_else(Self::not_found)?; + + transaction.input.push(TxIn { + previous_output: *outpoint, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::default(), + }); + + input_value += additional_input_value.to_sat(); + } let change_position = transaction.output.len() as i32; transaction.output.push(TxOut { - value: input_value.to_sat() - output_value, + value: input_value - output_value, script_pubkey: ScriptBuf::new(), }); diff --git a/tests/command_builder.rs b/tests/command_builder.rs index 32bc77246b..5cc9171c8e 100644 --- a/tests/command_builder.rs +++ b/tests/command_builder.rs @@ -132,6 +132,7 @@ impl CommandBuilder { command } + #[track_caller] fn run(self) -> (TempDir, String) { let child = self.command().spawn().unwrap(); @@ -164,10 +165,12 @@ impl CommandBuilder { fs::read_to_string(tempdir.path().join(path)).unwrap() } + #[track_caller] pub(crate) fn run_and_extract_stdout(self) -> String { self.run().1 } + #[track_caller] pub(crate) fn run_and_deserialize_output(self) -> T { let stdout = self.stdout_regex(".*").run_and_extract_stdout(); serde_json::from_str(&stdout) diff --git a/tests/etch.rs b/tests/etch.rs index 60bece10a0..587f9c34a1 100644 --- a/tests/etch.rs +++ b/tests/etch.rs @@ -161,7 +161,7 @@ fn runes_can_be_etched() { number: 0, rune: Rune(RUNE), spacers: 0b111111111111, - supply: 1000, + supply: 10000, symbol: Some('¢'), timestamp: ord::timestamp(2), } @@ -174,7 +174,7 @@ fn runes_can_be_etched() { .rpc_server(&rpc_server) .run_and_deserialize_output::(); - assert_eq!(output.runes.unwrap()[&Rune(RUNE)], 1000); + assert_eq!(output.runes.unwrap()[&Rune(RUNE)], 10000); } #[test] diff --git a/tests/lib.rs b/tests/lib.rs index 758ad07e97..22ce083c99 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -16,7 +16,7 @@ use { block::BlockJson, inscription::InscriptionJson, inscriptions::InscriptionsJson, output::OutputJson, sat::SatJson, }, - Rune, RuneId, SatPoint, + Edict, Rune, RuneId, Runestone, SatPoint, }, pretty_assertions::assert_eq as pretty_assert_eq, regex::Regex, diff --git a/tests/wallet/inscribe.rs b/tests/wallet/inscribe.rs index a2696e15aa..2d7a347c53 100644 --- a/tests/wallet/inscribe.rs +++ b/tests/wallet/inscribe.rs @@ -1423,7 +1423,7 @@ fn batch_inscribe_works_with_some_destinations_set_and_others_not() { ".*
address
{}
.*", - rpc_server.get_change_addresses()[0] + rpc_server.change_addresses()[0] ), ); diff --git a/tests/wallet/send.rs b/tests/wallet/send.rs index da3e106030..19edc69134 100644 --- a/tests/wallet/send.rs +++ b/tests/wallet/send.rs @@ -54,7 +54,7 @@ fn send_unknown_inscription() { "wallet send --fee-rate 1 bc1qcqgs2pps4u4yedfyl5pysdjjncs8et5utseepv {txid}i0" )) .rpc_server(&rpc_server) - .expected_stderr(format!("error: Inscription {txid}i0 not found\n")) + .expected_stderr(format!("error: inscription {txid}i0 not found\n")) .expected_exit_code(1) .run_and_extract_stdout(); } @@ -509,3 +509,447 @@ fn send_btc_does_not_send_locked_utxos() { .stderr_regex("error:.*") .run_and_extract_stdout(); } + +#[test] +fn sending_rune_that_has_not_been_etched_is_an_error() { + let rpc_server = test_bitcoincore_rpc::builder() + .network(Network::Regtest) + .build(); + + create_wallet(&rpc_server); + + let coinbase_tx = &rpc_server.mine_blocks(1)[0].txdata[0]; + let outpoint = OutPoint::new(coinbase_tx.txid(), 0); + + rpc_server.lock(outpoint); + + CommandBuilder::new("--chain regtest --index-runes wallet send --fee-rate 1 bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw 1FOO") + .rpc_server(&rpc_server) + .expected_exit_code(1) + .expected_stderr("error: rune `FOO` has not been etched\n") + .run_and_extract_stdout(); +} + +#[test] +fn sending_rune_with_excessive_precision_is_an_error() { + let rpc_server = test_bitcoincore_rpc::builder() + .network(Network::Regtest) + .build(); + + create_wallet(&rpc_server); + + etch(&rpc_server, Rune(RUNE)); + + CommandBuilder::new(format!( + "--chain regtest --index-runes wallet send --fee-rate 1 bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw 1.1{}", + Rune(RUNE) + )) + .rpc_server(&rpc_server) + .expected_exit_code(1) + .expected_stderr("error: excessive precision\n") + .run_and_extract_stdout(); +} + +#[test] +fn sending_rune_with_insufficient_balance_is_an_error() { + let rpc_server = test_bitcoincore_rpc::builder() + .network(Network::Regtest) + .build(); + + create_wallet(&rpc_server); + + etch(&rpc_server, Rune(RUNE)); + + CommandBuilder::new(format!( + "--chain regtest --index-runes wallet send --fee-rate 1 bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw 1001{}", + Rune(RUNE) + )) + .rpc_server(&rpc_server) + .expected_exit_code(1) + .expected_stderr("error: insufficient `AAAAAAAAAAAAA` balance, only ¢1000 in wallet\n") + .run_and_extract_stdout(); +} + +#[test] +fn sending_rune_works() { + let rpc_server = test_bitcoincore_rpc::builder() + .network(Network::Regtest) + .build(); + + create_wallet(&rpc_server); + + etch(&rpc_server, Rune(RUNE)); + + let output = CommandBuilder::new(format!( + "--chain regtest --index-runes wallet send --fee-rate 1 bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw 1000{}", + Rune(RUNE) + )) + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + rpc_server.mine_blocks(1); + + let balances = CommandBuilder::new("--regtest --index-runes balances") + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + assert_eq!( + balances, + ord::subcommand::balances::Output { + runes: vec![( + Rune(RUNE), + vec![( + OutPoint { + txid: output.transaction, + vout: 2 + }, + 1000 + )] + .into_iter() + .collect() + ),] + .into_iter() + .collect(), + } + ); +} + +#[test] +fn sending_spaced_rune_works() { + let rpc_server = test_bitcoincore_rpc::builder() + .network(Network::Regtest) + .build(); + + create_wallet(&rpc_server); + + etch(&rpc_server, Rune(RUNE)); + + let output = CommandBuilder::new( + "--chain regtest --index-runes wallet send --fee-rate 1 bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw 1000A•AAAAAAAAAAAA", + ) + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + rpc_server.mine_blocks(1); + + let balances = CommandBuilder::new("--regtest --index-runes balances") + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + assert_eq!( + balances, + ord::subcommand::balances::Output { + runes: vec![( + Rune(RUNE), + vec![( + OutPoint { + txid: output.transaction, + vout: 2 + }, + 1000 + )] + .into_iter() + .collect() + ),] + .into_iter() + .collect(), + } + ); +} + +#[test] +fn sending_rune_with_divisibility_works() { + let rpc_server = test_bitcoincore_rpc::builder() + .network(Network::Regtest) + .build(); + + create_wallet(&rpc_server); + + rpc_server.mine_blocks(1); + + let rune = Rune(RUNE); + + CommandBuilder::new( + format!( + "--index-runes --regtest wallet etch --rune {} --divisibility 1 --fee-rate 0 --supply 100 --symbol ¢", + rune, + ) + ) + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + rpc_server.mine_blocks(1); + + let output = CommandBuilder::new(format!( + "--chain regtest --index-runes wallet send --fee-rate 1 bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw 10.1{}", + rune + )) + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + rpc_server.mine_blocks(1); + + let balances = CommandBuilder::new("--regtest --index-runes balances") + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + assert_eq!( + balances, + ord::subcommand::balances::Output { + runes: vec![( + Rune(RUNE), + vec![ + ( + OutPoint { + txid: output.transaction, + vout: 1 + }, + 899 + ), + ( + OutPoint { + txid: output.transaction, + vout: 2 + }, + 101 + ) + ] + .into_iter() + .collect() + ),] + .into_iter() + .collect(), + } + ); +} + +#[test] +fn sending_rune_leaves_unspent_runes_in_wallet() { + let rpc_server = test_bitcoincore_rpc::builder() + .network(Network::Regtest) + .build(); + + create_wallet(&rpc_server); + + etch(&rpc_server, Rune(RUNE)); + + let output = CommandBuilder::new(format!( + "--chain regtest --index-runes wallet send --fee-rate 1 bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw 750{}", + Rune(RUNE) + )) + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + rpc_server.mine_blocks(1); + + let balances = CommandBuilder::new("--regtest --index-runes balances") + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + assert_eq!( + balances, + ord::subcommand::balances::Output { + runes: vec![( + Rune(RUNE), + vec![ + ( + OutPoint { + txid: output.transaction, + vout: 1 + }, + 250 + ), + ( + OutPoint { + txid: output.transaction, + vout: 2 + }, + 750 + ) + ] + .into_iter() + .collect() + ),] + .into_iter() + .collect(), + } + ); + + let tx = rpc_server.tx(3, 1); + + assert_eq!(tx.txid(), output.transaction); + + let address = Address::from_script(&tx.output[1].script_pubkey, Network::Regtest).unwrap(); + + assert!(rpc_server + .change_addresses() + .iter() + .any(|change_address| change_address == &address)); +} + +#[test] +fn sending_rune_creates_transaction_with_expected_runestone() { + let rpc_server = test_bitcoincore_rpc::builder() + .network(Network::Regtest) + .build(); + + create_wallet(&rpc_server); + + let rune = Rune(RUNE); + + etch(&rpc_server, rune); + + let output = CommandBuilder::new(format!( + "--chain regtest --index-runes wallet send --fee-rate 1 bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw 750{}", + rune, + )) + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + rpc_server.mine_blocks(1); + + let balances = CommandBuilder::new("--regtest --index-runes balances") + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + assert_eq!( + balances, + ord::subcommand::balances::Output { + runes: vec![( + rune, + vec![ + ( + OutPoint { + txid: output.transaction, + vout: 1 + }, + 250 + ), + ( + OutPoint { + txid: output.transaction, + vout: 2 + }, + 750 + ) + ] + .into_iter() + .collect() + ),] + .into_iter() + .collect(), + } + ); + + let tx = rpc_server.tx(3, 1); + + assert_eq!(tx.txid(), output.transaction); + + assert_eq!( + Runestone::from_transaction(&tx).unwrap(), + Runestone { + etching: None, + edicts: vec![Edict { + id: RuneId { + height: 2, + index: 1 + } + .into(), + amount: 750, + output: 2 + }], + burn: false, + }, + ); +} + +#[test] +fn error_messages_use_spaced_runes() { + let rpc_server = test_bitcoincore_rpc::builder() + .network(Network::Regtest) + .build(); + + create_wallet(&rpc_server); + + etch(&rpc_server, Rune(RUNE)); + + CommandBuilder::new( + "--chain regtest --index-runes wallet send --fee-rate 1 bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw 1001A•AAAAAAAAAAAA", + ) + .rpc_server(&rpc_server) + .expected_exit_code(1) + .expected_stderr("error: insufficient `A•AAAAAAAAAAAA` balance, only ¢1000 in wallet\n") + .run_and_extract_stdout(); + + CommandBuilder::new("--chain regtest --index-runes wallet send --fee-rate 1 bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw 1F•OO") + .rpc_server(&rpc_server) + .expected_exit_code(1) + .expected_stderr("error: rune `FOO` has not been etched\n") + .run_and_extract_stdout(); +} + +#[test] +fn sending_rune_does_not_send_inscription() { + let rpc_server = test_bitcoincore_rpc::builder() + .network(Network::Regtest) + .build(); + + create_wallet(&rpc_server); + + rpc_server.mine_blocks_with_subsidy(1, 10000); + + let rune = Rune(RUNE); + + CommandBuilder::new("--chain regtest --index-runes wallet inscribe --fee-rate 0 --file foo.txt") + .write("foo.txt", "FOO") + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + rpc_server.mine_blocks_with_subsidy(1, 10000); + + assert_eq!( + CommandBuilder::new("--regtest --index-runes wallet balance") + .rpc_server(&rpc_server) + .run_and_deserialize_output::(), + ord::subcommand::wallet::balance::Output { + cardinal: 10000, + ordinal: 10000, + runic: Some(0), + runes: Some(BTreeMap::new()), + total: 20000, + } + ); + + CommandBuilder::new( + format!( + "--index-runes --regtest wallet etch --rune {} --divisibility 0 --fee-rate 0 --supply 1000 --symbol ¢", + rune + ) + ) + .rpc_server(&rpc_server) + .run_and_deserialize_output::(); + + rpc_server.mine_blocks_with_subsidy(1, 0); + + assert_eq!( + CommandBuilder::new("--regtest --index-runes wallet balance") + .rpc_server(&rpc_server) + .run_and_deserialize_output::(), + ord::subcommand::wallet::balance::Output { + cardinal: 0, + ordinal: 10000, + runic: Some(10000), + runes: Some(vec![(rune, 1000)].into_iter().collect()), + total: 20000, + } + ); + + CommandBuilder::new(format!( + "--chain regtest --index-runes wallet send --fee-rate 0 bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw 1000{}", + rune + )) + .rpc_server(&rpc_server) + .expected_exit_code(1) + .stderr_regex("error:.*") + .run_and_extract_stdout(); +}