diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c9aa2a2673..824999cfc3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -404,3 +404,22 @@ jobs: source musl-toolchain/build.sh # Build for musl cargo --verbose build --bin kaspad --bin rothschild --bin kaspa-wallet --release --target x86_64-unknown-linux-musl + + build-python-sdk: + name: Build Python SDK + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + working-directory: python + target: x86_64 + args: --release --strip --out target/dist --features py-sdk --interpreter python3.12 + manylinux: auto \ No newline at end of file diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 537eeef898..9993110b9b 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -198,3 +198,85 @@ jobs: asset_path: "./${{ env.archive }}" asset_name: "${{ env.asset_name }}" asset_content_type: application/zip + + build-python-sdk: + name: Building Python SDK + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-latest + target: x86_64 + manylinux: auto + - runner: ubuntu-latest + target: aarch64 + # ring crate 0.17 fails to build for aarch64 using manylinux: auto, hence manylinux_2_28 + manylinux: manylinux_2_28 + - runner: macos-latest + target: x86_64-apple-darwin + - runner: macos-latest + target: aarch64-apple-darwin + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '>=3.10 <3.13' + + - name: Install cross-compilation tools (Linux only) + if: runner.os == 'Linux' && matrix.platform.target != 'x86_64' + run: | + sudo apt-get update + if [ "${{ matrix.platform.target }}" == "aarch64" ]; then + sudo apt-get install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross + echo "CC=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + elif [ "${{ matrix.platform.target }}" == "armv7" ]; then + sudo apt-get install -y gcc-arm-linux-gnueabihf + echo "CC=arm-linux-gnueabihf-gcc" >> $GITHUB_ENV + fi + + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + working-directory: python + target: ${{ matrix.platform.target }} + args: --release --strip --out target/dist --features py-sdk --interpreter python3.10 python3.11 python3.12 + sccache: 'true' + manylinux: ${{ matrix.platform.manylinux || '' }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ runner.os }}-${{ matrix.platform.target }} + path: python/target/dist/ + + collect-python-sdk-artifacts: + name: Collect Python SDK Artifacts + needs: build-python-sdk + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Zip artifacts + run: | + cd artifacts + sudo zip -r ../kaspa-python-sdk.zip . + + - name: Upload consolidated release asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./kaspa-python-sdk.zip + asset_name: "kaspa-python-sdk-${{ github.event.release.tag_name }}.zip" + asset_content_type: application/zip + diff --git a/Cargo.lock b/Cargo.lock index a951993e90..0f4149068e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2136,6 +2136,12 @@ dependencies = [ "serde", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "inout" version = "0.1.3" @@ -2180,6 +2186,12 @@ dependencies = [ "uuid 0.8.2", ] +[[package]] +name = "inventory" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f958d3d68f4167080a18141e10381e7634563984a537f2a49a30fd8e53ac5767" + [[package]] name = "ipnet" version = "2.10.1" @@ -2270,6 +2282,7 @@ dependencies = [ "borsh", "criterion", "js-sys", + "pyo3", "serde", "smallvec", "thiserror", @@ -2323,6 +2336,7 @@ dependencies = [ "kaspa-utils", "once_cell", "pbkdf2", + "pyo3", "rand", "rand_core", "ripemd", @@ -2458,12 +2472,15 @@ dependencies = [ "kaspa-consensus-core", "kaspa-hashes", "kaspa-math", + "kaspa-python-core", "kaspa-txscript", "kaspa-utils", "kaspa-wasm-core", + "pyo3", "rand", "secp256k1", "serde", + "serde-pyobject", "serde-wasm-bindgen", "serde_json", "thiserror", @@ -2493,8 +2510,10 @@ dependencies = [ "kaspa-math", "kaspa-merkle", "kaspa-muhash", + "kaspa-python-core", "kaspa-txscript-errors", "kaspa-utils", + "pyo3", "rand", "secp256k1", "serde", @@ -2744,6 +2763,7 @@ dependencies = [ "kaspa-utils", "keccak", "once_cell", + "pyo3", "rand", "serde", "sha2", @@ -3017,6 +3037,27 @@ dependencies = [ "workflow-wasm", ] +[[package]] +name = "kaspa-python-core" +version = "0.15.3" +dependencies = [ + "cfg-if 1.0.0", + "faster-hex", + "pyo3", +] + +[[package]] +name = "kaspa-python-macros" +version = "0.15.3" +dependencies = [ + "convert_case 0.6.0", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", +] + [[package]] name = "kaspa-rpc-core" version = "0.15.3" @@ -3046,8 +3087,10 @@ dependencies = [ "kaspa-utils", "log", "paste", + "pyo3", "rand", "serde", + "serde-pyobject", "serde-wasm-bindgen", "serde_json", "smallvec", @@ -3168,6 +3211,7 @@ dependencies = [ "borsh", "cfg-if 1.0.0", "criterion", + "faster-hex", "hex", "hexplay", "indexmap 2.6.0", @@ -3175,11 +3219,13 @@ dependencies = [ "kaspa-addresses", "kaspa-consensus-core", "kaspa-hashes", + "kaspa-python-core", "kaspa-txscript-errors", "kaspa-utils", "kaspa-wasm-core", "log", "parking_lot", + "pyo3", "rand", "secp256k1", "serde", @@ -3339,6 +3385,8 @@ dependencies = [ "kaspa-hashes", "kaspa-metrics-core", "kaspa-notify", + "kaspa-python-core", + "kaspa-python-macros", "kaspa-rpc-core", "kaspa-txscript", "kaspa-txscript-errors", @@ -3348,16 +3396,20 @@ dependencies = [ "kaspa-wallet-pskt", "kaspa-wasm-core", "kaspa-wrpc-client", + "kaspa-wrpc-python", "kaspa-wrpc-wasm", "md-5", "pad", "pbkdf2", + "pyo3", + "pyo3-async-runtimes", "rand", "regex", "ripemd", "secp256k1", "separator", "serde", + "serde-pyobject", "serde-wasm-bindgen", "serde_json", "serde_repr", @@ -3386,6 +3438,7 @@ version = "0.15.3" dependencies = [ "async-trait", "borsh", + "cfg-if 1.0.0", "downcast", "faster-hex", "hmac", @@ -3397,6 +3450,7 @@ dependencies = [ "kaspa-txscript-errors", "kaspa-utils", "kaspa-wasm-core", + "pyo3", "rand", "ripemd", "secp256k1", @@ -3509,6 +3563,7 @@ dependencies = [ "kaspa-rpc-core", "kaspa-rpc-macros", "paste", + "pyo3", "rand", "regex", "rustls", @@ -3562,6 +3617,30 @@ dependencies = [ "workflow-rpc", ] +[[package]] +name = "kaspa-wrpc-python" +version = "0.15.3" +dependencies = [ + "ahash", + "cfg-if 1.0.0", + "futures", + "kaspa-addresses", + "kaspa-consensus-core", + "kaspa-notify", + "kaspa-python-macros", + "kaspa-rpc-core", + "kaspa-rpc-macros", + "kaspa-wrpc-client", + "pyo3", + "pyo3-async-runtimes", + "serde-pyobject", + "serde_json", + "thiserror", + "workflow-core", + "workflow-log", + "workflow-rpc", +] + [[package]] name = "kaspa-wrpc-server" version = "0.15.3" @@ -4712,6 +4791,112 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3804877ffeba468c806c2ad9057bbbae92e4b2c410c2f108baaa0042f241fa4c" +[[package]] +name = "pyo3" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d922163ba1f79c04bc49073ba7b32fd5a8d3b76a87c955921234b8e77333c51" +dependencies = [ + "cfg-if 1.0.0", + "indoc", + "inventory", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2529f0be73ffd2be0cc43c013a640796558aa12d7ca0aab5cc14f375b4733031" +dependencies = [ + "futures", + "once_cell", + "pin-project-lite", + "pyo3", + "pyo3-async-runtimes-macros", + "tokio", +] + +[[package]] +name = "pyo3-async-runtimes-macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22c26fd8e9fc19f53f0c1e00bf61471de6789f7eb263056f7f944a9cceb5823e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "pyo3-build-config" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc38c5feeb496c8321091edf3d63e9a6829eab4b863b4a6a65f26f3e9cc6b179" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94845622d88ae274d2729fcefc850e63d7a3ddff5e3ce11bd88486db9f1d357d" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e655aad15e09b94ffdb3ce3d217acf652e26bbc37697ef012f5e5e348c716e5e" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1e3f09eecd94618f60a455a23def79f79eba4dc561a97324bf9ac8c6df30ce" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "python" +version = "0.15.3" +dependencies = [ + "cfg-if 1.0.0", + "kaspa-addresses", + "kaspa-bip32", + "kaspa-consensus-client", + "kaspa-consensus-core", + "kaspa-hashes", + "kaspa-txscript", + "kaspa-wallet-core", + "kaspa-wallet-keys", + "kaspa-wrpc-python", + "pyo3", +] + [[package]] name = "quinn" version = "0.11.5" @@ -5188,6 +5373,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-pyobject" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca4b0aad8b225845739a0030a0d5cc2ae949c56a86a7daf9226c7df7c2016d16" +dependencies = [ + "pyo3", + "serde", +] + [[package]] name = "serde-value" version = "0.7.0" @@ -5594,6 +5789,12 @@ dependencies = [ "libc", ] +[[package]] +name = "target-lexicon" +version = "0.12.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc403891a21bcfb7c37834ba66a547a8f402146eba7265b5a6d88059c9ff2f" + [[package]] name = "tempfile" version = "3.13.0" @@ -6079,6 +6280,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unindent" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 7141101f9a..68ba1e6c17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,8 @@ members = [ "rpc/wrpc/server", "rpc/wrpc/client", "rpc/wrpc/proxy", - "rpc/wrpc/wasm", + "rpc/wrpc/bindings/python", + "rpc/wrpc/bindings/wasm", "rpc/wrpc/examples/subscriber", "rpc/wrpc/examples/simple_client", "mining", @@ -59,6 +60,9 @@ members = [ "metrics/core", "metrics/perf_monitor", "utils/alloc", + "python", + "python/macros", + "python/core", ] [workspace.package] @@ -112,6 +116,9 @@ kaspa-p2p-flows = { version = "0.15.3", path = "protocol/flows" } kaspa-p2p-lib = { version = "0.15.3", path = "protocol/p2p" } kaspa-perf-monitor = { version = "0.15.3", path = "metrics/perf_monitor" } kaspa-pow = { version = "0.15.3", path = "consensus/pow" } +kaspa-python = { version = "0.15.3", path = "python" } +kaspa-python-core = { version = "0.15.3", path = "python/core" } +kaspa-python-macros = { version = "0.15.3", path = "python/macros" } kaspa-rpc-core = { version = "0.15.3", path = "rpc/core" } kaspa-rpc-macros = { version = "0.15.3", path = "rpc/macros" } kaspa-rpc-service = { version = "0.15.3", path = "rpc/service" } @@ -130,8 +137,9 @@ kaspa-wasm = { version = "0.15.3", path = "wasm" } kaspa-wasm-core = { version = "0.15.3", path = "wasm/core" } kaspa-wrpc-client = { version = "0.15.3", path = "rpc/wrpc/client" } kaspa-wrpc-proxy = { version = "0.15.3", path = "rpc/wrpc/proxy" } +kaspa-wrpc-python = { version = "0.15.3", path = "rpc/wrpc/bindings/python" } kaspa-wrpc-server = { version = "0.15.3", path = "rpc/wrpc/server" } -kaspa-wrpc-wasm = { version = "0.15.3", path = "rpc/wrpc/wasm" } +kaspa-wrpc-wasm = { version = "0.15.3", path = "rpc/wrpc/bindings/wasm" } kaspa-wrpc-example-subscriber = { version = "0.15.3", path = "rpc/wrpc/examples/subscriber" } kaspad = { version = "0.15.3", path = "kaspad" } kaspa-alloc = { version = "0.15.3", path = "utils/alloc" } @@ -215,6 +223,8 @@ paste = "1.0.14" pbkdf2 = "0.12.2" portable-atomic = { version = "1.5.1", features = ["float"] } prost = "0.13.2" +pyo3 = { version = "0.22.5", features = ["multiple-pymethods"] } +pyo3-async-runtimes = { version = "0.22", features = ["attributes", "tokio-runtime"] } rand = "0.8.5" rand_chacha = "0.3.1" rand_core = { version = "0.6.4", features = ["std"] } @@ -235,6 +245,7 @@ seqlock = "0.2.0" serde = { version = "1.0.190", features = ["derive", "rc"] } serde_bytes = "0.11.12" serde_json = "1.0.107" +serde-pyobject = "0.4.0" serde_repr = "0.1.18" serde-value = "0.7.0" serde-wasm-bindgen = "0.6.1" diff --git a/consensus/client/Cargo.toml b/consensus/client/Cargo.toml index 698348508f..c6851eaa17 100644 --- a/consensus/client/Cargo.toml +++ b/consensus/client/Cargo.toml @@ -11,12 +11,19 @@ repository.workspace = true [features] wasm32-sdk = [] wasm32-types = [] +py-sdk = [ + "pyo3", + "kaspa-hashes/py-sdk", + "kaspa-python-core/py-sdk", + "serde-pyobject", +] [dependencies] kaspa-addresses.workspace = true kaspa-consensus-core.workspace = true kaspa-hashes.workspace = true kaspa-math.workspace = true +kaspa-python-core = { workspace = true, optional = true } kaspa-txscript.workspace = true kaspa-utils.workspace = true kaspa-wasm-core.workspace = true @@ -26,9 +33,11 @@ cfg-if.workspace = true faster-hex.workspace = true hex.workspace = true js-sys.workspace = true +pyo3 = { workspace = true, optional = true } rand.workspace = true secp256k1.workspace = true serde_json.workspace = true +serde-pyobject = { workspace = true, optional = true } serde-wasm-bindgen.workspace = true serde.workspace = true thiserror.workspace = true diff --git a/consensus/client/src/error.rs b/consensus/client/src/error.rs index e632f517d5..c6dd318c3f 100644 --- a/consensus/client/src/error.rs +++ b/consensus/client/src/error.rs @@ -1,5 +1,7 @@ //! The [`Error`](enum@Error) enum used by this crate +#[cfg(feature = "py-sdk")] +use pyo3::{exceptions::PyException, prelude::PyErr}; use thiserror::Error; use wasm_bindgen::{JsError, JsValue}; use workflow_wasm::jserror::JsErrorData; @@ -106,3 +108,10 @@ impl From for Error { Self::SerdeWasmBindgen(JsValue::from(err).into()) } } + +#[cfg(feature = "py-sdk")] +impl From for PyErr { + fn from(value: Error) -> PyErr { + PyException::new_err(value.to_string()) + } +} diff --git a/consensus/client/src/imports.rs b/consensus/client/src/imports.rs index 844753fb57..80dd786ea0 100644 --- a/consensus/client/src/imports.rs +++ b/consensus/client/src/imports.rs @@ -6,3 +6,18 @@ pub use serde::{Deserialize, Serialize}; pub use std::sync::{Arc, Mutex, MutexGuard}; pub use wasm_bindgen::prelude::*; pub use workflow_wasm::prelude::*; + +cfg_if::cfg_if! { + if #[cfg(feature = "py-sdk")] { + pub use kaspa_addresses::Address; + pub use kaspa_python_core::types::PyBinary; + pub use kaspa_utils::hex::FromHex; + pub use pyo3::{ + exceptions::PyException, + prelude::*, + types::PyDict, + }; + pub use serde_pyobject; + pub use std::str::FromStr; + } +} diff --git a/consensus/client/src/input.rs b/consensus/client/src/input.rs index a5018199d5..4e3d08a977 100644 --- a/consensus/client/src/input.rs +++ b/consensus/client/src/input.rs @@ -79,6 +79,7 @@ impl TransactionInputInner { /// Represents a Kaspa transaction input /// @category Consensus #[derive(Clone, Debug, Serialize, Deserialize, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct TransactionInput { inner: Arc>, @@ -182,6 +183,78 @@ impl TransactionInput { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl TransactionInput { + #[new] + #[pyo3(signature = (previous_outpoint, signature_script, sequence, sig_op_count, utxo=None))] + pub fn constructor_py( + previous_outpoint: TransactionOutpoint, + signature_script: PyBinary, + sequence: u64, + sig_op_count: u8, + utxo: Option, + ) -> PyResult { + Ok(Self::new(previous_outpoint, Some(signature_script.into()), sequence, sig_op_count, utxo)) + } + + #[getter] + #[pyo3(name = "previous_outpoint")] + pub fn get_previous_outpoint_py(&self) -> TransactionOutpoint { + self.inner().previous_outpoint.clone() + } + + #[setter] + #[pyo3(name = "previous_outpoint")] + pub fn set_previous_outpoint_py(&mut self, outpoint: TransactionOutpoint) -> PyResult<()> { + self.inner().previous_outpoint = outpoint; + Ok(()) + } + + #[getter] + #[pyo3(name = "signature_script")] + pub fn get_signature_script_as_hex_py(&self) -> Option { + self.inner().signature_script.as_ref().map(|script| script.to_hex()) + } + + #[setter] + #[pyo3(name = "signature_script")] + pub fn set_signature_script_as_hex_py(&mut self, signature_script: PyBinary) -> PyResult<()> { + self.set_signature_script(signature_script.into()); + Ok(()) + } + + #[getter] + #[pyo3(name = "sequence")] + pub fn get_sequence_py(&self) -> u64 { + self.inner().sequence + } + + #[setter] + #[pyo3(name = "sequence")] + pub fn set_sequence_py(&mut self, sequence: u64) { + self.inner().sequence = sequence; + } + + #[getter] + #[pyo3(name = "sig_op_count")] + pub fn get_sig_op_count_py(&self) -> u8 { + self.inner().sig_op_count + } + + #[setter] + #[pyo3(name = "sig_op_count")] + pub fn set_sig_op_count_py(&mut self, sig_op_count: u8) { + self.inner().sig_op_count = sig_op_count; + } + + #[getter] + #[pyo3(name = "utxo")] + pub fn get_utxo_py(&self) -> Option { + self.inner().utxo.clone() + } +} + impl TransactionInput { pub fn set_signature_script(&self, signature_script: Vec) { self.inner().signature_script.replace(signature_script); diff --git a/consensus/client/src/outpoint.rs b/consensus/client/src/outpoint.rs index a9b39f5e4f..ae82165e43 100644 --- a/consensus/client/src/outpoint.rs +++ b/consensus/client/src/outpoint.rs @@ -83,6 +83,7 @@ impl TryFrom<&JsValue> for TransactionOutpointInner { /// @category Consensus #[derive(Clone, Debug, Serialize, Deserialize, CastFromJs)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct TransactionOutpoint { inner: Arc, @@ -147,6 +148,32 @@ cfg_if! { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl TransactionOutpoint { + #[new] + pub fn ctor_py(transaction_id: TransactionId, index: u32) -> TransactionOutpoint { + Self { inner: Arc::new(TransactionOutpointInner { transaction_id, index }) } + } + + #[pyo3(name = "get_id")] + pub fn id_string_py(&self) -> String { + format!("{}-{}", self.get_transaction_id_as_string(), self.get_index()) + } + + #[getter] + #[pyo3(name = "transaction_id")] + pub fn get_transaction_id_as_string_py(&self) -> String { + self.inner().transaction_id.to_string() + } + + #[getter] + #[pyo3(name = "index")] + pub fn get_index_py(&self) -> TransactionIndexType { + self.inner().index + } +} + impl std::fmt::Display for TransactionOutpoint { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let inner = self.inner(); @@ -162,6 +189,15 @@ impl TryFrom<&JsValue> for TransactionOutpoint { } } +#[cfg(feature = "py-sdk")] +impl TryFrom<&Bound<'_, PyDict>> for TransactionOutpoint { + type Error = PyErr; + fn try_from(dict: &Bound) -> PyResult { + let inner: TransactionOutpointInner = serde_pyobject::from_pyobject(dict.clone())?; + Ok(TransactionOutpoint { inner: Arc::new(inner) }) + } +} + impl From for TransactionOutpoint { fn from(outpoint: cctx::TransactionOutpoint) -> Self { let transaction_id = outpoint.transaction_id; diff --git a/consensus/client/src/output.rs b/consensus/client/src/output.rs index 17b4a58c80..2f5dc7bca3 100644 --- a/consensus/client/src/output.rs +++ b/consensus/client/src/output.rs @@ -60,6 +60,7 @@ pub struct TransactionOutputInner { /// @category Consensus #[derive(Clone, Debug, Serialize, Deserialize, CastFromJs)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct TransactionOutput { inner: Arc>, @@ -112,6 +113,39 @@ impl TransactionOutput { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl TransactionOutput { + #[new] + pub fn ctor_py(value: u64, script_public_key: ScriptPublicKey) -> TransactionOutput { + Self { inner: Arc::new(Mutex::new(TransactionOutputInner { value, script_public_key: script_public_key.clone() })) } + } + + #[getter] + #[pyo3(name = "value")] + pub fn get_value_py(&self) -> u64 { + self.inner().value + } + + #[setter] + #[pyo3(name = "value")] + pub fn set_value_py(&self, v: u64) { + self.inner().value = v; + } + + #[getter] + #[pyo3(name = "script_public_key")] + pub fn get_script_public_key_py(&self) -> ScriptPublicKey { + self.inner().script_public_key.clone() + } + + #[setter] + #[pyo3(name = "script_public_key")] + pub fn set_script_public_key_py(&self, v: ScriptPublicKey) { + self.inner().script_public_key = v.clone(); + } +} + impl AsRef for TransactionOutput { fn as_ref(&self) -> &TransactionOutput { self diff --git a/consensus/client/src/transaction.rs b/consensus/client/src/transaction.rs index 17cc381265..b620074929 100644 --- a/consensus/client/src/transaction.rs +++ b/consensus/client/src/transaction.rs @@ -87,6 +87,7 @@ pub struct TransactionInner { /// used by transaction inputs. /// @category Consensus #[derive(Clone, Debug, Serialize, Deserialize, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct Transaction { inner: Arc>, @@ -278,6 +279,156 @@ impl Transaction { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl Transaction { + #[pyo3(name = "is_coinbase")] + pub fn is_coinbase_py(&self) -> bool { + self.inner().subnetwork_id == subnets::SUBNETWORK_ID_COINBASE + } + + #[pyo3(name = "finalize")] + pub fn finalize_py(&self) -> PyResult { + let tx: cctx::Transaction = self.into(); + self.inner().id = tx.id(); + Ok(self.inner().id) + } + + #[getter] + #[pyo3(name = "id")] + pub fn id_string_py(&self) -> String { + self.inner().id.to_string() + } + + #[new] + pub fn constructor_py( + version: u16, + inputs: Vec, + outputs: Vec, + lock_time: u64, + subnetwork_id: PyBinary, + gas: u64, + payload: PyBinary, + mass: u64, + ) -> PyResult { + let subnetwork_id: SubnetworkId = subnetwork_id + .data + .as_slice() + .try_into() + .map_err(|err| PyException::new_err(format!("subnetwork_id conversion error: {}", err)))?; + + Ok(Transaction::new(None, version, inputs, outputs, lock_time, subnetwork_id, gas, payload.into(), mass)?) + } + + #[getter] + #[pyo3(name = "inputs")] + pub fn get_inputs_as_py_list(&self) -> PyResult> { + Ok(self.inner.lock().unwrap().inputs.clone()) + } + + #[setter] + #[pyo3(name = "inputs")] + pub fn set_inputs_from_py_list(&mut self, v: Vec) { + self.inner().inputs = v; + } + + #[pyo3(name = "addresses")] + pub fn addresses_py(&self, network_type: &str) -> PyResult> { + let network_type = NetworkType::from_str(network_type)?; + let mut list = std::collections::HashSet::new(); + for input in &self.inner.lock().unwrap().inputs { + if let Some(utxo) = input.get_utxo() { + if let Some(address) = &utxo.utxo.address { + list.insert(address.clone()); + } else if let Ok(address) = + extract_script_pub_key_address(&utxo.utxo.script_public_key, NetworkType::try_from(network_type)?.into()) + { + list.insert(address); + } + } + } + Ok(list.into_iter().collect()) + } + + #[getter] + #[pyo3(name = "outputs")] + pub fn get_outputs_as_py_list(&self) -> PyResult> { + Ok(self.inner.lock().unwrap().outputs.clone()) + } + + #[setter] + #[pyo3(name = "outputs")] + pub fn set_outputs_from_py_list(&mut self, v: Vec) { + self.inner().outputs = v; + } + + #[getter] + #[pyo3(name = "version")] + pub fn get_version_py(&self) -> u16 { + self.inner().version + } + + #[setter] + #[pyo3(name = "version")] + pub fn set_version_py(&mut self, v: u16) { + self.inner().version = v; + } + + #[getter] + #[pyo3(name = "lock_time")] + pub fn get_lock_time_py(&self) -> u64 { + self.inner().lock_time + } + + #[setter] + #[pyo3(name = "lock_time")] + pub fn set_lock_time_py(&mut self, v: u64) { + self.inner().lock_time = v; + } + + #[getter] + #[pyo3(name = "gas")] + pub fn get_gas_py(&self) -> u64 { + self.inner().gas + } + + #[setter] + #[pyo3(name = "gas")] + pub fn set_gas_py(&mut self, v: u64) { + self.inner().gas = v; + } + + #[getter] + #[pyo3(name = "subnetwork_id")] + pub fn get_subnetwork_id_as_hex_py(&self) -> String { + self.inner().subnetwork_id.to_hex() + } + + #[setter] + #[pyo3(name = "subnetwork_id")] + pub fn set_subnetwork_id_from_py_value(&mut self, v: &str) -> PyResult<()> { + let subnetwork_id = Vec::from_hex(v) + .unwrap_or_else(|err| panic!("subnetwork_id decode error {}", err)) + .as_slice() + .try_into() + .map_err(|err| PyException::new_err(format!("subnetwork_id conversion error: {}", err)))?; + self.inner().subnetwork_id = subnetwork_id; + Ok(()) + } + + #[getter] + #[pyo3(name = "payload")] + pub fn get_payload_as_hex_string_py(&self) -> String { + self.inner().payload.to_hex() + } + + #[setter] + #[pyo3(name = "payload")] + pub fn set_payload_from_py_value(&mut self, v: PyBinary) { + self.inner.lock().unwrap().payload = v.into(); + } +} + impl TryCastFromJs for Transaction { type Error = Error; fn try_cast_from<'a, R>(value: &'a R) -> std::result::Result, Self::Error> @@ -512,3 +663,13 @@ impl Transaction { string::SerializableTransaction::deserialize_from_json(json)?.try_into() } } + +#[cfg(feature = "py-sdk")] +#[pymethods] +impl Transaction { + #[pyo3(name = "serialize_to_dict")] + pub fn serialize_to_dict_py(&self, py: Python) -> PyResult> { + let tx = numeric::SerializableTransaction::from_client_transaction(self)?; + Ok(serde_pyobject::to_pyobject(py, &tx)?.to_object(py)) + } +} diff --git a/consensus/client/src/utils.rs b/consensus/client/src/utils.rs index 7e08556fec..d55e78a189 100644 --- a/consensus/client/src/utils.rs +++ b/consensus/client/src/utils.rs @@ -23,6 +23,13 @@ pub fn pay_to_address_script(address: &AddressT) -> Result { Ok(standard::pay_to_address_script(address.as_ref())) } +#[cfg(feature = "py-sdk")] +#[pyfunction] +#[pyo3(name = "pay_to_address_script")] +pub fn pay_to_address_script_py(address: Address) -> Result { + Ok(standard::pay_to_address_script(&address)) +} + /// Takes a script and returns an equivalent pay-to-script-hash script. /// @param redeem_script - The redeem script ({@link HexString} or Uint8Array). /// @category Wallet SDK @@ -32,6 +39,13 @@ pub fn pay_to_script_hash_script(redeem_script: BinaryT) -> Result PyResult { + Ok(standard::pay_to_script_hash_script(redeem_script.data.as_slice())) +} + /// Generates a signature script that fits a pay-to-script-hash script. /// @param redeem_script - The redeem script ({@link HexString} or Uint8Array). /// @param signature - The signature ({@link HexString} or Uint8Array). @@ -44,6 +58,15 @@ pub fn pay_to_script_hash_signature_script(redeem_script: BinaryT, signature: Bi Ok(script.to_hex().into()) } +#[cfg(feature = "py-sdk")] +#[pyfunction] +#[pyo3(name = "pay_to_script_hash_signature_script")] +pub fn pay_to_script_hash_signature_script_py(redeem_script: PyBinary, signature: PyBinary) -> PyResult { + let script = standard::pay_to_script_hash_signature_script(redeem_script.data, signature.data) + .map_err(|err| PyException::new_err(format!("{}", err.to_string())))?; + Ok(String::from_utf8(script)?) +} + /// Returns the address encoded in a script public key. /// @param script_public_key - The script public key ({@link ScriptPublicKey}). /// @param network - The network type. @@ -59,6 +82,16 @@ pub fn address_from_script_public_key(script_public_key: &ScriptPublicKeyT, netw } } +#[cfg(feature = "py-sdk")] +#[pyfunction] +#[pyo3(name = "address_from_script_public_key")] +pub fn address_from_script_public_key_py(script_public_key: &ScriptPublicKey, network: &str) -> PyResult
{ + match standard::extract_script_pub_key_address(script_public_key, NetworkType::from_str(network)?.try_into()?) { + Ok(address) => Ok(address), + Err(err) => Err(pyo3::exceptions::PyException::new_err(format!("{}", err))), + } +} + /// Returns true if the script passed is a pay-to-pubkey. /// @param script - The script ({@link HexString} or Uint8Array). /// @category Wallet SDK @@ -68,6 +101,13 @@ pub fn is_script_pay_to_pubkey(script: BinaryT) -> Result { Ok(ScriptClass::is_pay_to_pubkey(script.as_slice())) } +#[cfg(feature = "py-sdk")] +#[pyfunction] +#[pyo3(name = "is_script_pay_to_pubkey")] +pub fn is_script_pay_to_pubkey_py(script: PyBinary) -> PyResult { + Ok(ScriptClass::is_pay_to_pubkey(script.data.as_slice())) +} + /// Returns returns true if the script passed is an ECDSA pay-to-pubkey. /// @param script - The script ({@link HexString} or Uint8Array). /// @category Wallet SDK @@ -77,6 +117,13 @@ pub fn is_script_pay_to_pubkey_ecdsa(script: BinaryT) -> Result { Ok(ScriptClass::is_pay_to_pubkey_ecdsa(script.as_slice())) } +#[cfg(feature = "py-sdk")] +#[pyfunction] +#[pyo3(name = "is_script_pay_to_pubkey_ecdsa")] +pub fn is_script_pay_to_pubkey_ecdsa_py(script: PyBinary) -> PyResult { + Ok(ScriptClass::is_pay_to_pubkey_ecdsa(script.data.as_slice())) +} + /// Returns true if the script passed is a pay-to-script-hash (P2SH) format, false otherwise. /// @param script - The script ({@link HexString} or Uint8Array). /// @category Wallet SDK @@ -85,3 +132,10 @@ pub fn is_script_pay_to_script_hash(script: BinaryT) -> Result { let script = script.try_as_vec_u8()?; Ok(ScriptClass::is_pay_to_script_hash(script.as_slice())) } + +#[cfg(feature = "py-sdk")] +#[pyfunction] +#[pyo3(name = "is_script_pay_to_script_hash")] +pub fn is_script_pay_to_script_hash_py(script: PyBinary) -> PyResult { + Ok(ScriptClass::is_pay_to_script_hash(script.data.as_slice())) +} diff --git a/consensus/client/src/utxo.rs b/consensus/client/src/utxo.rs index bbfc1199d1..fdcee86739 100644 --- a/consensus/client/src/utxo.rs +++ b/consensus/client/src/utxo.rs @@ -58,6 +58,7 @@ pub type UtxoEntryId = TransactionOutpointInner; /// @category Wallet SDK #[derive(Clone, Debug, Serialize, Deserialize, CastFromJs)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct UtxoEntry { #[wasm_bindgen(getter_with_clone)] @@ -82,6 +83,46 @@ impl UtxoEntry { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl UtxoEntry { + #[getter] + #[pyo3(name = "address")] + pub fn address_py(&self) -> Option
{ + self.address.clone() + } + + #[getter] + #[pyo3(name = "outpoint")] + pub fn outpoint_py(&self) -> TransactionOutpoint { + self.outpoint.clone() + } + + #[getter] + #[pyo3(name = "amount")] + pub fn amount_py(&self) -> u64 { + self.amount.clone() + } + + #[getter] + #[pyo3(name = "script_public_key")] + pub fn script_public_key_py(&self) -> ScriptPublicKey { + self.script_public_key.clone() + } + + #[getter] + #[pyo3(name = "block_daa_score")] + pub fn block_daa_score_py(&self) -> u64 { + self.block_daa_score.clone() + } + + #[getter] + #[pyo3(name = "block_daa_score")] + pub fn is_coinbase_py(&self) -> bool { + self.is_coinbase.clone() + } +} + impl UtxoEntry { #[inline(always)] pub fn amount(&self) -> u64 { @@ -139,6 +180,7 @@ impl From<&UtxoEntry> for cctx::UtxoEntry { /// /// @category Wallet SDK #[derive(Clone, Debug, Serialize, Deserialize, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct UtxoEntryReference { #[wasm_bindgen(skip)] @@ -191,6 +233,52 @@ impl UtxoEntryReference { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl UtxoEntryReference { + #[getter] + #[pyo3(name = "entry")] + pub fn entry_py(&self) -> UtxoEntry { + self.as_ref().clone() + } + + #[getter] + #[pyo3(name = "outpoint")] + pub fn outpoint_py(&self) -> TransactionOutpoint { + self.utxo.outpoint.clone() + } + + #[getter] + #[pyo3(name = "address")] + pub fn address_py(&self) -> Option
{ + self.utxo.address.clone() + } + + #[getter] + #[pyo3(name = "amount")] + pub fn amount_py(&self) -> u64 { + self.utxo.amount + } + + #[getter] + #[pyo3(name = "is_coinbase")] + pub fn is_coinbase_py(&self) -> bool { + self.utxo.is_coinbase + } + + #[getter] + #[pyo3(name = "block_daa_score")] + pub fn block_daa_score_py(&self) -> u64 { + self.utxo.block_daa_score + } + + #[getter] + #[pyo3(name = "script_public_key")] + pub fn script_public_key_py(&self) -> ScriptPublicKey { + self.utxo.script_public_key.clone() + } +} + impl UtxoEntryReference { #[inline(always)] pub fn id(&self) -> UtxoEntryId { @@ -298,6 +386,7 @@ impl TryCastFromJs for UtxoEntry { /// Please consider using `UtxoContext` instead. /// @category Wallet SDK #[derive(Default, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct UtxoEntries(Arc>); @@ -350,6 +439,34 @@ impl UtxoEntries { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl UtxoEntries { + #[getter] + #[pyo3(name = "items")] + pub fn get_items_as_py_list(&self) -> Vec { + self.0.as_ref().clone().into_iter().collect() + } + + #[setter] + #[pyo3(name = "items")] + pub fn set_items_from_py_list(&mut self, v: Vec) { + self.0 = Arc::new(v); + } + + #[pyo3(name = "sort")] + pub fn sort_py(&mut self) { + let mut items = (*self.0).clone(); + items.sort_by_key(|e| e.amount()); + self.0 = Arc::new(items); + } + + #[pyo3(name = "amount")] + pub fn amount_py(&self) -> u64 { + self.0.iter().map(|e| e.amount()).sum() + } +} + impl UtxoEntries { pub fn items(&self) -> Arc> { self.0.clone() @@ -453,6 +570,42 @@ impl TryCastFromJs for UtxoEntryReference { } } +#[cfg(feature = "py-sdk")] +impl TryFrom<&Bound<'_, PyDict>> for UtxoEntryReference { + type Error = PyErr; + fn try_from(dict: &Bound) -> PyResult { + let address = Address::try_from( + dict.get_item("address")?.ok_or_else(|| PyException::new_err("Key `address` not present"))?.extract::()?, + )?; + + let outpoint = TransactionOutpoint::try_from( + dict.get_item("outpoint")?.ok_or_else(|| PyException::new_err("Key `outpoint` not present"))?.downcast::()?, + )?; + + let utxo_entry_value = dict.get_item("utxoEntry")?.ok_or_else(|| PyException::new_err("Key `utxoEntry` not present"))?; + let utxo_entry = utxo_entry_value.downcast::()?; + + let amount: u64 = utxo_entry.get_item("amount")?.ok_or_else(|| PyException::new_err("Key `amount` not present"))?.extract()?; + + let script_public_key = ScriptPublicKey::from_hex( + utxo_entry + .get_item("scriptPublicKey")? + .ok_or_else(|| PyException::new_err("Key `scriptPublicKey` not present"))? + .extract::<&str>()?, + ) + .map_err(|err| PyException::new_err(format!("{}", err)))?; + + let block_daa_score: u64 = + utxo_entry.get_item("blockDaaScore")?.ok_or_else(|| PyException::new_err("Key `blockDaaScore` not present"))?.extract()?; + + let is_coinbase: bool = + utxo_entry.get_item("isCoinbase")?.ok_or_else(|| PyException::new_err("Key `is_coinbase` not present"))?.extract()?; + + let utxo = UtxoEntry { address: Some(address), outpoint, amount, script_public_key, block_daa_score, is_coinbase }; + Ok(UtxoEntryReference { utxo: Arc::new(utxo) }) + } +} + impl UtxoEntryReference { pub fn simulated(amount: u64) -> Self { use kaspa_addresses::{Prefix, Version}; diff --git a/consensus/core/Cargo.toml b/consensus/core/Cargo.toml index 228b4ac11d..c0aa6baadb 100644 --- a/consensus/core/Cargo.toml +++ b/consensus/core/Cargo.toml @@ -13,6 +13,10 @@ repository.workspace = true devnet-prealloc = [] wasm32-sdk = [] default = [] +py-sdk = [ + "pyo3", + "kaspa-python-core/py-sdk", +] [dependencies] arc-swap.workspace = true @@ -30,8 +34,10 @@ kaspa-hashes.workspace = true kaspa-math.workspace = true kaspa-merkle.workspace = true kaspa-muhash.workspace = true +kaspa-python-core = { workspace = true, optional = true } kaspa-txscript-errors.workspace = true kaspa-utils.workspace = true +pyo3 = { workspace = true, optional = true } rand.workspace = true secp256k1.workspace = true serde_json.workspace = true diff --git a/consensus/core/src/hashing/wasm.rs b/consensus/core/src/hashing/wasm.rs index 4c9c94b223..96fd17d3b2 100644 --- a/consensus/core/src/hashing/wasm.rs +++ b/consensus/core/src/hashing/wasm.rs @@ -1,9 +1,14 @@ use super::sighash_type::{self, SigHashType}; +#[cfg(feature = "py-sdk")] +use pyo3::prelude::*; use wasm_bindgen::prelude::*; /// Kaspa Sighash types allowed by consensus /// @category Consensus +#[derive(PartialEq)] +#[cfg_attr(feature = "py-sdk", pyclass(eq, eq_int))] #[wasm_bindgen] +#[derive(Clone, Copy)] pub enum SighashType { All, None, diff --git a/consensus/core/src/network.rs b/consensus/core/src/network.rs index 18e52eacbf..0fc7a45d4c 100644 --- a/consensus/core/src/network.rs +++ b/consensus/core/src/network.rs @@ -13,6 +13,8 @@ use borsh::{BorshDeserialize, BorshSerialize}; use kaspa_addresses::Prefix; +#[cfg(feature = "py-sdk")] +use pyo3::{exceptions::PyException, PyErr}; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use std::fmt::{Debug, Display, Formatter}; use std::ops::Deref; @@ -112,6 +114,13 @@ impl FromStr for NetworkType { } } +#[cfg(feature = "py-sdk")] +impl From for PyErr { + fn from(value: NetworkTypeError) -> PyErr { + PyException::new_err(value.to_string()) + } +} + impl Display for NetworkType { #[inline] fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { @@ -187,6 +196,13 @@ impl From for JsValue { } } +#[cfg(feature = "py-sdk")] +impl From for PyErr { + fn from(value: NetworkIdError) -> PyErr { + PyException::new_err(value.to_string()) + } +} + /// /// NetworkId is a unique identifier for a kaspa network instance. /// It is composed of a network type and an optional suffix. diff --git a/consensus/core/src/tx/script_public_key.rs b/consensus/core/src/tx/script_public_key.rs index dfed2ab5ce..f53e479dfd 100644 --- a/consensus/core/src/tx/script_public_key.rs +++ b/consensus/core/src/tx/script_public_key.rs @@ -6,6 +6,8 @@ use kaspa_utils::{ hex::{FromHex, ToHex}, serde_bytes::FromHexVisitor, }; +#[cfg(feature = "py-sdk")] +use pyo3::prelude::*; use serde::{ de::{Error, Visitor}, Deserialize, Deserializer, Serialize, Serializer, @@ -50,6 +52,7 @@ export interface IScriptPublicKey { /// Represents a Kaspad ScriptPublicKey /// @category Consensus #[derive(Default, PartialEq, Eq, Clone, Hash, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct ScriptPublicKey { pub version: ScriptPublicKeyVersion, @@ -350,6 +353,21 @@ impl ScriptPublicKey { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl ScriptPublicKey { + #[new] + pub fn constructor_py(version: u16, script: kaspa_python_core::types::PyBinary) -> PyResult { + Ok(ScriptPublicKey::new(version, script.data.into())) + } + + #[getter] + #[pyo3(name = "script")] + pub fn script_as_hex_py(&self) -> String { + self.script.to_hex() + } +} + // // Borsh serializers need to be manually implemented for `ScriptPublicKey` since // smallvec does not currently support Borsh diff --git a/crypto/addresses/Cargo.toml b/crypto/addresses/Cargo.toml index 15fdb975ed..b1c474c1b8 100644 --- a/crypto/addresses/Cargo.toml +++ b/crypto/addresses/Cargo.toml @@ -9,9 +9,16 @@ include.workspace = true license.workspace = true repository.workspace = true +[features] +default = [] +py-sdk = [ + "pyo3", +] + [dependencies] borsh.workspace = true js-sys.workspace = true +pyo3 = { workspace = true, optional = true } serde.workspace = true smallvec.workspace = true thiserror.workspace = true diff --git a/crypto/addresses/src/lib.rs b/crypto/addresses/src/lib.rs index 8e3ea385a8..ba47210114 100644 --- a/crypto/addresses/src/lib.rs +++ b/crypto/addresses/src/lib.rs @@ -7,6 +7,8 @@ //! use borsh::{BorshDeserialize, BorshSerialize}; +#[cfg(feature = "py-sdk")] +use pyo3::{exceptions::PyException, prelude::*}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use smallvec::SmallVec; use std::fmt::{Display, Formatter}; @@ -203,6 +205,7 @@ pub type PayloadVec = SmallVec<[u8; PAYLOAD_VECTOR_SIZE]>; /// /// @category Address #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Hash, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct Address { #[wasm_bindgen(skip)] @@ -277,6 +280,57 @@ impl Address { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl Address { + #[new] + pub fn constructor_py(address: &str) -> PyResult
{ + Ok(address.try_into()?) + } + + #[staticmethod] + #[pyo3(name = "validate")] + pub fn validate_py(address: &str) -> bool { + Self::try_from(address).is_ok() + } + + #[pyo3(name = "to_string")] + pub fn address_to_string_py(&self) -> String { + self.into() + } + + #[getter] + #[pyo3(name = "version")] + pub fn version_to_string_py(&self) -> String { + self.version.to_string() + } + + #[getter] + #[pyo3(name = "prefix")] + pub fn prefix_to_string_py(&self) -> String { + self.prefix.to_string() + } + + #[setter] + #[pyo3(name = "prefix")] + pub fn set_prefix_from_str_py(&mut self, prefix: &str) -> PyResult<()> { + self.prefix = Prefix::try_from(prefix)?; + Ok(()) + } + + #[pyo3(name = "payload")] + pub fn payload_to_string_py(&self) -> String { + self.encode_payload() + } + + #[pyo3(name = "short")] + pub fn short_py(&self, n: usize) -> String { + let payload = self.encode_payload(); + let n = std::cmp::min(n, payload.len() / 4); + format!("{}:{}....{}", self.prefix, &payload[0..n], &payload[payload.len() - n..]) + } +} + impl Display for Address { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", String::from(self)) @@ -524,6 +578,13 @@ impl TryCastFromJs for Address { } } +#[cfg(feature = "py-sdk")] +impl From for PyErr { + fn from(value: AddressError) -> PyErr { + PyException::new_err(value.to_string()) + } +} + #[wasm_bindgen] extern "C" { /// WASM (TypeScript) type representing an Address-like object: `Address | string`. diff --git a/crypto/hashes/Cargo.toml b/crypto/hashes/Cargo.toml index 1fb144934e..579780a1d2 100644 --- a/crypto/hashes/Cargo.toml +++ b/crypto/hashes/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true [features] no-asm = ["keccak"] +py-sdk = ["pyo3"] [dependencies] blake2b_simd.workspace = true @@ -20,6 +21,7 @@ js-sys.workspace = true kaspa-utils.workspace = true keccak = { workspace = true, optional = true } once_cell.workspace = true +pyo3 = { workspace = true, optional = true } serde.workspace = true sha2.workspace = true wasm-bindgen.workspace = true diff --git a/crypto/hashes/src/lib.rs b/crypto/hashes/src/lib.rs index d9ff47997c..1fc77328be 100644 --- a/crypto/hashes/src/lib.rs +++ b/crypto/hashes/src/lib.rs @@ -7,6 +7,8 @@ use kaspa_utils::{ mem_size::MemSizeEstimator, serde_impl_deser_fixed_bytes_ref, serde_impl_ser_fixed_bytes_ref, }; +#[cfg(feature = "py-sdk")] +use pyo3::{exceptions::PyException, prelude::*}; use std::{ array::TryFromSliceError, fmt::{Debug, Display, Formatter}, @@ -23,6 +25,7 @@ pub use hashers::*; // TODO: Check if we use hash more as an array of u64 or of bytes and change the default accordingly /// @category General #[derive(Eq, Clone, Copy, Default, PartialOrd, Ord, BorshSerialize, BorshDeserialize, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen] pub struct Hash([u8; HASH_SIZE]); @@ -184,6 +187,20 @@ impl Hash { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl Hash { + #[new] + pub fn constructor_py(hex_str: &str) -> PyResult { + Ok(Hash::from_str(hex_str).map_err(|err| PyException::new_err(format!("{}", err)))?) + } + + #[pyo3(name = "to_string")] + pub fn py_to_string(&self) -> String { + self.to_string() + } +} + type TryFromError = workflow_wasm::error::Error; impl TryCastFromJs for Hash { type Error = TryFromError; diff --git a/crypto/txscript/Cargo.toml b/crypto/txscript/Cargo.toml index 46e3103e8f..fb3a5f1cb6 100644 --- a/crypto/txscript/Cargo.toml +++ b/crypto/txscript/Cargo.toml @@ -13,6 +13,10 @@ repository.workspace = true name = "kip-10" [features] +py-sdk = [ + "pyo3", + "kaspa-python-core/py-sdk", +] wasm32-core = [] wasm32-sdk = [] @@ -20,17 +24,20 @@ wasm32-sdk = [] blake2b_simd.workspace = true borsh.workspace = true cfg-if.workspace = true +faster-hex.workspace = true hexplay.workspace = true indexmap.workspace = true itertools.workspace = true kaspa-addresses.workspace = true kaspa-consensus-core.workspace = true kaspa-hashes.workspace = true +kaspa-python-core = { workspace = true, optional = true } kaspa-txscript-errors.workspace = true kaspa-utils.workspace = true kaspa-wasm-core.workspace = true log.workspace = true parking_lot.workspace = true +pyo3 = { workspace = true, optional = true } rand.workspace = true secp256k1.workspace = true serde_json.workspace = true diff --git a/crypto/txscript/src/bindings/mod.rs b/crypto/txscript/src/bindings/mod.rs new file mode 100644 index 0000000000..7c0cb4800f --- /dev/null +++ b/crypto/txscript/src/bindings/mod.rs @@ -0,0 +1,7 @@ +pub mod opcodes; + +#[cfg(feature = "py-sdk")] +pub mod python; + +#[cfg(any(feature = "wasm32-core", feature = "wasm32-sdk"))] +pub mod wasm; diff --git a/crypto/txscript/src/wasm/opcodes.rs b/crypto/txscript/src/bindings/opcodes.rs similarity index 95% rename from crypto/txscript/src/wasm/opcodes.rs rename to crypto/txscript/src/bindings/opcodes.rs index 40492cc837..3bcf1e038c 100644 --- a/crypto/txscript/src/wasm/opcodes.rs +++ b/crypto/txscript/src/bindings/opcodes.rs @@ -1,8 +1,12 @@ +#[cfg(feature = "py-sdk")] +use pyo3::prelude::*; pub use wasm_bindgen::prelude::*; /// Kaspa Transaction Script Opcodes /// @see {@link ScriptBuilder} /// @category Consensus +#[derive(Clone, PartialEq)] +#[cfg_attr(feature = "py-sdk", pyclass(eq, eq_int))] #[wasm_bindgen] pub enum Opcodes { OpFalse = 0x00, @@ -294,3 +298,12 @@ pub enum Opcodes { OpPubKey = 0xfe, OpInvalidOpCode = 0xff, } + +#[cfg(feature = "py-sdk")] +#[pymethods] +impl Opcodes { + #[getter] + pub fn value(&self) -> u8 { + self.clone() as u8 + } +} diff --git a/crypto/txscript/src/bindings/python/builder.rs b/crypto/txscript/src/bindings/python/builder.rs new file mode 100644 index 0000000000..95f9d4eed4 --- /dev/null +++ b/crypto/txscript/src/bindings/python/builder.rs @@ -0,0 +1,145 @@ +use crate::bindings::opcodes::Opcodes; +use crate::{script_builder as native, standard}; +use kaspa_consensus_core::tx::ScriptPublicKey; +use kaspa_python_core::types::PyBinary; +use kaspa_utils::hex::ToHex; +use pyo3::{exceptions::PyException, prelude::*}; +use std::sync::{Arc, Mutex, MutexGuard}; + +#[derive(Clone)] +#[pyclass] +pub struct ScriptBuilder { + script_builder: Arc>, +} + +impl ScriptBuilder { + #[inline] + pub fn inner(&self) -> MutexGuard { + self.script_builder.lock().unwrap() + } +} + +impl Default for ScriptBuilder { + fn default() -> Self { + Self { script_builder: Arc::new(Mutex::new(native::ScriptBuilder::new())) } + } +} + +#[pymethods] +impl ScriptBuilder { + #[new] + pub fn new() -> Self { + Self::default() + } + + #[staticmethod] + pub fn from_script(script: PyBinary) -> PyResult { + let builder = ScriptBuilder::default(); + builder.inner().extend(script.as_ref()); + + Ok(builder) + } + + pub fn add_op(&self, op: &Bound) -> PyResult { + let op = extract_ops(op)?; + let mut inner = self.inner(); + inner.add_op(op[0]).map_err(|err| PyException::new_err(format!("{}", err)))?; + + Ok(self.clone()) + } + + pub fn add_ops(&self, opcodes: &Bound) -> PyResult { + let ops = extract_ops(opcodes)?; + self.inner().add_ops(&ops.as_slice()).map_err(|err| PyException::new_err(format!("{}", err)))?; + + Ok(self.clone()) + } + + pub fn add_data(&self, data: PyBinary) -> PyResult { + let mut inner = self.inner(); + inner.add_data(data.as_ref()).map_err(|err| PyException::new_err(format!("{}", err)))?; + + Ok(self.clone()) + } + + pub fn add_i64(&self, value: i64) -> PyResult { + let mut inner = self.inner(); + inner.add_i64(value).map_err(|err| PyException::new_err(format!("{}", err)))?; + + Ok(self.clone()) + } + + pub fn add_lock_time(&self, lock_time: u64) -> PyResult { + let mut inner = self.inner(); + inner.add_lock_time(lock_time).map_err(|err| PyException::new_err(format!("{}", err)))?; + + Ok(self.clone()) + } + + pub fn add_sequence(&self, sequence: u64) -> PyResult { + let mut inner = self.inner(); + inner.add_sequence(sequence).map_err(|err| PyException::new_err(format!("{}", err)))?; + + Ok(self.clone()) + } + + #[staticmethod] + pub fn canonical_data_size(data: PyBinary) -> PyResult { + let size = native::ScriptBuilder::canonical_data_size(data.as_ref()) as u32; + + Ok(size) + } + + pub fn to_string(&self) -> String { + let inner = self.inner(); + + inner.script().to_vec().iter().map(|b| format!("{:02x}", b)).collect() + } + + pub fn drain(&self) -> String { + let mut inner = self.inner(); + + String::from_utf8(inner.drain()).unwrap() + } + + #[pyo3(name = "create_pay_to_script_hash_script")] + pub fn pay_to_script_hash_script(&self) -> ScriptPublicKey { + let inner = self.inner(); + let script = inner.script(); + + standard::pay_to_script_hash_script(script) + } + + #[pyo3(name = "encode_pay_to_script_hash_signature_script")] + pub fn pay_to_script_hash_signature_script(&self, signature: PyBinary) -> PyResult { + let inner = self.inner(); + let script = inner.script(); + let generated_script = standard::pay_to_script_hash_signature_script(script.into(), signature.into()) + .map_err(|err| PyException::new_err(format!("{}", err)))?; + + Ok(generated_script.to_hex().into()) + } +} + +// PY-TODO change to PyOpcode struct and handle similar to PyBinary? +fn extract_ops(input: &Bound) -> PyResult> { + if let Ok(opcode) = extract_op(&input) { + // Single u8 or Opcodes variant + Ok(vec![opcode]) + } else if let Ok(list) = input.downcast::() { + // List of u8 or Opcodes variants + list.iter().map(|item| extract_op(&item)).collect::>>() + } else { + Err(PyException::new_err("Expected an Opcodes enum variant or an integer.")) + } +} + +fn extract_op(item: &Bound) -> PyResult { + if let Ok(op) = item.extract::() { + Ok(op) + } else if let Ok(op) = item.extract::() { + Ok(op.value()) + } else { + Err(PyException::new_err("Expected Opcodes enum variant or u8")) + } +} diff --git a/crypto/txscript/src/bindings/python/mod.rs b/crypto/txscript/src/bindings/python/mod.rs new file mode 100644 index 0000000000..d3fddd29c9 --- /dev/null +++ b/crypto/txscript/src/bindings/python/mod.rs @@ -0,0 +1,2 @@ +pub mod builder; +pub use self::builder::*; diff --git a/crypto/txscript/src/wasm/builder.rs b/crypto/txscript/src/bindings/wasm/builder.rs similarity index 100% rename from crypto/txscript/src/wasm/builder.rs rename to crypto/txscript/src/bindings/wasm/builder.rs diff --git a/crypto/txscript/src/wasm/mod.rs b/crypto/txscript/src/bindings/wasm/mod.rs similarity index 76% rename from crypto/txscript/src/wasm/mod.rs rename to crypto/txscript/src/bindings/wasm/mod.rs index e88e580c7d..849efad492 100644 --- a/crypto/txscript/src/wasm/mod.rs +++ b/crypto/txscript/src/bindings/wasm/mod.rs @@ -6,10 +6,10 @@ use cfg_if::cfg_if; cfg_if! { if #[cfg(any(feature = "wasm32-sdk", feature = "wasm32-core"))] { - pub mod opcodes; + // pub mod opcodes; pub mod builder; - pub use self::opcodes::*; + pub use crate::bindings::opcodes::*; pub use self::builder::*; } } diff --git a/crypto/txscript/src/error.rs b/crypto/txscript/src/error.rs index 7d45fb05e0..bc97320012 100644 --- a/crypto/txscript/src/error.rs +++ b/crypto/txscript/src/error.rs @@ -1,4 +1,6 @@ use crate::script_builder; +#[cfg(feature = "py-sdk")] +use pyo3::{exceptions::PyException, prelude::PyErr}; use thiserror::Error; use wasm_bindgen::{JsError, JsValue}; use workflow_wasm::jserror::JsErrorData; @@ -87,3 +89,10 @@ impl From for Error { Self::SerdeWasmBindgen(JsValue::from(err).into()) } } + +#[cfg(feature = "py-sdk")] +impl From for PyErr { + fn from(value: Error) -> Self { + PyException::new_err(value.to_string()) + } +} diff --git a/crypto/txscript/src/lib.rs b/crypto/txscript/src/lib.rs index a82be592f6..6f7d879c82 100644 --- a/crypto/txscript/src/lib.rs +++ b/crypto/txscript/src/lib.rs @@ -1,6 +1,8 @@ extern crate alloc; extern crate core; +#[cfg(any(feature = "wasm32-sdk", feature = "py-sdk", feature = "py-sdk"))] +pub mod bindings; pub mod caches; mod data_stack; pub mod error; @@ -9,8 +11,6 @@ pub mod result; pub mod script_builder; pub mod script_class; pub mod standard; -#[cfg(feature = "wasm32-sdk")] -pub mod wasm; use crate::caches::Cache; use crate::data_stack::{DataStack, Stack}; diff --git a/crypto/txscript/src/script_builder.rs b/crypto/txscript/src/script_builder.rs index 7a5b28ca5a..1f97e06168 100644 --- a/crypto/txscript/src/script_builder.rs +++ b/crypto/txscript/src/script_builder.rs @@ -74,7 +74,7 @@ impl ScriptBuilder { &self.script } - #[cfg(any(test, target_arch = "wasm32"))] + #[cfg(any(test, target_arch = "wasm32", feature = "py-sdk"))] pub fn extend(&mut self, data: &[u8]) { self.script.extend(data); } diff --git a/python/.cargo/config.toml b/python/.cargo/config.toml new file mode 100644 index 0000000000..59c989e695 --- /dev/null +++ b/python/.cargo/config.toml @@ -0,0 +1,11 @@ +[target.x86_64-apple-darwin] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] + +[target.aarch64-apple-darwin] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] \ No newline at end of file diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000000..ae412d6a07 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1 @@ +env/ \ No newline at end of file diff --git a/python/Cargo.toml b/python/Cargo.toml new file mode 100644 index 0000000000..923f1bfaaa --- /dev/null +++ b/python/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "python" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +include.workspace = true + +[lib] +name = "kaspa" +crate-type = ["cdylib"] + +[dependencies] +cfg-if.workspace = true +kaspa-addresses.workspace = true +kaspa-bip32.workspace = true +kaspa-consensus-core.workspace = true +kaspa-consensus-client.workspace = true +kaspa-hashes.workspace = true +kaspa-txscript.workspace = true +kaspa-wallet-core.workspace = true +kaspa-wallet-keys.workspace = true +kaspa-wrpc-python.workspace = true +pyo3.workspace = true + +[features] +default = [] +py-sdk = [ + "pyo3/extension-module", + "kaspa-addresses/py-sdk", + "kaspa-consensus-client/py-sdk", + "kaspa-txscript/py-sdk", + "kaspa-wallet-keys/py-sdk", + "kaspa-wallet-core/py-sdk", + "kaspa-wrpc-python/py-sdk", +] + +[lints] +workspace = true diff --git a/python/LICENSE b/python/LICENSE new file mode 100644 index 0000000000..b66757abcb --- /dev/null +++ b/python/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2022-2024 Kaspa developers + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000000..63c5b01d60 --- /dev/null +++ b/python/README.md @@ -0,0 +1,57 @@ +# Kaspa Python SDK +Rusty-Kaspa Python SDK exposes select Rusty-Kaspa source for use in Python applications, allowing Python developers to interact with the Kaspa BlockDAG. + +This package is built from Rusty-Kaspa's Rust source code using [PyO3](https://pyo3.rs/v0.20.0/) and [Maturin](https://www.maturin.rs) to build bindings for Python. + +> [!IMPORTANT] +> Kaspa Python SDK is currently in Beta (maybe even Alpha in some regards) status. Please use accordingly. + +## Features +A goal of this package is to mirror Kaspa's WASM SDK as closely as possible. From both a feature coverage and usage perspective. + +The following main feature categories are currently exposed for use from Python: +- wRPC Client +- Transaction generation +- Key management + +This package does not yet fully mirror WASM SDK, gaps mostly exist around wallet functionality. Future work will bring this as close as possible. The ability to read Rusty-Kaspa's RocksDB database from Python is in progress. + +## Installing from Source +This package can currently be installed from source. + +### Instructions +1. To build the Python SDK from source, you need to have the Rust environment installed. To do that, follow instructions in the [Installation section of Rusty Kaspa README](https://github.com/kaspanet/rusty-kaspa?tab=readme-ov-file#installation). +2. `cd rusty-kaspa/python` to enter Python SDK crate +3. Run `./build-release` script to build source and built (wheel) dists. +4. The resulting wheel (`.whl`) file location will be printed: `Built wheel for CPython 3.x to `. The `.whl` file can be copied to another location or machine and installed there with `pip install <.whl filepath>` + +### `maturin develop` vs. `maturin build` +For full details, please see `build-release` script, `build-dev` script, and [Maturin](https://www.maturin.rs) documentation. + +Build & install in current active virtual env: `maturin develop --release --features py-sdk` + +Build source and built (wheel) distributions: `maturin build --release --strip --sdist --features py-sdk`. + +## Usage from Python + +The Python SDK module name is `kaspa`. The following example shows how to connect an RPC client to Kaspa's PNN (Public Node Network). + +```python +import asyncio +from kapsa import Resolver, RpcClient + +async def main(): + resolver = Resolver() + client = RpcClient(resolver) + print(await client.get_server_info()) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +More detailed examples can be found in `./examples`. + +## SDK Project Layout +The Python package `kaspa` is built from the `kaspa-python` crate, which is located at `./python`. + +As such, the Rust `kaspa` function in `./python/src/lib.rs` is a good starting point. This function uses PyO3 to add functionality to the package. diff --git a/python/build-dev b/python/build-dev new file mode 100755 index 0000000000..42612490f1 --- /dev/null +++ b/python/build-dev @@ -0,0 +1,29 @@ +#!/bin/bash + +set -e + +VENV_DIR="env" + +if [ ! -d "$VENV_DIR" ]; then + echo "Creating virtual environment in '$VENV_DIR'" + python3 -m venv $VENV_DIR +else + echo "Virtual environment already exists, using '$VENV_DIR'" +fi + +echo "Activating virtual environment '$VENV_DIR'" +source $VENV_DIR/bin/activate + +if ! command -v maturin &> /dev/null; then + echo "Maturin not found in '$VENV_DIR', installing Maturin" + pip install maturin +else + echo "Maturin is already installed in '$VENV_DIR'" + maturin --version +fi + +BUILD_CMD="maturin develop --target-dir ./target --features py-sdk" +echo "Building with command '$BUILD_CMD'" +$BUILD_CMD + +echo "Build complete." diff --git a/python/build-release b/python/build-release new file mode 100755 index 0000000000..480adcabbd --- /dev/null +++ b/python/build-release @@ -0,0 +1,29 @@ +#!/bin/bash + +set -e + +VENV_DIR="env" + +if [ ! -d "$VENV_DIR" ]; then + echo "Creating virtual environment in '$VENV_DIR'" + python3 -m venv $VENV_DIR +else + echo "Virtual environment already exists, using '$VENV_DIR'" +fi + +echo "Activating virtual environment '$VENV_DIR'" +source $VENV_DIR/bin/activate + +if ! command -v maturin &> /dev/null; then + echo "Maturin not found in '$VENV_DIR', installing Maturin" + pip install maturin +else + echo "Maturin is already installed in '$VENV_DIR'" + maturin --version +fi + +BUILD_CMD="maturin build --release --strip --sdist --target-dir target --out target/wheels --features py-sdk" +echo "Building with command '$BUILD_CMD'" +$BUILD_CMD + +echo "Build complete." diff --git a/python/core/Cargo.toml b/python/core/Cargo.toml new file mode 100644 index 0000000000..61e3879bb8 --- /dev/null +++ b/python/core/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "kaspa-python-core" +description = "Kaspa core Python types" +version.workspace = true +edition.workspace = true +authors.workspace = true +include.workspace = true +license.workspace = true +repository.workspace = true + +[features] +py-sdk = [] + +[dependencies] +cfg-if.workspace = true +pyo3.workspace = true +faster-hex.workspace = true + +[lints] +workspace = true diff --git a/python/core/src/lib.rs b/python/core/src/lib.rs new file mode 100644 index 0000000000..79ae98c23e --- /dev/null +++ b/python/core/src/lib.rs @@ -0,0 +1,5 @@ +cfg_if::cfg_if! { + if #[cfg(feature = "py-sdk")] { + pub mod types; + } +} diff --git a/python/core/src/types.rs b/python/core/src/types.rs new file mode 100644 index 0000000000..5007ecc588 --- /dev/null +++ b/python/core/src/types.rs @@ -0,0 +1,64 @@ +use pyo3::exceptions::PyException; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyList}; + +pub struct PyBinary { + pub data: Vec, +} + +impl<'a> FromPyObject<'a> for PyBinary { + fn extract_bound(value: &Bound) -> PyResult { + if let Ok(str) = value.extract::() { + // Python `str` (of valid hex) + let mut data = vec![0u8; str.len() / 2]; + match faster_hex::hex_decode(str.as_bytes(), &mut data) { + Ok(()) => Ok(PyBinary { data }), + Err(_) => Err(PyException::new_err("Invalid hex string")), + } + } else if let Ok(py_bytes) = value.downcast::() { + // Python `bytes` type + Ok(PyBinary { data: py_bytes.as_bytes().to_vec() }) + } else if let Ok(op_list) = value.downcast::() { + // Python `[int]` (list of bytes) + let data = op_list.iter().map(|item| item.extract::()).collect::>>()?; + Ok(PyBinary { data }) + } else { + Err(PyException::new_err("Expected `str` (of valid hex), `bytes`, or `[int]`")) + } + } +} + +impl TryFrom<&Bound<'_, PyAny>> for PyBinary { + type Error = PyErr; + fn try_from(value: &Bound) -> Result { + if let Ok(str) = value.extract::() { + // Python `str` (of valid hex) + let mut data = vec![0u8; str.len() / 2]; + match faster_hex::hex_decode(str.as_bytes(), &mut data) { + Ok(()) => Ok(PyBinary { data }), // Hex string + Err(_) => Err(PyException::new_err("Invalid hex string")), + } + } else if let Ok(py_bytes) = value.downcast::() { + // Python `bytes` type + Ok(PyBinary { data: py_bytes.as_bytes().to_vec() }) + } else if let Ok(op_list) = value.downcast::() { + // Python `[int]` (list of bytes) + let data = op_list.iter().map(|item| item.extract::().unwrap()).collect(); + Ok(PyBinary { data }) + } else { + Err(PyException::new_err("Expected `str` (of valid hex), `bytes`, or `[int]`")) + } + } +} + +impl Into> for PyBinary { + fn into(self) -> Vec { + self.data + } +} + +impl AsRef<[u8]> for PyBinary { + fn as_ref(&self) -> &[u8] { + self.data.as_slice() + } +} diff --git a/python/examples/addresses.py b/python/examples/addresses.py new file mode 100644 index 0000000000..77f6f8ef54 --- /dev/null +++ b/python/examples/addresses.py @@ -0,0 +1,64 @@ +from kaspa import ( + PublicKey, + PublicKeyGenerator, + PrivateKey, + Keypair, + # create_address +) + +def demo_generate_address_from_public_key_hex_string(): + # Compressed public key "02dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659" + public_key = PublicKey("02dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659") + print("\nGiven compressed public key: 02dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659") + print(public_key.to_string()) + print(public_key.to_address("mainnet").to_string()) + + # x-only public key: "dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659" + x_only_public_key = PublicKey("dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659") + print("\nGiven x-only public key: dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659") + print(x_only_public_key.to_string()) + print(x_only_public_key.to_address("mainnet").to_string()) + + # EDR public key + full_der_public_key = PublicKey("0421eb0c4270128b16c93c5f0dac48d56051a6237dae997b58912695052818e348b0a895cbd0c93a11ee7afac745929d96a4642a71831f54a7377893af71a2e2ae") + print("\nGiven x-only public key: 0421eb0c4270128b16c93c5f0dac48d56051a6237dae997b58912695052818e348b0a895cbd0c93a11ee7afac745929d96a4642a71831f54a7377893af71a2e2ae") + print(full_der_public_key.to_string()) + print(full_der_public_key.to_address("mainnet").to_string()) + +def demo_generate_address_from_private_key_hex_string(): + private_key = PrivateKey("b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da56a784d9045190cfef") + print("\nGiven private key b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da56a784d9045190cfef") + print(private_key.to_keypair().to_address("mainnet").to_string()) + +def demo_generate_random(): + keypair = Keypair.random() + print("\nRandom Generation") + print(keypair.private_key) + print(keypair.public_key) + print(keypair.to_address("mainnet").to_string()) + +if __name__ == "__main__": + demo_generate_address_from_public_key_hex_string() + demo_generate_address_from_private_key_hex_string() + demo_generate_random() + + # HD Wallet style pub key gen + xpub = PublicKeyGenerator.from_master_xprv( + "kprv5y2qurMHCsXYrNfU3GCihuwG3vMqFji7PZXajMEqyBkNh9UZUJgoHYBLTKu1eM4MvUtomcXPQ3Sw9HZ5ebbM4byoUciHo1zrPJBQfqpLorQ", + False, + 0 + ) + print(xpub.to_string()) + + # Generates the first 10 Receive Public Keys and their addresses + compressed_public_keys = xpub.receive_pubkeys(0, 10) + print("\nreceive address compressed_public_keys") + for key in compressed_public_keys: + print(key.to_string(), key.to_address("mainnet").to_string()) + + # Generates the first 10 Change Public Keys and their addresses + compressed_public_keys = xpub.change_pubkeys(0, 10) + print("\nchange address compressed_public_keys") + for key in compressed_public_keys: + print(key.to_string(), key.to_address("mainnet").to_string()) + diff --git a/python/examples/derivation.py b/python/examples/derivation.py new file mode 100644 index 0000000000..56afb9ba63 --- /dev/null +++ b/python/examples/derivation.py @@ -0,0 +1,55 @@ +from kaspa import ( + DerivationPath, + Mnemonic, + PublicKey, + XPrv +) + +if __name__ == "__main__": + mnemonic = Mnemonic("hunt bitter praise lift buyer topic crane leopard uniform network inquiry over grain pass match crush marine strike doll relax fortune trumpet sunny silk") + seed = mnemonic.to_seed() + + xprv = XPrv(seed) + + # Create receive wallet + receive_wallet_xpub = xprv.derive_path("m/44'/111111'/0'/0").to_xpub() + # Derive receive wallet for second address + pubkey2 = receive_wallet_xpub.derive_child(1, False).to_public_key() + print(f'Receive Address: {pubkey2.to_address("mainnet").to_string()}') + + # Create change wallet + change_wallet_xpub = xprv.derive_path("m/44'/111111'/0'/1").to_xpub() + # Derive change wallet for first address + pubkey3 = change_wallet_xpub.derive_child(0, False).to_public_key() + print(f'Change Address: {pubkey3.to_address("mainnet").to_string()}') + + # Derive address via public key + private_key = xprv.derive_path("m/44'/111111'/0'/0/1").to_private_key() + print(f'Address via private key: {private_key.to_address("mainnet").to_string()}') + print(f'Private key: {private_key.to_string()}') + + # XPrv with ktrv prefix + ktrv = xprv.into_string("ktrv") + print(f'ktrv prefix: {ktrv}') + + # Create derivation path + path = DerivationPath("m/1'") + path.push(2, True) + path.push(3, False) + print(f'Derivation Path: {path.to_string()}') + + # Derive by path string + print(f'{xprv.derive_path("m/1'/2'/3").into_string("xprv")}') + # Derive by DerivationPath object + print(f'{xprv.derive_path(path).into_string("xprv")}') + # Create XPrv from ktrvx string and derive it + print(f'{XPrv.from_xprv(ktrv).derive_path("m/1'/2'/3").into_string("xprv")}') + + # Get xpub + xpub = xprv.to_xpub() + # Derive xpub + print(xpub.derive_path("m/1").into_string("xpub")) + # Get public key from xpub + print(xpub.to_public_key().to_string()) + + diff --git a/python/examples/message_signing.py b/python/examples/message_signing.py new file mode 100644 index 0000000000..e0ee0a7484 --- /dev/null +++ b/python/examples/message_signing.py @@ -0,0 +1,12 @@ +from kaspa import PrivateKey, PublicKey, sign_message, verify_message + +if __name__ == "__main__": + message = "Hello Kaspa!" + private_key = PrivateKey("b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da56a784d9045190cfef") + public_key = PublicKey("dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659") + + signature = sign_message(message, private_key) + print(f'Signature: {signature}') + + valid_sig = verify_message(message, signature, public_key) + print('Valid sig' if valid_sig else 'Invalid sig') diff --git a/python/examples/mnemonic.py b/python/examples/mnemonic.py new file mode 100644 index 0000000000..a080b6d067 --- /dev/null +++ b/python/examples/mnemonic.py @@ -0,0 +1,19 @@ +from kaspa import Language, Mnemonic + +if __name__ == "__main__": + mnemonic1 = Mnemonic.random() + print(f'mnemonic 1: {mnemonic1.phrase}') + + mnemonic2 = Mnemonic(phrase=mnemonic1.phrase) + print(f'mnemonic 2: {mnemonic2.phrase}') + + # Create seed with a recovery password (25th word) + seed1 = mnemonic1.to_seed("my_password") + print(f'seed1: {seed1}') + + seed2 = mnemonic2.to_seed("my_password") + print(f'seed2: {seed2}') + + # Create seed without recovery password + seed3 = mnemonic1.to_seed() + print(f'seed3 (no recovery password): {seed3}') \ No newline at end of file diff --git a/python/examples/rpc/all_calls.py b/python/examples/rpc/all_calls.py new file mode 100644 index 0000000000..b9ebabacaa --- /dev/null +++ b/python/examples/rpc/all_calls.py @@ -0,0 +1,176 @@ +import asyncio + +from kaspa import Resolver, RpcClient + + +async def main(): + client = RpcClient(resolver=Resolver()) + await client.connect() + + ### + # Get some sample data for request parameters + ### + block_dag_info_response = await client.get_block_dag_info() + tip_hashes = block_dag_info_response["tipHashes"] + + block = await client.get_block(request={ + "hash": tip_hashes[0], + "includeTransactions": True + }) + + addresses = [] + transaction_ids = [] + subnetwork_ids = set() + for tx in block["block"]["transactions"]: + transaction_ids.append(tx["verboseData"]["transactionId"]) + subnetwork_ids.add(tx["subnetworkId"]) + + for output in tx["outputs"]: + addresses.append(output["verboseData"]["scriptPublicKeyAddress"]) + addresses = list(set(addresses)) + + ### + # Sample requests + ### + await client.get_block_count() + + await client.get_block_dag_info() + + await client.get_coin_supply() + + await client.get_connected_peer_info() + + await client.get_info() + + await client.get_peer_addresses() + + await client.get_metrics(request={ + "processMetrics": True, + "connectionMetrics": True, + "bandwidthMetrics": True, + "consensusMetrics": True, + "storageMetrics": True, + "customMetrics": True, + }) + + await client.get_connections(request={ + "includeProfileData": True + }) + + await client.get_sink() + + await client.get_sink_blue_score() + + await client.ping() + + # await client.shutdown() + + await client.get_server_info() + + await client.get_sync_status() + + # await client.add_peer(request=) + + # await client.ban(request=) + + await client.estimate_network_hashes_per_second(request={ + "windowSize": 1000, + "startHash": block_dag_info_response["tipHashes"][0] + }) + + await client.get_balance_by_address(request={ + "address": addresses[0] + }) + + await client.get_balances_by_addresses(request={ + "addresses": addresses + }) + + await client.get_block(request={ + "hash": block_dag_info_response["tipHashes"][0], + "includeTransactions": True + }) + + await client.get_blocks(request={ + "lowHash": block_dag_info_response["pruningPointHash"], + "includeBlocks": True, + "includeTransactions": True, + }) + + await client.get_block_template(request={ + "payAddress": addresses[0], + "extraData": list("my miner name is...".encode('utf-8')) + }) + + # await client.get_current_block_color(request={ + # "hash": block_dag_info_response["pruningPointHash"] + # }) + + await client.get_daa_score_timestamp_estimate(request={ + "daaScores": [block_dag_info_response["virtualDaaScore"]] + }) + + await client.get_fee_estimate(request={}) + + await client.get_fee_estimate_experimental(request={ + "verbose": True + }) + + await client.get_current_network(request={}) + + # await client.get_headers(request={ + # "startHash": block_dag_info_response["tipHashes"][0], + # "limit": 5, + # "isAscending": True + # }) + + mempool_entries = await client.get_mempool_entries(request={ + "includeOrphanPool": False, + "includeOrphanPool": False, + "filterTransactionPool": False, + }) + + await client.get_mempool_entries_by_addresses(request={ + "addresses": addresses, + "includeOrphanPool": False, + "filterTransactionPool": False, + }) + + if len(mempool_entries) > 0: + try: + await client.get_mempool_entry(request={ + "transactionId": mempool_entries["mempoolEntries"][0]["transaction"]["verboseData"]["transactionId"], + "includeOrphanPool": False, + "filterTransactionPool": False, + }) + except Exception as e: + print(e) + + # await client.get_subnetwork(request={ + # "subnetworkId": list(subnetwork_ids)[0] + # }) + + await client.get_utxos_by_addresses(request={ + "addresses": addresses + }) + + await client.get_virtual_chain_from_block(request={ + "startHash": tip_hashes[0], + "includeAcceptedTransactionIds": True + }) + + # await client.resolve_finality_conflict(request) + + # await client.submit_block(request) + + # await client.submit_transaction(request) + + # await client.submit_transaction_replacement(request) + + # await client.unban(request) + + await client.disconnect() + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/python/examples/rpc/get_balances_by_addresses.py b/python/examples/rpc/get_balances_by_addresses.py new file mode 100644 index 0000000000..34601d7ef0 --- /dev/null +++ b/python/examples/rpc/get_balances_by_addresses.py @@ -0,0 +1,18 @@ +import asyncio +from kaspa import Resolver, RpcClient + +async def main(): + client = RpcClient(resolver=Resolver()) + await client.connect() + + balances = await client.get_balances_by_addresses(request={ + "addresses": ["kaspa:qpamkvhgh0kzx50gwvvp5xs8ktmqutcy3dfs9dc3w7lm9rq0zs76vf959mmrp"] + }) + + print(balances) + + await client.disconnect() + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/python/examples/rpc/resolver.py b/python/examples/rpc/resolver.py new file mode 100644 index 0000000000..a89694da4f --- /dev/null +++ b/python/examples/rpc/resolver.py @@ -0,0 +1,26 @@ +import asyncio +from kaspa import Resolver, RpcClient + +async def main(): + resolver = Resolver() + + # Connect to mainnet PNN + client = RpcClient(resolver=resolver) + await client.connect() + print(f'client connected to {await client.get_current_network()}') + await client.disconnect() + + client.set_network_id("testnet-10") + await client.connect() + print(f'client connected to {await client.get_current_network()}') + await client.disconnect() + + client.set_network_id("testnet-11") + await client.connect() + print(f'client connected to {await client.get_current_network()}') + await client.disconnect() + +if __name__ == "__main__": + asyncio.run(main()) + + \ No newline at end of file diff --git a/python/examples/rpc/subscriptions.py b/python/examples/rpc/subscriptions.py new file mode 100644 index 0000000000..bee24f9431 --- /dev/null +++ b/python/examples/rpc/subscriptions.py @@ -0,0 +1,52 @@ +import asyncio + +from kaspa import RpcClient, Resolver + + +def subscription_callback(event, name, **kwargs): + # print(event['nonexistent key']) + + # try: + # print(event['nonexistent key']) + # except KeyError: + # print('caught key error exception') + + print(f"{name} | {event}") + +def block_added_handler(event): + print(f"block_added_handler: {event}") + +async def rpc_subscriptions(client: RpcClient): + # client.add_event_listener("all", subscription_callback, callback_id=1, kwarg1="Im a kwarg!!") + client.add_event_listener("all", subscription_callback, name="all") + client.add_event_listener("block-added", block_added_handler) + + await client.subscribe_virtual_daa_score_changed() + await client.subscribe_virtual_chain_changed(True) + await client.subscribe_block_added() + await client.subscribe_new_block_template() + + await asyncio.sleep(5) + + client.remove_event_listener("all") + print("Removed all event listeners. Sleeping for 5 seconds before unsubscribing. Should see nothing print.") + + await asyncio.sleep(5) + + await client.unsubscribe_virtual_daa_score_changed() + await client.unsubscribe_virtual_chain_changed(True) + await client.unsubscribe_block_added() + await client.unsubscribe_new_block_template() + +async def main(): + client = RpcClient(resolver=Resolver(), network_id="testnet-11") + + await client.connect() + print(f"Client is connected: {client.is_connected}") + + await rpc_subscriptions(client) + await client.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/python/examples/transactions/estimate.py b/python/examples/transactions/estimate.py new file mode 100644 index 0000000000..442ec3909f --- /dev/null +++ b/python/examples/transactions/estimate.py @@ -0,0 +1,29 @@ +import asyncio +from kaspa import Generator, PrivateKey, Resolver, RpcClient, kaspa_to_sompi + +async def main(): + private_key = PrivateKey("389840d7696e89c38856a066175e8e92697f0cf182b854c883237a50acaf1f69") + + source_address = private_key.to_keypair().to_address("testnet") + print(f'Source Address: {source_address.to_string()}') + + client = RpcClient(resolver=Resolver(), network_id="testnet-10") + await client.connect() + + entries = await client.get_utxos_by_addresses({"addresses": [source_address]}) + + generator = Generator( + network_id="testnet-10", + entries=entries["entries"], + outputs=[{"address": source_address, "amount": kaspa_to_sompi(0.2)}], + priority_fee=kaspa_to_sompi(0.0002), + change_address=source_address + ) + + estimate = generator.estimate() + print(estimate.final_transaction_id) + + await client.disconnect() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/python/examples/transactions/generator.py b/python/examples/transactions/generator.py new file mode 100644 index 0000000000..2ec877e883 --- /dev/null +++ b/python/examples/transactions/generator.py @@ -0,0 +1,45 @@ +import asyncio +from kaspa import ( + Generator, + PrivateKey, + PublicKey, + Resolver, + RpcClient, + kaspa_to_sompi +) + +async def main(): + private_key = PrivateKey("389840d7696e89c38856a066175e8e92697f0cf182b854c883237a50acaf1f69") + source_address = private_key.to_keypair().to_address("testnet") + print(f'Source Address: {source_address.to_string()}') + + client = RpcClient(resolver=Resolver(), network_id="testnet-10") + await client.connect() + + entries = await client.get_utxos_by_addresses({"addresses": [source_address]}) + entries = entries["entries"] + + entries = sorted(entries, key=lambda x: x['utxoEntry']['amount'], reverse=True) + total = sum(item['utxoEntry']['amount'] for item in entries) + + generator = Generator( + network_id="testnet-10", + entries=entries, + outputs=[ + {"address": source_address, "amount": kaspa_to_sompi(10)}, + {"address": source_address, "amount": kaspa_to_sompi(10)}, + {"address": source_address, "amount": kaspa_to_sompi(10)} + ], + change_address=source_address, + priority_fee=kaspa_to_sompi(10), + ) + + for pending_tx in generator: + print(pending_tx.sign([private_key])) + tx_id = await pending_tx.submit(client) + print(tx_id) + + print(generator.summary().transactions) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/python/examples/transactions/krc20_deploy.py b/python/examples/transactions/krc20_deploy.py new file mode 100644 index 0000000000..16fde57665 --- /dev/null +++ b/python/examples/transactions/krc20_deploy.py @@ -0,0 +1,101 @@ +import asyncio +import json + +from kaspa import ( + Opcodes, + PrivateKey, + Resolver, + RpcClient, + ScriptBuilder, + address_from_script_public_key, + create_transactions, +) + + +async def main(): + client = RpcClient(resolver=Resolver(), network_id='testnet-10') + await client.connect() + + private_key = PrivateKey('389840d7696e89c38856a066175e8e92697f0cf182b854c883237a50acaf1f69') + public_key = private_key.to_public_key() + address = public_key.to_address('testnet') + print(f'Address: {address.to_string()}') + print(f'XOnly Pub Key: {public_key.to_x_only_public_key().to_string()}') + + ###################### + # Commit tx + + data = { + 'p': 'krc-20', + 'op': 'deploy', + 'tick': 'TPYSDK', + 'max': '112121115100107', + 'lim': '1000', + } + + script = ScriptBuilder()\ + .add_data(public_key.to_x_only_public_key().to_string())\ + .add_op(Opcodes.OpCheckSig)\ + .add_op(Opcodes.OpFalse)\ + .add_op(Opcodes.OpIf)\ + .add_data(b'kasplex')\ + .add_i64(0)\ + .add_data(json.dumps(data, separators=(',', ':')).encode('utf-8'))\ + .add_op(Opcodes.OpEndIf) + print(f'Script: {script.to_string()}') + + p2sh_address = address_from_script_public_key(script.create_pay_to_script_hash_script(), 'testnet') + print(f'P2SH Address: {p2sh_address.to_string()}') + + utxos = await client.get_utxos_by_addresses(request={'addresses': [address]}) + + commit_txs = create_transactions( + priority_entries=[], + entries=utxos["entries"], + outputs=[{ 'address': p2sh_address.to_string(), 'amount': 1 * 100_000_000 }], + change_address=address, + priority_fee=1 * 100_000_000, + network_id='testnet-10' + ) + + commit_tx_id = None + for transaction in commit_txs['transactions']: + transaction.sign([private_key], False) + commit_tx_id = await transaction.submit(client) + print('Commit TX ID:', commit_tx_id) + + await asyncio.sleep(10) + + ##################### + # Reveal tx + + utxos = await client.get_utxos_by_addresses(request={'addresses': [address]}) + reveal_utxos = await client.get_utxos_by_addresses(request={'addresses': [p2sh_address]}) + + for entry in reveal_utxos['entries']: + if entry['outpoint']['transactionId'] == commit_tx_id: + reveal_utxos = entry + + reveal_txs = create_transactions( + priority_entries=[reveal_utxos], + entries=utxos['entries'], + outputs=[], + change_address=address, + priority_fee=1005 * 100_000_000, + network_id='testnet-10' + ) + + for transaction in reveal_txs['transactions']: + transaction.sign([private_key], False) + + commit_output = next((i for i, input in enumerate(transaction.transaction.inputs) + if input.signature_script == ''), None) + + if commit_output is not None: + sig = transaction.create_input_signature(commit_output, private_key) + transaction.fill_input(commit_output, script.encode_pay_to_script_hash_signature_script(sig)) + + print('Reveal TX ID:', await transaction.submit(client)) + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/python/examples/transactions/single_transaction.py b/python/examples/transactions/single_transaction.py new file mode 100644 index 0000000000..0ce82712af --- /dev/null +++ b/python/examples/transactions/single_transaction.py @@ -0,0 +1,48 @@ +import asyncio +from kaspa import ( + Keypair, + PrivateKey, + RpcClient, + Resolver, + PaymentOutput, + create_transaction, + sign_transaction +) + +async def main(): + private_key = PrivateKey("389840d7696e89c38856a066175e8e92697f0cf182b854c883237a50acaf1f69") + keypair = private_key.to_keypair() + address = keypair.to_address(network="testnet") + print(address.to_string()) + + client = RpcClient(resolver=Resolver(), network_id="testnet-10") + await client.connect() + print(f"Client is connected: {client.is_connected}") + + utxos = await client.get_utxos_by_addresses({"addresses": [address]}) + utxos = utxos["entries"] + + utxos = sorted(utxos, key=lambda x: x['utxoEntry']['amount'], reverse=True) + total = sum(item['utxoEntry']['amount'] for item in utxos) + + fee_rates = await client.get_fee_estimate() + fee = int(fee_rates["estimate"]["priorityBucket"]["feerate"]) + + fee = max(fee, 5000) + output_amount = int(total - fee) + + change_address = address + outputs = [ + {"address": change_address, "amount": output_amount}, + ] + + tx = create_transaction(utxos, outputs, 0, None, 1) + tx_signed = sign_transaction(tx, [private_key], True) + + print(await client.submit_transaction({ + "transaction": tx_signed, + "allow_orphan": True + })) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/python/examples/wallet_utils.py b/python/examples/wallet_utils.py new file mode 100644 index 0000000000..a1720b9471 --- /dev/null +++ b/python/examples/wallet_utils.py @@ -0,0 +1,8 @@ +from kaspa import kaspa_to_sompi, sompi_to_kaspa, sompi_to_kaspa_string_with_suffix + +if __name__ == "__main__": + print(kaspa_to_sompi(100.833)) + + sompi = 499_922_100 + print(sompi_to_kaspa(sompi)) + print(sompi_to_kaspa_string_with_suffix(sompi, "mainnet")) \ No newline at end of file diff --git a/python/kaspa.pyi b/python/kaspa.pyi new file mode 100644 index 0000000000..e96ae7b6f4 --- /dev/null +++ b/python/kaspa.pyi @@ -0,0 +1,1137 @@ +from enum import Enum +from typing import Any, Callable, Iterator, Optional, Union + +class Address: + + def __init__(self, address: str) -> None: ... + + @staticmethod + def validate(address: str) -> bool: ... + + def to_string(self) -> str: ... + + @property + def version(self) -> str: ... + + @property + def prefix(self) -> str: ... + + @prefix.setter + def set_prefix(self, prefix: str) -> None: ... + + def payload(self) -> str: ... + + def short(self, n: int) -> str: ... + + +class SighashType(Enum): + All = 1 + 'None' = 2 + Single = 3 + AllAnyOneCanPay = 4 + NoneAnyOneCanPay = 5 + SingleAnyOneCanPay = 6 + + +class ScriptPublicKey: + + def __init__(self, version: int, script: Union[str, bytes, list[int]]) -> None: ... + + @property + def script(self) -> str: ... + + +class Transaction: + + def is_coinbase(self) -> bool: ... + + def finalize(self) -> str: ... + + @property + def id(self) -> str: ... + + def __init__( + self, + version: int, + inputs: list[TransactionInput], + outputs: list[TransactionOutput], + lock_time: int, + subnetwork_id: Union[str, bytes, list[int]], + gas: int, + payload: Union[str, bytes, list[int]], + mass: int + ) -> None: ... + + @property + def inputs(self) -> list[TransactionInput]: ... + + @inputs.setter + def inputs(self, v: list[TransactionInput]) -> None: ... + + def addresses(self, network_type: str) -> list[Address]: ... + + @property + def outputs(self) -> list[TransactionOutput]: ... + + @outputs.setter + def outputs(self, v: list[TransactionOutput]) -> None: ... + + @property + def version(self) -> int: ... + + @version.setter + def version(self, v: int) -> None: ... + + @property + def lock_time(self) -> int: ... + + @lock_time.setter + def lock_time(self, v: int) -> None: ... + + @property + def gas(self) -> int: ... + + @gas.setter + def gas(self, v: int) -> None: ... + + @property + def subnetwork_id(self) -> str: ... + + @subnetwork_id.setter + def subnetwork_id(self, v: str) -> None: ... + + @property + def payload(self) -> str: ... + + @payload.setter + def payload(self, v: Union[str, bytes, list[int]]) -> None: ... + + def serialize_to_dict(self) -> dict: ... + + +class TransactionInput: + + def __init__( + self, + previous_outpoint: TransactionOutpoint, + signature_script: Union[str, bytes, list[int]], + sequence: int, + sig_op_count: int, + utxo: Optional[UtxoEntryReference] + ) -> None: ... + + @property + def previous_outpoint(self) -> TransactionOutpoint: ... + + @previous_outpoint.setter + def previous_outpoint(self, outpoint: TransactionOutpoint) -> None: ... + + @property + def signature_script(self) -> str: ... + + @signature_script.setter + def signature_script(self, signature_script: Union[str, bytes, list[int]]) -> None: ... + + @property + def sequence(self) -> int: ... + + @sequence.setter + def sequence(self, sequence: int) -> None: ... + + @property + def sig_op_count(self) -> int: ... + + @sig_op_count.setter + def sig_op_count(self, sig_op_count: int) -> None: ... + + @property + def utxo(self) -> Optional[UtxoEntryReference]: ... + + +class TransactionOutpoint: + + def __init__(self, transaction_id: str, index: int) -> None: ... + + def get_id(self) -> str: ... + + @property + def transaction_id(self) -> str: ... + + @property + def index(self) -> int: ... + + +class TransactionOutput: + + def __init__(self, value: int, script_public_key: ScriptPublicKey) -> None: ... + + @property + def value(self) -> int: ... + + @value.setter + def value(self, v: int) -> None: ... + + @property + def script_public_key(self) -> int: ... + + @script_public_key.setter + def script_public_key(self, v: ScriptPublicKey) -> None: ... + + +class UtxoEntries: + + @property + def items(self) -> list[UtxoEntryReference]: ... + + @items.setter + def items(self, v: list[UtxoEntryReference]): ... + + def sort(self) -> None: ... + + def amount(self) -> int: ... + + +class UtxoEntry: + + @property + def address(self) -> Optional[Address]: ... + + @property + def outpoint(self) -> TransactionOutpoint: ... + + @property + def amount(self) -> int: ... + + @property + def script_public_key(self) -> ScriptPublicKey: ... + + @property + def block_daa_score(self) -> int: ... + + @property + def is_coinbase(self) -> bool: ... + + +class UtxoEntryReference: + + @property + def entry(self) -> UtxoEntry: ... + + @property + def outpoint(self) -> TransactionOutpoint: ... + + @property + def address(self) -> Optional[Address]: ... + + @property + def amount(self) -> int: ... + + @property + def is_coinbase(self) -> bool: ... + + @property + def block_daa_score(self) -> int: ... + + @property + def script_public_key(self) -> ScriptPublicKey: ... + + +def address_from_script_public_key(script_public_key: ScriptPublicKey, network: str) -> Address: ... + +def pay_to_address_script(address: Address) -> ScriptPublicKey: ... + +def pay_to_script_hash_script(redeem_script: Union[str, bytes, list[int]]) -> ScriptBuilder: ... + +def pay_to_script_hash_signature_script(redeem_script: Union[str, bytes, list[int]], signature: Union[str, bytes, list[int]]) -> str: ... + +def is_script_pay_to_pubkey(script: Union[str, bytes, list[int]]) -> bool: ... + +def is_script_pay_to_pubkey_ecdsa(script: Union[str, bytes, list[int]]) -> bool: ... + +def is_script_pay_to_script_hash(script: Union[str, bytes, list[int]]) -> bool: ... + +class Hash: + + def __init__(self, hex_str: str) -> None: ... + + def to_string(self) -> str: ... + + +class Language(Enum): + English: 1 + + +class Mnemonic: + + def __init__(self, phrase: str, language: Optional[Language]) -> None: ... + + @staticmethod + def validate(phrase: str, language: Optional[Language]) -> bool: ... + + @property + def entropy(self) -> str: ... + + @entropy.setter + def entropy(self, entropy: str) -> None: ... + + @staticmethod + def random(word_count: Optional[int]) -> Mnemonic: ... + + @property + def phrase(self) -> str: ... + + @phrase.setter + def phrase(self, phrase: str) -> None: ... + + def to_seed(self, password: Optional[str]) -> str: ... + + +class ScriptBuilder: + + def __init__(self) -> None: ... + + @staticmethod + def from_script(script: Union[str, bytes, list[int]]) -> ScriptBuilder: ... + + def add_op(self, op: Union[Opcodes, int]) -> ScriptBuilder: ... + + def add_ops(self, opcodes: Union[list[Opcodes], list[int]]) -> ScriptBuilder: ... + + def add_data(self, data: Union[str, bytes, list[int]]) -> ScriptBuilder: ... + + def add_i64(self, value: int) -> ScriptBuilder: ... + + def add_lock_time(self, lock_time: int) -> ScriptBuilder: ... + + def add_sequence(self, sequence: int) -> ScriptBuilder: ... + + @staticmethod + def canonical_data_size(data: Union[str, bytes, list[int]]) -> int: ... + + def to_string(self) -> str: ... + + def drain(self) -> str: ... + + def create_pay_to_script_hash_script(self) -> ScriptPublicKey: ... + + def encode_pay_to_script_hash_signature_script(self, signature: Union[str, bytes, list[int]]) -> str: ... + + +class Opcodes(Enum): + OpFalse = 0x00 + + OpData1 = 0x01 + OpData2 = 0x02 + OpData3 = 0x03 + OpData4 = 0x04 + OpData5 = 0x05 + OpData6 = 0x06 + OpData7 = 0x07 + OpData8 = 0x08 + OpData9 = 0x09 + OpData10 = 0x0a + OpData11 = 0x0b + OpData12 = 0x0c + OpData13 = 0x0d + OpData14 = 0x0e + OpData15 = 0x0f + OpData16 = 0x10 + OpData17 = 0x11 + OpData18 = 0x12 + OpData19 = 0x13 + OpData20 = 0x14 + OpData21 = 0x15 + OpData22 = 0x16 + OpData23 = 0x17 + OpData24 = 0x18 + OpData25 = 0x19 + OpData26 = 0x1a + OpData27 = 0x1b + OpData28 = 0x1c + OpData29 = 0x1d + OpData30 = 0x1e + OpData31 = 0x1f + OpData32 = 0x20 + OpData33 = 0x21 + OpData34 = 0x22 + OpData35 = 0x23 + OpData36 = 0x24 + OpData37 = 0x25 + OpData38 = 0x26 + OpData39 = 0x27 + OpData40 = 0x28 + OpData41 = 0x29 + OpData42 = 0x2a + OpData43 = 0x2b + OpData44 = 0x2c + OpData45 = 0x2d + OpData46 = 0x2e + OpData47 = 0x2f + OpData48 = 0x30 + OpData49 = 0x31 + OpData50 = 0x32 + OpData51 = 0x33 + OpData52 = 0x34 + OpData53 = 0x35 + OpData54 = 0x36 + OpData55 = 0x37 + OpData56 = 0x38 + OpData57 = 0x39 + OpData58 = 0x3a + OpData59 = 0x3b + OpData60 = 0x3c + OpData61 = 0x3d + OpData62 = 0x3e + OpData63 = 0x3f + OpData64 = 0x40 + OpData65 = 0x41 + OpData66 = 0x42 + OpData67 = 0x43 + OpData68 = 0x44 + OpData69 = 0x45 + OpData70 = 0x46 + OpData71 = 0x47 + OpData72 = 0x48 + OpData73 = 0x49 + OpData74 = 0x4a + OpData75 = 0x4b + + OpPushData1 = 0x4c + OpPushData2 = 0x4d + OpPushData4 = 0x4e + + Op1Negate = 0x4f + + OpReserved = 0x50 + + OpTrue = 0x51 + + Op2 = 0x52 + Op3 = 0x53 + Op4 = 0x54 + Op5 = 0x55 + Op6 = 0x56 + Op7 = 0x57 + Op8 = 0x58 + Op9 = 0x59 + Op10 = 0x5a + Op11 = 0x5b + Op12 = 0x5c + Op13 = 0x5d + Op14 = 0x5e + Op15 = 0x5f + Op16 = 0x60 + + OpNop = 0x61 + OpVer = 0x62 + OpIf = 0x63 + OpNotIf = 0x64 + OpVerIf = 0x65 + OpVerNotIf = 0x66 + + OpElse = 0x67 + OpEndIf = 0x68 + OpVerify = 0x69 + OpReturn = 0x6a + OpToAltStack = 0x6b + OpFromAltStack = 0x6c + + Op2Drop = 0x6d + Op2Dup = 0x6e + Op3Dup = 0x6f + Op2Over = 0x70 + Op2Rot = 0x71 + Op2Swap = 0x72 + OpIfDup = 0x73 + OpDepth = 0x74 + OpDrop = 0x75 + OpDup = 0x76 + OpNip = 0x77 + OpOver = 0x78 + OpPick = 0x79 + + OpRoll = 0x7a + OpRot = 0x7b + OpSwap = 0x7c + OpTuck = 0x7d + + # Splice opcodes. + OpCat = 0x7e + OpSubStr = 0x7f + OpLeft = 0x80 + OpRight = 0x81 + + OpSize = 0x82 + + # Bitwise logic opcodes. + OpInvert = 0x83 + OpAnd = 0x84 + OpOr = 0x85 + OpXor = 0x86 + + OpEqual = 0x87 + OpEqualVerify = 0x88 + + OpReserved1 = 0x89 + OpReserved2 = 0x8a + + # Numeric related opcodes. + Op1Add = 0x8b + Op1Sub = 0x8c + Op2Mul = 0x8d + Op2Div = 0x8e + OpNegate = 0x8f + OpAbs = 0x90 + OpNot = 0x91 + Op0NotEqual = 0x92 + + OpAdd = 0x93 + OpSub = 0x94 + OpMul = 0x95 + OpDiv = 0x96 + OpMod = 0x97 + OpLShift = 0x98 + OpRShift = 0x99 + + OpBoolAnd = 0x9a + OpBoolOr = 0x9b + + OpNumEqual = 0x9c + OpNumEqualVerify = 0x9d + OpNumNotEqual = 0x9e + + OpLessThan = 0x9f + OpGreaterThan = 0xa0 + OpLessThanOrEqual = 0xa1 + OpGreaterThanOrEqual = 0xa2 + OpMin = 0xa3 + OpMax = 0xa4 + OpWithin = 0xa5 + + # Undefined opcodes. + OpUnknown166 = 0xa6 + OpUnknown167 = 0xa7 + + # Crypto opcodes. + OpSHA256 = 0xa8 + + OpCheckMultiSigECDSA = 0xa9 + + OpBlake2b = 0xaa + OpCheckSigECDSA = 0xab + OpCheckSig = 0xac + OpCheckSigVerify = 0xad + OpCheckMultiSig = 0xae + OpCheckMultiSigVerify = 0xaf + OpCheckLockTimeVerify = 0xb0 + OpCheckSequenceVerify = 0xb1 + + # Undefined opcodes. + OpUnknown178 = 0xb2 + OpUnknown179 = 0xb3 + OpUnknown180 = 0xb4 + OpUnknown181 = 0xb5 + OpUnknown182 = 0xb6 + OpUnknown183 = 0xb7 + OpUnknown184 = 0xb8 + OpUnknown185 = 0xb9 + OpUnknown186 = 0xba + OpUnknown187 = 0xbb + OpUnknown188 = 0xbc + OpUnknown189 = 0xbd + OpUnknown190 = 0xbe + OpUnknown191 = 0xbf + OpUnknown192 = 0xc0 + OpUnknown193 = 0xc1 + OpUnknown194 = 0xc2 + OpUnknown195 = 0xc3 + OpUnknown196 = 0xc4 + OpUnknown197 = 0xc5 + OpUnknown198 = 0xc6 + OpUnknown199 = 0xc7 + OpUnknown200 = 0xc8 + OpUnknown201 = 0xc9 + OpUnknown202 = 0xca + OpUnknown203 = 0xcb + OpUnknown204 = 0xcc + OpUnknown205 = 0xcd + OpUnknown206 = 0xce + OpUnknown207 = 0xcf + OpUnknown208 = 0xd0 + OpUnknown209 = 0xd1 + OpUnknown210 = 0xd2 + OpUnknown211 = 0xd3 + OpUnknown212 = 0xd4 + OpUnknown213 = 0xd5 + OpUnknown214 = 0xd6 + OpUnknown215 = 0xd7 + OpUnknown216 = 0xd8 + OpUnknown217 = 0xd9 + OpUnknown218 = 0xda + OpUnknown219 = 0xdb + OpUnknown220 = 0xdc + OpUnknown221 = 0xdd + OpUnknown222 = 0xde + OpUnknown223 = 0xdf + OpUnknown224 = 0xe0 + OpUnknown225 = 0xe1 + OpUnknown226 = 0xe2 + OpUnknown227 = 0xe3 + OpUnknown228 = 0xe4 + OpUnknown229 = 0xe5 + OpUnknown230 = 0xe6 + OpUnknown231 = 0xe7 + OpUnknown232 = 0xe8 + OpUnknown233 = 0xe9 + OpUnknown234 = 0xea + OpUnknown235 = 0xeb + OpUnknown236 = 0xec + OpUnknown237 = 0xed + OpUnknown238 = 0xee + OpUnknown239 = 0xef + OpUnknown240 = 0xf0 + OpUnknown241 = 0xf1 + OpUnknown242 = 0xf2 + OpUnknown243 = 0xf3 + OpUnknown244 = 0xf4 + OpUnknown245 = 0xf5 + OpUnknown246 = 0xf6 + OpUnknown247 = 0xf7 + OpUnknown248 = 0xf8 + OpUnknown249 = 0xf9 + + OpSmallInteger = 0xfa + OpPubKeys = 0xfb + OpUnknown252 = 0xfc + OpPubKeyHash = 0xfd + OpPubKey = 0xfe + OpInvalidOpCode = 0xff + + +def sign_message(message: str, private_key: PrivateKey) -> str: ... + +def verify_message(message: str, signature: str, public_key: PublicKey) -> bool: ... + +def sign_transaction(tx: Transaction, signer: list[PrivateKey], verify_sig: bool) -> Transaction: ... + +class Generator: + + def __init__( + self, + network_id: str, + entries: list[Union[UtxoEntryReference, dict]], + outputs: list[Union[PaymentOutput, dict]], + change_address: Address, + payload: Optional[Union[str, bytes, list[int]]], + priority_fee: Optional[int], + priority_entries: Optional[list[Union[UtxoEntryReference, dict]]], + sig_op_count: Optional[int], + minimun_signatures: Optional[int] + ) -> None: ... + + def estimate( + self, + network_id: str, + entries: list[dict], + outputs: list[dict], + change_address: Address, + payload: Optional[str], + priority_fee: Optional[str], + priority_entries: Optional[list[dict]], + sig_op_count: Optional[int], + minimun_signatures: Optional[int] + ) -> GeneratorSummary: ... + + def summary(self) -> GeneratorSummary: ... + + def __iter__(self) -> Iterator[PendingTransaction]: ... + + def __next__(self) -> PendingTransaction: ... + + +class PendingTransaction: + + @property + def id(self) -> str: ... + + @property + def payment_amount(self) -> Optional[int]: ... + + @property + def change_amount(self) -> int: ... + + @property + def fee_amount(self) -> int: ... + + @property + def mass(self) -> int: ... + + @property + def minimum_signatures(self) -> int: ... + + @property + def aggregate_input_amount(self) -> int: ... + + @property + def aggregate_output_amount(self) -> int: ... + + @property + def transaction_type(self) -> str: ... + + def addresses(self) -> list[Address]: ... + + def get_utxo_entries(self) -> list[UtxoEntryReference]: ... + + def create_input_signature(self, input_index: int, private_key: PrivateKey, sighash_type: Optional[SighashType]) -> str: ... + + def fill_input(self, input_index: int, signature_script: Union[str, bytes, list[int]]) -> None: ... + + def sign_input(self, input_index: int, private_key: PrivateKey, sighash_type: Optional[SighashType]) -> None: ... + + def sign(self, private_keys: list[PrivateKey], check_fully_signed: Optional[bool]) -> None: ... + + def submit(self, rpc_client: RpcClient) -> str: ... + + @property + def transaction(self) -> Transaction: ... + + +class GeneratorSummary: + + @property + def network_type(self) -> str: ... + + @property + def utxos(self) -> int: ... + + @property + def fees(self) -> int: ... + + @property + def transactions(self) -> int: ... + + @property + def final_amount(self) -> Optional[int]: ... + + @property + def final_transaction_id(self) -> Optional[str]: ... + + +def calculate_transaction_fee(network_id: str, tx: Transaction, minimum_signatures: Optional[int]) -> Optional[int]: ... + +def calculate_transaction_mass(network_id: str, tx: Transaction, minimum_signatures: Optional[int]) -> int: ... + +def update_transaction_mass(network_id: str, tx: Transaction, minimum_signatures: Optional[int]) -> bool: ... + +def create_transaction( + utxo_entry_source: list[dict], + outputs: list[dict], + priority_fee: int, + payload: Optional[list[int]], + sig_op_count: Optional[int] +) -> Transaction: ... + + +def create_transactions( + network_id: str, + entries: list[dict], + outputs: list[dict], + change_address: Address, + payload: Optional[str], + priority_fee: Optional[int], + priority_entries: Optional[list[dict]], + sig_op_count: Optional[int], + minimum_signatures: Optional[int] +) -> dict: ... + +def estimate_transactions( + network_id: str, + entries: list[dict], + outputs: list[dict], + change_address: Address, + payload: Optional[str], + priority_fee: Optional[int], + priority_entries: Optional[list[dict]], + sig_op_count: Optional[int], + minimum_signatures: Optional[int] +) -> GeneratorSummary: ... + +def kaspa_to_sompi(kaspa: float) -> int: ... + +def sompi_to_kaspa(sompi: int) -> float: ... + +def sompi_to_kaspa_string_with_suffix(sompi: int, network: str) -> str: ... + +class PaymentOutput: + + def __init__(self, address: Address, amount: int) -> None: ... + + +class DerivationPath: + + def __init__(self, path: str) -> None: ... + + def is_empty(self) -> bool: ... + + def length(self) -> int: ... + + def parent(self) -> Optional[DerivationPath]: ... + + def push(self, child_number: int, hardened: bool) -> None: ... + + def to_string(self) -> str: ... + + +class Keypair: + # def new(self) -> Keypair: ... + + @property + def xonly_public_key(self) -> str: ... + + @property + def public_key(self) -> str: ... + + @property + def private_key(self) -> str: ... + + def to_address(self, network: str) -> Address: ... + + def to_address_ecdsa(self, network: str) -> Address: ... + + @staticmethod + def random() -> Keypair: ... + + @staticmethod + def from_private_key(secret_key: PrivateKey) -> Keypair: ... + + +class PrivateKey: + + def __init__(self, secret_key: str) -> None: ... + + def to_string(self) -> str: ... + + def to_public_key(self) -> PublicKey: ... + + def to_address(self, network: str) -> Address: ... + + def to_address_ecdsa(self, network: str) -> Address: ... + + def to_keypair(self) -> Keypair: ... + + +class PrivateKeyGenerator: + + def __init__(self, xprv: str, is_multisig: bool, + account_index: int, cosigner_index: int) -> str: ... + + def receive_key(self, index: int) -> PrivateKey: ... + + def change_key(self, index: int) -> PrivateKey: ... + + +class PublicKey: + + def __init__(self, key: str) -> None: ... + + def to_string(self) -> str: ... + + def to_address(self, network: str) -> Address: ... + + def to_address_ecdsa(self, network: str) -> Address: ... + + def to_x_only_public_key(self) -> XOnlyPublicKey: ... + + +class PublicKeyGenerator: + + @staticmethod + def from_xpub(kpub: str, cosigner_index: Optional[int]) ->PublicKeyGenerator: ... + + @staticmethod + def from_master_xprv(xprv: str, is_multisig: bool, account_index: int, cosigner_index: Optional[int]) -> PublicKeyGenerator: ... + + def receive_pubkeys(self, start: int, end: int) -> list[PublicKey]: ... + + def receive_pubkey(self, index: int) -> list[PublicKey]: ... + + def receive_pubkeys_as_strings(self, start: int, end: int) -> list[str]: ... + + def receive_pubkey_as_string(self, index: int) -> str: ... + + def receive_addresses(self, network_type: str, start: int, end: int) -> list[Address]: ... + + def receive_address(self, network_type: str, index: int) -> Address: ... + + def receive_addresses_as_strings(self, network_type: str, start: int, end: int) -> list[str]: ... + + def receive_address_as_string(self, network_type: str, index: int) -> str: ... + + def change_pubkeys(self, start: int, end: int) -> list[PublicKey]: ... + + def change_pubkey(self, index: int) -> list[PublicKey]: ... + + def change_pubkeys_as_strings(self, start: int, end: int) -> list[str]: ... + + def change_pubkey_as_string(self, index: int) -> str: ... + + def change_addresses(self, network_type: str, start: int, end: int) -> list[Address]: ... + + def change_address(self, network_type: str, index: int) -> Address: ... + + def change_addresses_as_strings(self, network_type: str, start: int, end: int) -> list[str]: ... + + def change_address_as_string(self, network_type: str, index: int) -> str: ... + + def to_string(self) -> str: ... + + +class XOnlyPublicKey: + + def __init__(self, key: str) -> None: ... + + def to_string(self) -> str: ... + + def to_address(self, network: str) -> Address: ... + + def to_address_ecdsa(self, network: str) -> Address: ... + + @staticmethod + def from_address(address: Address) -> XOnlyPublicKey: ... + + +class XPrv: + + def __init__(self, seed: str) -> None: ... + + @staticmethod + def from_xprv(xprv: str) -> XPrv: ... + + def derive_child(self, child_number: int, hardened: Optional[bool]) -> XPrv: ... + + def derive_path(self, path: Union[str, DerivationPath]) -> XPrv: ... + + def into_string(self, prefix: str) -> str: ... + + def to_string(self) -> str: ... + + def to_xpub(self) -> XPub: ... + + def to_private_key(self) -> XPub: ... + + @property + def xprv(self) -> str: ... + + @property + def private_key(self) -> str: ... + + @property + def depth(self) -> int: ... + + @property + def parent_fingerprint(self) -> int: ... + + @property + def child_number(self) -> int: ... + + @property + def chain_code(self) -> str: ... + + +class XPub: + + def __init__(self, xpub: str) -> None: ... + + def derive_child(self, child_number: int, hardened: Optional[bool]) -> XPub: ... + + def derive_path(self, path: str) -> XPub: ... + + def to_str(self, prefix: str) -> str: ... + + def to_public_key(self) -> PublicKey: ... + + @property + def xpub(self) -> str: ... + + @property + def depth(self) -> int: ... + + @property + def parent_fingerprint(self) -> str: ... + + @property + def child_number(self) -> int: ... + + @property + def chain_code(self) -> str: ... + + +class Resolver: + + def __init__(self, urls: Optional[list[str]], tls: Optional[int]) -> None: ... + + def urls(self) -> list[str]: ... + + def get_node(self, encoding: str, network_id: str) -> dict: ... + + def get_url(self, encoding: str, network_id: str) -> str: ... + + def connect(self, encoding: str, network_id: str) -> RpcClient: ... + + +class RpcClient: + + def __init__(self, resolver: Optional[Resolver], url: Optional[str], encoding: Optional[str], network_id: Optional[str]) -> None: ... + + @property + def url(self) -> str: ... + + @property + def resolver(self) -> Optional[Resolver]: ... + + def set_resolver(self, Resolver) -> None: ... + + def set_network_id(self, network_id: str) -> None: ... + + @property + def is_connected(self) -> bool: ... + + @property + def encoding(self) -> str: ... + + @property + def node_id(self) -> str: ... + + async def connect(self, block_async_connect: Optional[bool], strategy: Optional[str], url: Optional[str], timeout_duration: Optional[int], retry_interval: Optional[int]) -> None: ... + + async def disconnect(self) -> None: ... + + async def start(self) -> None: ... + + # def trigger_abort(self) -> None: ... + + def add_event_listener(self, event: str, callback: Callable[..., Any], *args: Any, **kwargs: Optional[Any]) -> None: ... + + def remove_event_listener(self, event: str, callback: Callable[..., Any]) -> None: ... + + def remove_all_event_listeners(self) -> None: ... + + # @staticmethod + # def default_port(encoding: str, network: str) -> int: ... + + # @staticmethod + # def parse_url(url: str, encoding: str, network: str) -> str: ... + + async def subscribe_utxos_changed(self, addresses: list[Address]) -> None: ... + + async def unsubscribe_utxos_changed(self, addresses: list[Address]) -> None: ... + + async def subscribe_virtual_chain_changed(self, include_accepted_transaction_ids: bool) -> None: ... + + async def unsubscribe_virtual_chain_changed(self, include_accepted_transaction_ids: bool) -> None: ... + + async def subscribe_block_added(self) -> None: ... + + async def unsubscribe_block_added(self) -> None: ... + + async def subscribe_finality_conflict(self) -> None: ... + + async def unsubscribe_finality_conflict(self) -> None: ... + + async def subscribe_finality_conflict_resolved(self) -> None: ... + + async def unsubscribe_finality_conflict_resolved(self) -> None: ... + + async def subscribe_new_block_template(self) -> None: ... + + async def unsubscribe_new_block_template(self) -> None: ... + + async def subscribe_pruning_point_utxo_set_override(self) -> None: ... + + async def unsubscribe_pruning_point_utxo_set_override(self) -> None: ... + + async def subscribe_sink_blue_score_changed(self) -> None: ... + + async def unsubscribe_sink_blue_score_changed(self) -> None: ... + + async def subscribe_virtual_daa_score_changed(self) -> None: ... + + async def unsubscribe_virtual_daa_score_changed(self) -> None: ... + + async def get_block_count(self, request: Optional[dict]) -> dict: ... + + async def get_block_dag_info(self, request: Optional[dict]) -> dict: ... + + async def get_coin_supply(self, request: Optional[dict]) -> dict: ... + + async def get_connected_peer_info(self, request: Optional[dict]) -> dict: ... + + async def get_info(self, request: Optional[dict]) -> dict: ... + + async def get_peer_addresses(self, request: Optional[dict]) -> dict: ... + + async def get_sink(self, request: Optional[dict]) -> dict: ... + + async def get_sink_blue_score(self, request: Optional[dict]) -> dict: ... + + async def ping(self, request: Optional[dict]) -> dict: ... + + async def shutdown(self, request: Optional[dict]) -> dict: ... + + async def get_server_info(self, request: Optional[dict]) -> dict: ... + + async def get_sync_status(self, request: Optional[dict]) -> dict: ... + + async def add_peer(self, request: dict) -> dict: ... + + async def ban(self, request: dict) -> dict: ... + + async def estimate_network_hashes_per_second(self, request: dict) -> dict: ... + + async def get_balance_by_address(self, request: dict) -> dict: ... + + async def get_balances_by_addresses(self, request: dict) -> dict: ... + + async def get_block(self, request: dict) -> dict: ... + + async def get_blocks(self, request: dict) -> dict: ... + + async def get_block_template(self, request: dict) -> dict: ... + + async def get_connections(self, request: dict) -> dict: ... + + async def get_current_block_color(self, request: dict) -> dict: ... + + async def get_daa_score_timestamp_estimate(self, request: dict) -> dict: ... + + async def get_fee_estimate(self, request: dict) -> dict: ... + + async def get_fee_estimate_experimental(self, request: dict) -> dict: ... + + async def get_current_network(self, request: dict) -> dict: ... + + async def get_headers(self, request: dict) -> dict: ... + + async def get_mempool_entries(self, request: dict) -> dict: ... + + async def get_mempool_entries_by_addresses(self, request: dict) -> dict: ... + + async def get_mempool_entry(self, request: dict) -> dict: ... + + async def get_metrics(self, request: dict) -> dict: ... + + async def get_subnetwork(self, request: dict) -> dict: ... + + async def get_utxos_by_addresses(self, request: dict) -> dict: ... + + async def get_virtual_chain_from_block(self, request: dict) -> dict: ... + + async def resolve_finality_conflict(self, request: dict) -> dict: ... + + # async def submit_block(self, request: dict) -> dict: ... + + async def submit_transaction(self, request: dict) -> dict: ... + + async def submit_transaction_replacement(self, request: dict) -> dict: ... + + async def unban(self, request: dict) -> dict: ... diff --git a/python/macros/Cargo.toml b/python/macros/Cargo.toml new file mode 100644 index 0000000000..fde72d699d --- /dev/null +++ b/python/macros/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "kaspa-python-macros" +rust-version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +version.workspace = true +repository.workspace = true +keywords = ["kaspa","python"] +categories = [] +exclude = ["/.*", "/test"] +description = """ +Macros for the Kaspa Python bindings +""" + +[lib] +proc-macro = true + +[dependencies] +convert_case.workspace = true +proc-macro-error = { version = "1.0.0", default-features = false } +proc-macro2 = { version = "1.0.43" } +quote = "1.0.21" +regex.workspace = true +syn = {version="1.0.99",features=["full","fold","extra-traits","parsing","proc-macro"]} # do not update! diff --git a/python/macros/src/lib.rs b/python/macros/src/lib.rs new file mode 100644 index 0000000000..9a64b3288e --- /dev/null +++ b/python/macros/src/lib.rs @@ -0,0 +1,10 @@ +use proc_macro::TokenStream; +use proc_macro_error::proc_macro_error; + +mod py_async; + +#[proc_macro] +#[proc_macro_error] +pub fn py_async(input: TokenStream) -> TokenStream { + py_async::py_async(input) +} diff --git a/python/macros/src/py_async.rs b/python/macros/src/py_async.rs new file mode 100644 index 0000000000..81585fee9d --- /dev/null +++ b/python/macros/src/py_async.rs @@ -0,0 +1,58 @@ +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use std::convert::Into; +use syn::{ + parse::{Parse, ParseStream}, + parse_macro_input, + punctuated::Punctuated, + Error, Expr, ExprAsync, Result, Token, +}; + +#[derive(Debug)] +struct PyAsync { + py: Expr, + block: ExprAsync, +} + +impl Parse for PyAsync { + fn parse(input: ParseStream) -> Result { + let parsed = Punctuated::::parse_terminated(input).unwrap(); + if parsed.len() != 2 { + return Err(Error::new_spanned(parsed, "usage: py_async!{py, async move { Ok(()) }}".to_string())); + } + + let mut iter = parsed.iter(); + // python object (py: Python) + let py = iter.next().unwrap().clone(); + + // async block to encapsulate + let block = match iter.next().unwrap().clone() { + Expr::Async(block) => block, + statement => { + return Err(Error::new_spanned(statement, "the argument must be an async block".to_string())); + } + }; + + Ok(PyAsync { py, block }) + } +} + +impl ToTokens for PyAsync { + fn to_tokens(&self, tokens: &mut TokenStream) { + let PyAsync { py, block } = self; + + quote! { + let __fut__ = #block; + let __py_fut__ = pyo3_async_runtimes::tokio::future_into_py(#py, __fut__)?; + pyo3::prelude::Python::with_gil(|py| Ok(__py_fut__.into_py(#py))) + } + .to_tokens(tokens); + } +} + +pub fn py_async(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let py_async = parse_macro_input!(input as PyAsync); + let token_stream = py_async.to_token_stream(); + // println!("MACRO: {}", token_stream.to_string()); + token_stream.into() +} diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000000..cd26b1cefa --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "kaspa" +version = "0.15.3" +description = "Kaspa Python SDK" +requires-python = ">=3.8" +readme = "README.md" +license = "ISC" +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Rust", +] +dependencies = [] + +[project.urls] +"Source" = "https://github.com/kaspanet/rusty-kaspa/tree/master/python" + +[package.metadata.maturin] +name = "kaspa" +description = "Kaspa Python SDK" + +[tool.maturin] +name = "kaspa" +bindings = "pyo3" +features = ["pyo3/extension-module"] +strip = true \ No newline at end of file diff --git a/python/src/lib.rs b/python/src/lib.rs new file mode 100644 index 0000000000..be1d4a6124 --- /dev/null +++ b/python/src/lib.rs @@ -0,0 +1,68 @@ +cfg_if::cfg_if! { + if #[cfg(feature = "py-sdk")] { + use pyo3::prelude::*; + + #[pymodule] + fn kaspa(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + + m.add_class::()?; + m.add_class::()?; + + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(kaspa_consensus_client::address_from_script_public_key_py, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_consensus_client::pay_to_address_script_py, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_consensus_client::pay_to_script_hash_script_py, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_consensus_client::pay_to_script_hash_signature_script_py, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_consensus_client::is_script_pay_to_pubkey_py, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_consensus_client::is_script_pay_to_pubkey_ecdsa_py, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_consensus_client::is_script_pay_to_script_hash_py, m)?)?; + + m.add_class::()?; + + m.add_class::()?; + m.add_class::()?; + + m.add_class::()?; + m.add_class::()?; + + m.add_function(wrap_pyfunction!(kaspa_wallet_core::bindings::python::message::py_sign_message, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_wallet_core::bindings::python::message::py_verify_message, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_wallet_core::bindings::python::signer::py_sign_transaction, m)?)?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(kaspa_wallet_core::bindings::python::tx::mass::calculate_unsigned_transaction_fee, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_wallet_core::bindings::python::tx::mass::calculate_unsigned_transaction_mass, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_wallet_core::bindings::python::tx::mass::update_unsigned_transaction_mass, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_wallet_core::bindings::python::tx::utils::create_transaction_py, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_wallet_core::bindings::python::tx::utils::create_transactions_py, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_wallet_core::bindings::python::tx::utils::estimate_transactions_py, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_wallet_core::bindings::python::utils::kaspa_to_sompi, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_wallet_core::bindings::python::utils::sompi_to_kaspa, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_wallet_core::bindings::python::utils::sompi_to_kaspa_string_with_suffix, m)?)?; + m.add_class::()?; + + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + m.add_class::()?; + m.add_class::()?; + + Ok(()) + } + } +} diff --git a/rpc/core/Cargo.toml b/rpc/core/Cargo.toml index f2e9f72f9e..f83a642701 100644 --- a/rpc/core/Cargo.toml +++ b/rpc/core/Cargo.toml @@ -14,6 +14,10 @@ wasm32-sdk = [ "kaspa-consensus-client/wasm32-sdk", "kaspa-consensus-wasm/wasm32-sdk" ] +py-sdk = [ + "pyo3", + "serde-pyobject" +] [dependencies] kaspa-addresses.workspace = true @@ -43,7 +47,9 @@ js-sys.workspace = true log.workspace = true paste.workspace = true rand.workspace = true +pyo3 = { workspace = true, optional = true } serde-wasm-bindgen.workspace = true +serde-pyobject = { workspace = true, optional = true } serde.workspace = true smallvec.workspace = true thiserror.workspace = true diff --git a/rpc/core/src/api/notifications.rs b/rpc/core/src/api/notifications.rs index 503af0de85..ec243a66ce 100644 --- a/rpc/core/src/api/notifications.rs +++ b/rpc/core/src/api/notifications.rs @@ -13,7 +13,11 @@ use kaspa_notify::{ Subscription, }, }; +#[cfg(feature = "py-sdk")] +use pyo3::prelude::*; use serde::{Deserialize, Serialize}; +#[cfg(feature = "py-sdk")] +use serde_pyobject::to_pyobject; use std::sync::Arc; use wasm_bindgen::JsValue; use workflow_serializer::prelude::*; @@ -66,6 +70,23 @@ impl Notification { Notification::VirtualChainChanged(v) => to_value(&v), } } + + #[cfg(feature = "py-sdk")] + pub fn to_pyobject(&self, py: Python) -> PyResult { + let bound_obj = match self { + Notification::BlockAdded(v) => to_pyobject(py, &v), + Notification::FinalityConflict(v) => to_pyobject(py, &v), + Notification::FinalityConflictResolved(v) => to_pyobject(py, &v), + Notification::NewBlockTemplate(v) => to_pyobject(py, &v), + Notification::PruningPointUtxoSetOverride(v) => to_pyobject(py, &v), + Notification::UtxosChanged(v) => to_pyobject(py, &v), + Notification::VirtualDaaScoreChanged(v) => to_pyobject(py, &v), + Notification::SinkBlueScoreChanged(v) => to_pyobject(py, &v), + Notification::VirtualChainChanged(v) => to_pyobject(py, &v), + }?; + + Ok(bound_obj.to_object(py)) + } } impl NotificationTrait for Notification { diff --git a/rpc/core/src/bindings/mod.rs b/rpc/core/src/bindings/mod.rs new file mode 100644 index 0000000000..b35fa03fb4 --- /dev/null +++ b/rpc/core/src/bindings/mod.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "py-sdk")] +pub mod python; + +#[cfg(feature = "wasm32-sdk")] +pub mod wasm; diff --git a/rpc/core/src/bindings/python/messages.rs b/rpc/core/src/bindings/python/messages.rs new file mode 100644 index 0000000000..b1522c0c60 --- /dev/null +++ b/rpc/core/src/bindings/python/messages.rs @@ -0,0 +1,261 @@ +use crate::{message::*, RpcTransaction, RpcTransactionInput, RpcTransactionOutput}; +use kaspa_addresses::Address; +use kaspa_consensus_client::Transaction; +use pyo3::{ + exceptions::PyException, + prelude::*, + types::{PyDict, PyList}, +}; +use serde_pyobject::from_pyobject; + +macro_rules! try_from_no_args { + ($to_type:ty, $body:block) => { + impl TryFrom> for $to_type { + type Error = PyErr; + fn try_from(_: Bound<'_, PyDict>) -> PyResult { + $body + } + } + }; +} + +macro_rules! try_from_args { + ($name:ident : $to_type:ty, $body:block) => { + impl TryFrom> for $to_type { + type Error = PyErr; + fn try_from($name: Bound<'_, PyDict>) -> PyResult { + $body + } + } + }; +} + +try_from_no_args!(GetBlockCountRequest, { Ok(GetBlockCountRequest {}) }); + +try_from_no_args!(GetBlockDagInfoRequest, { Ok(GetBlockDagInfoRequest {}) }); + +try_from_no_args!(GetCoinSupplyRequest, { Ok(GetCoinSupplyRequest {}) }); + +try_from_no_args!(GetConnectedPeerInfoRequest, { Ok(GetConnectedPeerInfoRequest {}) }); + +try_from_no_args!(GetInfoRequest, { Ok(GetInfoRequest {}) }); + +try_from_no_args!(GetPeerAddressesRequest, { Ok(GetPeerAddressesRequest {}) }); + +try_from_no_args!(GetSinkRequest, { Ok(GetSinkRequest {}) }); + +try_from_no_args!(GetSinkBlueScoreRequest, { Ok(GetSinkBlueScoreRequest {}) }); + +try_from_no_args!(PingRequest, { Ok(PingRequest {}) }); + +try_from_no_args!(ShutdownRequest, { Ok(ShutdownRequest {}) }); + +try_from_no_args!(GetServerInfoRequest, { Ok(GetServerInfoRequest {}) }); + +try_from_no_args!(GetSyncStatusRequest, { Ok(GetSyncStatusRequest {}) }); + +try_from_no_args!(GetFeeEstimateRequest, { Ok(GetFeeEstimateRequest {}) }); + +try_from_no_args!(GetCurrentNetworkRequest, { Ok(GetCurrentNetworkRequest {}) }); + +try_from_args!(dict : AddPeerRequest, { Ok(from_pyobject(dict)?) }); + +try_from_args!(dict : BanRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : EstimateNetworkHashesPerSecondRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetBalanceByAddressRequest, { + let address_value = dict.get_item("address")? + .ok_or_else(|| PyException::new_err("Key `address` not present"))?; + + let address = if let Ok(address) = address_value.extract::
() { + address + } else if let Ok(s) = address_value.extract::() { + Address::try_from(s) + .map_err(|err| PyException::new_err(format!("{}", err)))? + } else { + return Err(PyException::new_err("Addresses must be either an Address instance or a string")); + }; + + Ok(GetBalanceByAddressRequest { address }) +}); + +try_from_args! ( dict : GetBalancesByAddressesRequest, { + let items = dict.get_item("addresses")? + .ok_or_else(|| PyException::new_err("Key `addresses` not present"))?; + + let list = items.downcast::() + .map_err(|_| PyException::new_err("`addresses` should be a list"))?; + + let addresses = list.iter().map(|item| { + if let Ok(address) = item.extract::
() { + Ok(address) + } else if let Ok(s) = item.extract::() { + let address = Address::try_from(s) + .map_err(|err| PyException::new_err(format!("{}", err)))?; + Ok(address) + } else { + Err(PyException::new_err("Addresses must be either an Address instance or an address as a string")) + } + }).collect::>>()?; + + Ok(GetBalancesByAddressesRequest { addresses }) +}); + +try_from_args! ( dict : GetBlockRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetBlocksRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetBlockTemplateRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetConnectionsRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetCurrentBlockColorRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetDaaScoreTimestampEstimateRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetFeeEstimateExperimentalRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetHeadersRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetMempoolEntriesRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetMempoolEntriesByAddressesRequest, { + let items = dict.get_item("addresses")? + .ok_or_else(|| PyException::new_err("Key `addresses` not present"))?; + + let list = items.downcast::() + .map_err(|_| PyException::new_err("`addresses` should be a list"))?; + + let addresses = list.iter().map(|item| { + if let Ok(address) = item.extract::
() { + Ok(address) + } else if let Ok(s) = item.extract::() { + let address = Address::try_from(s) + .map_err(|err| PyException::new_err(format!("{}", err)))?; + Ok(address) + } else { + Err(PyException::new_err("Addresses must be either an Address instance or an address as a string")) + } + }).collect::>>()?; + + let include_orphan_pool = dict.get_item("includeOrphanPool")? + .ok_or_else(|| PyException::new_err("Key `include_orphan_pool` not present"))? + .extract::()?; + + let filter_transaction_pool = dict.get_item("filterTransactionPool")? + .ok_or_else(|| PyException::new_err("Key `filter_transaction_pool` not present"))? + .extract::()?; + + Ok(GetMempoolEntriesByAddressesRequest { addresses, include_orphan_pool, filter_transaction_pool }) +}); + +try_from_args! ( dict : GetMempoolEntryRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetMetricsRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetSubnetworkRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : GetUtxosByAddressesRequest, { + let items = dict.get_item("addresses")? + .ok_or_else(|| PyException::new_err("Key `addresses` not present"))?; + let list = items.downcast::() + .map_err(|_| PyException::new_err("`addresses` should be a list"))?; + + let addresses = list.iter().map(|item| { + if let Ok(address) = item.extract::
() { + Ok(address) + } else if let Ok(s) = item.extract::() { + let address = Address::try_from(s) + .map_err(|err| PyException::new_err(format!("{}", err)))?; + Ok(address) + } else { + Err(PyException::new_err("Addresses must be either an Address instance or an address as a string")) + } + }).collect::>>()?; + + Ok(GetUtxosByAddressesRequest { addresses }) +}); + +try_from_args! ( dict : GetVirtualChainFromBlockRequest, { + Ok(from_pyobject(dict)?) +}); + +try_from_args! ( dict : ResolveFinalityConflictRequest, { + Ok(from_pyobject(dict)?) +}); + +// PY-TODO +// try_from_args! ( dict : SubmitBlockRequest, { +// Ok(from_pyobject(dict)?) +// }); + +try_from_args! ( dict : SubmitTransactionRequest, { + let transaction: Transaction = dict.get_item("transaction")? + .ok_or_else(|| PyException::new_err("Key `transactions` not present"))? + .extract()?; + let inner = transaction.inner(); + + let allow_orphan: bool = dict.get_item("allow_orphan")? + .ok_or_else(|| PyException::new_err("Key `allow_orphan` not present"))? + .extract()?; + + let inputs: Vec = + inner.inputs.clone().into_iter().map(|input| input.into()).collect::>(); + let outputs: Vec = + inner.outputs.clone().into_iter().map(|output| output.into()).collect::>(); + + let rpc_transaction = RpcTransaction { + version: inner.version, + inputs, + outputs, + lock_time: inner.lock_time, + subnetwork_id: inner.subnetwork_id.clone(), + gas: inner.gas, + payload: inner.payload.clone(), + mass: inner.mass, + verbose_data: None, + }; + + Ok(SubmitTransactionRequest { transaction: rpc_transaction, allow_orphan }) +}); + +try_from_args! ( dict : SubmitTransactionReplacementRequest, { + let transaction: Transaction = dict.get_item("transaction")? + .ok_or_else(|| PyException::new_err("Key `transactions` not present"))? + .extract()?; + + Ok(SubmitTransactionReplacementRequest { transaction: transaction.into() }) +}); + +try_from_args! ( dict : UnbanRequest, { + Ok(from_pyobject(dict)?) +}); diff --git a/rpc/core/src/bindings/python/mod.rs b/rpc/core/src/bindings/python/mod.rs new file mode 100644 index 0000000000..ba63992f3c --- /dev/null +++ b/rpc/core/src/bindings/python/mod.rs @@ -0,0 +1 @@ +pub mod messages; diff --git a/rpc/core/src/wasm/convert.rs b/rpc/core/src/bindings/wasm/convert.rs similarity index 100% rename from rpc/core/src/wasm/convert.rs rename to rpc/core/src/bindings/wasm/convert.rs diff --git a/rpc/core/src/wasm/message.rs b/rpc/core/src/bindings/wasm/message.rs similarity index 100% rename from rpc/core/src/wasm/message.rs rename to rpc/core/src/bindings/wasm/message.rs diff --git a/rpc/core/src/wasm/mod.rs b/rpc/core/src/bindings/wasm/mod.rs similarity index 100% rename from rpc/core/src/wasm/mod.rs rename to rpc/core/src/bindings/wasm/mod.rs diff --git a/rpc/core/src/error.rs b/rpc/core/src/error.rs index 0e2bfee225..76bf631f33 100644 --- a/rpc/core/src/error.rs +++ b/rpc/core/src/error.rs @@ -4,6 +4,8 @@ use kaspa_consensus_core::{subnets::SubnetworkConversionError, tx::TransactionId}; use kaspa_utils::networking::IpAddress; +#[cfg(feature = "py-sdk")] +use pyo3::{exceptions::PyException, prelude::PyErr}; use std::{net::AddrParseError, num::TryFromIntError}; use thiserror::Error; use workflow_core::channel::ChannelError; @@ -160,4 +162,11 @@ impl From for RpcError { } } +#[cfg(feature = "py-sdk")] +impl From for PyErr { + fn from(value: RpcError) -> Self { + PyException::new_err(value.to_string()) + } +} + pub type RpcResult = std::result::Result; diff --git a/rpc/core/src/lib.rs b/rpc/core/src/lib.rs index a2ece77d4d..14b32f5737 100644 --- a/rpc/core/src/lib.rs +++ b/rpc/core/src/lib.rs @@ -15,11 +15,13 @@ #![recursion_limit = "256"] pub mod api; +#[cfg(any(feature = "wasm32-sdk", feature = "py-sdk"))] +pub mod bindings; pub mod convert; pub mod error; pub mod model; pub mod notify; -pub mod wasm; +// pub mod wasm; pub mod prelude { //! Re-exports of the most commonly used types and traits in this crate. diff --git a/rpc/macros/src/lib.rs b/rpc/macros/src/lib.rs index 9ca49bf54a..a6d60b2ec5 100644 --- a/rpc/macros/src/lib.rs +++ b/rpc/macros/src/lib.rs @@ -10,6 +10,18 @@ pub fn build_wrpc_client_interface(input: TokenStream) -> TokenStream { wrpc::client::build_wrpc_client_interface(input) } +#[proc_macro] +#[proc_macro_error] +pub fn build_wrpc_python_interface(input: TokenStream) -> TokenStream { + wrpc::python::build_wrpc_python_interface(input) +} + +#[proc_macro] +#[proc_macro_error] +pub fn build_wrpc_python_subscriptions(input: TokenStream) -> TokenStream { + wrpc::python::build_wrpc_python_subscriptions(input) +} + #[proc_macro] #[proc_macro_error] pub fn declare_typescript_wasm_interface(input: TokenStream) -> TokenStream { diff --git a/rpc/macros/src/wrpc/client.rs b/rpc/macros/src/wrpc/client.rs index 12f41687a7..b0c3b162c9 100644 --- a/rpc/macros/src/wrpc/client.rs +++ b/rpc/macros/src/wrpc/client.rs @@ -19,10 +19,7 @@ impl Parse for RpcTable { fn parse(input: ParseStream) -> Result { let parsed = Punctuated::::parse_terminated(input).unwrap(); if parsed.len() != 2 { - return Err(Error::new_spanned( - parsed, - "usage: build_wrpc_client_interface!(interface, RpcApiOps,[getInfo, ..])".to_string(), - )); + return Err(Error::new_spanned(parsed, "usage: build_wrpc_client_interface!(RpcApiOps,[getInfo, ..])".to_string())); } let mut iter = parsed.iter(); diff --git a/rpc/macros/src/wrpc/mod.rs b/rpc/macros/src/wrpc/mod.rs index 8c8238cdb8..1864a05ca7 100644 --- a/rpc/macros/src/wrpc/mod.rs +++ b/rpc/macros/src/wrpc/mod.rs @@ -1,4 +1,5 @@ pub mod client; +pub mod python; pub mod server; pub mod test; pub mod wasm; diff --git a/rpc/macros/src/wrpc/python.rs b/rpc/macros/src/wrpc/python.rs new file mode 100644 index 0000000000..630fa3a95d --- /dev/null +++ b/rpc/macros/src/wrpc/python.rs @@ -0,0 +1,197 @@ +use crate::handler::*; +use convert_case::{Case, Casing}; +use proc_macro2::{Ident, Span, TokenStream}; +use quote::{quote, ToTokens}; +use regex::Regex; +use std::convert::Into; +use syn::{ + parse::{Parse, ParseStream}, + parse_macro_input, + punctuated::Punctuated, + Error, Expr, ExprArray, Result, Token, +}; + +#[derive(Debug)] +struct RpcHandlers { + handlers_no_args: ExprArray, + handlers_with_args: ExprArray, +} + +impl Parse for RpcHandlers { + fn parse(input: ParseStream) -> Result { + let parsed = Punctuated::::parse_terminated(input).unwrap(); + if parsed.len() != 2 { + return Err(Error::new_spanned( + parsed, + "usage: build_wrpc_python_interface!([fn no args, ..],[fn with args, ..])".to_string(), + )); + } + + let mut iter = parsed.iter(); + let handlers_no_args = get_handlers(iter.next().unwrap().clone())?; + let handlers_with_args = get_handlers(iter.next().unwrap().clone())?; + + let handlers = RpcHandlers { handlers_no_args, handlers_with_args }; + Ok(handlers) + } +} + +impl ToTokens for RpcHandlers { + fn to_tokens(&self, tokens: &mut TokenStream) { + let mut targets_no_args = Vec::new(); + let mut targets_with_args = Vec::new(); + + for handler in self.handlers_no_args.elems.iter() { + let Handler { fn_call, fn_no_suffix, request_type, response_type, .. } = Handler::new(handler); + + targets_no_args.push(quote! { + + #[pymethods] + impl RpcClient { + #[pyo3(signature = (request=None))] + fn #fn_no_suffix(&self, py: Python, request: Option>) -> PyResult> { + let client = self.inner.client.clone(); + + let request: #request_type = request.unwrap_or_else(|| PyDict::new_bound(py)).try_into()?; + + let py_fut = pyo3_async_runtimes::tokio::future_into_py(py, async move { + let response : #response_type = client.#fn_call(None, request).await?; + Python::with_gil(|py| { + Ok(serde_pyobject::to_pyobject(py, &response)?.to_object(py)) + }) + })?; + + Python::with_gil(|py| Ok(py_fut.into_py(py))) + } + } + }); + } + + for handler in self.handlers_with_args.elems.iter() { + let Handler { fn_call, fn_no_suffix, request_type, response_type, .. } = Handler::new(handler); + + targets_with_args.push(quote! { + + #[pymethods] + impl RpcClient { + fn #fn_no_suffix(&self, py: Python, request: Bound<'_, PyDict>) -> PyResult> { + let client = self.inner.client.clone(); + + let request : #request_type = request.try_into()?; + + let py_fut = pyo3_async_runtimes::tokio::future_into_py(py, async move { + let response : #response_type = client.#fn_call(None, request).await?; + + Python::with_gil(|py| { + Ok(serde_pyobject::to_pyobject(py, &response)?.to_object(py)) + }) + })?; + + Python::with_gil(|py| Ok(py_fut.into_py(py))) + } + } + }); + } + + quote! { + #(#targets_no_args)* + #(#targets_with_args)* + } + .to_tokens(tokens); + } +} + +pub fn build_wrpc_python_interface(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let rpc_table = parse_macro_input!(input as RpcHandlers); + + let ts = rpc_table.to_token_stream(); + // println!("MACRO: {}", ts.to_string()); + ts.into() +} + +#[derive(Debug)] +struct RpcSubscriptions { + handlers: ExprArray, +} + +impl Parse for RpcSubscriptions { + fn parse(input: ParseStream) -> Result { + let parsed = Punctuated::::parse_terminated(input).unwrap(); + if parsed.len() != 1 { + return Err(Error::new_spanned(parsed, "usage: build_wrpc_python_!([getInfo, ..])".to_string())); + } + + let mut iter = parsed.iter(); + // Intake enum variants as an array + let handlers = get_handlers(iter.next().unwrap().clone())?; + + Ok(RpcSubscriptions { handlers }) + } +} + +impl ToTokens for RpcSubscriptions { + fn to_tokens(&self, tokens: &mut TokenStream) { + let mut targets = Vec::new(); + + for handler in self.handlers.elems.iter() { + // TODO docs (name, docs) + let (name, _) = match handler { + syn::Expr::Path(expr_path) => (expr_path.path.to_token_stream().to_string(), &expr_path.attrs), + _ => { + continue; + } + }; + + let name = format!("Notify{}", name.as_str()); + let regex = Regex::new(r"^Notify").unwrap(); + let blank = regex.replace(&name, ""); + let subscribe = regex.replace(&name, "Subscribe"); + let unsubscribe = regex.replace(&name, "Unsubscribe"); + let scope = Ident::new(&blank, Span::call_site()); + let sub_scope = Ident::new(format!("{blank}Scope").as_str(), Span::call_site()); + let fn_subscribe_snake = Ident::new(&subscribe.to_case(Case::Snake), Span::call_site()); + let fn_unsubscribe_snake = Ident::new(&unsubscribe.to_case(Case::Snake), Span::call_site()); + + targets.push(quote! { + #[pymethods] + impl RpcClient { + fn #fn_subscribe_snake(&self, py: Python) -> PyResult> { + if let Some(listener_id) = self.listener_id() { + let client = self.inner.client.clone(); + py_async! {py, async move { + client.start_notify(listener_id, Scope::#scope(#sub_scope {})).await?; + Ok(()) + }} + } else { + Err(PyErr::new::("RPC subscribe on a closed connection")) + } + } + + fn #fn_unsubscribe_snake(&self, py: Python) -> PyResult> { + if let Some(listener_id) = self.listener_id() { + let client = self.inner.client.clone(); + py_async! {py, async move { + client.stop_notify(listener_id, Scope::#scope(#sub_scope {})).await?; + Ok(()) + }} + } else { + Err(PyErr::new::("RPC unsubscribe on a closed connection")) + } + } + } + }); + } + + quote! { + #(#targets)* + } + .to_tokens(tokens); + } +} + +pub fn build_wrpc_python_subscriptions(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let rpc_table = parse_macro_input!(input as RpcSubscriptions); + let ts = rpc_table.to_token_stream(); + // println!("MACRO: {}", ts.to_string()); + ts.into() +} diff --git a/rpc/wrpc/bindings/python/Cargo.toml b/rpc/wrpc/bindings/python/Cargo.toml new file mode 100644 index 0000000000..ac477096b7 --- /dev/null +++ b/rpc/wrpc/bindings/python/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "kaspa-wrpc-python" +description = "Kaspa wRPC Python client" +rust-version.workspace = true +version.workspace = true +edition.workspace = true +authors.workspace = true +include.workspace = true +license.workspace = true +repository.workspace = true + +[features] +default = [] +py-sdk = [ + "pyo3/extension-module", + "kaspa-addresses/py-sdk", + "kaspa-rpc-core/py-sdk", + "kaspa-wrpc-client/py-sdk", +] + +[dependencies] +ahash.workspace = true +cfg-if.workspace = true +futures.workspace = true +kaspa-addresses.workspace = true +kaspa-consensus-core.workspace = true +kaspa-notify.workspace = true +kaspa-rpc-core.workspace = true +kaspa-rpc-macros.workspace = true +kaspa-wrpc-client.workspace = true +kaspa-python-macros.workspace = true +pyo3.workspace = true +pyo3-async-runtimes.workspace = true +serde_json.workspace = true +serde-pyobject.workspace = true +thiserror.workspace = true +workflow-core.workspace = true +workflow-log.workspace = true +workflow-rpc.workspace = true + +[lints] +workspace = true diff --git a/rpc/wrpc/bindings/python/src/client.rs b/rpc/wrpc/bindings/python/src/client.rs new file mode 100644 index 0000000000..b905305d4b --- /dev/null +++ b/rpc/wrpc/bindings/python/src/client.rs @@ -0,0 +1,590 @@ +use crate::resolver::Resolver; +use ahash::AHashMap; +use futures::*; +use kaspa_addresses::Address; +use kaspa_notify::listener::ListenerId; +use kaspa_notify::notification::Notification; +use kaspa_notify::scope::{Scope, UtxosChangedScope, VirtualChainChangedScope, VirtualDaaScoreChangedScope}; +use kaspa_notify::{connection::ChannelType, events::EventType}; +use kaspa_python_macros::py_async; +use kaspa_rpc_core::api::rpc::RpcApi; +use kaspa_rpc_core::model::*; +use kaspa_rpc_core::notify::connection::ChannelConnection; +use kaspa_rpc_macros::{build_wrpc_python_interface, build_wrpc_python_subscriptions}; +use kaspa_wrpc_client::{client::ConnectOptions, error::Error, prelude::*, result::Result, KaspaRpcClient, WrpcEncoding}; +use pyo3::{ + exceptions::PyException, + prelude::*, + types::{PyDict, PyModule, PyTuple}, +}; +use std::str::FromStr; +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; +use workflow_core::channel::{Channel, DuplexChannel}; +use workflow_log::*; +use workflow_rpc::{client::Ctl, encoding::Encoding}; + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +enum NotificationEvent { + All, + Notification(EventType), + RpcCtl(Ctl), +} + +impl FromStr for NotificationEvent { + type Err = Error; + fn from_str(s: &str) -> Result { + if s == "all" { + Ok(NotificationEvent::All) + } else if let Ok(ctl) = Ctl::from_str(s) { + Ok(NotificationEvent::RpcCtl(ctl)) + } else if let Ok(event) = EventType::from_str(s) { + Ok(NotificationEvent::Notification(event)) + } else { + Err(Error::custom(format!("Invalid notification event type: `{}`", s))) + } + } +} + +#[derive(Clone)] +struct PyCallback { + callback: Arc, + args: Option>>, + kwargs: Option>>, +} + +impl PyCallback { + fn add_event_to_args(&self, py: Python, event: Bound) -> PyResult> { + match &self.args { + Some(existing_args) => { + let tuple_ref = existing_args.bind(py); + + let mut new_args: Vec = tuple_ref.iter().map(|arg| arg.to_object(py)).collect(); + new_args.push(event.into()); + + Ok(Py::from(PyTuple::new_bound(py, new_args))) + } + None => Ok(Py::from(PyTuple::new_bound(py, [event]))), + } + } + + fn execute(&self, py: Python, event: Bound) -> PyResult { + let args = self.add_event_to_args(py, event)?; + let kwargs = self.kwargs.as_ref().map(|kw| kw.bind(py)); + + let result = self.callback.call_bound(py, args.bind(py), kwargs).map_err(|err| { + // let fn_name: String = self.callback.getattr(py, "__name__").unwrap().extract(py).unwrap(); + + let traceback = PyModule::import_bound(py, "traceback") + .and_then(|traceback| { + traceback.call_method( + "format_exception", + (err.get_type_bound(py), err.value_bound(py), err.traceback_bound(py)), + None, + ) + }) + .and_then(|formatted| { + let trace_lines: Vec = + formatted.extract().unwrap_or_else(|_| vec!["".to_string()]); + Ok(trace_lines.join("")) + }) + .unwrap_or_else(|_| "".to_string()); + + PyException::new_err(format!("{}", traceback)) + })?; + + Ok(result) + } +} + +pub struct Inner { + client: Arc, + resolver: Option, + notification_task: Arc, + notification_ctl: DuplexChannel, + callbacks: Arc>>>, + listener_id: Arc>>, + notification_channel: Channel, +} + +impl Inner { + fn notification_callbacks(&self, event: NotificationEvent) -> Option> { + let notification_callbacks = self.callbacks.lock().unwrap(); + let all = notification_callbacks.get(&NotificationEvent::All).cloned(); + let target = notification_callbacks.get(&event).cloned(); + match (all, target) { + (Some(mut vec_all), Some(vec_target)) => { + vec_all.extend(vec_target); + Some(vec_all) + } + (Some(vec_all), None) => Some(vec_all), + (None, Some(vec_target)) => Some(vec_target), + (None, None) => None, + } + } +} + +#[pyclass] +#[derive(Clone)] +pub struct RpcClient { + inner: Arc, +} + +impl RpcClient { + pub fn new( + resolver: Option, + url: Option, + encoding: Option, + network_id: Option, + ) -> Result { + let client = Arc::new(KaspaRpcClient::new( + encoding.unwrap_or(Encoding::Borsh), + url.as_deref(), + Some(resolver.as_ref().unwrap().clone().into()), + network_id, + None, + )?); + + let rpc_client = RpcClient { + inner: Arc::new(Inner { + client, + resolver, + notification_task: Arc::new(AtomicBool::new(false)), + notification_ctl: DuplexChannel::oneshot(), + callbacks: Arc::new(Default::default()), + listener_id: Arc::new(Mutex::new(None)), + notification_channel: Channel::unbounded(), + }), + }; + + Ok(rpc_client) + } +} + +#[pymethods] +impl RpcClient { + #[new] + #[pyo3(signature = (resolver=None, url=None, encoding=None, network_id=None))] + fn ctor( + resolver: Option, + url: Option, + encoding: Option, + network_id: Option, + ) -> PyResult { + let encoding = WrpcEncoding::from_str(&encoding.unwrap_or("borsh".to_string())) + .map_err(|err| PyException::new_err(format!("{}", err)))?; + let network_id = NetworkId::from_str(&network_id.unwrap_or(String::from("mainnet")))?; + + Ok(Self::new(resolver, url, Some(encoding), Some(network_id))?) + } + + #[getter] + fn url(&self) -> Option { + self.inner.client.url() + } + + #[getter] + fn resolver(&self) -> Option { + self.inner.resolver.clone() + } + + fn set_resolver(&self, resolver: Resolver) -> PyResult<()> { + self.inner.client.set_resolver(resolver.into())?; + Ok(()) + } + + fn set_network_id(&self, network_id: String) -> PyResult<()> { + let network_id = NetworkId::from_str(&network_id)?; + self.inner.client.set_network_id(&network_id)?; + Ok(()) + } + + #[getter] + fn is_connected(&self) -> bool { + self.inner.client.is_connected() + } + + #[getter] + fn encoding(&self) -> String { + self.inner.client.encoding().to_string() + } + + #[getter] + #[pyo3(name = "node_id")] + fn resolver_node_id(&self) -> Option { + self.inner.client.node_descriptor().map(|node| node.uid.clone()) + } + + #[pyo3(signature = (block_async_connect=None, strategy=None, url=None, timeout_duration=None, retry_interval=None))] + pub fn connect( + &self, + py: Python, + block_async_connect: Option, + strategy: Option, + url: Option, + timeout_duration: Option, + retry_interval: Option, + ) -> PyResult> { + let block_async_connect = block_async_connect.unwrap_or(true); + let strategy = match strategy { + Some(strategy) => ConnectStrategy::from_str(&strategy).map_err(|err| PyException::new_err(format!("{}", err)))?, + None => ConnectStrategy::Retry, + }; + let connect_timeout: Option = timeout_duration.and_then(|ms| Some(Duration::from_millis(ms))); + let retry_interval: Option = retry_interval.and_then(|ms| Some(Duration::from_millis(ms))); + + let options = ConnectOptions { block_async_connect, strategy, url, connect_timeout, retry_interval }; + + self.start_notification_task(py)?; + + let client = self.inner.client.clone(); + py_async! {py, async move { + let _ = client.connect(Some(options)).await.map_err(|e| PyException::new_err(e.to_string())); + Ok(()) + }} + } + + fn disconnect(&self, py: Python) -> PyResult> { + let client = self.clone(); + + py_async! {py, async move { + client.inner.client.disconnect().await?; + client.stop_notification_task().await?; + Ok(()) + }} + } + + fn start(&self, py: Python) -> PyResult> { + self.start_notification_task(py)?; + let inner = self.inner.clone(); + py_async! {py, async move { + inner.client.start().await?; + Ok(()) + }} + } + + // fn stop() PY-TODO + // fn trigger_abort() PY-TODO + + #[pyo3(signature = (event, callback, *args, **kwargs))] + fn add_event_listener( + &self, + py: Python, + event: String, + callback: PyObject, + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult<()> { + let event = NotificationEvent::from_str(event.as_str())?; + + let args = args.to_object(py).extract::>(py)?; + + let kwargs = match kwargs { + Some(kw) => kw.to_object(py).extract::>(py)?, + None => PyDict::new_bound(py).into(), + }; + + let py_callback = PyCallback { callback: Arc::new(callback), args: Some(Arc::new(args)), kwargs: Some(Arc::new(kwargs)) }; + + self.inner.callbacks.lock().unwrap().entry(event).or_default().push(py_callback); + Ok(()) + } + + #[pyo3(signature = (event, callback=None))] + fn remove_event_listener(&self, py: Python, event: String, callback: Option) -> PyResult<()> { + let event = NotificationEvent::from_str(event.as_str())?; + let mut callbacks = self.inner.callbacks.lock().unwrap(); + + match (&event, callback) { + (NotificationEvent::All, None) => { + // Remove all callbacks from "all" events + callbacks.clear(); + } + (NotificationEvent::All, Some(callback)) => { + // Remove given callback from "all" events + for callbacks in callbacks.values_mut() { + callbacks.retain(|c| { + let cb_ref = c.callback.bind(py); + let callback_ref = callback.bind(py); + cb_ref.as_ref().ne(callback_ref.as_ref()).unwrap_or(true) + }); + } + } + (_, None) => { + // Remove all callbacks from given event + callbacks.remove(&event); + } + (_, Some(callback)) => { + // Remove given callback from given event + if let Some(callbacks) = callbacks.get_mut(&event) { + callbacks.retain(|c| { + let cb_ref = c.callback.bind(py); + let callback_ref = callback.bind(py); + cb_ref.as_ref().ne(callback_ref.as_ref()).unwrap_or(true) + }); + } + } + } + Ok(()) + } + + // fn clear_event_listener PY-TODO + // fn default_port PY-TODO + // fn parse_url PY-TODO + + fn remove_all_event_listeners(&self) -> PyResult<()> { + *self.inner.callbacks.lock().unwrap() = Default::default(); + Ok(()) + } +} + +impl RpcClient { + // fn new_with_rpc_client() PY-TODO + + pub fn listener_id(&self) -> Option { + *self.inner.listener_id.lock().unwrap() + } + + pub fn client(&self) -> &Arc { + &self.inner.client + } + + async fn stop_notification_task(&self) -> Result<()> { + if self.inner.notification_task.load(Ordering::SeqCst) { + self.inner.notification_ctl.signal(()).await?; + self.inner.notification_task.store(false, Ordering::SeqCst); + } + Ok(()) + } + + fn start_notification_task(&self, py: Python) -> Result<()> { + if self.inner.notification_task.load(Ordering::SeqCst) { + return Ok(()); + } + + self.inner.notification_task.store(true, Ordering::SeqCst); + + let ctl_receiver = self.inner.notification_ctl.request.receiver.clone(); + let ctl_sender = self.inner.notification_ctl.response.sender.clone(); + let notification_receiver = self.inner.notification_channel.receiver.clone(); + let ctl_multiplexer_channel = + self.inner.client.rpc_client().ctl_multiplexer().as_ref().expect("Python RpcClient ctl_multiplexer is None").channel(); + let this = self.clone(); + + let _ = pyo3_async_runtimes::tokio::future_into_py(py, async move { + loop { + select_biased! { + msg = ctl_multiplexer_channel.recv().fuse() => { + if let Ok(ctl) = msg { + + match ctl { + Ctl::Connect => { + let listener_id = this.inner.client.register_new_listener(ChannelConnection::new( + "kaspapy-wrpc-client-python", + this.inner.notification_channel.sender.clone(), + ChannelType::Persistent, + )); + *this.inner.listener_id.lock().unwrap() = Some(listener_id); + } + Ctl::Disconnect => { + let listener_id = this.inner.listener_id.lock().unwrap().take(); + if let Some(listener_id) = listener_id { + if let Err(err) = this.inner.client.unregister_listener(listener_id).await { + panic!("Error in unregister_listener: {:?}",err); + } + } + } + } + + let event = NotificationEvent::RpcCtl(ctl); + if let Some(handlers) = this.inner.notification_callbacks(event) { + for handler in handlers.into_iter() { + Python::with_gil(|py| { + let event = PyDict::new_bound(py); + event.set_item("type", ctl.to_string()).unwrap(); + event.set_item("rpc", this.url()).unwrap(); + + handler.execute(py, event).unwrap_or_else(|err| panic!("{}", err)); + }); + } + } + } + }, + msg = notification_receiver.recv().fuse() => { + if let Ok(notification) = &msg { + match ¬ification { + kaspa_rpc_core::Notification::UtxosChanged(utxos_changed_notification) => { + let event_type = notification.event_type(); + let notification_event = NotificationEvent::Notification(event_type); + if let Some(handlers) = this.inner.notification_callbacks(notification_event) { + let UtxosChangedNotification { added, removed } = utxos_changed_notification; + + for handler in handlers.into_iter() { + Python::with_gil(|py| { + let added = serde_pyobject::to_pyobject(py, added).unwrap(); + let removed = serde_pyobject::to_pyobject(py, removed).unwrap(); + + let event = PyDict::new_bound(py); + event.set_item("type", event_type.to_string()).unwrap(); + event.set_item("added", &added.to_object(py)).unwrap(); + event.set_item("removed", &removed.to_object(py)).unwrap(); + + handler.execute(py, event).unwrap_or_else(|err| panic!("{}", err)); + }) + } + } + }, + _ => { + let event_type = notification.event_type(); + let notification_event = NotificationEvent::Notification(event_type); + if let Some(handlers) = this.inner.notification_callbacks(notification_event) { + for handler in handlers.into_iter() { + Python::with_gil(|py| { + let event = PyDict::new_bound(py); + event.set_item("type", event_type.to_string()).unwrap(); + event.set_item("data", ¬ification.to_pyobject(py).unwrap()).unwrap(); + + handler.execute(py, event).unwrap_or_else(|err| panic!("{}", err)); + }); + } + } + } + } + } + } + _ = ctl_receiver.recv().fuse() => { + break; + }, + + } + } + + if let Some(listener_id) = this.listener_id() { + this.inner.listener_id.lock().unwrap().take(); + if let Err(err) = this.inner.client.unregister_listener(listener_id).await { + log_error!("Error in unregister_listener: {:?}", err); + } + } + + ctl_sender.send(()).await.ok(); + + Python::with_gil(|_| Ok(())) + }); + + Ok(()) + } +} + +#[pymethods] +impl RpcClient { + fn subscribe_utxos_changed(&self, py: Python, addresses: Vec
) -> PyResult> { + if let Some(listener_id) = self.listener_id() { + let client = self.inner.client.clone(); + py_async! {py, async move { + client.start_notify(listener_id, Scope::UtxosChanged(UtxosChangedScope { addresses })).await?; + Ok(()) + }} + } else { + Err(PyException::new_err("RPC subscribe on a closed connection")) + } + } + + fn unsubscribe_utxos_changed(&self, py: Python, addresses: Vec
) -> PyResult> { + if let Some(listener_id) = self.listener_id() { + let client = self.inner.client.clone(); + py_async! {py, async move { + client.stop_notify(listener_id, Scope::UtxosChanged(UtxosChangedScope { addresses })).await?; + Ok(()) + }} + } else { + Err(PyException::new_err("RPC unsubscribe on a closed connection")) + } + } + + fn subscribe_virtual_chain_changed(&self, py: Python, include_accepted_transaction_ids: bool) -> PyResult> { + if let Some(listener_id) = self.listener_id() { + let client = self.inner.client.clone(); + py_async! {py, async move { + client.start_notify(listener_id, Scope::VirtualChainChanged(VirtualChainChangedScope { include_accepted_transaction_ids })).await?; + Ok(()) + }} + } else { + Err(PyException::new_err("RPC subscribe on a closed connection")) + } + } + + fn unsubscribe_virtual_chain_changed(&self, py: Python, include_accepted_transaction_ids: bool) -> PyResult> { + if let Some(listener_id) = self.listener_id() { + let client = self.inner.client.clone(); + py_async! {py, async move { + client.stop_notify(listener_id, Scope::VirtualChainChanged(VirtualChainChangedScope { include_accepted_transaction_ids })).await?; + Ok(()) + }} + } else { + Err(PyException::new_err("RPC unsubscribe on a closed connection")) + } + } +} + +build_wrpc_python_subscriptions!([ + // UtxosChanged - defined above due to parameter `addresses: Vec
`` + // VirtualChainChanged - defined above due to paramter `include_accepted_transaction_ids: bool` + BlockAdded, + FinalityConflict, + FinalityConflictResolved, + NewBlockTemplate, + PruningPointUtxoSetOverride, + SinkBlueScoreChanged, + VirtualDaaScoreChanged, +]); + +build_wrpc_python_interface!( + [ + GetBlockCount, + GetBlockDagInfo, + GetCoinSupply, + GetConnectedPeerInfo, + GetInfo, + GetPeerAddresses, + GetSink, + GetSinkBlueScore, + Ping, + Shutdown, + GetServerInfo, + GetSyncStatus, + GetFeeEstimate, + GetCurrentNetwork, + ], + [ + AddPeer, + Ban, + EstimateNetworkHashesPerSecond, + GetBalanceByAddress, + GetBalancesByAddresses, + GetBlock, + GetBlocks, + GetBlockTemplate, + GetConnections, + GetCurrentBlockColor, + GetDaaScoreTimestampEstimate, + GetFeeEstimateExperimental, + GetHeaders, + GetMempoolEntries, + GetMempoolEntriesByAddresses, + GetMempoolEntry, + GetMetrics, + GetSubnetwork, + GetUtxosByAddresses, + GetVirtualChainFromBlock, + ResolveFinalityConflict, + // SubmitBlock, PY-TODO + SubmitTransaction, + SubmitTransactionReplacement, + Unban, + ] +); diff --git a/rpc/wrpc/bindings/python/src/lib.rs b/rpc/wrpc/bindings/python/src/lib.rs new file mode 100644 index 0000000000..b76dd4b88a --- /dev/null +++ b/rpc/wrpc/bindings/python/src/lib.rs @@ -0,0 +1,8 @@ +use cfg_if::cfg_if; + +cfg_if! { + if #[cfg(feature = "py-sdk")] { + pub mod client; + pub mod resolver; + } +} diff --git a/rpc/wrpc/bindings/python/src/resolver.rs b/rpc/wrpc/bindings/python/src/resolver.rs new file mode 100644 index 0000000000..71b4a007b3 --- /dev/null +++ b/rpc/wrpc/bindings/python/src/resolver.rs @@ -0,0 +1,70 @@ +use kaspa_consensus_core::network::NetworkId; +use kaspa_python_macros::py_async; +use kaspa_wrpc_client::{Resolver as NativeResolver, WrpcEncoding}; +use pyo3::{exceptions::PyException, prelude::*}; +use std::{str::FromStr, sync::Arc}; + +#[derive(Debug, Clone)] +#[pyclass] +pub struct Resolver { + resolver: NativeResolver, +} + +impl Resolver { + pub fn new(resolver: NativeResolver) -> Self { + Self { resolver } + } +} + +#[pymethods] +impl Resolver { + #[new] + #[pyo3(signature = (urls=None, tls=None))] + pub fn ctor(urls: Option>, tls: Option) -> PyResult { + let tls = tls.unwrap_or(false); + if let Some(urls) = urls { + Ok(Self { resolver: NativeResolver::new(Some(urls.into_iter().map(|url| Arc::new(url)).collect::>()), tls) }) + } else { + Ok(Self { resolver: NativeResolver::default() }) + } + } +} + +#[pymethods] +impl Resolver { + fn urls(&self) -> Vec { + self.resolver.urls().unwrap_or_default().into_iter().map(|url| (*url).clone()).collect::>() + } + + fn get_node(&self, py: Python, encoding: &str, network_id: &str) -> PyResult> { + let encoding = WrpcEncoding::from_str(encoding).map_err(|err| PyException::new_err(format!("{}", err)))?; + let network_id = NetworkId::from_str(network_id)?; + + let resolver = self.resolver.clone(); + py_async! {py, async move { + let node = resolver.get_node(encoding, network_id).await?; + Python::with_gil(|py| { + Ok(serde_pyobject::to_pyobject(py, &node)?.to_object(py)) + }) + }} + } + + fn get_url(&self, py: Python, encoding: &str, network_id: &str) -> PyResult> { + let encoding = WrpcEncoding::from_str(encoding).map_err(|err| PyException::new_err(format!("{}", err)))?; + let network_id = NetworkId::from_str(network_id)?; + + let resolver = self.resolver.clone(); + py_async! {py, async move { + let url = resolver.get_url(encoding, network_id).await?; + Ok(url) + }} + } + + // fn connect() TODO +} + +impl From for NativeResolver { + fn from(resolver: Resolver) -> Self { + resolver.resolver + } +} diff --git a/rpc/wrpc/wasm/.gitignore b/rpc/wrpc/bindings/wasm/.gitignore similarity index 100% rename from rpc/wrpc/wasm/.gitignore rename to rpc/wrpc/bindings/wasm/.gitignore diff --git a/rpc/wrpc/wasm/Cargo.toml b/rpc/wrpc/bindings/wasm/Cargo.toml similarity index 100% rename from rpc/wrpc/wasm/Cargo.toml rename to rpc/wrpc/bindings/wasm/Cargo.toml diff --git a/rpc/wrpc/wasm/build-node b/rpc/wrpc/bindings/wasm/build-node similarity index 100% rename from rpc/wrpc/wasm/build-node rename to rpc/wrpc/bindings/wasm/build-node diff --git a/rpc/wrpc/wasm/build-web b/rpc/wrpc/bindings/wasm/build-web similarity index 100% rename from rpc/wrpc/wasm/build-web rename to rpc/wrpc/bindings/wasm/build-web diff --git a/rpc/wrpc/wasm/nodejs/index.js b/rpc/wrpc/bindings/wasm/nodejs/index.js similarity index 100% rename from rpc/wrpc/wasm/nodejs/index.js rename to rpc/wrpc/bindings/wasm/nodejs/index.js diff --git a/rpc/wrpc/wasm/nodejs/package.json b/rpc/wrpc/bindings/wasm/nodejs/package.json similarity index 100% rename from rpc/wrpc/wasm/nodejs/package.json rename to rpc/wrpc/bindings/wasm/nodejs/package.json diff --git a/rpc/wrpc/wasm/src/client.rs b/rpc/wrpc/bindings/wasm/src/client.rs similarity index 99% rename from rpc/wrpc/wasm/src/client.rs rename to rpc/wrpc/bindings/wasm/src/client.rs index ccd9cb284b..42b6637545 100644 --- a/rpc/wrpc/wasm/src/client.rs +++ b/rpc/wrpc/bindings/wasm/src/client.rs @@ -19,7 +19,7 @@ use kaspa_notify::events::EventType; use kaspa_notify::listener; use kaspa_notify::notification::Notification as NotificationT; use kaspa_rpc_core::api::ctl; -pub use kaspa_rpc_core::wasm::message::*; +pub use kaspa_rpc_core::bindings::wasm::message::*; pub use kaspa_rpc_macros::{ build_wrpc_wasm_bindgen_interface, build_wrpc_wasm_bindgen_subscriptions, declare_typescript_wasm_interface as declare, }; diff --git a/rpc/wrpc/wasm/src/imports.rs b/rpc/wrpc/bindings/wasm/src/imports.rs similarity index 100% rename from rpc/wrpc/wasm/src/imports.rs rename to rpc/wrpc/bindings/wasm/src/imports.rs diff --git a/rpc/wrpc/wasm/src/lib.rs b/rpc/wrpc/bindings/wasm/src/lib.rs similarity index 100% rename from rpc/wrpc/wasm/src/lib.rs rename to rpc/wrpc/bindings/wasm/src/lib.rs diff --git a/rpc/wrpc/wasm/src/notify.rs b/rpc/wrpc/bindings/wasm/src/notify.rs similarity index 100% rename from rpc/wrpc/wasm/src/notify.rs rename to rpc/wrpc/bindings/wasm/src/notify.rs diff --git a/rpc/wrpc/wasm/src/resolver.rs b/rpc/wrpc/bindings/wasm/src/resolver.rs similarity index 100% rename from rpc/wrpc/wasm/src/resolver.rs rename to rpc/wrpc/bindings/wasm/src/resolver.rs diff --git a/rpc/wrpc/wasm/web/index.html b/rpc/wrpc/bindings/wasm/web/index.html similarity index 100% rename from rpc/wrpc/wasm/web/index.html rename to rpc/wrpc/bindings/wasm/web/index.html diff --git a/rpc/wrpc/client/Cargo.toml b/rpc/wrpc/client/Cargo.toml index 1cc0a21917..52e4e25a5b 100644 --- a/rpc/wrpc/client/Cargo.toml +++ b/rpc/wrpc/client/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true [features] wasm32-sdk = ["kaspa-consensus-wasm/wasm32-sdk","kaspa-rpc-core/wasm32-sdk","workflow-rpc/wasm32-sdk"] +py-sdk = ["pyo3"] default = [] [lib] @@ -30,6 +31,7 @@ kaspa-notify.workspace = true kaspa-rpc-core.workspace = true kaspa-rpc-macros.workspace = true paste.workspace = true +pyo3 = { workspace = true, optional = true } rand.workspace = true regex.workspace = true serde_json.workspace = true diff --git a/rpc/wrpc/client/src/error.rs b/rpc/wrpc/client/src/error.rs index 657027ed0b..290eae9939 100644 --- a/rpc/wrpc/client/src/error.rs +++ b/rpc/wrpc/client/src/error.rs @@ -1,5 +1,7 @@ //! [`Error`](enum@Error) variants for the wRPC client library. +#[cfg(feature = "py-sdk")] +use pyo3::{exceptions::PyException, prelude::PyErr}; use thiserror::Error; use wasm_bindgen::JsError; use wasm_bindgen::JsValue; @@ -116,6 +118,13 @@ impl From for JsValue { } } +#[cfg(feature = "py-sdk")] +impl From for PyErr { + fn from(value: Error) -> Self { + PyException::new_err(value.to_string()) + } +} + // impl From for Error { // fn from(err: workflow_wasm::serde::Error) -> Self { // Self::ToValue(err.to_string()) diff --git a/wallet/bip32/Cargo.toml b/wallet/bip32/Cargo.toml index efc31baf48..28eed2d284 100644 --- a/wallet/bip32/Cargo.toml +++ b/wallet/bip32/Cargo.toml @@ -11,6 +11,12 @@ repository.workspace = true include = ["src/**/*.rs", "Cargo.toml", "src/**/*.txt"] # include.workspace = true +[features] +default = [] +py-sdk = [ + "pyo3", +] + [dependencies] borsh.workspace = true bs58.workspace = true @@ -20,6 +26,7 @@ js-sys.workspace = true kaspa-utils.workspace = true once_cell.workspace = true pbkdf2.workspace = true +pyo3 = { workspace = true, optional = true } rand_core.workspace = true rand.workspace = true ripemd.workspace = true diff --git a/wallet/bip32/src/error.rs b/wallet/bip32/src/error.rs index 2ef733bbcd..978837643a 100644 --- a/wallet/bip32/src/error.rs +++ b/wallet/bip32/src/error.rs @@ -2,6 +2,8 @@ use core::fmt::{self, Display}; use core::str::Utf8Error; +#[cfg(feature = "py-sdk")] +use pyo3::{exceptions::PyException, PyErr}; use std::sync::PoisonError; use thiserror::Error; use wasm_bindgen::JsValue; @@ -132,3 +134,10 @@ impl From for JsValue { JsValue::from(value.to_string()) } } + +#[cfg(feature = "py-sdk")] +impl From for PyErr { + fn from(value: Error) -> PyErr { + PyException::new_err(value.to_string()) + } +} diff --git a/wallet/bip32/src/mnemonic/language.rs b/wallet/bip32/src/mnemonic/language.rs index bb4f386db7..2162b02497 100644 --- a/wallet/bip32/src/mnemonic/language.rs +++ b/wallet/bip32/src/mnemonic/language.rs @@ -6,6 +6,8 @@ //! Adapted from the `bip39` crate use super::bits::{Bits, Bits11}; +#[cfg(feature = "py-sdk")] +use pyo3::prelude::*; use std::{collections::BTreeMap, vec::Vec}; use wasm_bindgen::prelude::*; @@ -17,7 +19,8 @@ use wasm_bindgen::prelude::*; /// @see {@link Mnemonic} /// /// @category Wallet SDK -#[derive(Copy, Clone, Debug, Default)] +#[derive(Copy, Clone, Debug, Default, PartialEq)] +#[cfg_attr(feature = "py-sdk", pyclass(eq, eq_int))] #[wasm_bindgen] pub enum Language { /// English is presently the only supported language diff --git a/wallet/bip32/src/mnemonic/phrase.rs b/wallet/bip32/src/mnemonic/phrase.rs index eaa7e7096e..ccd923b11f 100644 --- a/wallet/bip32/src/mnemonic/phrase.rs +++ b/wallet/bip32/src/mnemonic/phrase.rs @@ -8,6 +8,8 @@ use crate::Result; use crate::{Error, KEY_SIZE}; use borsh::{BorshDeserialize, BorshSerialize}; use kaspa_utils::hex::*; +#[cfg(feature = "py-sdk")] +use pyo3::prelude::*; use rand_core::{CryptoRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -45,6 +47,7 @@ impl TryFrom for WordCount { /// BIP39 mnemonic phrases: sequences of words representing cryptographic keys. /// @category Wallet SDK #[derive(Clone)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct Mnemonic { /// Language @@ -107,6 +110,67 @@ impl Mnemonic { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl Mnemonic { + #[new] + #[pyo3(signature = (phrase, language=None))] + pub fn constructor_py(phrase: &str, language: Option) -> Result { + Mnemonic::new(phrase, language.unwrap_or(Language::English)) + } + + #[staticmethod] + #[pyo3(name = "validate")] + #[pyo3(signature = (phrase, language=None))] + pub fn validate_py(phrase: &str, language: Option) -> bool { + Mnemonic::new(phrase, language.unwrap_or(Language::English)).is_ok() + } + + #[getter] + #[pyo3(name = "entropy")] + pub fn get_entropy_py(&self) -> String { + self.entropy.to_hex() + } + + #[setter] + #[pyo3(name = "entropy")] + pub fn set_entropy_py(&mut self, entropy: &str) { + let vec = Vec::::from_hex(entropy).unwrap_or_else(|err| panic!("invalid entropy `{entropy}`: {err}")); + let len = vec.len(); + if len != 16 && len != 32 { + panic!("Invalid entropy: `{entropy}`") + } + self.entropy = vec; + } + + #[staticmethod] + #[pyo3(name = "random")] + #[pyo3(signature = (word_count=None))] + pub fn create_random_py(word_count: Option) -> Result { + let word_count = word_count.unwrap_or(24) as usize; + Mnemonic::random(word_count.try_into()?, Default::default()) + } + + #[getter] + #[pyo3(name = "phrase")] + pub fn phrase_string_py(&self) -> String { + self.phrase.clone() + } + + #[setter] + #[pyo3(name = "phrase")] + pub fn set_phrase_py(&mut self, phrase: String) { + self.phrase = phrase; + } + + #[pyo3(name = "to_seed")] + #[pyo3(signature = (password=None))] + pub fn create_seed_py(&self, password: Option<&str>) -> String { + let password = password.unwrap_or_default(); + self.to_seed(password).as_bytes().to_vec().to_hex() + } +} + impl Mnemonic { pub fn random(word_count: WordCount, language: Language) -> Result { Mnemonic::random_impl(word_count, rand::thread_rng(), language) diff --git a/wallet/core/Cargo.toml b/wallet/core/Cargo.toml index 3e057b6a77..31b2c6447c 100644 --- a/wallet/core/Cargo.toml +++ b/wallet/core/Cargo.toml @@ -26,6 +26,16 @@ wasm32-sdk = [ "wasm32-core" ] default = ["wasm32-sdk"] +py-sdk = [ + "kaspa-consensus-core/py-sdk", + "kaspa-python-core/py-sdk", + "kaspa-python-macros", + "kaspa-wallet-keys/py-sdk", + "kaspa-wrpc-python/py-sdk", + "pyo3", + "pyo3-async-runtimes", + "serde-pyobject", +] # default = [] [lib] @@ -65,6 +75,8 @@ kaspa-core.workspace = true kaspa-hashes.workspace = true kaspa-metrics-core.workspace = true kaspa-notify.workspace = true +kaspa-python-core = { workspace = true, optional = true } +kaspa-python-macros = { workspace = true, optional = true } kaspa-rpc-core.workspace = true kaspa-txscript-errors.workspace = true kaspa-txscript.workspace = true @@ -74,16 +86,20 @@ kaspa-wallet-macros.workspace = true kaspa-wallet-pskt.workspace = true kaspa-wasm-core.workspace = true kaspa-wrpc-client.workspace = true +kaspa-wrpc-python = { workspace = true, optional = true } kaspa-wrpc-wasm.workspace = true md-5.workspace = true pad.workspace = true pbkdf2.workspace = true +pyo3 = { workspace = true, optional = true } +pyo3-async-runtimes = { workspace = true, optional = true } rand.workspace = true regex.workspace = true ripemd.workspace = true secp256k1.workspace = true separator.workspace = true serde_json.workspace = true +serde-pyobject = { workspace = true, optional = true } serde-wasm-bindgen.workspace = true serde.workspace = true sha1.workspace = true diff --git a/wallet/core/src/bindings/mod.rs b/wallet/core/src/bindings/mod.rs new file mode 100644 index 0000000000..8c74934cac --- /dev/null +++ b/wallet/core/src/bindings/mod.rs @@ -0,0 +1,7 @@ +#[cfg(feature = "py-sdk")] +pub mod python; + +pub mod signer; + +#[cfg(any(feature = "wasm32-sdk", feature = "wasm32-core"))] +pub mod wasm; diff --git a/wallet/core/src/bindings/python/message.rs b/wallet/core/src/bindings/python/message.rs new file mode 100644 index 0000000000..c3ba2c7d16 --- /dev/null +++ b/wallet/core/src/bindings/python/message.rs @@ -0,0 +1,27 @@ +use crate::imports::*; +use crate::message::*; +use kaspa_wallet_keys::privatekey::PrivateKey; +use kaspa_wallet_keys::publickey::PublicKey; + +#[pyfunction] +#[pyo3(name = "sign_message")] +#[pyo3(signature = (message, private_key, no_aux_rand=false))] +pub fn py_sign_message(message: String, private_key: PrivateKey, no_aux_rand: bool) -> PyResult { + let mut privkey_bytes = [0u8; 32]; + privkey_bytes.copy_from_slice(&private_key.secret_bytes()); + let pm = PersonalMessage(&message); + let sign_options = SignMessageOptions { no_aux_rand }; + let sig_vec = sign_message(&pm, &privkey_bytes, &sign_options).map_err(|err| PyException::new_err(format!("{}", err)))?; + privkey_bytes.zeroize(); + Ok(faster_hex::hex_string(sig_vec.as_slice()).into()) +} + +#[pyfunction] +#[pyo3(name = "verify_message")] +pub fn py_verify_message(message: String, signature: String, public_key: PublicKey) -> PyResult { + let pm = PersonalMessage(&message); + let mut signature_bytes = [0u8; 64]; + faster_hex::hex_decode(signature.as_bytes(), &mut signature_bytes).map_err(|err| PyException::new_err(format!("{}", err)))?; + + Ok(verify_message(&pm, &signature_bytes.to_vec(), &public_key.xonly_public_key).is_ok()) +} diff --git a/wallet/core/src/bindings/python/mod.rs b/wallet/core/src/bindings/python/mod.rs new file mode 100644 index 0000000000..e5a8c8c3d9 --- /dev/null +++ b/wallet/core/src/bindings/python/mod.rs @@ -0,0 +1,4 @@ +pub mod message; +pub mod signer; +pub mod tx; +pub mod utils; diff --git a/wallet/core/src/bindings/python/signer.rs b/wallet/core/src/bindings/python/signer.rs new file mode 100644 index 0000000000..ccec99586b --- /dev/null +++ b/wallet/core/src/bindings/python/signer.rs @@ -0,0 +1,50 @@ +use crate::bindings::signer::{sign_hash, sign_transaction}; +use crate::imports::*; +use kaspa_consensus_client::Transaction; +use kaspa_consensus_core::hashing::wasm::SighashType; +use kaspa_consensus_core::sign::sign_input; +use kaspa_consensus_core::tx::PopulatedTransaction; +use kaspa_hashes::Hash; +use kaspa_wallet_keys::privatekey::PrivateKey; + +#[pyfunction] +#[pyo3(name = "sign_transaction")] +pub fn py_sign_transaction(tx: &Transaction, signer: Vec, verify_sig: bool) -> PyResult { + let mut private_keys: Vec<[u8; 32]> = vec![]; + for key in signer.iter() { + private_keys.push(key.secret_bytes()); + } + + let tx = + sign_transaction(tx, &private_keys, verify_sig).map_err(|err| PyException::new_err(format!("Unable to sign: {err:?}")))?; + private_keys.zeroize(); + Ok(tx.clone()) +} + +#[pyfunction] +#[pyo3(signature = (tx, input_index, private_key, sighash_type=None))] +pub fn create_input_signature( + tx: &Transaction, + input_index: u8, + private_key: &PrivateKey, + sighash_type: Option, +) -> PyResult { + let (cctx, utxos) = tx.tx_and_utxos()?; + let populated_transaction = PopulatedTransaction::new(&cctx, utxos); + + let signature = sign_input( + &populated_transaction, + input_index.into(), + &private_key.secret_bytes(), + sighash_type.unwrap_or(SighashType::All).into(), + ); + + Ok(signature.to_hex().into()) +} + +#[pyfunction] +pub fn sign_script_hash(script_hash: String, privkey: &PrivateKey) -> Result { + let script_hash = Hash::from_str(&script_hash)?; + let result = sign_hash(script_hash, &privkey.into())?; + Ok(result.to_hex()) +} diff --git a/wallet/core/src/bindings/python/tx/generator/generator.rs b/wallet/core/src/bindings/python/tx/generator/generator.rs new file mode 100644 index 0000000000..6115e675a3 --- /dev/null +++ b/wallet/core/src/bindings/python/tx/generator/generator.rs @@ -0,0 +1,223 @@ +use crate::bindings::python::tx::generator::pending::PendingTransaction; +use crate::bindings::python::tx::generator::summary::GeneratorSummary; +use crate::imports::*; +use crate::tx::{generator as native, Fees, PaymentDestination, PaymentOutput, PaymentOutputs}; + +pub struct PyUtxoEntries { + pub entries: Vec, +} + +impl FromPyObject<'_> for PyUtxoEntries { + fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { + // Must be list + let list = ob.downcast::()?; + + let entries = list + .iter() + .map(|item| { + if let Ok(entry) = item.extract::() { + Ok(entry) + } else if let Ok(entry) = item.downcast::() { + UtxoEntryReference::try_from(entry) + } else { + Err(PyException::new_err("All entries must be UtxoEntryReference instance or compatible dict")) + } + }) + .collect::>>()?; + + Ok(PyUtxoEntries { entries }) + } +} + +pub struct PyOutputs { + pub outputs: Vec, +} + +impl FromPyObject<'_> for PyOutputs { + fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { + // Must be list + let list = ob.downcast::()?; + + let outputs = list + .iter() + .map(|item| { + if let Ok(output) = item.extract::() { + Ok(output) + } else if let Ok(output) = item.downcast::() { + PaymentOutput::try_from(output) + } else { + Err(PyException::new_err("All outputs must be PaymentOutput instance or compatible dict")) + } + }) + .collect::>>()?; + + Ok(PyOutputs { outputs }) + } +} + +#[pyclass] +pub struct Generator { + inner: Arc, +} + +#[pymethods] +impl Generator { + #[new] + #[pyo3(signature = (network_id, entries, change_address, outputs=None, payload=None, priority_fee=None, priority_entries=None, sig_op_count=None, minimum_signatures=None))] + pub fn ctor( + network_id: &str, + entries: PyUtxoEntries, + change_address: Address, + outputs: Option, + payload: Option, + priority_fee: Option, + priority_entries: Option, + sig_op_count: Option, + minimum_signatures: Option, + ) -> PyResult { + let settings = GeneratorSettings::new( + outputs, + change_address, + priority_fee, + entries.entries, + priority_entries.map(|p| p.entries), + sig_op_count, + minimum_signatures, + payload.map(Into::into), + network_id, + ); + + let settings = match settings.source { + GeneratorSource::UtxoEntries(utxo_entries) => { + let change_address = settings + .change_address + .ok_or_else(|| PyException::new_err("changeAddress is required for Generator constructor with UTXO entries"))?; + + let network_id = settings + .network_id + .ok_or_else(|| PyException::new_err("networkId is required for Generator constructor with UTXO entries"))?; + + native::GeneratorSettings::try_new_with_iterator( + network_id, + Box::new(utxo_entries.into_iter()), + settings.priority_utxo_entries, + change_address, + settings.sig_op_count, + settings.minimum_signatures, + settings.final_transaction_destination, + settings.final_priority_fee, + settings.payload, + settings.multiplexer, + )? + } + GeneratorSource::UtxoContext(_) => unimplemented!(), + }; + + let abortable = Abortable::default(); + let generator = native::Generator::try_new(settings, None, Some(&abortable))?; + + Ok(Self { inner: Arc::new(generator) }) + } + + pub fn estimate(&self) -> PyResult { + self.inner.iter().collect::>>()?; + Ok(self.inner.summary().into()) + } + + pub fn summary(&self) -> GeneratorSummary { + self.inner.summary().into() + } +} + +impl Generator { + pub fn iter(&self) -> impl Iterator> { + self.inner.iter() + } + + pub fn stream(&self) -> impl Stream> { + self.inner.stream() + } +} + +#[pymethods] +impl Generator { + fn __iter__(slf: PyRefMut) -> PyResult> { + Ok(slf.into()) + } + + fn __next__(slf: PyRefMut) -> PyResult> { + match slf.inner.iter().next() { + Some(result) => match result { + Ok(transaction) => Ok(Some(transaction.into())), + Err(e) => Err(PyErr::new::(format!("{}", e))), + }, + None => Ok(None), + } + } +} + +enum GeneratorSource { + UtxoEntries(Vec), + UtxoContext(UtxoContext), + // Account(Account), +} + +struct GeneratorSettings { + pub network_id: Option, + pub source: GeneratorSource, + pub priority_utxo_entries: Option>, + pub multiplexer: Option>>, + pub final_transaction_destination: PaymentDestination, + pub change_address: Option
, + pub final_priority_fee: Fees, + pub sig_op_count: u8, + pub minimum_signatures: u16, + pub payload: Option>, +} + +impl GeneratorSettings { + pub fn new( + outputs: Option, + change_address: Address, + priority_fee: Option, + entries: Vec, + priority_entries: Option>, + sig_op_count: Option, + minimum_signatures: Option, + payload: Option>, + network_id: &str, + ) -> GeneratorSettings { + let network_id = NetworkId::from_str(network_id).unwrap(); + + let final_transaction_destination = match outputs { + Some(py_outputs) => PaymentOutputs { outputs: py_outputs.outputs }.into(), + None => PaymentDestination::Change, + }; + + let final_priority_fee = match priority_fee { + Some(fee) => fee.try_into().unwrap(), + None => Fees::None, + }; + + // PY-TODO support GeneratorSource::UtxoContext when available + let generator_source = + GeneratorSource::UtxoEntries(entries.iter().map(|entry| UtxoEntryReference::try_from(entry.clone()).unwrap()).collect()); + + let sig_op_count = sig_op_count.unwrap_or(1); + + let minimum_signatures = minimum_signatures.unwrap_or(1); + + GeneratorSettings { + network_id: Some(network_id), + source: generator_source, + priority_utxo_entries: priority_entries, + multiplexer: None, + final_transaction_destination, + change_address: Some(change_address), + final_priority_fee, + sig_op_count, + minimum_signatures, + payload, + } + } +} diff --git a/wallet/core/src/bindings/python/tx/generator/mod.rs b/wallet/core/src/bindings/python/tx/generator/mod.rs new file mode 100644 index 0000000000..9bb85ea89c --- /dev/null +++ b/wallet/core/src/bindings/python/tx/generator/mod.rs @@ -0,0 +1,7 @@ +pub mod generator; +pub mod pending; +pub mod summary; + +pub use generator::*; +pub use pending::*; +pub use summary::*; diff --git a/wallet/core/src/bindings/python/tx/generator/pending.rs b/wallet/core/src/bindings/python/tx/generator/pending.rs new file mode 100644 index 0000000000..f1842c3dba --- /dev/null +++ b/wallet/core/src/bindings/python/tx/generator/pending.rs @@ -0,0 +1,140 @@ +use crate::imports::*; +use crate::tx::generator as native; +use kaspa_consensus_client::Transaction; +use kaspa_consensus_core::hashing::wasm::SighashType; +use kaspa_python_macros::py_async; +use kaspa_wallet_keys::privatekey::PrivateKey; +use kaspa_wrpc_python::client::RpcClient; + +#[pyclass] +pub struct PendingTransaction { + inner: native::PendingTransaction, +} + +#[pymethods] +impl PendingTransaction { + #[getter] + fn id(&self) -> String { + self.inner.id().to_string() + } + + #[getter] + #[pyo3(name = "payment_amount")] + fn payment_value(&self) -> Option { + self.inner.payment_value() + } + + #[getter] + #[pyo3(name = "change_amount")] + fn change_value(&self) -> u64 { + self.inner.change_value() + } + + #[getter] + #[pyo3(name = "fee_amount")] + fn fees(&self) -> u64 { + self.inner.fees() + } + + #[getter] + fn mass(&self) -> u64 { + self.inner.mass() + } + + #[getter] + fn minimum_signatures(&self) -> u16 { + self.inner.minimum_signatures() + } + + #[getter] + #[pyo3(name = "aggregate_input_amount")] + fn aggregate_input_value(&self) -> u64 { + self.inner.aggregate_input_value() + } + + #[getter] + #[pyo3(name = "aggregate_output_amount")] + fn aggregate_output_value(&self) -> u64 { + self.inner.aggregate_output_value() + } + + #[getter] + #[pyo3(name = "transaction_type")] + fn kind(&self) -> String { + if self.inner.is_batch() { + "batch".to_string() + } else { + "final".to_string() + } + } + + fn addresses(&self) -> Vec
{ + self.inner.addresses().clone() + } + + fn get_utxo_entries(&self) -> Vec { + self.inner.utxo_entries().values().map(|utxo_entry| UtxoEntryReference::from(utxo_entry.clone())).collect() + } + + #[pyo3(signature = (input_index, private_key, sighash_type=None))] + fn create_input_signature( + &self, + input_index: u8, + private_key: &PrivateKey, + sighash_type: Option<&SighashType>, + ) -> PyResult { + let signature = self.inner.create_input_signature( + input_index.into(), + &private_key.secret_bytes(), + sighash_type.cloned().unwrap_or(SighashType::All).into(), + )?; + + Ok(signature.to_hex()) + } + + fn fill_input(&self, input_index: u8, signature_script: PyBinary) -> PyResult<()> { + self.inner.fill_input(input_index.into(), signature_script.into())?; + + Ok(()) + } + + #[pyo3(signature = (input_index, private_key, sighash_type=None))] + fn sign_input(&self, input_index: u8, private_key: &PrivateKey, sighash_type: Option<&SighashType>) -> PyResult<()> { + self.inner.sign_input( + input_index.into(), + &private_key.secret_bytes(), + sighash_type.cloned().unwrap_or(SighashType::All).into(), + )?; + + Ok(()) + } + + #[pyo3(signature = (private_keys, check_fully_signed=None))] + fn sign(&self, private_keys: Vec, check_fully_signed: Option) -> PyResult<()> { + let mut keys = private_keys.iter().map(|key| key.secret_bytes()).collect::>(); + self.inner.try_sign_with_keys(&keys, check_fully_signed)?; + keys.zeroize(); + Ok(()) + } + + fn submit(&self, py: Python, rpc_client: &RpcClient) -> PyResult> { + let inner = self.inner.clone(); + let rpc: Arc = rpc_client.client().clone(); + + py_async! {py, async move { + let txid = inner.try_submit(&rpc).await?; + Ok(txid.to_string()) + }} + } + + #[getter] + fn transaction(&self) -> PyResult { + Ok(Transaction::from_cctx_transaction(&self.inner.transaction(), self.inner.utxo_entries())) + } +} + +impl From for PendingTransaction { + fn from(pending_transaction: native::PendingTransaction) -> Self { + Self { inner: pending_transaction } + } +} diff --git a/wallet/core/src/bindings/python/tx/generator/summary.rs b/wallet/core/src/bindings/python/tx/generator/summary.rs new file mode 100644 index 0000000000..2f0a1198d9 --- /dev/null +++ b/wallet/core/src/bindings/python/tx/generator/summary.rs @@ -0,0 +1,60 @@ +use crate::imports::*; +use crate::tx::generator as core; + +/// +/// A class containing a summary produced by transaction {@link Generator}. +/// This class contains the number of transactions, the aggregated fees, +/// the aggregated UTXOs and the final transaction amount that includes +/// both network and QoS (priority) fees. +/// +/// @see {@link createTransactions}, {@link IGeneratorSettingsObject}, {@link Generator} +/// @category Wallet SDK +/// +#[pyclass] +pub struct GeneratorSummary { + inner: core::GeneratorSummary, +} + +#[pymethods] +impl GeneratorSummary { + #[getter] + pub fn network_type(&self) -> String { + self.inner.network_type().to_string() + } + + #[getter] + #[pyo3(name = "utxos")] + pub fn aggregated_utxos(&self) -> usize { + self.inner.aggregated_utxos() + } + + #[getter] + #[pyo3(name = "fees")] + pub fn aggregated_fees(&self) -> u64 { + self.inner.aggregated_fees() + } + + #[getter] + #[pyo3(name = "transactions")] + pub fn number_of_generated_transactions(&self) -> usize { + self.inner.number_of_generated_transactions() + } + + #[getter] + #[pyo3(name = "final_amount")] + pub fn final_transaction_amount(&self) -> Option { + self.inner.final_transaction_amount() + } + + #[getter] + #[pyo3(name = "final_transaction_id")] + pub fn final_transaction_id(&self) -> Option { + self.inner.final_transaction_id().map(|id| id.to_string()) + } +} + +impl From for GeneratorSummary { + fn from(inner: core::GeneratorSummary) -> Self { + Self { inner } + } +} diff --git a/wallet/core/src/bindings/python/tx/mass.rs b/wallet/core/src/bindings/python/tx/mass.rs new file mode 100644 index 0000000000..a8ad4720d3 --- /dev/null +++ b/wallet/core/src/bindings/python/tx/mass.rs @@ -0,0 +1,57 @@ +use crate::imports::*; +use crate::tx::{mass, MAXIMUM_STANDARD_TRANSACTION_MASS}; +use kaspa_consensus_client::Transaction; +use kaspa_consensus_core::config::params::Params; + +#[pyfunction] +pub fn maximum_standard_transaction_mass() -> u64 { + MAXIMUM_STANDARD_TRANSACTION_MASS +} + +#[pyfunction] +#[pyo3(name = "calculate_transaction_mass")] +#[pyo3(signature = (network_id, tx, minimum_signatures=None))] +pub fn calculate_unsigned_transaction_mass(network_id: &str, tx: &Transaction, minimum_signatures: Option) -> PyResult { + let network_id = NetworkId::from_str(network_id)?; + let consensus_params = Params::from(network_id); + let network_params = NetworkParams::from(network_id); + let mc = mass::MassCalculator::new(&consensus_params, &network_params); + Ok(mc.calc_overall_mass_for_unsigned_client_transaction(tx, minimum_signatures.unwrap_or(1))?) +} + +#[pyfunction] +#[pyo3(name = "update_transaction_mass")] +#[pyo3(signature = (network_id, tx, minimum_signatures=None))] +pub fn update_unsigned_transaction_mass(network_id: &str, tx: &Transaction, minimum_signatures: Option) -> PyResult { + let network_id = NetworkId::from_str(network_id)?; + let consensus_params = Params::from(network_id); + let network_params = NetworkParams::from(network_id); + let mc = mass::MassCalculator::new(&consensus_params, network_params); + let mass = mc.calc_overall_mass_for_unsigned_client_transaction(tx, minimum_signatures.unwrap_or(1))?; + if mass > MAXIMUM_STANDARD_TRANSACTION_MASS { + Ok(false) + } else { + tx.set_mass(mass); + Ok(true) + } +} + +#[pyfunction] +#[pyo3(name = "calculate_transaction_fee")] +#[pyo3(signature = (network_id, tx, minimum_signatures=None))] +pub fn calculate_unsigned_transaction_fee( + network_id: &str, + tx: &Transaction, + minimum_signatures: Option, +) -> PyResult> { + let network_id = NetworkId::from_str(network_id)?; + let consensus_params = Params::from(network_id); + let network_params = NetworkParams::from(network_id); + let mc = mass::MassCalculator::new(&consensus_params, network_params); + let mass = mc.calc_overall_mass_for_unsigned_client_transaction(tx, minimum_signatures.unwrap_or(1))?; + if mass > MAXIMUM_STANDARD_TRANSACTION_MASS { + Ok(None) + } else { + Ok(Some(mc.calc_fee_for_mass(mass))) + } +} diff --git a/wallet/core/src/bindings/python/tx/mod.rs b/wallet/core/src/bindings/python/tx/mod.rs new file mode 100644 index 0000000000..f76b3b2bf6 --- /dev/null +++ b/wallet/core/src/bindings/python/tx/mod.rs @@ -0,0 +1,3 @@ +pub mod generator; +pub mod mass; +pub mod utils; diff --git a/wallet/core/src/bindings/python/tx/utils.rs b/wallet/core/src/bindings/python/tx/utils.rs new file mode 100644 index 0000000000..a384e98597 --- /dev/null +++ b/wallet/core/src/bindings/python/tx/utils.rs @@ -0,0 +1,108 @@ +use crate::bindings::python::tx::generator::{Generator, GeneratorSummary, PendingTransaction, PyOutputs, PyUtxoEntries}; +use crate::imports::*; +use kaspa_consensus_client::*; +use kaspa_consensus_core::subnets::SUBNETWORK_ID_NATIVE; + +#[pyfunction] +#[pyo3(name = "create_transaction")] +#[pyo3(signature = (utxo_entry_source, outputs, priority_fee, payload=None, sig_op_count=None))] +pub fn create_transaction_py( + utxo_entry_source: PyUtxoEntries, + outputs: PyOutputs, + priority_fee: u64, + payload: Option, + sig_op_count: Option, +) -> PyResult { + let payload: Vec = payload.map(Into::into).unwrap_or_default(); + let sig_op_count = sig_op_count.unwrap_or(1); + + let mut total_input_amount = 0; + let mut entries = vec![]; + + let inputs = utxo_entry_source + .entries + .into_iter() + .enumerate() + .map(|(sequence, reference)| { + let UtxoEntryReference { utxo } = &reference; + total_input_amount += utxo.amount(); + entries.push(reference.clone()); + TransactionInput::new(utxo.outpoint.clone(), None, sequence as u64, sig_op_count, Some(reference)) + }) + .collect::>(); + + if priority_fee > total_input_amount { + return Err(PyException::new_err(format!("priority fee({priority_fee}) > amount({total_input_amount})"))); + } + + let outputs = outputs.outputs.into_iter().map(|output| output.into()).collect::>(); + let transaction = Transaction::new(None, 0, inputs, outputs, 0, SUBNETWORK_ID_NATIVE, 0, payload, 0)?; + + Ok(transaction) +} + +#[pyfunction] +#[pyo3(name = "create_transactions")] +#[pyo3(signature = (network_id, entries, change_address, outputs=None, payload=None, priority_fee=None, priority_entries=None, sig_op_count=None, minimum_signatures=None))] +pub fn create_transactions_py<'a>( + py: Python<'a>, + network_id: &str, + entries: PyUtxoEntries, + change_address: Address, + outputs: Option, + payload: Option, + priority_fee: Option, + priority_entries: Option, + sig_op_count: Option, + minimum_signatures: Option, +) -> PyResult> { + let generator = Generator::ctor( + network_id, + entries, + change_address, + outputs, + payload.map(Into::into), + priority_fee, + priority_entries, + sig_op_count, + minimum_signatures, + )?; + + let transactions = + generator.iter().map(|r| r.map(PendingTransaction::from).map(|tx| tx.into_py(py))).collect::>>()?; + let summary = generator.summary().into_py(py); + let dict = PyDict::new_bound(py); + dict.set_item("transactions", &transactions)?; + dict.set_item("summary", &summary)?; + Ok(dict) +} + +#[pyfunction] +#[pyo3(name = "estimate_transactions")] +#[pyo3(signature = (network_id, entries, change_address, outputs=None, payload=None, priority_fee=None, priority_entries=None, sig_op_count=None, minimum_signatures=None))] +pub fn estimate_transactions_py<'a>( + network_id: &str, + entries: PyUtxoEntries, + change_address: Address, + outputs: Option, + payload: Option, + priority_fee: Option, + priority_entries: Option, + sig_op_count: Option, + minimum_signatures: Option, +) -> PyResult { + let generator = Generator::ctor( + network_id, + entries, + change_address, + outputs, + payload.map(Into::into), + priority_fee, + priority_entries, + sig_op_count, + minimum_signatures, + )?; + + generator.iter().collect::>>()?; + Ok(generator.summary()) +} diff --git a/wallet/core/src/bindings/python/utils.rs b/wallet/core/src/bindings/python/utils.rs new file mode 100644 index 0000000000..0221acb905 --- /dev/null +++ b/wallet/core/src/bindings/python/utils.rs @@ -0,0 +1,20 @@ +use crate::result::Result; +use kaspa_consensus_core::network::NetworkType; +use pyo3::prelude::*; +use std::str::FromStr; + +#[pyfunction] +pub fn kaspa_to_sompi(kaspa: f64) -> u64 { + crate::utils::kaspa_to_sompi(kaspa) +} + +#[pyfunction] +pub fn sompi_to_kaspa(sompi: u64) -> f64 { + crate::utils::sompi_to_kaspa(sompi) +} + +#[pyfunction] +pub fn sompi_to_kaspa_string_with_suffix(sompi: u64, network: &str) -> Result { + let network_type = NetworkType::from_str(network)?; + Ok(crate::utils::sompi_to_kaspa_string_with_suffix(sompi, &network_type)) +} diff --git a/wallet/core/src/bindings/signer.rs b/wallet/core/src/bindings/signer.rs new file mode 100644 index 0000000000..67c44cfe28 --- /dev/null +++ b/wallet/core/src/bindings/signer.rs @@ -0,0 +1,30 @@ +use crate::result::Result; +use kaspa_consensus_client::{sign_with_multiple_v3, Transaction}; +use kaspa_consensus_core::tx::PopulatedTransaction; +use kaspa_consensus_core::{hashing::sighash_type::SIG_HASH_ALL, sign::verify}; +use kaspa_hashes::Hash; + +pub fn sign_transaction<'a>(tx: &'a Transaction, private_keys: &[[u8; 32]], verify_sig: bool) -> Result<&'a Transaction> { + let tx = sign(tx, private_keys)?; + if verify_sig { + let (cctx, utxos) = tx.tx_and_utxos()?; + let populated_transaction = PopulatedTransaction::new(&cctx, utxos); + verify(&populated_transaction)?; + } + Ok(tx) +} + +/// Sign a transaction using schnorr, returns a new transaction with the signatures added. +/// The resulting transaction may be partially signed if the supplied keys are not sufficient +/// to sign all of its inputs. +pub fn sign<'a>(tx: &'a Transaction, privkeys: &[[u8; 32]]) -> Result<&'a Transaction> { + Ok(sign_with_multiple_v3(tx, privkeys)?.unwrap()) +} + +pub fn sign_hash(sig_hash: Hash, privkey: &[u8; 32]) -> Result> { + let msg = secp256k1::Message::from_digest_slice(sig_hash.as_bytes().as_slice())?; + let schnorr_key = secp256k1::Keypair::from_seckey_slice(secp256k1::SECP256K1, privkey)?; + let sig: [u8; 64] = *schnorr_key.sign_schnorr(msg).as_ref(); + let signature = std::iter::once(65u8).chain(sig).chain([SIG_HASH_ALL.to_u8()]).collect(); + Ok(signature) +} diff --git a/wallet/core/src/wasm/api/extensions.rs b/wallet/core/src/bindings/wasm/api/extensions.rs similarity index 100% rename from wallet/core/src/wasm/api/extensions.rs rename to wallet/core/src/bindings/wasm/api/extensions.rs diff --git a/wallet/core/src/wasm/api/message.rs b/wallet/core/src/bindings/wasm/api/message.rs similarity index 99% rename from wallet/core/src/wasm/api/message.rs rename to wallet/core/src/bindings/wasm/api/message.rs index 8a023267b8..befc92aff2 100644 --- a/wallet/core/src/wasm/api/message.rs +++ b/wallet/core/src/bindings/wasm/api/message.rs @@ -3,10 +3,10 @@ use super::extensions::*; use crate::account::descriptor::IAccountDescriptor; use crate::api::message::*; +use crate::bindings::wasm::tx::fees::IFees; +use crate::bindings::wasm::tx::GeneratorSummary; use crate::imports::*; use crate::tx::{Fees, PaymentDestination, PaymentOutputs}; -use crate::wasm::tx::fees::IFees; -use crate::wasm::tx::GeneratorSummary; use js_sys::Array; use serde_wasm_bindgen::from_value; use workflow_wasm::serde::to_value; diff --git a/wallet/core/src/wasm/api/mod.rs b/wallet/core/src/bindings/wasm/api/mod.rs similarity index 100% rename from wallet/core/src/wasm/api/mod.rs rename to wallet/core/src/bindings/wasm/api/mod.rs diff --git a/wallet/core/src/wasm/balance.rs b/wallet/core/src/bindings/wasm/balance.rs similarity index 100% rename from wallet/core/src/wasm/balance.rs rename to wallet/core/src/bindings/wasm/balance.rs diff --git a/wallet/core/src/wasm/cryptobox.rs b/wallet/core/src/bindings/wasm/cryptobox.rs similarity index 100% rename from wallet/core/src/wasm/cryptobox.rs rename to wallet/core/src/bindings/wasm/cryptobox.rs diff --git a/wallet/core/src/wasm/encryption.rs b/wallet/core/src/bindings/wasm/encryption.rs similarity index 100% rename from wallet/core/src/wasm/encryption.rs rename to wallet/core/src/bindings/wasm/encryption.rs diff --git a/wallet/core/src/wasm/events.rs b/wallet/core/src/bindings/wasm/events.rs similarity index 100% rename from wallet/core/src/wasm/events.rs rename to wallet/core/src/bindings/wasm/events.rs diff --git a/wallet/core/src/wasm/message.rs b/wallet/core/src/bindings/wasm/message.rs similarity index 100% rename from wallet/core/src/wasm/message.rs rename to wallet/core/src/bindings/wasm/message.rs diff --git a/wallet/core/src/wasm/mod.rs b/wallet/core/src/bindings/wasm/mod.rs similarity index 100% rename from wallet/core/src/wasm/mod.rs rename to wallet/core/src/bindings/wasm/mod.rs diff --git a/wallet/core/src/wasm/notify.rs b/wallet/core/src/bindings/wasm/notify.rs similarity index 100% rename from wallet/core/src/wasm/notify.rs rename to wallet/core/src/bindings/wasm/notify.rs diff --git a/wallet/core/src/wasm/signer.rs b/wallet/core/src/bindings/wasm/signer.rs similarity index 69% rename from wallet/core/src/wasm/signer.rs rename to wallet/core/src/bindings/wasm/signer.rs index 157f06d909..c64f9c4703 100644 --- a/wallet/core/src/wasm/signer.rs +++ b/wallet/core/src/bindings/wasm/signer.rs @@ -1,12 +1,11 @@ +use crate::bindings::signer::{sign_hash, sign_transaction}; use crate::imports::*; use crate::result::Result; use js_sys::Array; -use kaspa_consensus_client::{sign_with_multiple_v3, Transaction}; +use kaspa_consensus_client::Transaction; use kaspa_consensus_core::hashing::wasm::SighashType; use kaspa_consensus_core::sign::sign_input; use kaspa_consensus_core::tx::PopulatedTransaction; -use kaspa_consensus_core::{hashing::sighash_type::SIG_HASH_ALL, sign::verify}; -use kaspa_hashes::Hash; use kaspa_wallet_keys::privatekey::PrivateKey; use kaspa_wasm_core::types::HexString; use serde_wasm_bindgen::from_value; @@ -50,23 +49,6 @@ pub fn js_sign_transaction(tx: &Transaction, signer: &PrivateKeyArrayT, verify_s } } -fn sign_transaction<'a>(tx: &'a Transaction, private_keys: &[[u8; 32]], verify_sig: bool) -> Result<&'a Transaction> { - let tx = sign(tx, private_keys)?; - if verify_sig { - let (cctx, utxos) = tx.tx_and_utxos()?; - let populated_transaction = PopulatedTransaction::new(&cctx, utxos); - verify(&populated_transaction)?; - } - Ok(tx) -} - -/// Sign a transaction using schnorr, returns a new transaction with the signatures added. -/// The resulting transaction may be partially signed if the supplied keys are not sufficient -/// to sign all of its inputs. -pub fn sign<'a>(tx: &'a Transaction, privkeys: &[[u8; 32]]) -> Result<&'a Transaction> { - Ok(sign_with_multiple_v3(tx, privkeys)?.unwrap()) -} - /// `createInputSignature()` is a helper function to sign a transaction input with a specific SigHash type using a private key. /// @category Wallet SDK #[wasm_bindgen(js_name = "createInputSignature")] @@ -96,11 +78,3 @@ pub fn sign_script_hash(script_hash: JsValue, privkey: &PrivateKey) -> Result Result> { - let msg = secp256k1::Message::from_digest_slice(sig_hash.as_bytes().as_slice())?; - let schnorr_key = secp256k1::Keypair::from_seckey_slice(secp256k1::SECP256K1, privkey)?; - let sig: [u8; 64] = *schnorr_key.sign_schnorr(msg).as_ref(); - let signature = std::iter::once(65u8).chain(sig).chain([SIG_HASH_ALL.to_u8()]).collect(); - Ok(signature) -} diff --git a/wallet/core/src/wasm/tx/fees.rs b/wallet/core/src/bindings/wasm/tx/fees.rs similarity index 100% rename from wallet/core/src/wasm/tx/fees.rs rename to wallet/core/src/bindings/wasm/tx/fees.rs diff --git a/wallet/core/src/wasm/tx/generator/generator.rs b/wallet/core/src/bindings/wasm/tx/generator/generator.rs similarity index 98% rename from wallet/core/src/wasm/tx/generator/generator.rs rename to wallet/core/src/bindings/wasm/tx/generator/generator.rs index 5724b84811..269337e31e 100644 --- a/wallet/core/src/wasm/tx/generator/generator.rs +++ b/wallet/core/src/bindings/wasm/tx/generator/generator.rs @@ -1,11 +1,11 @@ +use crate::bindings::wasm::tx::generator::*; +use crate::bindings::wasm::tx::IFees; use crate::imports::*; use crate::result::Result; use crate::tx::{generator as native, Fees, PaymentDestination, PaymentOutputs}; use crate::utxo::{TryIntoUtxoEntryReferences, UtxoEntryReference}; -use crate::wasm::tx::generator::*; -use crate::wasm::tx::IFees; // use crate::wasm::wallet::Account; -use crate::wasm::UtxoContext; +use crate::bindings::wasm::UtxoContext; // TODO-WASM fix outputs #[wasm_bindgen(typescript_custom_section)] diff --git a/wallet/core/src/wasm/tx/generator/mod.rs b/wallet/core/src/bindings/wasm/tx/generator/mod.rs similarity index 100% rename from wallet/core/src/wasm/tx/generator/mod.rs rename to wallet/core/src/bindings/wasm/tx/generator/mod.rs diff --git a/wallet/core/src/wasm/tx/generator/pending.rs b/wallet/core/src/bindings/wasm/tx/generator/pending.rs similarity index 99% rename from wallet/core/src/wasm/tx/generator/pending.rs rename to wallet/core/src/bindings/wasm/tx/generator/pending.rs index 58c36375d6..7f45fda4d2 100644 --- a/wallet/core/src/wasm/tx/generator/pending.rs +++ b/wallet/core/src/bindings/wasm/tx/generator/pending.rs @@ -1,7 +1,7 @@ +use crate::bindings::wasm::PrivateKeyArrayT; use crate::imports::*; use crate::result::Result; use crate::tx::generator as native; -use crate::wasm::PrivateKeyArrayT; use kaspa_consensus_client::{numeric, string}; use kaspa_consensus_client::{Transaction, TransactionT}; use kaspa_consensus_core::hashing::wasm::SighashType; diff --git a/wallet/core/src/wasm/tx/generator/summary.rs b/wallet/core/src/bindings/wasm/tx/generator/summary.rs similarity index 100% rename from wallet/core/src/wasm/tx/generator/summary.rs rename to wallet/core/src/bindings/wasm/tx/generator/summary.rs diff --git a/wallet/core/src/wasm/tx/mass.rs b/wallet/core/src/bindings/wasm/tx/mass.rs similarity index 100% rename from wallet/core/src/wasm/tx/mass.rs rename to wallet/core/src/bindings/wasm/tx/mass.rs diff --git a/wallet/core/src/wasm/tx/mod.rs b/wallet/core/src/bindings/wasm/tx/mod.rs similarity index 100% rename from wallet/core/src/wasm/tx/mod.rs rename to wallet/core/src/bindings/wasm/tx/mod.rs diff --git a/wallet/core/src/wasm/tx/utils.rs b/wallet/core/src/bindings/wasm/tx/utils.rs similarity index 99% rename from wallet/core/src/wasm/tx/utils.rs rename to wallet/core/src/bindings/wasm/tx/utils.rs index c1229444a1..d99ad8bbd9 100644 --- a/wallet/core/src/wasm/tx/utils.rs +++ b/wallet/core/src/bindings/wasm/tx/utils.rs @@ -1,7 +1,7 @@ +use crate::bindings::wasm::tx::generator::*; use crate::imports::*; use crate::result::Result; use crate::tx::{IPaymentOutputArray, PaymentOutputs}; -use crate::wasm::tx::generator::*; use kaspa_consensus_client::*; use kaspa_consensus_core::subnets::SUBNETWORK_ID_NATIVE; use kaspa_wallet_macros::declare_typescript_wasm_interface as declare; diff --git a/wallet/core/src/wasm/utils.rs b/wallet/core/src/bindings/wasm/utils.rs similarity index 100% rename from wallet/core/src/wasm/utils.rs rename to wallet/core/src/bindings/wasm/utils.rs diff --git a/wallet/core/src/wasm/utxo/context.rs b/wallet/core/src/bindings/wasm/utxo/context.rs similarity index 99% rename from wallet/core/src/wasm/utxo/context.rs rename to wallet/core/src/bindings/wasm/utxo/context.rs index 3298a4829e..0e2563549a 100644 --- a/wallet/core/src/wasm/utxo/context.rs +++ b/wallet/core/src/bindings/wasm/utxo/context.rs @@ -1,9 +1,9 @@ +use crate::bindings::wasm::utxo::UtxoProcessor; +use crate::bindings::wasm::{Balance, BalanceStrings}; use crate::imports::*; use crate::result::Result; use crate::utxo as native; use crate::utxo::{UtxoContextBinding, UtxoContextId}; -use crate::wasm::utxo::UtxoProcessor; -use crate::wasm::{Balance, BalanceStrings}; use kaspa_addresses::AddressOrStringArrayT; use kaspa_consensus_client::UtxoEntryReferenceArrayT; use kaspa_hashes::Hash; diff --git a/wallet/core/src/wasm/utxo/mod.rs b/wallet/core/src/bindings/wasm/utxo/mod.rs similarity index 100% rename from wallet/core/src/wasm/utxo/mod.rs rename to wallet/core/src/bindings/wasm/utxo/mod.rs diff --git a/wallet/core/src/wasm/utxo/processor.rs b/wallet/core/src/bindings/wasm/utxo/processor.rs similarity index 98% rename from wallet/core/src/wasm/utxo/processor.rs rename to wallet/core/src/bindings/wasm/utxo/processor.rs index d68f10f763..905833325f 100644 --- a/wallet/core/src/wasm/utxo/processor.rs +++ b/wallet/core/src/bindings/wasm/utxo/processor.rs @@ -1,9 +1,11 @@ +use crate::bindings::wasm::notify::{ + UtxoProcessorEventTarget, UtxoProcessorNotificationCallback, UtxoProcessorNotificationTypeOrCallback, +}; use crate::error::Error; use crate::events::{EventKind, Events}; use crate::imports::*; use crate::result::Result; use crate::utxo as native; -use crate::wasm::notify::{UtxoProcessorEventTarget, UtxoProcessorNotificationCallback, UtxoProcessorNotificationTypeOrCallback}; use kaspa_consensus_core::network::NetworkIdT; use kaspa_wallet_macros::declare_typescript_wasm_interface as declare; use kaspa_wasm_core::events::{get_event_targets, Sink}; diff --git a/wallet/core/src/wasm/wallet/keydata.rs b/wallet/core/src/bindings/wasm/wallet/keydata.rs similarity index 100% rename from wallet/core/src/wasm/wallet/keydata.rs rename to wallet/core/src/bindings/wasm/wallet/keydata.rs diff --git a/wallet/core/src/wasm/wallet/mod.rs b/wallet/core/src/bindings/wasm/wallet/mod.rs similarity index 100% rename from wallet/core/src/wasm/wallet/mod.rs rename to wallet/core/src/bindings/wasm/wallet/mod.rs diff --git a/wallet/core/src/wasm/wallet/storage.rs b/wallet/core/src/bindings/wasm/wallet/storage.rs similarity index 100% rename from wallet/core/src/wasm/wallet/storage.rs rename to wallet/core/src/bindings/wasm/wallet/storage.rs diff --git a/wallet/core/src/wasm/wallet/wallet.rs b/wallet/core/src/bindings/wasm/wallet/wallet.rs similarity index 98% rename from wallet/core/src/wasm/wallet/wallet.rs rename to wallet/core/src/bindings/wasm/wallet/wallet.rs index bd91bedf22..5359649427 100644 --- a/wallet/core/src/wasm/wallet/wallet.rs +++ b/wallet/core/src/bindings/wasm/wallet/wallet.rs @@ -1,8 +1,8 @@ +use crate::bindings::wasm::notify::{WalletEventTarget, WalletNotificationCallback, WalletNotificationTypeOrCallback}; use crate::imports::*; use crate::storage::local::interface::LocalStore; use crate::storage::WalletDescriptor; use crate::wallet as native; -use crate::wasm::notify::{WalletEventTarget, WalletNotificationCallback, WalletNotificationTypeOrCallback}; use kaspa_wallet_macros::declare_typescript_wasm_interface as declare; use kaspa_wasm_core::events::{get_event_targets, Sink}; use kaspa_wrpc_wasm::{IConnectOptions, Resolver, RpcClient, RpcConfig, WrpcEncoding}; diff --git a/wallet/core/src/error.rs b/wallet/core/src/error.rs index 8992a8a924..ecaca1c973 100644 --- a/wallet/core/src/error.rs +++ b/wallet/core/src/error.rs @@ -9,6 +9,8 @@ use kaspa_bip32::Error as BIP32Error; use kaspa_consensus_core::sign::Error as CoreSignError; use kaspa_rpc_core::RpcError as KaspaRpcError; use kaspa_wrpc_client::error::Error as KaspaWorkflowRpcError; +#[cfg(feature = "py-sdk")] +use pyo3::{exceptions::PyException, prelude::*}; use std::sync::PoisonError; use thiserror::Error; use wasm_bindgen::JsValue; @@ -441,3 +443,10 @@ impl From> for Error { Error::Custom(e.to_string()) } } + +#[cfg(feature = "py-sdk")] +impl From for PyErr { + fn from(value: Error) -> Self { + PyException::new_err(value.to_string()) + } +} diff --git a/wallet/core/src/imports.rs b/wallet/core/src/imports.rs index 9129d8349f..d2ba9e97c8 100644 --- a/wallet/core/src/imports.rs +++ b/wallet/core/src/imports.rs @@ -65,3 +65,14 @@ cfg_if! { pub use workflow_wasm::convert::CastFromJs; } } + +cfg_if! { + if #[cfg(feature = "py-sdk")] { + pub use kaspa_python_core::types::PyBinary; + pub use pyo3::{ + exceptions::PyException, + prelude::*, + types::{PyDict, PyList}, + }; + } +} diff --git a/wallet/core/src/lib.rs b/wallet/core/src/lib.rs index 09cc3ca7fc..991368ae78 100644 --- a/wallet/core/src/lib.rs +++ b/wallet/core/src/lib.rs @@ -96,8 +96,8 @@ pub mod utils; pub mod utxo; pub mod wallet; -#[cfg(any(feature = "wasm32-sdk", feature = "wasm32-core"))] -pub mod wasm; +#[cfg(any(feature = "wasm32-sdk", feature = "wasm32-core", feature = "py-sdk"))] +pub mod bindings; /// Returns the version of the Wallet framework. pub fn version() -> String { diff --git a/wallet/core/src/tx/payment.rs b/wallet/core/src/tx/payment.rs index c164e0d789..83fc91ac74 100644 --- a/wallet/core/src/tx/payment.rs +++ b/wallet/core/src/tx/payment.rs @@ -63,6 +63,7 @@ impl PaymentDestination { /// /// @category Wallet SDK #[derive(Clone, Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct PaymentOutput { #[wasm_bindgen(getter_with_clone)] @@ -97,6 +98,26 @@ impl TryCastFromJs for PaymentOutput { } } +#[cfg(feature = "py-sdk")] +impl TryFrom<&Bound<'_, PyDict>> for PaymentOutput { + type Error = PyErr; + fn try_from(value: &Bound) -> PyResult { + let address_value = value.get_item("address")?.ok_or_else(|| PyException::new_err("Key `address` not present"))?; + + let address = if let Ok(address) = address_value.extract::
() { + address + } else if let Ok(s) = address_value.extract::() { + Address::try_from(s).map_err(|err| PyException::new_err(format!("{}", err)))? + } else { + return Err(PyException::new_err("Addresses must be either an Address instance or a string")); + }; + + let amount: u64 = value.get_item("amount")?.ok_or_else(|| PyException::new_err("Key `amount` not present"))?.extract()?; + + Ok(PaymentOutput::new(address, amount)) + } +} + #[wasm_bindgen] impl PaymentOutput { #[wasm_bindgen(constructor)] @@ -105,6 +126,15 @@ impl PaymentOutput { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl PaymentOutput { + #[new] + pub fn new_py(address: Address, amount: u64) -> Self { + Self { address, amount } + } +} + impl From for TransactionOutput { fn from(value: PaymentOutput) -> Self { Self::new_with_inner(TransactionOutputInner { script_public_key: pay_to_address_script(&value.address), value: value.amount }) @@ -119,6 +149,7 @@ impl From for PaymentDestination { /// @category Wallet SDK #[derive(Clone, Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen] pub struct PaymentOutputs { #[wasm_bindgen(skip)] @@ -156,6 +187,15 @@ impl PaymentOutputs { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl PaymentOutputs { + #[new] + pub fn constructor_py(output_array: Vec) -> PyResult { + Ok(Self { outputs: output_array }) + } +} + impl TryCastFromJs for PaymentOutputs { type Error = Error; fn try_cast_from<'a, R>(value: &'a R) -> Result, Self::Error> @@ -179,6 +219,15 @@ impl TryCastFromJs for PaymentOutputs { } } +#[cfg(feature = "py-sdk")] +impl TryFrom>> for PaymentOutputs { + type Error = PyErr; + fn try_from(value: Vec<&Bound>) -> PyResult { + let outputs: Vec = value.iter().map(|utxo| PaymentOutput::try_from(*utxo)).collect::, _>>()?; + Ok(PaymentOutputs { outputs }) + } +} + impl From for Vec { fn from(value: PaymentOutputs) -> Self { value.outputs.into_iter().map(TransactionOutput::from).collect() diff --git a/wallet/keys/Cargo.toml b/wallet/keys/Cargo.toml index b352f42a92..226dda7e44 100644 --- a/wallet/keys/Cargo.toml +++ b/wallet/keys/Cargo.toml @@ -11,6 +11,11 @@ repository.workspace = true [features] default = [] +py-sdk = [ + "pyo3", + "kaspa-bip32/py-sdk", + "kaspa-consensus-core/py-sdk", +] [lib] crate-type = ["cdylib", "lib"] @@ -18,6 +23,7 @@ crate-type = ["cdylib", "lib"] [dependencies] async-trait.workspace = true borsh.workspace = true +cfg-if.workspace = true downcast.workspace = true faster-hex.workspace = true hmac.workspace = true @@ -29,6 +35,7 @@ kaspa-txscript-errors.workspace = true kaspa-txscript.workspace = true kaspa-utils.workspace = true kaspa-wasm-core.workspace = true +pyo3 = { workspace = true, optional = true } rand.workspace = true ripemd.workspace = true secp256k1.workspace = true diff --git a/wallet/keys/src/derivation_path.rs b/wallet/keys/src/derivation_path.rs index a5389ca37e..b2bfd33879 100644 --- a/wallet/keys/src/derivation_path.rs +++ b/wallet/keys/src/derivation_path.rs @@ -10,6 +10,7 @@ use workflow_wasm::prelude::*; /// /// @category Wallet SDK #[derive(Clone, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen] pub struct DerivationPath { inner: kaspa_bip32::DerivationPath, @@ -55,6 +56,44 @@ impl DerivationPath { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl DerivationPath { + #[new] + pub fn new_py(path: &str) -> PyResult { + let inner = kaspa_bip32::DerivationPath::from_str(path)?; + Ok(Self { inner }) + } + + #[pyo3(name = "is_empty")] + pub fn is_empty_py(&self) -> bool { + self.inner.is_empty() + } + + #[pyo3(name = "length")] + pub fn len_py(&self) -> usize { + self.inner.len() + } + + #[pyo3(name = "parent")] + pub fn parent_py(&self) -> Option { + self.inner.parent().map(|inner| Self { inner }) + } + + #[pyo3(name = "push")] + #[pyo3(signature = (child_number, hardened=None))] + pub fn push_py(&mut self, child_number: u32, hardened: Option) -> PyResult<()> { + let child = ChildNumber::new(child_number, hardened.unwrap_or(false))?; + self.inner.push(child); + Ok(()) + } + + #[pyo3(name = "to_string")] + pub fn to_str_py(&self) -> String { + self.inner.to_string() + } +} + impl TryCastFromJs for DerivationPath { type Error = Error; fn try_cast_from<'a, R>(value: &'a R) -> Result, Self::Error> diff --git a/wallet/keys/src/error.rs b/wallet/keys/src/error.rs index 0059a09420..a082d78146 100644 --- a/wallet/keys/src/error.rs +++ b/wallet/keys/src/error.rs @@ -3,6 +3,8 @@ //! use kaspa_bip32::Error as BIP32Error; +#[cfg(feature = "py-sdk")] +use pyo3::{exceptions::PyException, prelude::PyErr}; use std::sync::PoisonError; use thiserror::Error; use wasm_bindgen::JsValue; @@ -118,3 +120,10 @@ impl From for Error { Self::SerdeWasmBindgen(Sendable(Printable::new(err.into()))) } } + +#[cfg(feature = "py-sdk")] +impl From for PyErr { + fn from(value: Error) -> Self { + PyException::new_err(value.to_string()) + } +} diff --git a/wallet/keys/src/imports.rs b/wallet/keys/src/imports.rs index a1c0a5e0d2..08acec2d66 100644 --- a/wallet/keys/src/imports.rs +++ b/wallet/keys/src/imports.rs @@ -15,9 +15,13 @@ pub use borsh::{BorshDeserialize, BorshSerialize}; pub use js_sys::Array; pub use kaspa_addresses::{Address, Version as AddressVersion}; pub use kaspa_bip32::{ChildNumber, ExtendedPrivateKey, ExtendedPublicKey, SecretKey}; +#[cfg(feature = "py-sdk")] +pub use kaspa_consensus_core::network::NetworkType; pub use kaspa_consensus_core::network::{NetworkId, NetworkTypeT}; pub use kaspa_utils::hex::*; pub use kaspa_wasm_core::types::*; +#[cfg(feature = "py-sdk")] +pub use pyo3::{exceptions::PyException, prelude::*}; pub use serde::{Deserialize, Serialize}; pub use std::collections::HashMap; pub use std::str::FromStr; diff --git a/wallet/keys/src/keypair.rs b/wallet/keys/src/keypair.rs index 2cc3d57607..9924e9bc6a 100644 --- a/wallet/keys/src/keypair.rs +++ b/wallet/keys/src/keypair.rs @@ -26,6 +26,7 @@ use serde_wasm_bindgen::to_value; /// Data structure that contains a secret and public keys. /// @category Wallet SDK #[derive(Debug, Clone, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct Keypair { secret_key: secp256k1::SecretKey, @@ -39,7 +40,12 @@ impl Keypair { Self { secret_key, public_key, xonly_public_key } } - /// Get the [`PublicKey`] of this [`Keypair`]. + /// Get the `XOnlyPublicKey` of this [`Keypair`]. + #[wasm_bindgen(getter = xOnlyPublicKey)] + pub fn get_xonly_public_key(&self) -> JsValue { + to_value(&self.xonly_public_key).unwrap() + } + #[wasm_bindgen(getter = publicKey)] pub fn get_public_key(&self) -> String { PublicKey::from(&self.public_key).to_string() @@ -51,12 +57,6 @@ impl Keypair { PrivateKey::from(&self.secret_key).to_hex() } - /// Get the `XOnlyPublicKey` of this [`Keypair`]. - #[wasm_bindgen(getter = xOnlyPublicKey)] - pub fn get_xonly_public_key(&self) -> JsValue { - to_value(&self.xonly_public_key).unwrap() - } - /// Get the [`Address`] of this Keypair's [`PublicKey`]. /// Receives a [`NetworkType`](kaspa_consensus_core::network::NetworkType) /// to determine the prefix of the address. @@ -102,6 +102,62 @@ impl Keypair { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl Keypair { + #[getter] + #[pyo3(name = "xonly_public_key")] + pub fn get_xonly_public_key_py(&self) -> String { + self.xonly_public_key.to_string() + } + + #[getter] + #[pyo3(name = "public_key")] + pub fn get_public_key_py(&self) -> String { + PublicKey::from(&self.public_key).to_string() + } + + #[getter] + #[pyo3(name = "private_key")] + pub fn get_private_key_py(&self) -> String { + PrivateKey::from(&self.secret_key).to_hex() + } + + #[pyo3(name = "to_address")] + pub fn to_address_py(&self, network: &str) -> PyResult
{ + let payload = &self.xonly_public_key.serialize(); + let address = Address::new(NetworkType::from_str(network)?.try_into()?, AddressVersion::PubKey, payload); + Ok(address) + } + + #[pyo3(name = "to_address_ecdsa")] + pub fn to_address_ecdsa_py(&self, network: &str) -> PyResult
{ + let payload = &self.public_key.serialize(); + let address = Address::new(NetworkType::from_str(network)?.try_into()?, AddressVersion::PubKeyECDSA, payload); + Ok(address) + } + + #[staticmethod] + #[pyo3(name = "random")] + pub fn random_py() -> PyResult { + let secp = Secp256k1::new(); + let (secret_key, public_key) = secp.generate_keypair(&mut rand::thread_rng()); + let (xonly_public_key, _) = public_key.x_only_public_key(); + Ok(Keypair::new(secret_key, public_key, xonly_public_key)) + } + + #[staticmethod] + #[pyo3(name = "from_private_key")] + pub fn from_private_key_py(secret_key: &PrivateKey) -> PyResult { + let secp = Secp256k1::new(); + let secret_key = + secp256k1::SecretKey::from_slice(&secret_key.secret_bytes()).map_err(|e| PyException::new_err(format!("{e}")))?; + let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + let (xonly_public_key, _) = public_key.x_only_public_key(); + Ok(Keypair::new(secret_key, public_key, xonly_public_key)) + } +} + impl TryCastFromJs for Keypair { type Error = Error; fn try_cast_from<'a, R>(value: &'a R) -> Result, Self::Error> diff --git a/wallet/keys/src/privatekey.rs b/wallet/keys/src/privatekey.rs index 554bdf36e3..7d03436bad 100644 --- a/wallet/keys/src/privatekey.rs +++ b/wallet/keys/src/privatekey.rs @@ -9,6 +9,7 @@ use js_sys::{Array, Uint8Array}; /// Data structure that envelops a Private Key. /// @category Wallet SDK #[derive(Clone, Debug, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen] pub struct PrivateKey { inner: secp256k1::SecretKey, @@ -93,6 +94,49 @@ impl PrivateKey { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl PrivateKey { + #[new] + pub fn try_new_py(key: &str) -> PyResult { + let secret_key = secp256k1::SecretKey::from_str(key).map_err(|err| PyException::new_err(format!("{}", err)))?; + Ok(Self { inner: secret_key }) + } + + #[pyo3(name = "to_string")] + pub fn to_hex_py(&self) -> String { + use kaspa_utils::hex::ToHex; + self.secret_bytes().to_vec().to_hex() + } + + #[pyo3(name = "to_public_key")] + pub fn to_public_key_py(&self) -> PyResult { + Ok(PublicKey::from(secp256k1::PublicKey::from_secret_key_global(&self.inner))) + } + + #[pyo3(name = "to_address")] + pub fn to_address_py(&self, network: &str) -> PyResult
{ + let public_key = secp256k1::PublicKey::from_secret_key_global(&self.inner); + let (x_only_public_key, _) = public_key.x_only_public_key(); + let payload = x_only_public_key.serialize(); + let address = Address::new(NetworkType::from_str(network)?.try_into()?, AddressVersion::PubKey, &payload); + Ok(address) + } + + #[pyo3(name = "to_address_ecdsa")] + pub fn to_address_ecdsa_py(&self, network: &str) -> PyResult
{ + let public_key = secp256k1::PublicKey::from_secret_key_global(&self.inner); + let payload = public_key.serialize(); + let address = Address::new(NetworkType::from_str(network)?.try_into()?, AddressVersion::PubKeyECDSA, &payload); + Ok(address) + } + + #[pyo3(name = "to_keypair")] + pub fn to_keypair_py(&self) -> PyResult { + Keypair::from_private_key_py(self) + } +} + impl TryCastFromJs for PrivateKey { type Error = Error; fn try_cast_from<'a, R>(value: &'a R) -> Result, Self::Error> diff --git a/wallet/keys/src/privkeygen.rs b/wallet/keys/src/privkeygen.rs index 474dec8ec1..8a03e1c97d 100644 --- a/wallet/keys/src/privkeygen.rs +++ b/wallet/keys/src/privkeygen.rs @@ -15,12 +15,13 @@ use crate::imports::*; /// /// @see {@link PublicKeyGenerator}, {@link XPub}, {@link XPrv}, {@link Mnemonic} /// @category Wallet SDK -/// +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen] pub struct PrivateKeyGenerator { receive: ExtendedPrivateKey, change: ExtendedPrivateKey, } + #[wasm_bindgen] impl PrivateKeyGenerator { #[wasm_bindgen(constructor)] @@ -42,7 +43,11 @@ impl PrivateKeyGenerator { Ok(Self { receive, change }) } +} +#[cfg_attr(feature = "py-sdk", pymethods)] +#[wasm_bindgen] +impl PrivateKeyGenerator { #[wasm_bindgen(js_name=receiveKey)] pub fn receive_key(&self, index: u32) -> Result { let xkey = self.receive.derive_child(ChildNumber::new(index, false)?)?; @@ -55,3 +60,28 @@ impl PrivateKeyGenerator { Ok(PrivateKey::from(xkey.private_key())) } } + +#[cfg(feature = "py-sdk")] +#[pymethods] +impl PrivateKeyGenerator { + #[new] + #[pyo3(signature = (xprv, is_multisig, account_index, cosigner_index=None))] + pub fn new_py(xprv: String, is_multisig: bool, account_index: u64, cosigner_index: Option) -> PyResult { + let xprv = XPrv::from_xprv_str(xprv)?; + let xprv = xprv.inner(); + let receive = xprv.clone().derive_path(&WalletDerivationManager::build_derivate_path( + is_multisig, + account_index, + cosigner_index, + Some(kaspa_bip32::AddressType::Receive), + )?)?; + let change = xprv.clone().derive_path(&WalletDerivationManager::build_derivate_path( + is_multisig, + account_index, + cosigner_index, + Some(kaspa_bip32::AddressType::Change), + )?)?; + + Ok(Self { receive, change }) + } +} diff --git a/wallet/keys/src/pubkeygen.rs b/wallet/keys/src/pubkeygen.rs index a61eeb5ada..fb0a080fe6 100644 --- a/wallet/keys/src/pubkeygen.rs +++ b/wallet/keys/src/pubkeygen.rs @@ -18,10 +18,12 @@ use kaspa_consensus_core::network::NetworkType; /// @see {@link PrivateKeyGenerator}, {@link XPub}, {@link XPrv}, {@link Mnemonic} /// @category Wallet SDK /// +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen] pub struct PublicKeyGenerator { hd_wallet: WalletDerivationManager, } + #[wasm_bindgen] impl PublicKeyGenerator { #[wasm_bindgen(js_name=fromXPub)] @@ -221,3 +223,168 @@ impl PublicKeyGenerator { Ok(self.hd_wallet.to_string(None).to_string()) } } + +#[cfg(feature = "py-sdk")] +#[pymethods] +impl PublicKeyGenerator { + #[staticmethod] + #[pyo3(name = "from_xpub")] + #[pyo3(signature = (kpub, cosigner_index=None))] + fn from_xpub_py(kpub: &str, cosigner_index: Option) -> PyResult { + let kpub = XPub::try_new(kpub)?; + let xpub = kpub.inner(); + let hd_wallet = WalletDerivationManager::from_extended_public_key(xpub.clone(), cosigner_index)?; + Ok(Self { hd_wallet }) + } + + #[staticmethod] + #[pyo3(name = "from_master_xprv")] + #[pyo3(signature = (xprv, is_multisig, account_index, cosigner_index=None))] + fn from_master_xprv_py( + xprv: String, + is_multisig: bool, + account_index: u64, + cosigner_index: Option, + ) -> PyResult { + let path = WalletDerivationManager::build_derivate_path(is_multisig, account_index, None, None)?; + let xprv = XPrv::from_xprv_str(xprv)?.inner().clone().derive_path(&path)?; + let xpub = xprv.public_key(); + let hd_wallet = WalletDerivationManager::from_extended_public_key(xpub, cosigner_index)?; + Ok(Self { hd_wallet }) + } + + #[pyo3(name = "receive_pubkeys")] + fn receive_pubkeys_py(&self, mut start: u32, mut end: u32) -> PyResult> { + if start > end { + (start, end) = (end, start) + } + let pubkeys = self.hd_wallet.receive_pubkey_manager().derive_pubkey_range(start..end)?; + Ok(pubkeys.into_iter().map(|pk| PublicKey::from(pk)).collect()) + } + + #[pyo3(name = "receive_pubkey")] + pub fn receive_pubkey_py(&self, index: u32) -> PyResult { + Ok(self.hd_wallet.receive_pubkey_manager().derive_pubkey(index)?.into()) + } + + #[pyo3(name = "receive_pubkeys_as_strings")] + fn receive_pubkeys_as_strings_py(&self, mut start: u32, mut end: u32) -> PyResult> { + if start > end { + (start, end) = (end, start); + } + let pubkeys = self.hd_wallet.receive_pubkey_manager().derive_pubkey_range(start..end)?; + Ok(pubkeys.into_iter().map(|pk| PublicKey::from(pk).to_string()).collect()) + } + + #[pyo3(name = "receive_pubkey_as_string")] + pub fn receive_pubkey_as_string_py(&self, index: u32) -> PyResult { + Ok(self.hd_wallet.receive_pubkey_manager().derive_pubkey(index)?.to_string()) + } + + #[pyo3(name = "receive_addresses")] + fn receive_addresses_py(&self, network_type: &str, mut start: u32, mut end: u32) -> PyResult> { + if start > end { + (start, end) = (end, start); + } + let network_type = NetworkType::from_str(network_type)?; + let pubkeys = self.hd_wallet.receive_pubkey_manager().derive_pubkey_range(start..end)?; + let addresses = + pubkeys.into_iter().map(|pk| PublicKey::from(pk).to_address(network_type)).collect::>>()?; + Ok(addresses) + } + + #[pyo3(name = "receive_address")] + fn receive_address_py(&self, network_type: &str, index: u32) -> PyResult
{ + let network_type = NetworkType::from_str(network_type)?; + Ok(PublicKey::from(self.hd_wallet.receive_pubkey_manager().derive_pubkey(index)?).to_address(network_type)?) + } + + #[pyo3(name = "receive_addresses_as_strings")] + fn receive_addresses_as_strings_py(&self, network_type: &str, mut start: u32, mut end: u32) -> PyResult> { + if start > end { + (start, end) = (end, start); + } + let network_type = NetworkType::from_str(network_type)?; + let pubkeys = self.hd_wallet.receive_pubkey_manager().derive_pubkey_range(start..end)?; + let addresses = + pubkeys.into_iter().map(|pk| PublicKey::from(pk).to_address(network_type)).collect::>>()?; + Ok(addresses.into_iter().map(|a| a.address_to_string()).collect()) + } + + #[pyo3(name = "receive_address_as_string")] + fn receive_address_as_string_py(&self, network_type: &str, index: u32) -> PyResult { + Ok(PublicKey::from(self.hd_wallet.receive_pubkey_manager().derive_pubkey(index)?) + .to_address(NetworkType::from_str(network_type)?)? + .to_string()) + } + + #[pyo3(name = "change_pubkeys")] + pub fn change_pubkeys_py(&self, mut start: u32, mut end: u32) -> PyResult> { + if start > end { + (start, end) = (end, start); + } + let pubkeys = self.hd_wallet.change_pubkey_manager().derive_pubkey_range(start..end)?; + Ok(pubkeys.into_iter().map(|pk| PublicKey::from(pk)).collect()) + } + + #[pyo3(name = "change_pubkey")] + pub fn change_pubkey_py(&self, index: u32) -> PyResult { + Ok(self.hd_wallet.change_pubkey_manager().derive_pubkey(index)?.into()) + } + + #[pyo3(name = "change_pubkeys_as_strings")] + pub fn change_pubkeys_as_strings_py(&self, mut start: u32, mut end: u32) -> PyResult> { + if start > end { + (start, end) = (end, start); + } + let pubkeys = self.hd_wallet.change_pubkey_manager().derive_pubkey_range(start..end)?; + Ok(pubkeys.into_iter().map(|pk| PublicKey::from(pk).to_string()).collect()) + } + + #[pyo3(name = "change_pubkey_as_string")] + pub fn change_pubkey_as_string_py(&self, index: u32) -> PyResult { + Ok(self.hd_wallet.change_pubkey_manager().derive_pubkey(index)?.to_string()) + } + + #[pyo3(name = "change_addresses")] + pub fn change_addresses_py(&self, network_type: &str, mut start: u32, mut end: u32) -> PyResult> { + if start > end { + (start, end) = (end, start); + } + let network_type = NetworkType::from_str(network_type)?; + let pubkeys = self.hd_wallet.change_pubkey_manager().derive_pubkey_range(start..end)?; + let addresses = + pubkeys.into_iter().map(|pk| PublicKey::from(pk).to_address(network_type)).collect::>>()?; + Ok(addresses) + } + + #[pyo3(name = "change_address")] + pub fn change_address_py(&self, network_type: &str, index: u32) -> PyResult
{ + let network_type = NetworkType::from_str(network_type)?; + Ok(PublicKey::from(self.hd_wallet.change_pubkey_manager().derive_pubkey(index)?).to_address(network_type)?) + } + + #[pyo3(name = "change_addresses_as_strings")] + pub fn change_addresses_as_strings_py(&self, network_type: &str, mut start: u32, mut end: u32) -> PyResult> { + if start > end { + (start, end) = (end, start); + } + let network_type = NetworkType::from_str(network_type)?; + let pubkeys = self.hd_wallet.change_pubkey_manager().derive_pubkey_range(start..end)?; + let addresses = + pubkeys.into_iter().map(|pk| PublicKey::from(pk).to_address(network_type)).collect::>>()?; + Ok(addresses.into_iter().map(|a| a.address_to_string()).collect()) + } + + #[pyo3(name = "change_address_as_string")] + pub fn change_address_as_string_py(&self, network_type: &str, index: u32) -> PyResult { + Ok(PublicKey::from(self.hd_wallet.receive_pubkey_manager().derive_pubkey(index)?) + .to_address(NetworkType::from_str(network_type)?)? + .to_string()) + } + + #[pyo3(name = "to_string")] + pub fn to_string_py(&self) -> PyResult { + Ok(self.hd_wallet.to_string(None).to_string()) + } +} diff --git a/wallet/keys/src/publickey.rs b/wallet/keys/src/publickey.rs index 235eb80804..4baa8b5503 100644 --- a/wallet/keys/src/publickey.rs +++ b/wallet/keys/src/publickey.rs @@ -27,6 +27,7 @@ use sha2::Sha256; /// Only supports Schnorr-based addresses. /// @category Wallet SDK #[derive(Clone, Debug, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(js_name = PublicKey)] pub struct PublicKey { #[wasm_bindgen(skip)] @@ -84,6 +85,52 @@ impl PublicKey { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl PublicKey { + #[new] + pub fn try_new_py(key: &str) -> PyResult { + match secp256k1::PublicKey::from_str(key) { + Ok(public_key) => Ok((&public_key).into()), + Err(_) => { + let xonly_public_key = + secp256k1::XOnlyPublicKey::from_str(key).map_err(|err| PyException::new_err(format!("{}", err)))?; + Ok(Self { xonly_public_key, public_key: None }) + } + } + } + + #[pyo3(name = "to_string")] + pub fn to_string_impl_py(&self) -> String { + self.public_key.as_ref().map(|pk| pk.to_string()).unwrap_or_else(|| self.xonly_public_key.to_string()) + } + + #[pyo3(name = "to_address")] + pub fn to_address_py(&self, network: &str) -> PyResult
{ + Ok(self.to_address(NetworkType::from_str(network)?)?) + } + + #[pyo3(name = "to_address_ecdsa")] + pub fn to_address_ecdsa_py(&self, network: &str) -> PyResult
{ + Ok(self.to_address_ecdsa(NetworkType::from_str(network)?)?) + } + + #[pyo3(name = "to_x_only_public_key")] + pub fn to_x_only_public_key_py(&self) -> XOnlyPublicKey { + self.xonly_public_key.into() + } + + #[pyo3(name = "fingerprint")] + pub fn fingerprint_py(&self) -> Option { + if let Some(public_key) = self.public_key.as_ref() { + let digest = Ripemd160::digest(Sha256::digest(public_key.serialize().as_slice())); + Some(digest[..4].as_ref().to_hex().into()) + } else { + None + } + } +} + impl PublicKey { #[inline] pub fn to_address(&self, network_type: NetworkType) -> Result
{ @@ -191,6 +238,7 @@ impl TryFrom<&PublicKeyArrayT> for Vec { /// @see {@link PublicKey} /// @category Wallet SDK #[wasm_bindgen] +#[cfg_attr(feature = "py-sdk", pyclass)] #[derive(Clone, Debug, CastFromJs)] pub struct XOnlyPublicKey { #[wasm_bindgen(skip)] @@ -241,6 +289,43 @@ impl XOnlyPublicKey { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl XOnlyPublicKey { + #[new] + pub fn try_new_py(key: &str) -> PyResult { + let xonly_public_key = secp256k1::XOnlyPublicKey::from_str(key).map_err(|err| PyException::new_err(format!("{}", err)))?; + Ok(xonly_public_key.into()) + } + + #[pyo3(name = "to_string")] + pub fn to_string_impl_py(&self) -> String { + self.inner.to_string() + } + + #[pyo3(name = "to_address")] + pub fn to_address_py(&self, network: &str) -> PyResult
{ + let payload = &self.inner.serialize(); + let address = Address::new(NetworkType::from_str(network)?.try_into()?, AddressVersion::PubKey, payload); + Ok(address) + } + + #[pyo3(name = "to_address_ecdsa")] + pub fn to_address_ecdsa_py(&self, network: &str) -> PyResult
{ + let payload = &self.inner.serialize(); + let address = Address::new(NetworkType::from_str(network)?.try_into()?, AddressVersion::PubKeyECDSA, payload); + Ok(address) + } + + #[pyo3(name = "from_address")] + #[staticmethod] + pub fn from_address_py(address: &Address) -> PyResult { + let xonly_public_key = + secp256k1::XOnlyPublicKey::from_slice(&address.payload).map_err(|err| PyException::new_err(format!("{}", err)))?; + Ok(xonly_public_key.into()) + } +} + impl std::fmt::Display for XOnlyPublicKey { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.to_string_impl()) diff --git a/wallet/keys/src/xprv.rs b/wallet/keys/src/xprv.rs index c19e0b9cc8..717b85eee9 100644 --- a/wallet/keys/src/xprv.rs +++ b/wallet/keys/src/xprv.rs @@ -19,6 +19,7 @@ use crate::imports::*; /// #[derive(Clone, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct XPrv { inner: ExtendedPrivateKey, @@ -132,6 +133,108 @@ impl XPrv { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl XPrv { + #[new] + fn try_new_py(seed: &str) -> PyResult { + let seed_bytes = Vec::::from_hex(seed).map_err(|e| PyErr::new::(format!("{}", e)))?; + + let inner = ExtendedPrivateKey::::new(seed_bytes)?; + Ok(Self { inner }) + } + + #[staticmethod] + #[pyo3(name = "from_xprv")] + pub fn from_xprv_str_py(xprv: &str) -> PyResult { + Ok(Self { inner: ExtendedPrivateKey::::from_str(xprv)? }) + } + + #[pyo3(name = "derive_child")] + #[pyo3(signature = (child_number, hardened=None))] + pub fn derive_child_py(&self, child_number: u32, hardened: Option) -> PyResult { + let child_number = ChildNumber::new(child_number, hardened.unwrap_or(false))?; + let inner = self.inner.derive_child(child_number)?; + Ok(Self { inner }) + } + + #[pyo3(name = "derive_path")] + pub fn derive_path_py(&self, path: &Bound) -> PyResult { + let path = if let Ok(path_str) = path.extract::() { + Ok(DerivationPath::new(path_str.as_str())?) + } else if let Ok(path_obj) = path.extract::() { + Ok(path_obj) + } else { + Err(PyException::new_err("`path` must be of type `str` or `DerivationPath`")) + }?; + + let inner = self.inner.clone().derive_path((&path).into())?; + Ok(Self { inner }) + } + + #[pyo3(name = "into_string")] + pub fn into_string_py(&self, prefix: &str) -> PyResult { + let str = self.inner.to_extended_key(prefix.try_into()?).to_string(); + Ok(str) + } + + #[pyo3(name = "to_string")] + pub fn to_string_py(&self) -> PyResult { + let str = self.inner.to_extended_key("kprv".try_into()?).to_string(); + Ok(str) + } + + #[pyo3(name = "to_xpub")] + pub fn to_xpub_py(&self) -> PyResult { + let public_key = self.inner.public_key(); + Ok(public_key.into()) + } + + #[pyo3(name = "to_private_key")] + pub fn to_private_key_py(&self) -> PyResult { + let private_key = self.inner.private_key(); + Ok(private_key.into()) + } + + #[getter] + #[pyo3(name = "xprv")] + pub fn xprv_py(&self) -> PyResult { + let str = self.inner.to_extended_key("kprv".try_into()?).to_string(); + Ok(str) + } + + #[getter] + #[pyo3(name = "private_key")] + pub fn private_key_as_hex_string_py(&self) -> String { + use kaspa_bip32::PrivateKey; + self.inner.private_key().to_bytes().to_vec().to_hex() + } + + #[getter] + #[pyo3(name = "depth")] + pub fn depth_py(&self) -> u8 { + self.inner.attrs().depth + } + + #[getter] + #[pyo3(name = "parent_fingerprint")] + pub fn parent_fingerprint_as_hex_string_py(&self) -> String { + self.inner.attrs().parent_fingerprint.to_vec().to_hex() + } + + #[getter] + #[pyo3(name = "child_number")] + pub fn child_number_py(&self) -> u32 { + self.inner.attrs().child_number.into() + } + + #[getter] + #[pyo3(name = "chain_code")] + pub fn chain_code_as_hex_string_py(&self) -> String { + self.inner.attrs().chain_code.to_vec().to_hex() + } +} + impl<'a> From<&'a XPrv> for &'a ExtendedPrivateKey { fn from(xprv: &'a XPrv) -> Self { &xprv.inner diff --git a/wallet/keys/src/xpub.rs b/wallet/keys/src/xpub.rs index 8706f3fc91..95ac9b5100 100644 --- a/wallet/keys/src/xpub.rs +++ b/wallet/keys/src/xpub.rs @@ -19,6 +19,7 @@ use crate::imports::*; /// @category Wallet SDK /// #[derive(Clone, CastFromJs)] +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen(inspectable)] pub struct XPub { inner: ExtendedPublicKey, @@ -102,6 +103,72 @@ impl XPub { } } +#[cfg(feature = "py-sdk")] +#[pymethods] +impl XPub { + #[new] + pub fn try_new_py(xpub: &str) -> PyResult { + let inner = ExtendedPublicKey::::from_str(xpub)?; + Ok(Self { inner }) + } + + #[pyo3(name = "derive_child")] + #[pyo3(signature = (child_number, hardened=None))] + pub fn derive_child_py(&self, child_number: u32, hardened: Option) -> PyResult { + let child_number = ChildNumber::new(child_number, hardened.unwrap_or(false))?; + let inner = self.inner.derive_child(child_number)?; + Ok(Self { inner }) + } + + #[pyo3(name = "derive_path")] + pub fn derive_path_py(&self, path: &str) -> PyResult { + let path = DerivationPath::new(path)?; + let inner = self.inner.clone().derive_path((&path).into())?; + Ok(Self { inner }) + } + + #[pyo3(name = "into_string")] + pub fn to_str_py(&self, prefix: &str) -> PyResult { + Ok(self.inner.to_string(Some(prefix.try_into()?))) + } + + #[pyo3(name = "to_public_key")] + pub fn public_key_py(&self) -> PublicKey { + self.inner.public_key().into() + } + + #[getter] + #[pyo3(name = "xpub")] + pub fn xpub_py(&self) -> PyResult { + let str = self.inner.to_extended_key("kpub".try_into()?).to_string(); + Ok(str) + } + + #[getter] + #[pyo3(name = "depth")] + pub fn depth_py(&self) -> u8 { + self.inner.attrs().depth + } + + #[getter] + #[pyo3(name = "parent_fingerprint")] + pub fn parent_fingerprint_as_hex_string_py(&self) -> String { + self.inner.attrs().parent_fingerprint.to_vec().to_hex() + } + + #[getter] + #[pyo3(name = "child_number")] + pub fn child_number_py(&self) -> u32 { + self.inner.attrs().child_number.into() + } + + #[getter] + #[pyo3(name = "chain_code")] + pub fn chain_code_as_hex_string_py(&self) -> String { + self.inner.attrs().chain_code.to_vec().to_hex() + } +} + impl From> for XPub { fn from(inner: ExtendedPublicKey) -> Self { Self { inner } diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index d8b0f06a95..e3f8a6d0e3 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -160,7 +160,7 @@ cfg_if::cfg_if! { pub use kaspa_addresses::{Address, Version as AddressVersion}; pub use kaspa_consensus_core::tx::{ScriptPublicKey, Transaction, TransactionInput, TransactionOutpoint, TransactionOutput}; pub use kaspa_pow::wasm::*; - pub use kaspa_txscript::wasm::*; + pub use kaspa_txscript::bindings::wasm::*; pub mod rpc { //! Kaspa RPC interface @@ -171,7 +171,7 @@ cfg_if::cfg_if! { pub use kaspa_rpc_core::model::message::*; } pub use kaspa_rpc_core::api::rpc::RpcApi; - pub use kaspa_rpc_core::wasm::message::*; + pub use kaspa_rpc_core::bindings::wasm::message::*; pub use kaspa_wrpc_wasm::client::*; pub use kaspa_wrpc_wasm::resolver::*; @@ -180,14 +180,14 @@ cfg_if::cfg_if! { pub use kaspa_consensus_wasm::*; pub use kaspa_wallet_keys::prelude::*; - pub use kaspa_wallet_core::wasm::*; + pub use kaspa_wallet_core::bindings::wasm::*; } else if #[cfg(feature = "wasm32-core")] { pub use kaspa_addresses::{Address, Version as AddressVersion}; pub use kaspa_consensus_core::tx::{ScriptPublicKey, Transaction, TransactionInput, TransactionOutpoint, TransactionOutput}; pub use kaspa_pow::wasm::*; - pub use kaspa_txscript::wasm::*; + pub use kaspa_txscript::bindings::wasm::*; pub mod rpc { //! Kaspa RPC interface @@ -198,7 +198,7 @@ cfg_if::cfg_if! { pub use kaspa_rpc_core::model::message::*; } pub use kaspa_rpc_core::api::rpc::RpcApi; - pub use kaspa_rpc_core::wasm::message::*; + pub use kaspa_rpc_core::bindings::wasm::message::*; pub use kaspa_wrpc_wasm::client::*; pub use kaspa_wrpc_wasm::resolver::*; @@ -207,13 +207,13 @@ cfg_if::cfg_if! { pub use kaspa_consensus_wasm::*; pub use kaspa_wallet_keys::prelude::*; - pub use kaspa_wallet_core::wasm::*; + pub use kaspa_wallet_core::bindings::wasm::*; } else if #[cfg(feature = "wasm32-rpc")] { pub use kaspa_rpc_core::api::rpc::RpcApi; - pub use kaspa_rpc_core::wasm::message::*; - pub use kaspa_rpc_core::wasm::message::IPingRequest; + pub use kaspa_rpc_core::bindings::wasm::message::*; + pub use kaspa_rpc_core::bindings::wasm::message::IPingRequest; pub use kaspa_wrpc_wasm::client::*; pub use kaspa_wrpc_wasm::resolver::*; pub use kaspa_wrpc_wasm::notify::*;