diff --git a/Cargo.toml b/Cargo.toml index c0155b4..44f4575 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,10 @@ repository = "https://github.com/bitfinity-network/ord-rs" documentation = "https://docs.rs/ord-rs" edition = "2021" +[features] +default = [] +rune = ["ordinals"] + [dependencies] async-trait = "0.1" bitcoin = { version = "0.31", features = ["rand"] } @@ -16,6 +20,7 @@ ciborium = "0.2" hex = "0.4" http = "1" log = "0.4" +ordinals = { version = "0.0.7", optional = true } rand = { version = "0.8" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/error.rs b/src/error.rs index c4b537d..de94e4f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -33,6 +33,10 @@ pub enum OrdError { Utf8Encoding(#[from] std::str::Utf8Error), #[error("Inscription parser error: {0}")] InscriptionParser(#[from] InscriptionParseError), + #[error("Invalid inputs")] + InvalidInputs, + #[error("Invalid script type")] + InvalidScriptType, } /// Inscription parsing errors. diff --git a/src/lib.rs b/src/lib.rs index 84b28fc..0f10460 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,12 +19,6 @@ extern crate log; #[macro_use] extern crate serde; -mod error; -pub mod inscription; -mod result; -mod utils; -pub mod wallet; - pub use bitcoin; pub use error::{InscriptionParseError, OrdError}; pub use inscription::brc20::Brc20; @@ -38,3 +32,9 @@ pub use wallet::{ OrdTransactionBuilder, RevealTransactionArgs, SignCommitTransactionArgs, Utxo, Wallet, WalletType, }; + +mod error; +pub mod inscription; +mod result; +mod utils; +pub mod wallet; diff --git a/src/utils/fees.rs b/src/utils/fees.rs index ce86895..8d1a54f 100644 --- a/src/utils/fees.rs +++ b/src/utils/fees.rs @@ -79,7 +79,7 @@ pub fn estimate_reveal_fee( ) } -fn estimate_transaction_fees( +pub fn estimate_transaction_fees( script_type: ScriptType, unsigned_tx_size: usize, number_of_inputs: usize, diff --git a/src/wallet/builder.rs b/src/wallet/builder.rs index b11d375..c89c826 100644 --- a/src/wallet/builder.rs +++ b/src/wallet/builder.rs @@ -1,6 +1,3 @@ -pub mod signer; -mod taproot; - use bitcoin::absolute::LockTime; use bitcoin::script::{Builder as ScriptBuilder, PushBytesBuf}; use bitcoin::transaction::Version; @@ -17,6 +14,12 @@ use crate::utils::fees::{estimate_commit_fee, estimate_reveal_fee, MultisigConfi use crate::utils::push_bytes::bytes_to_push_bytes; use crate::{OrdError, OrdResult}; +#[cfg(feature = "rune")] +mod rune; + +pub mod signer; +mod taproot; + /// Ordinal-aware transaction builder for arbitrary (`Nft`) /// and `Brc20` inscriptions. pub struct OrdTransactionBuilder { @@ -263,6 +266,17 @@ impl OrdTransactionBuilder { .await } + /// Sign a generic transaction, returning a new signed transaction. + pub async fn sign_transaction( + &self, + unsigned_tx: &Transaction, + inputs: &[TxInputInfo], + ) -> OrdResult { + self.signer + .sign_transaction(unsigned_tx, inputs, &self.public_key) + .await + } + /// Create the reveal transaction pub async fn build_reveal_transaction( &mut self, @@ -482,6 +496,17 @@ pub struct Utxo { pub amount: Amount, } +/// Output of a previous transaction to be used as an input. +/// +/// This struct contains signature script in contrast to [Utxo] so it can be used to sign inputs +/// from different addresses. +pub struct TxInputInfo { + /// ID of the output. + pub outpoint: OutPoint, + /// Contents of the output. + pub tx_out: TxOut, +} + #[cfg(test)] mod test { use std::str::FromStr; diff --git a/src/wallet/builder/rune.rs b/src/wallet/builder/rune.rs new file mode 100644 index 0000000..d778040 --- /dev/null +++ b/src/wallet/builder/rune.rs @@ -0,0 +1,266 @@ +use bitcoin::absolute::LockTime; +use bitcoin::transaction::Version; +use bitcoin::{Address, Amount, FeeRate, ScriptBuf, Transaction, TxIn, TxOut}; +use ordinals::{Edict, RuneId, Runestone}; + +use crate::fees::estimate_transaction_fees; +use crate::wallet::builder::TxInputInfo; +use crate::wallet::ScriptType; +use crate::{OrdError, OrdTransactionBuilder}; + +/// Postage amount for rune transaction. +/// +/// The value is same as in `ord` tool. +pub const RUNE_POSTAGE: Amount = Amount::from_sat(10_000); + +/// Arguments for the [`OrdTransactionBuilder::create_edict_transaction`] method. +pub struct CreateEdictTxArgs { + /// Identifier of the rune to be transferred. + rune: RuneId, + /// Inputs that contain rune and funding BTC balances. + inputs: Vec, + /// Address of the recipient of the rune transfer. + destination: Address, + /// Address that will receive leftovers of BTC. + change_address: Address, + /// Address that will receive leftovers of runes. + rune_change_address: Address, + /// Amount of the rune to be transferred. + amount: u128, + /// Current BTC fee rate. + fee_rate: FeeRate, +} + +impl CreateEdictTxArgs { + fn input_amount(&self) -> Amount { + self.inputs + .iter() + .fold(Amount::ZERO, |a, b| a + b.tx_out.value) + } +} + +impl OrdTransactionBuilder { + /// Creates an unsigned rune edict transaction. + /// + /// This method doesn't check the runes balances, so it's the responsibility of the caller to + /// check that the inputs have enough of the given rune balance to make the transfer. As per + /// runes standard, if the inputs rune balance is less than specified transfer amount, the + /// amount will be reduced to the available balance amount. + /// + /// # Errors + /// * Returns [`OrdError::InsufficientBalance`] if the inputs BTC amount is not enough + /// to cover the outputs and transaction fee. + pub fn create_edict_transaction( + &self, + args: &CreateEdictTxArgs, + ) -> Result { + let runestone = Runestone { + edicts: vec![Edict { + id: args.rune, + amount: args.amount, + output: 2, + }], + etching: None, + mint: None, + pointer: None, + }; + + let runestone_out = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(runestone.encipher().into_bytes()), + }; + let rune_change_out = TxOut { + value: RUNE_POSTAGE, + script_pubkey: args.rune_change_address.script_pubkey(), + }; + let rune_destination_out = TxOut { + value: RUNE_POSTAGE, + script_pubkey: args.destination.script_pubkey(), + }; + let funding_change_out = TxOut { + value: Amount::ZERO, + script_pubkey: args.change_address.script_pubkey(), + }; + + let outputs = vec![ + runestone_out, + rune_change_out, + rune_destination_out, + funding_change_out, + ]; + + let inputs = args + .inputs + .iter() + .map(|rune_input| TxIn { + previous_output: rune_input.outpoint, + script_sig: Default::default(), + sequence: Default::default(), + witness: Default::default(), + }) + .collect(); + + let mut unsigned_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs, + output: outputs, + }; + + let fee_amount = estimate_transaction_fees( + ScriptType::P2TR, + unsigned_tx.vsize(), + unsigned_tx.input.len(), + args.fee_rate, + &None, + ); + let change_amount = args + .input_amount() + .checked_sub(fee_amount + RUNE_POSTAGE * 2) + .ok_or(OrdError::InsufficientBalance)?; + + unsigned_tx.output[3].value = change_amount; + + Ok(unsigned_tx) + } +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + use std::str::FromStr; + + use bitcoin::consensus::Decodable; + use bitcoin::key::Secp256k1; + use bitcoin::{Network, OutPoint, PrivateKey, PublicKey, Txid}; + + use super::*; + use crate::{Wallet, WalletType}; + + #[tokio::test] + async fn create_edict_transaction() { + const PRIVATE_KEY: &str = + "66c4e94a319776225307f6f89644a827c61150d2ac21b1fc110d330364088024"; + let private_key = PrivateKey::from_slice( + &hex::decode(PRIVATE_KEY).expect("failed to decode hex private key"), + Network::Regtest, + ) + .expect("invalid private key"); + let public_key = PublicKey::from_private_key(&Secp256k1::new(), &private_key); + let wallet = Wallet::new_with_signer(WalletType::Local { private_key }); + let builder = OrdTransactionBuilder::new(public_key, ScriptType::P2WSH, wallet); + + let args = CreateEdictTxArgs { + rune: RuneId::new(219, 1).unwrap(), + inputs: vec![ + TxInputInfo { + outpoint: OutPoint::new( + Txid::from_str( + "9100acad2da80d2198b257acc5d98a6265fda510bc8f1252334876dad4c289f4", + ) + .unwrap(), + 1, + ), + tx_out: TxOut { + value: Amount::from_sat(10000), + script_pubkey: ScriptBuf::from_hex( + "5120c57c572f5401e740701ce673bf6c826890eec9d7898bc0415f140cb252fdaf72", + ) + .unwrap(), + }, + }, + TxInputInfo { + outpoint: OutPoint::new( + Txid::from_str( + "9100acad2da80d2198b257acc5d98a6265fda510bc8f1252334876dad4c289f4", + ) + .unwrap(), + 2, + ), + tx_out: TxOut { + value: Amount::from_sat(10000), + script_pubkey: ScriptBuf::from_hex( + "51200c7598875b445a85a351dafcb08f05a7dc1e958b5f704d2a3f2aeb31f085abd4", + ) + .unwrap(), + }, + }, + TxInputInfo { + outpoint: OutPoint::new( + Txid::from_str( + "9100acad2da80d2198b257acc5d98a6265fda510bc8f1252334876dad4c289f4", + ) + .unwrap(), + 3, + ), + tx_out: TxOut { + value: Amount::from_sat(9943140), + script_pubkey: ScriptBuf::from_hex( + "5120ddf99a3af83d2f741c955394345df2abd67a33d4e9b27d6256b65cfb24b64236", + ) + .unwrap(), + }, + }, + ], + destination: Address::from_str( + "bcrt1pu8kl0t74qn89ljqs6ez558uyjvht3d93hsa2ha3u7654hgqjmadqlm20ps", + ) + .unwrap() + .assume_checked(), + change_address: Address::from_str( + "bcrt1pxsxjyxykvchklqaz0w6tk5wz28rmqn3efdt472g53s9m9hkwp3fs452s2t", + ) + .unwrap() + .assume_checked(), + rune_change_address: Address::from_str( + "bcrt1prsz63kjxu8qmgt8m0k6em7k9hkwwqqsykpts4ad5fkvq5yqt985sfl88qq", + ) + .unwrap() + .assume_checked(), + amount: 9500, + fee_rate: FeeRate::from_sat_per_vb(10).unwrap(), + }; + let unsigned_tx = builder + .create_edict_transaction(&args) + .expect("failed to create transaction"); + + let signed_tx = builder + .sign_transaction(&unsigned_tx, &args.inputs) + .await + .expect("failed to sign transaction"); + + eprintln!("Signed tx size: {}", signed_tx.vsize()); + + const EXPECTED: &str = "02000000000103f489c2d4da76483352128fbc10a5fd65628ad9c5ac57b298210da82dadac00910100000000fffffffff489c2d4da76483352128fbc10a5fd65628ad9c5ac57b298210da82dadac00910200000000fffffffff489c2d4da76483352128fbc10a5fd65628ad9c5ac57b298210da82dadac00910300000000fdffffff0400000000000000000a6a5d0700db01019c4a0210270000000000002251201c05a8da46e1c1b42cfb7db59dfac5bd9ce00204b0570af5b44d980a100b29e91027000000000000225120e1edf7afd504ce5fc810d6454a1f84932eb8b4b1bc3aabf63cf6a95ba012df5a6cab970000000000225120340d221896662f6f83a27bb4bb51c251c7b04e394b575f29148c0bb2dece0c53014037152ea3d7d70f9ff2a6df17413e71beb1b976e0800ff8c0bf285ac7dfc04345ecee174bccaa40a1ac12c80e141f77d616d58ac5520fa6cc5995e7a8ad0ea17b0140cf46d195ff294e0947cb915e5814806155c6db651c50062628ce72ffa4e078b0ed53ace9ac5ab514af0452420cfb30164867b01f6ad71d0fe92a3f90ead69ccf01405aeab94c3a51768d16ba58431f463691aea05e56b99b074f3f2ccb299c516d9901b81effde3b9964969de29d373dd5608c6fdd6a2c54df94e530b89a2b37488b00000000"; + let expected = + Transaction::consensus_decode(&mut Cursor::new(hex::decode(EXPECTED).unwrap())) + .expect("failed to decode expected transaction"); + + eprintln!("Expected tx size: {}", expected.vsize()); + + assert_eq!(signed_tx.version, expected.version); + assert_eq!(signed_tx.lock_time, expected.lock_time); + assert_eq!(signed_tx.input.len(), expected.input.len()); + assert_eq!(signed_tx.output.len(), expected.output.len()); + + for index in 0..signed_tx.input.len() { + // Do not compare witness since it depends on randomized value in each tx + assert_eq!( + signed_tx.input[index].previous_output, expected.input[index].previous_output, + "Input {index}" + ); + assert_eq!( + signed_tx.input[index].script_sig, expected.input[index].script_sig, + "Input {index}" + ); + } + + for index in 0..signed_tx.output.len() { + assert_eq!( + signed_tx.output[index].script_pubkey, expected.output[index].script_pubkey, + "Output {index}" + ); + //todo: add check of value after https://infinityswap.atlassian.net/browse/EPROD-830 + } + } +} diff --git a/src/wallet/builder/signer.rs b/src/wallet/builder/signer.rs index 032d582..f16786d 100644 --- a/src/wallet/builder/signer.rs +++ b/src/wallet/builder/signer.rs @@ -1,15 +1,17 @@ use bitcoin::hashes::Hash as _; -use bitcoin::key::Secp256k1; +use bitcoin::key::{Secp256k1, UntweakedKeypair}; use bitcoin::secp256k1::ecdsa::Signature; -use bitcoin::secp256k1::{self, All}; +use bitcoin::secp256k1::{self, All, Message}; use bitcoin::sighash::{Prevouts, SighashCache}; use bitcoin::taproot::{ControlBlock, LeafVersion}; use bitcoin::{ - PrivateKey, PublicKey, ScriptBuf, TapLeafHash, TapSighashType, Transaction, Witness, + PrivateKey, PublicKey, ScriptBuf, SegwitV0Sighash, TapLeafHash, TapSighashType, Transaction, + TxOut, Witness, }; use super::super::builder::Utxo; use super::taproot::TaprootPayload; +use crate::wallet::builder::TxInputInfo; use crate::{OrdError, OrdResult}; /// An abstraction over a transaction signer. @@ -125,6 +127,95 @@ impl Wallet { Ok(sighash_cache.into_transaction()) } + fn sign_tr( + &self, + prev_outs: &[&TxOut], + index: usize, + sighash_cache: &mut SighashCache, + ) -> OrdResult<()> { + let prevouts = Prevouts::All(prev_outs); + let sighash = sighash_cache.taproot_key_spend_signature_hash( + index, + &prevouts, + TapSighashType::Default, + )?; + + let keypair = match self.signer { + WalletType::Local { private_key } => { + UntweakedKeypair::from_secret_key(&self.secp, &private_key.inner) + } + WalletType::External { .. } => return Err(OrdError::TaprootCompute), + }; + let msg = Message::from(sighash); + let signature = self.secp.sign_schnorr_no_aux_rand(&msg, &keypair); + + // verify + self.secp + .verify_schnorr(&signature, &msg, &keypair.x_only_public_key().0)?; + + // append witness + let signature = bitcoin::taproot::Signature { + sig: signature, + hash_ty: TapSighashType::Default, + }; + let mut witness = Witness::new(); + witness.push(&signature.to_vec()); + + *sighash_cache.witness_mut(index).unwrap() = witness; + + Ok(()) + } + + /// Sign a generic transaction. + /// + /// The given transaction must have the same inputs as the ones given in the `prev_outs` argument. + /// The signature is checked against the given `own_pubkey` public key before being accepted + /// as valid and returned. + pub async fn sign_transaction( + &self, + transaction: &Transaction, + prev_outs: &[TxInputInfo], + own_pubkey: &PublicKey, + ) -> OrdResult { + if transaction.input.len() != prev_outs.len() { + return Err(OrdError::InvalidInputs); + } + + let mut cache = SighashCache::new(transaction.clone()); + for (index, input) in prev_outs.iter().enumerate() { + match &input.tx_out.script_pubkey { + s if s.is_p2wpkh() || s.is_p2wsh() => { + let sighash = cache.p2wpkh_signature_hash( + index, + s, + input.tx_out.value, + bitcoin::EcdsaSighashType::All, + )?; + + let signature = self.sign_msg_hash(sighash, own_pubkey).await?; + let ord_signature = bitcoin::ecdsa::Signature::sighash_all(signature).into(); + + self.append_witness_to_input( + &mut cache, + ord_signature, + index, + &own_pubkey.inner, + None, + None, + )?; + } + s if s.is_p2tr() => self.sign_tr( + &prev_outs.iter().map(|v| &v.tx_out).collect::>(), + index, + &mut cache, + )?, + _ => return Err(OrdError::InvalidScriptType), + } + } + + Ok(cache.into_transaction()) + } + async fn sign_ecdsa( &mut self, own_pubkey: &PublicKey, @@ -150,33 +241,7 @@ impl Wallet { )?, }; - let message = secp256k1::Message::from_digest(sighash.to_byte_array()); - - debug!("Signing transaction and verifying signature"); - let signature = match &self.signer { - WalletType::External { signer } => { - let msg_hex = hex::encode(sighash); - // sign - let sig_hex = signer.sign_with_ecdsa(&msg_hex).await; - let signature = Signature::from_compact(&hex::decode(&sig_hex)?)?; - - // verify - signer - .verify_ecdsa(&sig_hex, &msg_hex, &own_pubkey.to_string()) - .await; - signature - } - WalletType::Local { private_key } => { - // sign - let signature = self.secp.sign_ecdsa(&message, &private_key.inner); - // verify - self.secp - .verify_ecdsa(&message, &signature, &own_pubkey.inner)?; - signature - } - }; - - debug!("signature: {}", signature.serialize_der()); + let signature = self.sign_msg_hash(sighash, own_pubkey).await?; // append witness let signature = bitcoin::ecdsa::Signature::sighash_all(signature).into(); @@ -207,6 +272,42 @@ impl Wallet { Ok(hash.into_transaction()) } + async fn sign_msg_hash( + &self, + sighash: SegwitV0Sighash, + own_pubkey: &PublicKey, + ) -> OrdResult { + debug!("Signing transaction and verifying signature"); + let signature = match &self.signer { + WalletType::External { signer } => { + let msg_hex = hex::encode(sighash); + // sign + let sig_hex = signer.sign_with_ecdsa(&msg_hex).await; + let signature = Signature::from_compact(&hex::decode(&sig_hex)?)?; + + // verify + signer + .verify_ecdsa(&sig_hex, &msg_hex, &own_pubkey.to_string()) + .await; + signature + } + WalletType::Local { private_key } => { + let message = secp256k1::Message::from_digest(sighash.to_byte_array()); + + // sign + let signature = self.secp.sign_ecdsa(&message, &private_key.inner); + // verify + self.secp + .verify_ecdsa(&message, &signature, &own_pubkey.inner)?; + signature + } + }; + + debug!("signature: {}", signature.serialize_der()); + + Ok(signature) + } + fn append_witness_to_input( &self, sighasher: &mut SighashCache,