diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 438ba8ef36..4b297b6a89 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -52,7 +52,7 @@ jobs: sudo apt -y autoclean sudo apt clean rm --recursive --force "$AGENT_TOOLSDIRECTORY" - df -h + df -h # remove large packages manually (all but llvm) sudo apt-get remove -y '^aspnetcore-.*' || echo "::warning::The command [sudo apt-get remove -y '^aspnetcore-.*'] failed to complete successfully. Proceeding..." @@ -65,7 +65,7 @@ jobs: sudo apt-get remove -y google-cloud-cli --fix-missing || echo "::debug::The command [sudo apt-get remove -y google-cloud-cli --fix-missing] failed to complete successfully. Proceeding..." sudo apt-get autoremove -y || echo "::warning::The command [sudo apt-get autoremove -y] failed to complete successfully. Proceeding..." sudo apt-get clean || echo "::warning::The command [sudo apt-get clean] failed to complete successfully. Proceeding..." - df -h + df -h # Free up disk space on Ubuntu - name: Free Disk Space (Ubuntu) @@ -95,10 +95,10 @@ jobs: - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable - + - name: Set up cache uses: Swatinem/rust-cache@v2 - + - name: Install cargo-nextest run: cargo install cargo-nextest @@ -133,7 +133,6 @@ jobs: - name: Run kip-10 example run: cargo run --example kip-10 - # test-release: # name: Test Suite Release # runs-on: ${{ matrix.os }} @@ -217,7 +216,6 @@ jobs: - name: Run cargo clippy run: cargo clippy --workspace --tests --benches --examples -- -D warnings - check-wasm32: name: Check WASM32 runs-on: ubuntu-latest @@ -281,6 +279,78 @@ jobs: - name: Run cargo check of kaspa-wasm for wasm32 target run: cargo clippy -p kaspa-wasm --target wasm32-unknown-unknown + test-wasm32: + name: Test WASM32 + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Install Protoc + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install llvm + id: install_llvm + continue-on-error: true + run: | + wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc + sudo apt-get install -y clang-15 lldb-15 lld-15 clangd-15 clang-tidy-15 clang-format-15 clang-tools-15 llvm-15-dev lld-15 lldb-15 llvm-15-tools libomp-15-dev libc++-15-dev libc++abi-15-dev libclang-common-15-dev libclang-15-dev libclang-cpp15-dev libunwind-15-dev + # Make Clang 15 default + sudo update-alternatives --install /usr/bin/clang++ clang++ /usr/lib/llvm-15/bin/clang++ 100 + sudo update-alternatives --install /usr/bin/clang clang /usr/lib/llvm-15/bin/clang 100 + sudo update-alternatives --install /usr/bin/clang-format clang-format /usr/lib/llvm-15/bin/clang-format 100 + sudo update-alternatives --install /usr/bin/clang-tidy clang-tidy /usr/lib/llvm-15/bin/clang-tidy 100 + sudo update-alternatives --install /usr/bin/run-clang-tidy run-clang-tidy /usr/lib/llvm-15/bin/run-clang-tidy 100 + # Alias cc to clang + sudo update-alternatives --install /usr/bin/cc cc /usr/lib/llvm-15/bin/clang 0 + sudo update-alternatives --install /usr/bin/c++ c++ /usr/lib/llvm-15/bin/clang++ 0 + + - name: Install gcc-multilib + # gcc-multilib allows clang to find gnu libraries properly + run: | + sudo apt-get update + sudo apt install -y gcc-multilib + + - name: Install stable toolchain + if: steps.install_llvm.outcome == 'success' && steps.install_llvm.conclusion == 'success' + uses: dtolnay/rust-toolchain@stable + + - name: Add wasm32 target + run: rustup target add wasm32-unknown-unknown + + - name: Install wasm-pack + run: cargo install wasm-pack + + - name: Cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + # test runner is Node.js + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + # append here any new crates that contains wasm test suites (and aren't already covered) + + - name: Run wasm tests for consensus/core + run: wasm-pack test --node consensus/core + + - name: Run wasm tests for crypto/addresses + run: wasm-pack test --node crypto/addresses + + - name: Run wasm tests for wallet/pskt + run: wasm-pack test --node wallet/pskt + build-wasm32: name: Build WASM32 SDK runs-on: ubuntu-latest @@ -332,7 +402,7 @@ jobs: - name: Install NodeJS uses: actions/setup-node@v4 with: - node-version: '20' + node-version: "20" - name: Install NodeJS dependencies run: npm install --global typedoc typescript @@ -355,9 +425,9 @@ jobs: bash build-release popd - - name: Upload WASM build to GitHub + - name: Upload WASM build to GitHub uses: actions/upload-artifact@v4 - with: + with: name: kaspa-wasm32-sdk-${{ env.SHORT_SHA }} path: wasm/release/ build-release: @@ -387,7 +457,7 @@ jobs: key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo- - + - name: Cache Toolchain uses: actions/cache@v4 with: @@ -397,7 +467,6 @@ jobs: restore-keys: | ${{ runner.os }}-musl- - - name: Build RK with musl toolchain if: runner.os == 'Linux' run: | diff --git a/Cargo.lock b/Cargo.lock index 9490d944e4..6268fe508a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3504,6 +3504,7 @@ dependencies = [ "kaspa-txscript", "kaspa-txscript-errors", "kaspa-utils", + "kaspa-wallet-keys", "secp256k1", "separator", "serde", diff --git a/wallet/pskt/Cargo.toml b/wallet/pskt/Cargo.toml index a7b8e56f12..549fdb5ffc 100644 --- a/wallet/pskt/Cargo.toml +++ b/wallet/pskt/Cargo.toml @@ -26,6 +26,7 @@ kaspa-consensus-core.workspace = true kaspa-txscript-errors.workspace = true kaspa-txscript.workspace = true kaspa-utils.workspace = true +kaspa-wallet-keys.workspace = true bincode.workspace = true derive_builder.workspace = true diff --git a/wallet/pskt/src/error.rs b/wallet/pskt/src/error.rs index 9f3a26535d..73f96ef42a 100644 --- a/wallet/pskt/src/error.rs +++ b/wallet/pskt/src/error.rs @@ -2,7 +2,7 @@ use kaspa_txscript_errors::TxScriptError; -use crate::input::InputBuilderError; +use crate::{input::InputBuilderError, pskt::ExtractError}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -10,6 +10,8 @@ pub enum Error { Custom(String), #[error(transparent)] ConstructorError(#[from] ConstructorError), + #[error(transparent)] + ExtractError(#[from] ExtractError), #[error("OutputNotModifiable")] OutOfBounds, #[error("Missing UTXO entry")] diff --git a/wallet/pskt/src/wasm/bundle.rs b/wallet/pskt/src/wasm/bundle.rs index 745c3451ec..9c2cf7a9a6 100644 --- a/wallet/pskt/src/wasm/bundle.rs +++ b/wallet/pskt/src/wasm/bundle.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::ops::Deref; use super::error::*; @@ -5,8 +6,11 @@ use super::result::*; use crate::bundle::Bundle as Inner; use crate::pskt::Inner as PSKTInner; use crate::wasm::pskt::*; +use crate::wasm::signer::PrivateKeyArrayT; use crate::wasm::utils::sompi_to_kaspa_string_with_suffix; -use kaspa_consensus_core::network::{NetworkId, NetworkIdT}; +use kaspa_addresses::Address; +use kaspa_consensus_client::Transaction; +use kaspa_consensus_core::network::{NetworkId, NetworkIdT, NetworkTypeT}; use wasm_bindgen::prelude::*; use workflow_wasm::convert::TryCastFromJs; @@ -50,6 +54,15 @@ impl PSKB { self.0 .0.len() } + #[wasm_bindgen(js_name = "get")] + pub fn get(&self, index: usize) -> Result { + let inner: &PSKTInner = self.0 .0.get(index).ok_or_else(|| Error::Custom(format!("Index out of bounds: {index}")))?; + + let inner_clone: PSKTInner = inner.clone(); + + Ok(PSKT::from(State::NoOp(Some(inner_clone)))) + } + pub fn add(&mut self, pskt: &PSKT) -> Result<()> { let payload = pskt.payload_getter(); @@ -97,6 +110,45 @@ impl PSKB { pub fn merge(&mut self, other: &PSKB) { self.0.merge(other.clone().0); } + + /// Extracts all unique input addresses from all PSKTs in the bundle. + /// This is useful for figuring out which private keys are required for signing. + #[wasm_bindgen] + pub fn addresses(&self, network_id: &NetworkIdT) -> Result> { + let mut addresses = HashSet::with_capacity(self.length()); + + for i in 0..self.length() { + let pskt = self.get(i)?; + let pskt_addresses = pskt.addresses(network_id)?; + addresses.extend(pskt_addresses); + } + + Ok(Vec::from_iter(addresses)) + } + + #[wasm_bindgen] + pub fn sign(&self, private_keys: PrivateKeyArrayT, network_type: &NetworkTypeT) -> Result { + let mut new_bundle_inner = Inner::new(); + for i in 0..self.length() { + let pskt_wasm = self.get(i)?; + let signed_pskt_wasm = pskt_wasm.sign(private_keys.clone(), network_type)?; + let inner_pskt = signed_pskt_wasm.inner()?; + new_bundle_inner.add_inner(inner_pskt); + } + + Ok(PSKB(new_bundle_inner)) + } + + #[wasm_bindgen(js_name = "finalizeP2PK")] + pub fn finalize_p2pk(&self, network_type: &NetworkTypeT) -> Result> { + let mut transactions = Vec::with_capacity(self.length()); + for i in 0..self.length() { + let pskt = self.get(i)?; + let transaction = pskt.finalize_p2pk(network_type)?; + transactions.push(transaction); + } + Ok(transactions) + } } impl From for PSKB { @@ -166,7 +218,8 @@ mod tests { "outputCount": 0, "xpubs": {}, "id": null, - "proprietaries": {} + "proprietaries": {}, + "payload": "", }, "inputs": [ { @@ -225,7 +278,7 @@ mod tests { #[wasm_bindgen_test] fn _test_deser() { - let pskb = "PSKB5b7b22676c6f62616c223a7b2276657273696f6e223a302c22747856657273696f6e223a302c2266616c6c6261636b4c6f636b54696d65223a6e756c6c2c22696e707574734d6f6469666961626c65223a66616c73652c226f7574707574734d6f6469666961626c65223a66616c73652c22696e707574436f756e74223a302c226f7574707574436f756e74223a302c227870756273223a7b7d2c226964223a6e756c6c2c2270726f70726965746172696573223a7b7d7d2c22696e70757473223a5b7b227574786f456e747279223a7b22616d6f756e74223a3436383932383838372c227363726970745075626c69634b6579223a22303030303230326438613134313465363265303831666236626366363434653634386331383036316332383535353735636163373232663836333234636164393164643066616163222c22626c6f636b44616153636f7265223a38343938313138362c226973436f696e62617365223a66616c73657d2c2270726576696f75734f7574706f696e74223a7b227472616e73616374696f6e4964223a2236393135356430653333383065383831366466666532363731323934616431303466306233373736663335626365316132326630633231623166393038353030222c22696e646578223a307d2c2273657175656e6365223a6e756c6c2c226d696e54696d65223a6e756c6c2c227061727469616c53696773223a7b7d2c227369676861736854797065223a312c2272656465656d536372697074223a6e756c6c2c227369674f70436f756e74223a312c22626970333244657269766174696f6e73223a7b7d2c2266696e616c536372697074536967223a6e756c6c2c2270726f70726965746172696573223a7b7d7d5d2c226f757470757473223a5b7b22616d6f756e74223a313530303030303030302c227363726970745075626c69634b6579223a2230303030222c2272656465656d536372697074223a6e756c6c2c22626970333244657269766174696f6e73223a7b7d2c2270726f70726965746172696573223a7b7d7d5d7d5d"; + let pskb = "PSKB5b7b22676c6f62616c223a7b2276657273696f6e223a302c22747856657273696f6e223a302c2266616c6c6261636b4c6f636b54696d65223a6e756c6c2c22696e707574734d6f6469666961626c65223a66616c73652c226f7574707574734d6f6469666961626c65223a66616c73652c22696e707574436f756e74223a302c226f7574707574436f756e74223a302c227870756273223a7b7d2c226964223a6e756c6c2c2270726f70726965746172696573223a7b7d2c227061796c6f6164223a22227d2c22696e70757473223a5b7b227574786f456e747279223a7b22616d6f756e74223a3436383932383838372c227363726970745075626c69634b6579223a22303030303230326438613134313465363265303831666236626366363434653634386331383036316332383535353735636163373232663836333234636164393164643066616163222c22626c6f636b44616153636f7265223a38343938313138362c226973436f696e62617365223a66616c73657d2c2270726576696f75734f7574706f696e74223a7b227472616e73616374696f6e4964223a2236393135356430653333383065383831366466666532363731323934616431303466306233373736663335626365316132326630633231623166393038353030222c22696e646578223a307d2c2273657175656e6365223a6e756c6c2c226d696e54696d65223a6e756c6c2c227061727469616c53696773223a7b7d2c227369676861736854797065223a312c2272656465656d536372697074223a6e756c6c2c227369674f70436f756e74223a312c22626970333244657269766174696f6e73223a7b7d2c2266696e616c536372697074536967223a6e756c6c2c2270726f70726965746172696573223a7b7d7d5d2c226f757470757473223a5b7b22616d6f756e74223a313530303030303030302c227363726970745075626c69634b6579223a2230303030222c2272656465656d536372697074223a6e756c6c2c22626970333244657269766174696f6e73223a7b7d2c2270726f70726965746172696573223a7b7d7d5d7d5d"; // Deserialize the bundle let deserialized_bundle = PSKB::deserialize(pskb).expect("Failed to deserialize bundle"); assert_eq!(deserialized_bundle.length(), 1, "Should be length 1"); @@ -237,5 +290,6 @@ mod tests { inner.outputs.first().expect("output").script_public_key, ScriptPublicKey::from_str("0000").expect("convert valid spk") ); + assert_eq!(inner.global.payload, Some("".into())); } } diff --git a/wallet/pskt/src/wasm/error.rs b/wallet/pskt/src/wasm/error.rs index fed61144f6..db6d88697a 100644 --- a/wallet/pskt/src/wasm/error.rs +++ b/wallet/pskt/src/wasm/error.rs @@ -1,4 +1,5 @@ use super::pskt::State; +use kaspa_txscript::script_class::ScriptClass; use thiserror::Error; use wasm_bindgen::prelude::*; @@ -31,11 +32,20 @@ pub enum Error { #[error("PSKT must be initialized with a payload or CREATE role")] NotInitialized, + #[error("Invalid ScriptClass, expected {0}, got {1}")] + UnexpectedScriptClassError(ScriptClass, ScriptClass), + #[error(transparent)] ConsensusClient(#[from] kaspa_consensus_client::error::Error), #[error(transparent)] Pskt(#[from] crate::error::Error), + + #[error(transparent)] + NetworkIdError(#[from] kaspa_consensus_core::network::NetworkIdError), + + #[error(transparent)] + NetworkTypeError(#[from] kaspa_consensus_core::network::NetworkTypeError), } impl Error { diff --git a/wallet/pskt/src/wasm/mod.rs b/wallet/pskt/src/wasm/mod.rs index bf92489cfa..35e237780d 100644 --- a/wallet/pskt/src/wasm/mod.rs +++ b/wallet/pskt/src/wasm/mod.rs @@ -4,4 +4,5 @@ pub mod input; pub mod output; pub mod pskt; pub mod result; +pub mod signer; pub mod utils; diff --git a/wallet/pskt/src/wasm/pskt.rs b/wallet/pskt/src/wasm/pskt.rs index 4a8f214d9b..5644166223 100644 --- a/wallet/pskt/src/wasm/pskt.rs +++ b/wallet/pskt/src/wasm/pskt.rs @@ -1,12 +1,24 @@ -use crate::pskt::{Input, PSKT as Native}; +use crate::pskt::{Input, SignInputOk, PSKT as Native}; use crate::role::*; -use kaspa_consensus_core::network::NetworkType; -use kaspa_consensus_core::tx::TransactionId; +use crate::wasm::signer::PrivateKeyArrayT; +use kaspa_consensus_core::network::{NetworkId, NetworkIdT, NetworkType, NetworkTypeT}; +use kaspa_consensus_core::tx::{TransactionId, VerifiableTransaction}; +use kaspa_txscript::opcodes::codes::OpData65; +use kaspa_txscript::script_class::ScriptClass; use wasm_bindgen::prelude::*; // use js_sys::Object; +use crate::prelude::Signature; use crate::pskt::Inner; +use kaspa_addresses::{Address, Prefix}; use kaspa_consensus_client::{Transaction, TransactionInput, TransactionInputT, TransactionOutput, TransactionOutputT}; +use kaspa_consensus_core::hashing::sighash::{calc_schnorr_signature_hash, SigHashReusedValuesUnsync}; +use kaspa_txscript::extract_script_pub_key_address; +use kaspa_wallet_keys::privatekey::PrivateKey; +use secp256k1::Message; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::iter; +use std::ops::Deref; use std::str::FromStr; use std::sync::MutexGuard; use std::sync::{Arc, Mutex}; @@ -384,4 +396,317 @@ impl PSKT { .map_err(|e| Error::custom(format!("Failed to extract transaction: {e}")))?; Ok(tx.tx.mass()) } + + /// Extracts all input addresses from the PSKT. + /// This is useful for figuring out which private keys are required for signing. + #[wasm_bindgen] + pub fn addresses(&self, network_id: &NetworkIdT) -> Result> { + let network_id = NetworkId::try_cast_from(network_id)?.into_owned(); + let prefix: Prefix = network_id.into(); + + let state_guard = self.state(); + let inner = match state_guard.as_ref().unwrap() { + State::NoOp(Some(inner)) => inner, + State::Creator(pskt) => pskt.deref(), + State::Constructor(pskt) => pskt.deref(), + State::Updater(pskt) => pskt.deref(), + State::Signer(pskt) => pskt.deref(), + State::Combiner(pskt) => pskt.deref(), + State::Finalizer(pskt) => pskt.deref(), + State::Extractor(pskt) => pskt.deref(), + _ => return Err(Error::Custom("PSKT is not initialized".to_string())), + }; + + let addresses = inner + .inputs + .iter() + .filter_map(|input| input.utxo_entry.as_ref()) + .filter_map(|utxo_entry| extract_script_pub_key_address(&utxo_entry.script_public_key, prefix).ok()) + .collect::>(); + + Ok(addresses) + } + + /// Sign the PSKT with the provided private keys. + /// The method will find the inputs corresponding to the private keys and sign them. + /// This method performs partial signing, so if no private keys are provided, it will + /// return an unmodified PSKT. + #[wasm_bindgen] + pub fn sign(&self, private_keys: PrivateKeyArrayT, network_type: &NetworkTypeT) -> Result { + let prefix: Prefix = network_type.try_into()?; + + let private_keys: Vec = + private_keys.try_into().map_err(|e| Error::Custom(format!("Invalid private keys: {:?}", e)))?; + + let mut key_map: HashMap = HashMap::new(); + for pk in private_keys { + // TODO: address this unwrap + key_map.insert(pk.to_address(network_type).unwrap(), pk); + } + + let signer_pskt: Native = match self.take() { + State::NoOp(inner) => inner.ok_or(Error::NotInitialized)?.into(), + State::Creator(pskt) => pskt.constructor().signer(), + State::Constructor(pskt) => pskt.signer(), + State::Updater(pskt) => pskt.signer(), + State::Signer(pskt) => pskt, + State::Combiner(pskt) => pskt.signer(), + state => return Err(Error::state(state))?, + }; + + let reused_values = SigHashReusedValuesUnsync::new(); + let signed_pskt = signer_pskt.pass_signature_sync::<_, Error>(|tx, sighash| { + let signatures = tx + .as_verifiable() + .populated_inputs() + .enumerate() + .filter_map(|(idx, (_, utxo_entry))| { + extract_script_pub_key_address(&utxo_entry.script_public_key, prefix).ok().and_then(|address| { + key_map.get(&address).map(|private_key| { + let hash = calc_schnorr_signature_hash(&tx.as_verifiable(), idx, sighash[idx], &reused_values); + let msg = + Message::from_digest_slice(hash.as_bytes().as_slice()).map_err(|e| Error::Custom(e.to_string()))?; + + let keypair = secp256k1::Keypair::from_seckey_slice(secp256k1::SECP256K1, &private_key.secret_bytes()) + .map_err(|e| Error::Custom(e.to_string()))?; + + Ok(SignInputOk { + signature: Signature::Schnorr(keypair.sign_schnorr(msg)), + pub_key: keypair.public_key(), + key_source: None, + }) + }) + }) + }) + .collect::>>()?; + + Ok(signatures) + })?; + + self.replace(State::Signer(signed_pskt)) + } + + /// Get `Transaction` from a PSKT, by first finalizing it for P2PK. + /// This method is useful to broadcast a P2PK PSKT to the Kaspa Network, using `RpcClient.submitTransaction`. + #[wasm_bindgen(js_name = "finalizeP2PK")] + pub fn finalize_p2pk(&self, network_type: &NetworkTypeT) -> Result { + let network_type: NetworkType = network_type.try_into()?; + + let pskt_finalizer: Native = match self.take() { + State::NoOp(inner) => inner.ok_or(Error::NotInitialized)?.into(), + State::Creator(pskt) => pskt.constructor().signer().finalizer(), + State::Constructor(pskt) => pskt.signer().finalizer(), + State::Updater(pskt) => pskt.signer().finalizer(), + State::Signer(pskt) => pskt.finalizer(), + State::Combiner(pskt) => pskt.signer().finalizer(), + State::Finalizer(pskt) => pskt, + state => return Err(Error::state(state))?, + }; + + // pre-conditions check + for input in pskt_finalizer.inputs.iter() { + if input.redeem_script.is_some() { + return Err(Error::custom("finalize_p2pk does not support redeem scripts")); + } + + if let Some(utxo_entry) = &input.utxo_entry { + let script_class = ScriptClass::from_script(&utxo_entry.script_public_key); + + // only allow p2pk script class + match script_class { + ScriptClass::NonStandard => return Err(Error::UnexpectedScriptClassError(ScriptClass::PubKey, script_class)), + ScriptClass::PubKeyECDSA => return Err(Error::UnexpectedScriptClassError(ScriptClass::PubKey, script_class)), + ScriptClass::ScriptHash => return Err(Error::UnexpectedScriptClassError(ScriptClass::PubKey, script_class)), + ScriptClass::PubKey => (), + } + } else { + return Err(Error::custom("Input without UTXO entry cannot be finalized")); + } + } + + let result = pskt_finalizer.finalize_sync(|inner: &Inner| -> std::result::Result>, String> { + inner + .inputs + .iter() + .map(|input| { + if input.partial_sigs.len() != 1 { + return Err(format!( + "finalize_p2pk error: input for outpoint {} must have exactly one signature, but has {}", + input.previous_outpoint, + input.partial_sigs.len() + )); + } + + let signature_script: Vec = input + .partial_sigs + .values() + .next() + .map(|signature| { + iter::once(OpData65).chain(signature.into_bytes()).chain([input.sighash_type.to_u8()]).collect() + }) + .unwrap(); + + Ok(signature_script) + }) + .collect() + }); + + let finalized_pskt = match result { + Ok(finalized_pskt) => finalized_pskt, + Err(e) => return Err(Error::from(e.to_string())), + }; + + let mutable_transaction = finalized_pskt + .extractor()? + .extract_tx(&network_type.into()) + .map_err(|e| Error::custom(format!("Failed to extract transaction: {e}")))?; + + Ok(mutable_transaction.tx.into()) + } +} + +impl PSKT { + pub fn inner(&self) -> Result { + let state_guard = self.state(); + let state = state_guard.as_ref().ok_or(Error::NotInitialized)?; + + let inner_clone = match state { + State::NoOp(Some(inner)) => inner.clone(), + State::Creator(pskt) => pskt.deref().clone(), + State::Constructor(pskt) => pskt.deref().clone(), + State::Updater(pskt) => pskt.deref().clone(), + State::Signer(pskt) => pskt.deref().clone(), + State::Combiner(pskt) => pskt.deref().clone(), + State::Finalizer(pskt) => pskt.deref().clone(), + State::Extractor(pskt) => pskt.deref().clone(), + State::NoOp(None) => return Err(Error::NotInitialized), + }; + Ok(inner_clone) + } +} + +#[cfg(test)] +mod tests { + use js_sys::Array; + use kaspa_addresses::Version; + use kaspa_consensus_core::{ + hashing::sighash_type::SIG_HASH_ALL, + tx::{TransactionOutpoint, UtxoEntry}, + Hash, + }; + use kaspa_txscript::pay_to_address_script; + use kaspa_wallet_keys::prelude::PublicKey; + use wasm_bindgen_test::*; + + use crate::pskt::{Global, Inner as PSKTInner, InputBuilder}; + + use super::*; + + fn _address_from_private_key(private_key: &PrivateKey) -> Address { + let public_key = secp256k1::PublicKey::from_secret_key( + secp256k1::SECP256K1, + &secp256k1::SecretKey::from_slice(private_key.secret_bytes().as_slice()).unwrap(), + ); + let (x_only_public_key, _) = public_key.x_only_public_key(); + let payload = x_only_public_key.serialize(); + Address::new(Prefix::Testnet, Version::PubKey, &payload) + } + + fn _mock_pskt_inner(private_key: &PrivateKey) -> PSKTInner { + let script_public_key = pay_to_address_script(&_address_from_private_key(private_key)); + + let utxo_entry = UtxoEntry { amount: 1000, script_public_key, block_daa_score: 0, is_coinbase: false }; + + let input = InputBuilder::default() + .previous_outpoint(TransactionOutpoint::new( + Hash::from_str("4bb07535dfd58e0b3cd64fd7155280872a0471bcf83095526ace0e38c6000000").unwrap(), + 4294967291, + )) + .utxo_entry(utxo_entry) + .sig_op_count(1) + .build() + .unwrap(); + + PSKTInner { global: Global::default(), inputs: vec![input], outputs: vec![] } + } + + #[wasm_bindgen_test] + fn _test_pskt_addresses() { + let sk = secp256k1::SecretKey::new(&mut secp256k1::rand::thread_rng()); + let pk: PrivateKey = PrivateKey::from(&sk); + let inner = _mock_pskt_inner(&pk); + let address = _address_from_private_key(&pk); + + let pskt = PSKT::from(State::NoOp(Some(inner))); + + let network_id_t: NetworkIdT = JsValue::from_str("testnet-10").into(); + + let addresses = pskt.addresses(&network_id_t).unwrap(); + + assert_eq!(addresses.len(), 1); + assert_eq!(addresses[0], address); + } + + #[wasm_bindgen_test] + fn _test_pskt_sign() { + let sk = secp256k1::SecretKey::new(&mut secp256k1::rand::thread_rng()); + let pk: PrivateKey = PrivateKey::from(&sk); + + let inner = _mock_pskt_inner(&pk); + let pskt = PSKT::from(State::NoOp(Some(inner))); + + // Check that there are no partial sigs initially + let state: State = serde_wasm_bindgen::from_value(pskt.payload_getter()).unwrap(); + if let State::NoOp(Some(inner_before_sign)) = state { + assert!(inner_before_sign.inputs[0].partial_sigs.is_empty()); + } else { + panic!("Unexpected initial state"); + } + + let keys = Array::new(); + keys.push(&JsValue::from(pk.clone())); + let keys_t: PrivateKeyArrayT = JsValue::from(keys).into(); + + let signed_pskt = pskt.sign(keys_t, &JsValue::from_str("testnet-10").into()).unwrap(); + + let signed_state: State = serde_wasm_bindgen::from_value(signed_pskt.payload_getter()).unwrap(); + + match signed_state { + State::Signer(native_pskt) => { + let signed_inner = native_pskt.deref(); + assert_eq!(signed_inner.inputs[0].partial_sigs.len(), 1); + let (pub_key, _signature) = signed_inner.inputs[0].partial_sigs.iter().next().unwrap(); + + let wasm_pk: PublicKey = pub_key.into(); + assert_eq!(wasm_pk.to_string(), pk.to_public_key().unwrap().to_string()); + } + _ => panic!("PSKT is not in Signer state after signing"), + } + } + + #[wasm_bindgen_test] + fn _test_finalize_p2pk() { + let sk = secp256k1::SecretKey::new(&mut secp256k1::rand::thread_rng()); + let pk: PrivateKey = PrivateKey::from(&sk); + + let inner = _mock_pskt_inner(&pk); + let pskt = PSKT::from(State::NoOp(Some(inner))); + + let keys = Array::new(); + keys.push(&JsValue::from(pk.clone())); + let keys_t: PrivateKeyArrayT = JsValue::from(keys).into(); + let network_type_t: NetworkTypeT = JsValue::from_str("testnet-10").into(); + + let signed_pskt = pskt.sign(keys_t, &network_type_t).unwrap(); + + let transaction = signed_pskt.finalize_p2pk(&network_type_t).unwrap(); + + assert_eq!(transaction.inputs().len(), 1); + let script_sig = transaction.inputs()[0].signature_script.clone(); + // 1 (opcode) + 65 (data) = 66 bytes. + assert_eq!(script_sig.len(), 66); + assert_eq!(script_sig[0], kaspa_txscript::opcodes::codes::OpData65); + assert_eq!(script_sig[65], SIG_HASH_ALL.to_u8()); + assert_eq!(transaction.outputs().len(), 0); + } } diff --git a/wallet/pskt/src/wasm/signer.rs b/wallet/pskt/src/wasm/signer.rs new file mode 100644 index 0000000000..17fa13aeab --- /dev/null +++ b/wallet/pskt/src/wasm/signer.rs @@ -0,0 +1,29 @@ +// todo: this is a copy/paste from wallet/core/src/wasm +// i tried to mutualize it in wallet/core/keys, but it conflicted (circular dep with tx_script) +// it also feels overkill to create a package only for that +// need guidance on how to procede with architecturing + +use js_sys::Array; +use kaspa_wallet_keys::privatekey::PrivateKey; +use wasm_bindgen::prelude::*; +use workflow_wasm::prelude::TryCastFromJs; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(extends = js_sys::Array, is_type_of = Array::is_array, typescript_type = "(PrivateKey | HexString | Uint8Array)[]")] + #[derive(Clone, Debug, PartialEq, Eq)] + pub type PrivateKeyArrayT; +} + +impl TryFrom for Vec { + type Error = crate::error::Error; + fn try_from(keys: PrivateKeyArrayT) -> std::result::Result { + let mut private_keys: Vec = vec![]; + for key in keys.iter() { + private_keys + .push(PrivateKey::try_owned_from(key).map_err(|_| Self::Error::Custom("Unable to cast PrivateKey".to_string()))?); + } + + Ok(private_keys) + } +} diff --git a/wasm/examples/nodejs/javascript/general/pskb.js b/wasm/examples/nodejs/javascript/general/pskb.js new file mode 100644 index 0000000000..8c365a730e --- /dev/null +++ b/wasm/examples/nodejs/javascript/general/pskb.js @@ -0,0 +1,151 @@ +// @ts-ignore +globalThis.WebSocket = require("websocket").w3cwebsocket; // W3C WebSocket module shim + +const kaspa = require("../../../../nodejs/kaspa"); +const { parseArgs } = require("../utils"); +const { + PSKT, + PSKB, + TransactionInput, + Hash, + payToAddressScript, + TransactionOutput, + ScriptPublicKey, + Address, + NetworkId, +} = kaspa; + +kaspa.initConsolePanicHook(); +(async () => { + // Your UTXO + const utxo = { + outpoint: { + transactionId: new Hash( + "0e111d1b7116364b8c729e10863719ea790addcc3e9deff97894b23e07f1e741", + ), + index: 0, + }, + amount: 9799885899n, + scriptPublicKey: new ScriptPublicKey( + 0xc0de, + "0000202d8a1414e62e081fb6bcf644e648c18061c2855575cac722f86324cad91dd0faac", + ), + blockDaaScore: 84746196n, + isCoinbase: false, + address: new Address( + "kaspatest:qqkc59q5uchqs8akhnmyfejgcxqxrs59246u43ezlp3jfjkerhg05e3rk3cwf", + ), + }; + + // COMMIT TRANSACTION + let commitPskt = new PSKT(undefined); + commitPskt.inputsModifiable(); + commitPskt.outputsModifiable(); + commitPskt = commitPskt.toConstructor(); + + // Add input + commitPskt.input( + new TransactionInput({ + previousOutpoint: { + transactionId: utxo.outpoint.transactionId.toString(), + index: utxo.outpoint.index, + }, + sequence: 0n, + sigOpCount: 1, + utxo: { + outpoint: { + transactionId: utxo.outpoint.transactionId.toString(), + index: utxo.outpoint.index, + }, + amount: utxo.amount, + scriptPublicKey: utxo.scriptPublicKey, + blockDaaScore: utxo.blockDaaScore, + isCoinbase: utxo.isCoinbase, + address: utxo.address, + }, + signatureScript: "", + }), + ); + + // Add P2SH output (1 KAS) + const p2shScript = payToAddressScript(utxo.address); + commitPskt.output( + new TransactionOutput( + 100000000n, + new ScriptPublicKey(0, p2shScript.script), + ), + ); + + // Add change output + const changeScript = payToAddressScript(utxo.address); + commitPskt.output( + new TransactionOutput( + 9699885899n, + new ScriptPublicKey(0, changeScript.script), + ), + ); + + // Get commit transaction ID for reveal + commitPskt = commitPskt.toSigner(); + const commitTxId = commitPskt.calculateId(); + console.log("Commit transaction ID:", commitTxId.toString()); + // REVEAL TRANSACTION + let revealPskt = new PSKT(undefined); + revealPskt.inputsModifiable(); + revealPskt.outputsModifiable(); + revealPskt = revealPskt.toConstructor(); + + // Use commit tx ID for reveal input + const commitOutpoint = { + transactionId: commitTxId.toString(), + index: 0, // First output from commit tx (the P2SH output) + }; + + // Add input from P2SH with scriptSig + revealPskt.input( + new TransactionInput({ + previousOutpoint: commitOutpoint, + sequence: 0n, + sigOpCount: 1, + utxo: { + outpoint: commitOutpoint, + amount: 100000000n, + scriptPublicKey: new ScriptPublicKey(0, p2shScript.script), + blockDaaScore: 18446744073709551615n, // u64::MAX for reveal transaction + isCoinbase: false, + address: utxo.address, + }, + signatureScript: "", + }), + ); + + // Add output to reveal address + const revealScript = payToAddressScript( + "kaspatest:qpurs0zsder98a7mr285nxypnuc9nlcsadkt63agjazx9h0jl0x47njwssfq9", + ); + revealPskt.output( + new TransactionOutput( + 100000000n, + new ScriptPublicKey(0, revealScript.script), + ), + ); + + // Create single PSKB with both transactions + const pskb = new PSKB(); + pskb.add(commitPskt); + pskb.add(revealPskt); + + console.log("Combined PSKB:", pskb.serialize()); + + for (let i = 0; i < pskb.length; i++) { + const pskt = pskb.get(i); + console.log(`pskt ${i}/${pskb.length - 1}`, JSON.stringify(pskt, null, 2)); + + const _addresses = pskt.addresses(new NetworkId("testnet-10")); + console.log({ _addresses }); + } + + console.log("addresses used:", pskb.addresses(new NetworkId("testnet-10"))); + + console.log("bye!"); +})();