diff --git a/Cargo.lock b/Cargo.lock index 76cbb0a89..b70409bb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1383,6 +1383,37 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.60", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b" +dependencies = [ + "derive_builder_core", + "syn 2.0.60", +] + [[package]] name = "derive_more" version = "0.99.17" @@ -3587,6 +3618,25 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "kaspa-wallet-pskt" +version = "0.14.2" +dependencies = [ + "derive_builder", + "kaspa-bip32", + "kaspa-consensus-client", + "kaspa-consensus-core", + "kaspa-txscript", + "kaspa-txscript-errors", + "kaspa-utils", + "secp256k1", + "serde", + "serde-value", + "serde_json", + "serde_repr", + "thiserror", +] + [[package]] name = "kaspa-wasm" version = "0.14.2" diff --git a/Cargo.toml b/Cargo.toml index 2a548c844..2409c7354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "wallet/wasm", "wallet/bip32", "wallet/keys", + "wallet/pskt", "consensus", "consensus/core", "consensus/client", @@ -123,6 +124,7 @@ kaspa-utxoindex = { version = "0.14.2", path = "indexes/utxoindex" } kaspa-wallet = { version = "0.14.2", path = "wallet/native" } kaspa-wallet-cli-wasm = { version = "0.14.2", path = "wallet/wasm" } kaspa-wallet-keys = { version = "0.14.2", path = "wallet/keys" } +kaspa-wallet-pskt = { version = "0.14.1", path = "wallet/pskt" } kaspa-wallet-core = { version = "0.14.2", path = "wallet/core" } kaspa-wallet-macros = { version = "0.14.2", path = "wallet/macros" } kaspa-wasm = { version = "0.14.2", path = "wasm" } @@ -162,6 +164,7 @@ ctrlc = "3.4.1" crypto_box = { version = "0.9.1", features = ["chacha20"] } dashmap = "5.5.3" derivative = "2.2.0" +derive_builder = "0.20.0" derive_more = "0.99.17" dhat = "0.3.2" dirs = "5.0.1" @@ -228,6 +231,7 @@ serde = { version = "1.0.190", features = ["derive", "rc"] } serde_bytes = "0.11.12" serde_json = "1.0.107" serde_repr = "0.1.18" +serde-value = "0.7.0" serde-wasm-bindgen = "0.6.1" sha1 = "0.10.6" sha2 = "0.10.8" diff --git a/consensus/core/src/hashing/sighash_type.rs b/consensus/core/src/hashing/sighash_type.rs index 76d772f0d..a80091bba 100644 --- a/consensus/core/src/hashing/sighash_type.rs +++ b/consensus/core/src/hashing/sighash_type.rs @@ -1,3 +1,4 @@ +use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; pub const SIG_HASH_ALL: SigHashType = SigHashType(0b00000001); @@ -18,7 +19,7 @@ const ALLOWED_SIG_HASH_TYPES_VALUES: [u8; 6] = [ SIG_HASH_SINGLE.0 | SIG_HASH_ANY_ONE_CAN_PAY.0, ]; -#[derive(Copy, Clone)] +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] #[wasm_bindgen] pub struct SigHashType(pub(crate) u8); diff --git a/consensus/core/src/tx.rs b/consensus/core/src/tx.rs index 137633701..c2d3ba2e0 100644 --- a/consensus/core/src/tx.rs +++ b/consensus/core/src/tx.rs @@ -29,7 +29,7 @@ pub type TransactionId = kaspa_hashes::Hash; /// score of the block that accepts the tx, its public key script, and how /// much it pays. /// @category Consensus -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[serde(rename_all = "camelCase")] #[wasm_bindgen(inspectable, js_name = TransactionUtxoEntry)] pub struct UtxoEntry { @@ -53,7 +53,7 @@ impl MemSizeEstimator for UtxoEntry {} pub type TransactionIndexType = u32; /// Represents a Kaspa transaction outpoint -#[derive(Eq, Hash, PartialEq, Debug, Copy, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Eq, Default, Hash, PartialEq, Debug, Copy, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[serde(rename_all = "camelCase")] pub struct TransactionOutpoint { #[serde(with = "serde_bytes_fixed_ref")] diff --git a/kaspad/src/args.rs b/kaspad/src/args.rs index e11672915..2774269d3 100644 --- a/kaspad/src/args.rs +++ b/kaspad/src/args.rs @@ -366,7 +366,7 @@ Setting to 0 prevents the preallocation and sets the maximum to {}, leading to 0 .long("ram-scale") .require_equals(true) .value_parser(clap::value_parser!(f64)) - .help("Apply a scale factor to memory allocation bounds. Nodes with limited RAM (~4-8GB) should set this to ~0.3-0.5 respectively. Nodes with + .help("Apply a scale factor to memory allocation bounds. Nodes with limited RAM (~4-8GB) should set this to ~0.3-0.5 respectively. Nodes with a large RAM (~64GB) can set this value to ~3.0-4.0 and gain superior performance especially for syncing peers faster"), ) ; diff --git a/rpc/wrpc/server/src/address.rs b/rpc/wrpc/server/src/address.rs index 7dac4d75d..81ccabe5d 100644 --- a/rpc/wrpc/server/src/address.rs +++ b/rpc/wrpc/server/src/address.rs @@ -29,7 +29,17 @@ impl WrpcNetAddress { }; format!("0.0.0.0:{port}").parse().unwrap() } - WrpcNetAddress::Custom(address) => *address, + WrpcNetAddress::Custom(address) => { + if address.port_not_specified() { + let port = match encoding { + WrpcEncoding::Borsh => network_type.default_borsh_rpc_port(), + WrpcEncoding::SerdeJson => network_type.default_json_rpc_port(), + }; + address.with_port(port) + } else { + *address + } + } } } } @@ -63,3 +73,31 @@ impl TryFrom for WrpcNetAddress { WrpcNetAddress::from_str(&s) } } + +#[cfg(test)] +mod tests { + use super::*; + use kaspa_utils::networking::IpAddress; + + #[test] + fn test_wrpc_net_address_from_str() { + // Addresses + let port: u16 = 8080; + let addr = format!("1.2.3.4:{port}").parse::().unwrap(); + let addr_without_port = "1.2.3.4".parse::().unwrap(); + let ip_addr = "1.2.3.4".parse::().unwrap(); + // Test + for schema in WrpcEncoding::iter() { + for network in NetworkType::iter() { + let expected_port = match schema { + WrpcEncoding::Borsh => Some(network.default_borsh_rpc_port()), + WrpcEncoding::SerdeJson => Some(network.default_json_rpc_port()), + }; + // Custom address with port + assert_eq!(addr.to_address(&network, schema), ContextualNetAddress::new(ip_addr, Some(port))); + // Custom address without port + assert_eq!(addr_without_port.to_address(&network, schema), ContextualNetAddress::new(ip_addr, expected_port)) + } + } + } +} diff --git a/utils/src/lib.rs b/utils/src/lib.rs index 956e3a2b9..bd3143719 100644 --- a/utils/src/lib.rs +++ b/utils/src/lib.rs @@ -67,6 +67,7 @@ pub mod as_slice; /// assert_eq!(test_struct, from_json); /// ``` pub mod serde_bytes; +pub mod serde_bytes_optional; /// # Examples /// diff --git a/utils/src/networking.rs b/utils/src/networking.rs index bb38b4d04..ebd72b259 100644 --- a/utils/src/networking.rs +++ b/utils/src/networking.rs @@ -34,7 +34,7 @@ const TS_IP_ADDRESS: &'static str = r#" /// A bucket based on an ip's prefix bytes. /// for ipv4 it consists of 6 leading zero bytes, and the first two octets, /// for ipv6 it consists of the first 8 octets, -/// encoded into a big endian u64. +/// encoded into a big endian u64. #[derive(PartialEq, Eq, Hash, Copy, Clone, Debug)] pub struct PrefixBucket(u64); @@ -271,7 +271,7 @@ pub struct ContextualNetAddress { } impl ContextualNetAddress { - fn new(ip: IpAddress, port: Option) -> Self { + pub fn new(ip: IpAddress, port: Option) -> Self { Self { ip, port } } @@ -286,6 +286,14 @@ impl ContextualNetAddress { pub fn loopback() -> Self { Self { ip: IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)).into(), port: None } } + + pub fn port_not_specified(&self) -> bool { + self.port.is_none() + } + + pub fn with_port(&self, port: u16) -> Self { + Self { ip: self.ip, port: Some(port) } + } } impl From for ContextualNetAddress { diff --git a/utils/src/serde_bytes_optional.rs b/utils/src/serde_bytes_optional.rs new file mode 100644 index 000000000..308737667 --- /dev/null +++ b/utils/src/serde_bytes_optional.rs @@ -0,0 +1,111 @@ +pub use de::Deserialize; +pub use ser::Serialize; + +pub fn serialize(bytes: &T, serializer: S) -> Result +where + T: ?Sized + Serialize, + S: serde::Serializer, +{ + Serialize::serialize(bytes, serializer) +} + +pub fn deserialize<'de, T, D>(deserializer: D) -> Result +where + T: Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + Deserialize::deserialize(deserializer) +} + +mod de { + use std::fmt::Display; + + pub trait Deserialize<'de>: Sized { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>; + } + + impl<'de, T: crate::serde_bytes::Deserialize<'de>> Deserialize<'de> for Option + where + >::Error: Display, + { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct OptionalVisitor { + out: std::marker::PhantomData, + } + + impl<'de, T> serde::de::Visitor<'de> for OptionalVisitor + where + T: crate::serde_bytes::Deserialize<'de>, + { + type Value = Option; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("optional string, str or slice, vec of bytes") + } + + fn visit_unit(self) -> Result { + Ok(None) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + T::deserialize(deserializer).map(Some) + } + } + + let visitor = OptionalVisitor { out: std::marker::PhantomData }; + deserializer.deserialize_option(visitor) + } + } +} + +mod ser { + use serde::Serializer; + + pub trait Serialize { + #[allow(missing_docs)] + fn serialize(&self, serializer: S) -> Result + where + S: Serializer; + } + + impl Serialize for Option + where + T: crate::serde_bytes::Serialize + std::convert::AsRef<[u8]>, + { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + struct AsBytes(T); + + impl serde::Serialize for AsBytes + where + T: crate::serde_bytes::Serialize, + { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + crate::serde_bytes::Serialize::serialize(&self.0, serializer) + } + } + + match self { + Some(b) => serializer.serialize_some(&AsBytes(b)), + None => serializer.serialize_none(), + } + } + } +} diff --git a/wallet/bip32/src/derivation_path.rs b/wallet/bip32/src/derivation_path.rs index 414bf2bf9..6ef47703c 100644 --- a/wallet/bip32/src/derivation_path.rs +++ b/wallet/bip32/src/derivation_path.rs @@ -6,6 +6,7 @@ use core::{ fmt::{self, Display}, str::FromStr, }; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; /// Prefix for all derivation paths. const PREFIX: &str = "m"; @@ -16,6 +17,45 @@ pub struct DerivationPath { path: Vec, } +impl<'de> Deserialize<'de> for DerivationPath { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + struct DerivationPathVisitor; + impl<'de> de::Visitor<'de> for DerivationPathVisitor { + type Value = DerivationPath; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string containing list of permissions separated by a '+'") + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: de::Error, + { + DerivationPath::from_str(value).map_err(|err| de::Error::custom(err.to_string())) + } + fn visit_borrowed_str(self, v: &'de str) -> std::result::Result + where + E: de::Error, + { + DerivationPath::from_str(v).map_err(|err| de::Error::custom(err.to_string())) + } + } + + deserializer.deserialize_str(DerivationPathVisitor) + } +} + +impl Serialize for DerivationPath { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + impl DerivationPath { /// Iterate over the [`ChildNumber`] values in this derivation path. pub fn iter(&self) -> impl Iterator + '_ { diff --git a/wallet/pskt/Cargo.toml b/wallet/pskt/Cargo.toml new file mode 100644 index 000000000..f2d82cf07 --- /dev/null +++ b/wallet/pskt/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "kaspa-wallet-pskt" +keywords = ["kaspa", "wallet", "pskt", "psbt", "bip-370"] +description = "Partially Signed Kaspa Transaction" +categories = ["cryptography::cryptocurrencies"] +rust-version.workspace = true +version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +edition.workspace = true +include.workspace = true + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +wasm32-sdk = ["kaspa-consensus-client/wasm32-sdk"] +wasm32-types = ["kaspa-consensus-client/wasm32-types"] + +[dependencies] +kaspa-bip32.workspace = true +kaspa-consensus-client.workspace = true +kaspa-consensus-core.workspace = true +kaspa-txscript-errors.workspace = true +kaspa-txscript.workspace = true +kaspa-utils.workspace = true + +derive_builder.workspace = true +secp256k1.workspace = true +serde-value.workspace = true +serde.workspace = true +serde_repr.workspace = true +thiserror.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/wallet/pskt/examples/multisig.rs b/wallet/pskt/examples/multisig.rs new file mode 100644 index 000000000..a34bef9b5 --- /dev/null +++ b/wallet/pskt/examples/multisig.rs @@ -0,0 +1,119 @@ +use kaspa_consensus_core::{ + hashing::sighash::{calc_schnorr_signature_hash, SigHashReusedValues}, + tx::{TransactionId, TransactionOutpoint, UtxoEntry}, +}; +use kaspa_txscript::{multisig_redeem_script, opcodes::codes::OpData65, pay_to_script_hash_script, script_builder::ScriptBuilder}; +use kaspa_wallet_pskt::{Combiner, Creator, Extractor, Finalizer, Inner, InputBuilder, SignInputOk, Signature, Signer, Updater, PSKT}; +use secp256k1::{rand::thread_rng, Keypair}; +use std::{iter, str::FromStr}; + +fn main() { + let kps = [Keypair::new(secp256k1::SECP256K1, &mut thread_rng()), Keypair::new(secp256k1::SECP256K1, &mut thread_rng())]; + let redeem_script = multisig_redeem_script(kps.iter().map(|pk| pk.x_only_public_key().0.serialize()), 2).unwrap(); + // Create the PSKT. + let created = PSKT::::default().inputs_modifiable().outputs_modifiable(); + let ser = serde_json::to_string_pretty(&created).expect("Failed to serialize after creation"); + println!("Serialized after creation: {}", ser); + + // The first constructor entity receives the PSKT and adds an input. + let pskt: PSKT = serde_json::from_str(&ser).expect("Failed to deserialize"); + // let in_0 = dummy_out_point(); + let input_0 = InputBuilder::default() + .utxo_entry(UtxoEntry { + amount: 12793000000000, + script_public_key: pay_to_script_hash_script(&redeem_script), + block_daa_score: 36151168, + is_coinbase: false, + }) + .previous_outpoint(TransactionOutpoint { + transaction_id: TransactionId::from_str("63020db736215f8b1105a9281f7bcbb6473d965ecc45bb2fb5da59bd35e6ff84").unwrap(), + index: 0, + }) + .sig_op_count(2) + .redeem_script(redeem_script) + .build() + .unwrap(); + let pskt_in0 = pskt.constructor().input(input_0); + let ser_in_0 = serde_json::to_string_pretty(&pskt_in0).expect("Failed to serialize after adding first input"); + println!("Serialized after adding first input: {}", ser_in_0); + + let combiner_pskt: PSKT = serde_json::from_str(&ser).expect("Failed to deserialize"); + let combined_pskt = (combiner_pskt + pskt_in0).unwrap(); + let ser_combined = serde_json::to_string_pretty(&combined_pskt).expect("Failed to serialize after adding output"); + println!("Serialized after combining: {}", ser_combined); + + // The PSKT is now ready for handling with the updater role. + let updater_pskt: PSKT = serde_json::from_str(&ser_combined).expect("Failed to deserialize"); + let updater_pskt = updater_pskt.set_sequence(u64::MAX, 0).expect("Failed to set sequence"); + let ser_updated = serde_json::to_string_pretty(&updater_pskt).expect("Failed to serialize after setting sequence"); + println!("Serialized after setting sequence: {}", ser_updated); + + let signer_pskt: PSKT = serde_json::from_str(&ser_updated).expect("Failed to deserialize"); + let mut reused_values = SigHashReusedValues::new(); + let mut sign = |signer_pskt: PSKT, kp: &Keypair| { + signer_pskt + .pass_signature_sync(|tx, sighash| -> Result, String> { + let tx = dbg!(tx); + tx.tx + .inputs + .iter() + .enumerate() + .map(|(idx, _input)| { + let hash = calc_schnorr_signature_hash(&tx.as_verifiable(), idx, sighash[idx], &mut reused_values); + let msg = secp256k1::Message::from_digest_slice(hash.as_bytes().as_slice()).unwrap(); + Ok(SignInputOk { + signature: Signature::Schnorr(kp.sign_schnorr(msg)), + pub_key: kp.public_key(), + key_source: None, + }) + }) + .collect() + }) + .unwrap() + }; + let signed_0 = sign(signer_pskt.clone(), &kps[0]); + let signed_1 = sign(signer_pskt, &kps[1]); + let combiner_pskt: PSKT = serde_json::from_str(&ser_updated).expect("Failed to deserialize"); + let combined_signed = (combiner_pskt + signed_0).and_then(|combined| combined + signed_1).unwrap(); + let ser_combined_signed = serde_json::to_string_pretty(&combined_signed).expect("Failed to serialize after combining signed"); + println!("Combined Signed: {}", ser_combined_signed); + let pskt_finalizer: PSKT = serde_json::from_str(&ser_combined_signed).expect("Failed to deserialize"); + let pskt_finalizer = pskt_finalizer + .finalize_sync(|inner: &Inner| -> Result>, String> { + Ok(inner + .inputs + .iter() + .map(|input| -> Vec { + // todo actually required count can be retrieved from redeem_script, sigs can be taken from partial sigs according to required count + // considering xpubs sorted order + + let signatures: Vec<_> = kps + .iter() + .flat_map(|kp| { + let sig = input.partial_sigs.get(&kp.public_key()).unwrap().into_bytes(); + iter::once(OpData65).chain(sig).chain([input.sighash_type.to_u8()]) + }) + .collect(); + signatures + .into_iter() + .chain( + ScriptBuilder::new() + .add_data(input.redeem_script.as_ref().unwrap().as_slice()) + .unwrap() + .drain() + .iter() + .cloned(), + ) + .collect() + }) + .collect()) + }) + .unwrap(); + let ser_finalized = serde_json::to_string_pretty(&pskt_finalizer).expect("Failed to serialize after finalizing"); + println!("Finalized: {}", ser_finalized); + + let extractor_pskt: PSKT = serde_json::from_str(&ser_finalized).expect("Failed to deserialize"); + let tx = extractor_pskt.extract_tx().unwrap()(10).0; + let ser_tx = serde_json::to_string_pretty(&tx).unwrap(); + println!("Tx: {}", ser_tx); +} diff --git a/wallet/pskt/src/error.rs b/wallet/pskt/src/error.rs new file mode 100644 index 000000000..504119086 --- /dev/null +++ b/wallet/pskt/src/error.rs @@ -0,0 +1,15 @@ +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + ConstructorError(#[from] ConstructorError), + #[error("OutputNotModifiable")] + OutOfBounds, +} + +#[derive(thiserror::Error, Debug)] +pub enum ConstructorError { + #[error("InputNotModifiable")] + InputNotModifiable, + #[error("OutputNotModifiable")] + OutputNotModifiable, +} diff --git a/wallet/pskt/src/global.rs b/wallet/pskt/src/global.rs new file mode 100644 index 000000000..8e16b832b --- /dev/null +++ b/wallet/pskt/src/global.rs @@ -0,0 +1,165 @@ +use crate::{utils::combine_if_no_conflicts, KeySource, Version}; +use derive_builder::Builder; +use kaspa_consensus_core::tx::TransactionId; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{btree_map, BTreeMap}, + ops::Add, +}; + +type Xpub = kaspa_bip32::ExtendedPublicKey; + +#[derive(Debug, Clone, Builder, Serialize, Deserialize)] +#[builder(default)] +pub struct Global { + /// The version number of this PSKT. + pub version: Version, + /// The version number of the transaction being built. + pub tx_version: u16, + #[builder(setter(strip_option))] + /// The transaction locktime to use if no inputs specify a required locktime. + pub fallback_lock_time: Option, + + pub inputs_modifiable: bool, + pub outputs_modifiable: bool, + + /// The number of inputs in this PSKT. + pub input_count: usize, + /// The number of outputs in this PSKT. + pub output_count: usize, + /// A map from xpub to the used key fingerprint and derivation path as defined by BIP 32. + pub xpubs: BTreeMap, + pub id: Option, + /// Proprietary key-value pairs for this output. + pub proprietaries: BTreeMap, + /// Unknown key-value pairs for this output. + pub unknowns: BTreeMap, +} + +impl Add for Global { + type Output = Result; + + fn add(mut self, rhs: Self) -> Self::Output { + if self.version != rhs.version { + return Err(CombineError::VersionMismatch { this: self.version, that: rhs.version }); + } + if self.tx_version != rhs.tx_version { + return Err(CombineError::TxVersionMismatch { this: self.tx_version, that: rhs.tx_version }); + } + self.fallback_lock_time = match (self.fallback_lock_time, rhs.fallback_lock_time) { + (Some(lhs), Some(rhs)) if lhs != rhs => return Err(CombineError::LockTimeMismatch { this: lhs, that: rhs }), + (Some(v), _) | (_, Some(v)) => Some(v), + _ => None, + }; + // todo discussable, maybe throw error + self.inputs_modifiable &= rhs.inputs_modifiable; + self.outputs_modifiable &= rhs.outputs_modifiable; + self.input_count = self.input_count.max(rhs.input_count); + self.output_count = self.output_count.max(rhs.output_count); + // BIP 174: The Combiner must remove any duplicate key-value pairs, in accordance with + // the specification. It can pick arbitrarily when conflicts occur. + + // Merging xpubs + for (xpub, KeySource { key_fingerprint: fingerprint1, derivation_path: derivation1 }) in rhs.xpubs { + match self.xpubs.entry(xpub) { + btree_map::Entry::Vacant(entry) => { + entry.insert(KeySource::new(fingerprint1, derivation1)); + } + btree_map::Entry::Occupied(mut entry) => { + // Here in case of the conflict we select the version with algorithm: + // 1) if everything is equal we do nothing + // 2) report an error if + // - derivation paths are equal and fingerprints are not + // - derivation paths are of the same length, but not equal + // - derivation paths has different length, but the shorter one + // is not the strict suffix of the longer one + // 3) choose longest derivation otherwise + + let KeySource { key_fingerprint: fingerprint2, derivation_path: derivation2 } = entry.get().clone(); + + if (derivation1 == derivation2 && fingerprint1 == fingerprint2) + || (derivation1.len() < derivation2.len() + && derivation1.as_ref() == &derivation2.as_ref()[derivation2.len() - derivation1.len()..]) + { + continue; + } else if derivation2.as_ref() == &derivation1.as_ref()[derivation1.len() - derivation2.len()..] { + entry.insert(KeySource::new(fingerprint1, derivation1)); + continue; + } + return Err(CombineError::InconsistentKeySources(entry.key().clone())); + } + } + } + self.id = match (self.id, rhs.id) { + (Some(lhs), Some(rhs)) if lhs != rhs => return Err(CombineError::TransactionIdMismatch { this: lhs, that: rhs }), + (Some(v), _) | (_, Some(v)) => Some(v), + _ => None, + }; + + self.proprietaries = + combine_if_no_conflicts(self.proprietaries, rhs.proprietaries).map_err(CombineError::NotCompatibleProprietary)?; + self.unknowns = combine_if_no_conflicts(self.unknowns, rhs.unknowns).map_err(CombineError::NotCompatibleUnknownField)?; + Ok(self) + } +} + +impl Default for Global { + fn default() -> Self { + Global { + version: Version::Zero, + tx_version: kaspa_consensus_core::constants::TX_VERSION, + fallback_lock_time: None, + inputs_modifiable: false, + outputs_modifiable: false, + input_count: 0, + output_count: 0, + xpubs: Default::default(), + id: None, + proprietaries: Default::default(), + unknowns: Default::default(), + } + } +} + +/// Error combining two global maps. +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +pub enum CombineError { + #[error("The version numbers are not the same")] + /// The version numbers are not the same. + VersionMismatch { + /// Attempted to combine a PSKT with `this` version. + this: Version, + /// Into a PSKT with `that` version. + that: Version, + }, + #[error("The transaction version numbers are not the same")] + TxVersionMismatch { + /// Attempted to combine a PSKT with `this` tx version. + this: u16, + /// Into a PSKT with `that` tx version. + that: u16, + }, + #[error("The transaction lock times are not the same")] + LockTimeMismatch { + /// Attempted to combine a PSKT with `this` lock times. + this: u64, + /// Into a PSKT with `that` lock times. + that: u64, + }, + #[error("The transaction ids are not the same")] + TransactionIdMismatch { + /// Attempted to combine a PSKT with `this` tx id. + this: TransactionId, + /// Into a PSKT with `that` tx id. + that: TransactionId, + }, + + #[error("combining PSKT, key-source conflict for xpub {0}")] + /// Xpubs have inconsistent key sources. + InconsistentKeySources(Xpub), + + #[error("Two different unknown field values")] + NotCompatibleUnknownField(crate::utils::Error), + #[error("Two different proprietary values")] + NotCompatibleProprietary(crate::utils::Error), +} diff --git a/wallet/pskt/src/input.rs b/wallet/pskt/src/input.rs new file mode 100644 index 000000000..4c25600a1 --- /dev/null +++ b/wallet/pskt/src/input.rs @@ -0,0 +1,167 @@ +use crate::{ + utils::{combine_if_no_conflicts, Error as CombineMapErr}, + KeySource, PartialSigs, +}; +use derive_builder::Builder; +use kaspa_consensus_core::{ + hashing::sighash_type::{SigHashType, SIG_HASH_ALL}, + tx::{TransactionId, TransactionOutpoint, UtxoEntry}, +}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, marker::PhantomData, ops::Add}; + +// todo add unknown field? combine them by deduplicating, if there are different values - return error? +#[derive(Builder, Serialize, Deserialize, Debug, Clone)] +#[builder(default)] +#[builder(setter(skip))] +pub struct Input { + #[builder(setter(strip_option))] + pub utxo_entry: Option, + #[builder(setter)] + pub previous_outpoint: TransactionOutpoint, + /// The sequence number of this input. + /// + /// If omitted, assumed to be the final sequence number + pub sequence: Option, + #[builder(setter)] + /// The minimum Unix timestamp that this input requires to be set as the transaction's lock time. + pub min_time: Option, + /// A map from public keys to their corresponding signature as would be + /// pushed to the stack from a scriptSig. + pub partial_sigs: PartialSigs, + #[builder(setter)] + /// The sighash type to be used for this input. Signatures for this input + /// must use the sighash type. + pub sighash_type: SigHashType, + #[serde(with = "kaspa_utils::serde_bytes_optional")] + #[builder(setter(strip_option))] + /// The redeem script for this input. + pub redeem_script: Option>, + #[builder(setter(strip_option))] + pub sig_op_count: Option, + /// A map from public keys needed to sign this input to their corresponding + /// master key fingerprints and derivation paths. + pub bip32_derivations: BTreeMap>, + #[serde(with = "kaspa_utils::serde_bytes_optional")] + /// The finalized, fully-constructed scriptSig with signatures and any other + /// scripts necessary for this input to pass validation. + pub final_script_sig: Option>, + #[serde(skip_serializing, default)] + hidden: PhantomData<()>, // prevents manual filling of fields + #[builder(setter)] + /// Proprietary key-value pairs for this output. + pub proprietaries: BTreeMap, + #[serde(flatten)] + #[builder(setter)] + /// Unknown key-value pairs for this output. + pub unknowns: BTreeMap, +} + +impl Default for Input { + fn default() -> Self { + Self { + utxo_entry: Default::default(), + previous_outpoint: Default::default(), + sequence: Default::default(), + min_time: Default::default(), + partial_sigs: Default::default(), + sighash_type: SIG_HASH_ALL, + redeem_script: Default::default(), + sig_op_count: Default::default(), + bip32_derivations: Default::default(), + final_script_sig: Default::default(), + hidden: Default::default(), + proprietaries: Default::default(), + unknowns: Default::default(), + } + } +} + +impl Add for Input { + type Output = Result; + + fn add(mut self, rhs: Self) -> Self::Output { + if self.previous_outpoint.transaction_id != rhs.previous_outpoint.transaction_id { + return Err(CombineError::PreviousTxidMismatch { + this: self.previous_outpoint.transaction_id, + that: rhs.previous_outpoint.transaction_id, + }); + } + + if self.previous_outpoint.index != rhs.previous_outpoint.index { + return Err(CombineError::SpentOutputIndexMismatch { + this: self.previous_outpoint.index, + that: rhs.previous_outpoint.index, + }); + } + self.utxo_entry = match (self.utxo_entry.take(), rhs.utxo_entry) { + (None, None) => None, + (Some(utxo), None) | (None, Some(utxo)) => Some(utxo), + (Some(left), Some(right)) if left == right => Some(left), + (Some(left), Some(right)) => return Err(CombineError::NotCompatibleUtxos { this: left, that: right }), + }; + + // todo discuss merging. if sequence is equal - combine, otherwise use input which has bigger sequence number as is + self.sequence = self.sequence.max(rhs.sequence); + self.min_time = self.min_time.max(rhs.min_time); + self.partial_sigs.extend(rhs.partial_sigs); + // todo combine sighash? or always use sighash all since all signatures must be passed after completion of construction step + // self.sighash_type + + self.redeem_script = match (self.redeem_script.take(), rhs.redeem_script) { + (None, None) => None, + (Some(script), None) | (None, Some(script)) => Some(script), + (Some(script_left), Some(script_right)) if script_left == script_right => Some(script_left), + (Some(script_left), Some(script_right)) => { + return Err(CombineError::NotCompatibleRedeemScripts { this: script_left, that: script_right }) + } + }; + + // todo Does Combiner allowed to change final script sig?? + self.final_script_sig = match (self.final_script_sig.take(), rhs.final_script_sig) { + (None, None) => None, + (Some(script), None) | (None, Some(script)) => Some(script), + (Some(script_left), Some(script_right)) if script_left == script_right => Some(script_left), + (Some(script_left), Some(script_right)) => { + return Err(CombineError::NotCompatibleRedeemScripts { this: script_left, that: script_right }) + } + }; + + self.bip32_derivations = combine_if_no_conflicts(self.bip32_derivations, rhs.bip32_derivations)?; + self.proprietaries = + combine_if_no_conflicts(self.proprietaries, rhs.proprietaries).map_err(CombineError::NotCompatibleProprietary)?; + self.unknowns = combine_if_no_conflicts(self.unknowns, rhs.unknowns).map_err(CombineError::NotCompatibleUnknownField)?; + + Ok(self) + } +} + +/// Error combining two input maps. +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +pub enum CombineError { + #[error("The previous txids are not the same")] + PreviousTxidMismatch { + /// Attempted to combine a PSKT with `this` previous txid. + this: TransactionId, + /// Into a PSKT with `that` previous txid. + that: TransactionId, + }, + #[error("The spent output indexes are not the same")] + SpentOutputIndexMismatch { + /// Attempted to combine a PSKT with `this` spent output index. + this: u32, + /// Into a PSKT with `that` spent output index. + that: u32, + }, + #[error("Two different redeem scripts detected")] + NotCompatibleRedeemScripts { this: Vec, that: Vec }, + #[error("Two different utxos detected")] + NotCompatibleUtxos { this: UtxoEntry, that: UtxoEntry }, + + #[error("Two different derivations for the same key")] + NotCompatibleBip32Derivations(#[from] CombineMapErr>), + #[error("Two different unknown field values")] + NotCompatibleUnknownField(CombineMapErr), + #[error("Two different proprietary values")] + NotCompatibleProprietary(CombineMapErr), +} diff --git a/wallet/pskt/src/lib.rs b/wallet/pskt/src/lib.rs new file mode 100644 index 000000000..e26d5c9ea --- /dev/null +++ b/wallet/pskt/src/lib.rs @@ -0,0 +1,458 @@ +use kaspa_bip32::{secp256k1, DerivationPath, KeyFingerprint}; +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use std::{collections::BTreeMap, fmt::Display, fmt::Formatter, future::Future, marker::PhantomData, ops::Deref}; + +mod error; +mod global; +mod input; + +mod output; + +mod role; +mod utils; + +pub use error::Error; +pub use global::{Global, GlobalBuilder}; +pub use input::{Input, InputBuilder}; +use kaspa_consensus_core::tx::UtxoEntry; +use kaspa_consensus_core::{ + hashing::{sighash::SigHashReusedValues, sighash_type::SigHashType}, + subnets::SUBNETWORK_ID_NATIVE, + tx::{MutableTransaction, SignableTransaction, Transaction, TransactionId, TransactionInput, TransactionOutput}, +}; +use kaspa_txscript::{caches::Cache, TxScriptEngine}; +pub use output::{Output, OutputBuilder}; +pub use role::{Combiner, Constructor, Creator, Extractor, Finalizer, Signer, Updater}; + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct Inner { + /// The global map. + pub global: Global, + /// The corresponding key-value map for each input in the unsigned transaction. + pub inputs: Vec, + /// The corresponding key-value map for each output in the unsigned transaction. + pub outputs: Vec, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize_repr, Deserialize_repr)] +#[repr(u8)] +pub enum Version { + #[default] + Zero = 0, +} + +impl Display for Version { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Version::Zero => write!(f, "{}", Version::Zero as u8), + } + } +} + +/// Full information on the used extended public key: fingerprint of the +/// master extended public key and a derivation path from it. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct KeySource { + #[serde(with = "kaspa_utils::serde_bytes_fixed")] + pub key_fingerprint: KeyFingerprint, + pub derivation_path: DerivationPath, +} + +impl KeySource { + pub fn new(key_fingerprint: KeyFingerprint, derivation_path: DerivationPath) -> Self { + Self { key_fingerprint, derivation_path } + } +} + +pub type PartialSigs = BTreeMap; + +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] +pub enum Signature { + ECDSA(secp256k1::ecdsa::Signature), + Schnorr(secp256k1::schnorr::Signature), +} + +impl Signature { + pub fn into_bytes(self) -> [u8; 64] { + match self { + Signature::ECDSA(s) => s.serialize_compact(), + Signature::Schnorr(s) => s.serialize(), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PSKT { + #[serde(flatten)] + inner_pskt: Inner, + #[serde(skip_serializing, default)] + role: PhantomData, +} + +impl Clone for PSKT { + fn clone(&self) -> Self { + PSKT { inner_pskt: self.inner_pskt.clone(), role: Default::default() } + } +} + +impl Deref for PSKT { + type Target = Inner; + + fn deref(&self) -> &Self::Target { + &self.inner_pskt + } +} + +impl PSKT { + fn unsigned_tx(&self) -> SignableTransaction { + let tx = Transaction::new( + self.global.tx_version, + self.inputs + .iter() + .map(|Input { previous_outpoint, sequence, sig_op_count, .. }| TransactionInput { + previous_outpoint: *previous_outpoint, + signature_script: vec![], + sequence: sequence.unwrap_or(u64::MAX), + sig_op_count: sig_op_count.unwrap_or(0), + }) + .collect(), + self.outputs + .iter() + .map(|Output { amount, script_public_key, .. }: &Output| TransactionOutput { + value: *amount, + script_public_key: script_public_key.clone(), + }) + .collect(), + self.determine_lock_time(), + SUBNETWORK_ID_NATIVE, + 0, + vec![], + ); + let entries = self.inputs.iter().filter_map(|Input { utxo_entry, .. }| utxo_entry.clone()).collect(); + SignableTransaction::with_entries(tx, entries) + } + + fn calculate_id_internal(&self) -> TransactionId { + self.unsigned_tx().tx.id() + } + + fn determine_lock_time(&self) -> u64 { + self.inputs.iter().map(|input: &Input| input.min_time).max().unwrap_or(self.global.fallback_lock_time).unwrap_or(0) + } +} + +impl Default for PSKT { + fn default() -> Self { + PSKT { inner_pskt: Default::default(), role: Default::default() } + } +} + +impl PSKT { + /// Sets the fallback lock time. + pub fn fallback_lock_time(mut self, fallback: u64) -> Self { + self.inner_pskt.global.fallback_lock_time = Some(fallback); + self + } + + // todo generic const + /// Sets the inputs modifiable bit in the transaction modifiable flags. + pub fn inputs_modifiable(mut self) -> Self { + self.inner_pskt.global.inputs_modifiable = true; + self + } + // todo generic const + /// Sets the outputs modifiable bit in the transaction modifiable flags. + pub fn outputs_modifiable(mut self) -> Self { + self.inner_pskt.global.outputs_modifiable = true; + self + } + + pub fn constructor(self) -> PSKT { + PSKT { inner_pskt: self.inner_pskt, role: Default::default() } + } +} + +impl PSKT { + // todo generic const + /// Marks that the `PSKT` can not have any more inputs added to it. + pub fn no_more_inputs(mut self) -> Self { + self.inner_pskt.global.inputs_modifiable = false; + self + } + // todo generic const + /// Marks that the `PSKT` can not have any more outputs added to it. + pub fn no_more_outputs(mut self) -> Self { + self.inner_pskt.global.outputs_modifiable = false; + self + } + + /// Adds an input to the PSKT. + pub fn input(mut self, input: Input) -> Self { + self.inner_pskt.inputs.push(input); + self.inner_pskt.global.input_count += 1; + self + } + + /// Adds an output to the PSKT. + pub fn output(mut self, output: Output) -> Self { + self.inner_pskt.outputs.push(output); + self.inner_pskt.global.output_count += 1; + self + } + + /// Returns a PSKT [`Updater`] once construction is completed. + pub fn updater(self) -> PSKT { + let pskt = self.no_more_inputs().no_more_outputs(); + PSKT { inner_pskt: pskt.inner_pskt, role: Default::default() } + } + + pub fn signer(self) -> PSKT { + self.updater().signer() + } + + pub fn combiner(self) -> PSKT { + PSKT { inner_pskt: self.inner_pskt, role: Default::default() } + } +} + +impl PSKT { + pub fn set_sequence(mut self, n: u64, input_index: usize) -> Result { + self.inner_pskt.inputs.get_mut(input_index).ok_or(Error::OutOfBounds)?.sequence = Some(n); + Ok(self) + } + + pub fn signer(self) -> PSKT { + PSKT { inner_pskt: self.inner_pskt, role: Default::default() } + } + + pub fn combiner(self) -> PSKT { + PSKT { inner_pskt: self.inner_pskt, role: Default::default() } + } +} + +impl PSKT { + // todo use iterator instead of vector + pub fn pass_signature_sync(mut self, sign_fn: SignFn) -> Result + where + E: Display, + SignFn: FnOnce(SignableTransaction, Vec) -> Result, E>, + { + let unsigned_tx = self.unsigned_tx(); + let sighashes = self.inputs.iter().map(|input| input.sighash_type).collect(); + self.inner_pskt.inputs.iter_mut().zip(sign_fn(unsigned_tx, sighashes)?).for_each( + |(input, SignInputOk { signature, pub_key, key_source })| { + input.bip32_derivations.insert(pub_key, key_source); + input.partial_sigs.insert(pub_key, signature); + }, + ); + + Ok(self) + } + // todo use iterator instead of vector + pub async fn pass_signature(mut self, sign_fn: SignFn) -> Result + where + E: Display, + Fut: Future, E>>, + SignFn: FnOnce(SignableTransaction, Vec) -> Fut, + { + let unsigned_tx = self.unsigned_tx(); + let sighashes = self.inputs.iter().map(|input| input.sighash_type).collect(); + self.inner_pskt.inputs.iter_mut().zip(sign_fn(unsigned_tx, sighashes).await?).for_each( + |(input, SignInputOk { signature, pub_key, key_source })| { + input.bip32_derivations.insert(pub_key, key_source); + input.partial_sigs.insert(pub_key, signature); + }, + ); + Ok(self) + } + + pub fn calculate_id(&self) -> TransactionId { + self.calculate_id_internal() + } + + pub fn finalizer(self) -> PSKT { + PSKT { inner_pskt: self.inner_pskt, role: Default::default() } + } + + pub fn combiner(self) -> PSKT { + PSKT { inner_pskt: self.inner_pskt, role: Default::default() } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignInputOk { + pub signature: Signature, + pub pub_key: secp256k1::PublicKey, + pub key_source: Option, +} + +impl std::ops::Add> for PSKT { + type Output = Result; + + fn add(mut self, mut rhs: PSKT) -> Self::Output { + self.inner_pskt.global = (self.inner_pskt.global + rhs.inner_pskt.global)?; + macro_rules! combine { + ($left:expr, $right:expr, $err: ty) => { + if $left.len() > $right.len() { + $left.iter_mut().zip($right.iter_mut()).try_for_each(|(left, right)| -> Result<(), $err> { + *left = (std::mem::take(left) + std::mem::take(right))?; + Ok(()) + })?; + $left + } else { + $right.iter_mut().zip($left.iter_mut()).try_for_each(|(left, right)| -> Result<(), $err> { + *left = (std::mem::take(left) + std::mem::take(right))?; + Ok(()) + })?; + $right + } + }; + } + // todo add sort to build deterministic combination + self.inner_pskt.inputs = combine!(self.inner_pskt.inputs, rhs.inner_pskt.inputs, input::CombineError); + self.inner_pskt.outputs = combine!(self.inner_pskt.outputs, rhs.inner_pskt.outputs, output::CombineError); + Ok(self) + } +} + +impl PSKT { + pub fn signer(self) -> PSKT { + PSKT { inner_pskt: self.inner_pskt, role: Default::default() } + } + pub fn finalizer(self) -> PSKT { + PSKT { inner_pskt: self.inner_pskt, role: Default::default() } + } +} + +impl PSKT { + pub fn finalize_sync( + self, + final_sig_fn: impl FnOnce(&Inner) -> Result>, E>, + ) -> Result> { + let sigs = final_sig_fn(&self); + self.finalize_internal(sigs) + } + + pub async fn finalize(self, final_sig_fn: F) -> Result> + where + E: Display, + F: FnOnce(&Inner) -> Fut, + Fut: Future>, E>>, + { + let sigs = final_sig_fn(&self).await; + self.finalize_internal(sigs) + } + + pub fn id(&self) -> Option { + self.global.id + } + + pub fn extractor(self) -> Result, TxNotFinalized> { + if self.global.id.is_none() { + Err(TxNotFinalized {}) + } else { + Ok(PSKT { inner_pskt: self.inner_pskt, role: Default::default() }) + } + } + + fn finalize_internal(mut self, sigs: Result>, E>) -> Result> { + let sigs = sigs?; + if sigs.len() != self.inputs.len() { + return Err(FinalizeError::WrongFinalizedSigsCount { expected: self.inputs.len(), actual: sigs.len() }); + } + self.inner_pskt.inputs.iter_mut().enumerate().zip(sigs).try_for_each(|((idx, input), sig)| { + if sig.is_empty() { + return Err(FinalizeError::EmptySignature(idx)); + } + input.sequence = Some(input.sequence.unwrap_or(u64::MAX)); // todo discussable + input.final_script_sig = Some(sig); + Ok(()) + })?; + self.inner_pskt.global.id = Some(self.calculate_id_internal()); + Ok(self) + } +} + +impl PSKT { + pub fn extract_tx_unchecked(self) -> Result (Transaction, Vec>), TxNotFinalized> { + let tx = self.unsigned_tx(); + let entries = tx.entries; + let mut tx = tx.tx; + tx.inputs.iter_mut().zip(self.inner_pskt.inputs).try_for_each(|(dest, src)| { + dest.signature_script = src.final_script_sig.ok_or(TxNotFinalized {})?; + Ok(()) + })?; + Ok(move |mass| { + tx.set_mass(mass); + (tx, entries) + }) + } + + pub fn extract_tx(self) -> Result (Transaction, Vec>), ExtractError> { + let (tx, entries) = self.extract_tx_unchecked()?(0); + + let tx = MutableTransaction::with_entries(tx, entries.into_iter().flatten().collect()); + use kaspa_consensus_core::tx::VerifiableTransaction; + { + let tx = tx.as_verifiable(); + let cache = Cache::new(10_000); + let mut reused_values = SigHashReusedValues::new(); + + tx.populated_inputs().enumerate().try_for_each(|(idx, (input, entry))| { + TxScriptEngine::from_transaction_input(&tx, input, idx, entry, &mut reused_values, &cache)?.execute()?; + >::Ok(()) + })?; + } + let entries = tx.entries; + let tx = tx.tx; + let closure = move |mass| { + tx.set_mass(mass); + (tx, entries) + }; + Ok(closure) + } +} + +/// Error combining pskt. +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +pub enum CombineError { + #[error(transparent)] + Global(#[from] global::CombineError), + #[error(transparent)] + Inputs(#[from] input::CombineError), + #[error(transparent)] + Outputs(#[from] output::CombineError), +} + +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +pub enum FinalizeError { + #[error("Signatures count mismatch")] + WrongFinalizedSigsCount { expected: usize, actual: usize }, + #[error("Signatures at index: {0} is empty")] + EmptySignature(usize), + #[error(transparent)] + FinalaziCb(#[from] E), +} + +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +pub enum ExtractError { + #[error(transparent)] + TxScriptError(#[from] kaspa_txscript_errors::TxScriptError), + #[error(transparent)] + TxNotFinalized(#[from] TxNotFinalized), +} + +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +#[error("Transaction is not finalized")] +pub struct TxNotFinalized {} + +#[cfg(test)] +mod tests { + + // #[test] + // fn it_works() { + // let result = add(2, 2); + // assert_eq!(result, 4); + // } +} diff --git a/wallet/pskt/src/output.rs b/wallet/pskt/src/output.rs new file mode 100644 index 000000000..952b63d3f --- /dev/null +++ b/wallet/pskt/src/output.rs @@ -0,0 +1,82 @@ +use crate::utils::combine_if_no_conflicts; +use crate::KeySource; +use derive_builder::Builder; +use kaspa_consensus_core::tx::ScriptPublicKey; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, ops::Add}; + +#[derive(Builder, Default, Serialize, Deserialize, Clone, Debug)] +#[builder(default)] +pub struct Output { + /// The output's amount (serialized as sompi). + pub amount: u64, + /// The script for this output, also known as the scriptPubKey. + pub script_public_key: ScriptPublicKey, + #[builder(setter(strip_option))] + #[serde(with = "kaspa_utils::serde_bytes_optional")] + /// The redeem script for this output. + pub redeem_script: Option>, + /// A map from public keys needed to spend this output to their + /// corresponding master key fingerprints and derivation paths. + pub bip32_derivations: BTreeMap>, + /// Proprietary key-value pairs for this output. + pub proprietaries: BTreeMap, + #[serde(flatten)] + /// Unknown key-value pairs for this output. + pub unknowns: BTreeMap, +} + +impl Add for Output { + type Output = Result; + + fn add(mut self, rhs: Self) -> Self::Output { + if self.amount != rhs.amount { + return Err(CombineError::AmountMismatch { this: self.amount, that: rhs.amount }); + } + if self.script_public_key != rhs.script_public_key { + return Err(CombineError::ScriptPubkeyMismatch { this: self.script_public_key, that: rhs.script_public_key }); + } + self.redeem_script = match (self.redeem_script.take(), rhs.redeem_script) { + (None, None) => None, + (Some(script), None) | (None, Some(script)) => Some(script), + (Some(script_left), Some(script_right)) if script_left == script_right => Some(script_left), + (Some(script_left), Some(script_right)) => { + return Err(CombineError::NotCompatibleRedeemScripts { this: script_left, that: script_right }) + } + }; + self.bip32_derivations = combine_if_no_conflicts(self.bip32_derivations, rhs.bip32_derivations)?; + self.proprietaries = + combine_if_no_conflicts(self.proprietaries, rhs.proprietaries).map_err(CombineError::NotCompatibleProprietary)?; + self.unknowns = combine_if_no_conflicts(self.unknowns, rhs.unknowns).map_err(CombineError::NotCompatibleUnknownField)?; + + Ok(self) + } +} + +/// Error combining two output maps. +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +pub enum CombineError { + #[error("The amounts are not the same")] + AmountMismatch { + /// Attempted to combine a PSKT with `this` previous txid. + this: u64, + /// Into a PSKT with `that` previous txid. + that: u64, + }, + #[error("The script_pubkeys are not the same")] + ScriptPubkeyMismatch { + /// Attempted to combine a PSKT with `this` script_pubkey. + this: ScriptPublicKey, + /// Into a PSKT with `that` script_pubkey. + that: ScriptPublicKey, + }, + #[error("Two different redeem scripts detected")] + NotCompatibleRedeemScripts { this: Vec, that: Vec }, + + #[error("Two different derivations for the same key")] + NotCompatibleBip32Derivations(#[from] crate::utils::Error>), + #[error("Two different unknown field values")] + NotCompatibleUnknownField(crate::utils::Error), + #[error("Two different proprietary values")] + NotCompatibleProprietary(crate::utils::Error), +} diff --git a/wallet/pskt/src/role.rs b/wallet/pskt/src/role.rs new file mode 100644 index 000000000..84f55bb04 --- /dev/null +++ b/wallet/pskt/src/role.rs @@ -0,0 +1,27 @@ +/// Initializes the PSKT with 0 inputs and 0 outputs. +/// Reference: [BIP-370: Creator](https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#creator) +pub enum Creator {} + +/// Adds inputs and outputs to the PSKT. +/// Reference: [BIP-370: Constructor](https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#constructor) +pub enum Constructor {} + +/// Can set the sequence number. +/// Reference: [BIP-370: Updater](https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#updater) +pub enum Updater {} + +/// Creates cryptographic signatures for the inputs using private keys. +/// Reference: [BIP-370: Signer](https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#signer) +pub enum Signer {} + +/// Merges multiple PSKTs into one. +/// Reference: [BIP-174: Combiner](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki#combiner) +pub enum Combiner {} + +/// Completes the PSKT, ensuring all inputs have valid signatures, and finalizes the transaction. +/// Reference: [BIP-174: Input Finalizer](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki#input-finalizer) +pub enum Finalizer {} + +/// Extracts the final transaction from the PSKT once all parts are in place and the PSKT is fully signed. +/// Reference: [BIP-370: Transaction Extractor](https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#transaction-extractor) +pub enum Extractor {} diff --git a/wallet/pskt/src/utils.rs b/wallet/pskt/src/utils.rs new file mode 100644 index 000000000..28b7959ed --- /dev/null +++ b/wallet/pskt/src/utils.rs @@ -0,0 +1,29 @@ +use std::collections::BTreeMap; + +// todo optimize without cloning +pub fn combine_if_no_conflicts(mut lhs: BTreeMap, rhs: BTreeMap) -> Result, Error> +where + V: Eq + Clone, + K: Ord + Clone, +{ + if lhs.len() > rhs.len() { + if let Some((field, rhs, lhs)) = + rhs.iter().map(|(k, v)| (k, v, lhs.get(k))).find(|(_, v, rhs_v)| rhs_v.is_some_and(|rv| rv != *v)) + { + Err(Error { field: field.clone(), lhs: lhs.unwrap().clone(), rhs: rhs.clone() }) + } else { + lhs.extend(rhs); + Ok(lhs) + } + } else { + combine_if_no_conflicts(rhs, lhs) + } +} + +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +#[error("Conflict")] +pub struct Error { + pub field: K, + pub lhs: V, + pub rhs: V, +}