diff --git a/README.md b/README.md index 5f00a1e5..1f0b4fdd 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ A native extension module, `kaspa`, is built from these bindings using [PyO3](ht This SDK provides features in two primary categories: -- RPC Client - Connect to Kaspa nodes & PNN, perform calls, subscriptions, etc. -- Wallet Management - Wallet related functionality (key management, derivation, addresses, transactions, etc.). +- RPC Client — RPC API for the Kaspa node using WebSockets. +- Wallet SDK — Bindings for wallet-related primitives such as key management, derivation, and transactions. +- Managed Wallet — a fully managed interface for the Rusty Kaspa Wallet API bundled into one Python class. This package strives to mirror the [Kaspa WASM32 SDK](https://kaspa.aspectron.org/docs/) from a feature and API perspective, while respecting Python conventions. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b4cd4f58..b3570636 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,7 @@ - Wallet-specific exception classes populated into the `kaspa.exceptions` submodule, covering the rusty-kaspa wallet error variants (e.g. `WalletInsufficientFundsError`, `WalletAccountNotFoundError`, `WalletNotSyncedError`, etc.). - Examples under `examples/wallet/` demonstrating wallet usage - Pytest options `--network-id` and `--rpc-url` for targeting integration tests at a specific network / node. +- TypedDicts for `RpcClient` subscription event payloads — `BlockAddedEvent`, `VirtualChainChangedEvent`, `FinalityConflictEvent`, `FinalityConflictResolvedEvent`, `UtxosChangedEvent`, `SinkBlueScoreChangedEvent`, `VirtualDaaScoreChangedEvent`, `PruningPointUtxoSetOverrideEvent`, `NewBlockTemplateEvent`, `ConnectEvent`, `DisconnectEvent` — and their notification body TypedDicts (`RpcBlockAddedNotification`, etc.) for typing event-listener callbacks. ### Changed - `py_error_map!` macro extended to register wallet exception variants into the `kaspa.exceptions` submodule. @@ -28,6 +29,7 @@ - `pyproject.toml`: set `python-source = "python"` and moved the package stub tree under `python/kaspa/` (`kaspa.pyi` → `python/kaspa/__init__.pyi`). - `Hash` accepts `str` in addition to `Hash` instances wherever it is used as an argument, and gained `to_hex()` and `__repr__` methods. - Added `Balance` `__repr__` method. +- Documentation site reorganization ### Fixed - `AccountDescriptor.__repr__` now correctly renders optional fields. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 00000000..c90e1762 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,40 @@ +# Examples + +Runnable examples live in the SDK repository, not in these docs. Each +script is self-contained and demonstrates one feature end-to-end. + +[Browse examples on GitHub →](https://github.com/kaspanet/kaspa-python-sdk/tree/main/examples) + +## What's there + +- **[`rpc/`](https://github.com/kaspanet/kaspa-python-sdk/tree/main/examples/rpc)** + — connecting via the resolver, calling RPC methods, subscribing to + notifications. +- **[`wallet/`](https://github.com/kaspanet/kaspa-python-sdk/tree/main/examples/wallet)** + — managed `Wallet` lifecycle: creating, opening, accounts, sending, + export/import. +- **[`transactions/`](https://github.com/kaspanet/kaspa-python-sdk/tree/main/examples/transactions)** + — building transactions with the `Generator`, `UtxoContext`, + multisig, and KRC-20 deploys. +- **[`derivation.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/derivation.py)** + — BIP-32 / BIP-44 key derivation. +- **[`mnemonic.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/mnemonic.py)** + — generating and restoring mnemonics. +- **[`message_signing.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/message_signing.py)** + — signing and verifying messages with a private key. +- **[`addresses.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/addresses.py)** + — encoding, parsing, and validating Kaspa addresses. + +## Running them + +Clone the repo, install the SDK, and run any script directly: + +```bash +git clone https://github.com/kaspanet/kaspa-python-sdk +cd kaspa-python-sdk +pip install kaspa +python examples/rpc/all_calls.py +``` + +See [Installation](getting-started/installation.md) for build-from-source +instructions. diff --git a/docs/getting-started/examples.md b/docs/getting-started/examples.md deleted file mode 100644 index 983e3292..00000000 --- a/docs/getting-started/examples.md +++ /dev/null @@ -1,187 +0,0 @@ -# Examples - -This page contains a handful of brief examples showing core features of the Kaspa Python SDK. - -!!! danger "Security Warning" - **Handle Private Keys Securely** - - **These examples do not use proper private key/mnemonic/seed handling.** This is omitted for brevity. - - Never store your private keys in plain text, or directly in source code. Store securely offline. Anyone with access to this phrase has full control over your funds. - -## Examples on Github - -In addition to the examples below, a collection of examples can be found in the Github repository [here](https://github.com/kaspanet/kaspa-python-sdk/tree/master/examples). - -## Kaspa RPC Client - -```python -import asyncio -from kaspa import RpcClient, Resolver, NetworkId - -async def main(): - # Create a resolver to use available PNN nodes - resolver = Resolver() - - # Create RPC client - client = RpcClient( - resolver=resolver, - network_id=NetworkId("mainnet") - ) - - # Connect to the network - await client.connect() - print(f"Connected to: {client.url}") - - # Get BlockDAG info - info = await client.get_block_dag_info() - print(f"BlockDAG Info: {info}") - - await client.disconnect() - -asyncio.run(main()) -``` - -## Check Address Balances - -```python -import asyncio -from kaspa import RpcClient, Resolver, Address - -async def check_balance(address_str: str): - client = RpcClient(resolver=Resolver(), network_id="mainnet") - await client.connect() - - try: - result = await client.get_balance_by_address({ - "address": address_str - }) - - # Balance is in sompi (1 KAS = 100,000,000 sompi) - balance_sompi = result.get("balance", 0) - balance_kas = balance_sompi / 100_000_000 - print(f"Balance: {balance_kas} KAS") - - finally: - await client.disconnect() - -asyncio.run(check_balance("kaspa:qz...")) -``` - -## Creating a Wallet - -```python -from kaspa import Mnemonic, XPrv, PrivateKeyGenerator, NetworkType - -# Generate a new 24-word mnemonic -mnemonic = Mnemonic.random() -print(f"Your seed phrase: {mnemonic.phrase}") - -# IMPORTANT: Store this phrase securely! -# Anyone with this phrase can access your funds. - -# Convert mnemonic to seed -seed = mnemonic.to_seed() - -# Create extended private key from seed -xprv = XPrv(seed) - -# Create a key generator for deriving addresses -key_gen = PrivateKeyGenerator(xprv, False, 0) -``` - -## Generating Addresses - -With the key generator, you can derive addresses: - -```python -# ... continuation of example above - -# Get the first receive address -private_key = key_gen.receive_key(0) -address = private_key.to_address(NetworkType.Mainnet) -print(f"Your address: {address.to_string()}") - -# Generate multiple addresses -for i in range(5): - pk = key_gen.receive_key(i) - addr = pk.to_address(NetworkType.Mainnet) - print(f"Address {i}: {addr.to_string()}") -``` - -## Working with Existing Wallets - -To restore a wallet from an existing seed phrase: - -```python -from kaspa import Mnemonic, XPrv, PrivateKeyGenerator, NetworkType - -# Your existing seed phrase -phrase = "word1 word2 word3 ... word24" - -# Validate and create mnemonic -if Mnemonic.validate(phrase): - mnemonic = Mnemonic(phrase) - seed = mnemonic.to_seed() - xprv = XPrv(seed) - key_gen = PrivateKeyGenerator(xprv, False, 0) - - # Derive your first address - address = key_gen.receive_key(0).to_address(NetworkType.Mainnet) - print(f"Restored address: {address.to_string()}") - - # Derive additional addresses as needed... -else: - print("Invalid seed phrase!") -``` - -## Building a Transaction - -```python -import asyncio -from kaspa import ( - RpcClient, Resolver, Generator, PaymentOutput, - Address, PrivateKey, NetworkId -) - -async def send_transaction(): - client = RpcClient(resolver=Resolver(), network_id="mainnet") - await client.connect() - - try: - # Your private key (keep secret!) - private_key = PrivateKey("your-private-key-hex") - sender_address = private_key.to_address("mainnet") - - # Get UTXOs for your address - utxos_response = await client.get_utxos_by_addresses({ - "addresses": [sender_address.to_string()] - }) - - # Create payment output - recipient = Address("kaspa:recipient-address...") - amount = 100_000_000 # 1 KAS in sompi - payment = PaymentOutput(recipient, amount) - - # Create transaction generator - generator = Generator( - network_id=NetworkId("mainnet"), - entries=utxos_response["entries"], - change_address=sender_address, - outputs=[payment], - ) - - # Generate and sign transactions - for pending_tx in generator: - # Sign the transaction - pending_tx.sign([private_key]) - - # Submit to network - tx_id = await pending_tx.submit(client) - print(f"Transaction submitted: {tx_id}") - - finally: - await client.disconnect() - -asyncio.run(send_transaction()) -``` diff --git a/docs/getting-started/security.md b/docs/getting-started/security.md new file mode 100644 index 00000000..9872f512 --- /dev/null +++ b/docs/getting-started/security.md @@ -0,0 +1,68 @@ +# Security + +The SDK gives you direct access to mnemonics, seeds, private keys, and wallet +files. Anyone who obtains them can spend your funds. Treat them with care. + +## What counts as secret material + +| Type | Compromise consequence | +| --- | --- | +| **Mnemonic phrase** / **BIP-39 seed** | Full recovery of every wallet derived from it | +| **Extended private key (XPrv)** | Full control of every account derived under it | +| **Private key** | Full control of every UTXO that pays its address | +| **Wallet secret** (password) | Decrypts the on-disk wallet file | +| **Wallet file** (`.kaspa/`) | Encrypted, but only as strong as the password | + +## Why Python makes this hard + +Python wasn't designed for handling secrets. Be aware of: + +- **No secure memory.** `str` and `bytes` are immutable — you can't reliably + zero them after use. Copies linger in interpreter caches, tracebacks, and the + garbage collector until reclaimed. +- **Leaks via `repr` and logging.** Frameworks happily dump local variables in + exception traces, debuggers, and structured logs. A `PrivateKey` in scope + during an unhandled exception can land in your error tracker. +- **Process introspection.** Other code in the same interpreter (debuggers, + profilers, third-party libraries, malicious dependencies) can read your + memory. Supply-chain risk is real — audit what you `pip install`. +- **Shell history and env dumps.** Secrets passed as CLI args show up in + `ps`/`history`; secrets in `os.environ` show up in crash reports and + subprocess inheritance. + +## Handling secrets in Python + +- **Source secrets at runtime, never from source code.** Use environment + variables loaded from a `.env` file (with `python-dotenv`), an OS keyring + (`keyring`), a cloud secret manager (AWS Secrets Manager, GCP Secret + Manager, HashiCorp Vault), or `getpass.getpass()` for interactive prompts. +- **Keep secrets in narrow scope.** Load, use, drop the reference. Don't + attach them to long-lived objects, module globals, or class attributes. +- **Strip `print()` and disable verbose logging in production.** Filter + sensitive fields out of structured logs before they reach disk or a SaaS + collector. +- **Isolate signing.** For high-value keys, sign in a separate process, + container, or hardware device — not the same Python process that handles + network I/O. +- **Pin and audit dependencies.** Use a lockfile, review transitive deps, and + prefer `pip install --require-hashes` for production installs. + +## Operational rules + +1. **Never commit secrets to git.** Add wallet storage paths and any + `*.json`, `seed.txt`, `mnemonic.txt` artefacts to `.gitignore` *before* + generating real keys. +2. **Never paste a real mnemonic into source code, an issue, a chat message, + an LLM prompt, or a screenshot.** +3. **Don't reuse mainnet keys for testing.** Use a fresh testnet mnemonic. +4. **Use a wallet passphrase ("25th word") for high-value wallets.** +5. **Store backups offline** — paper, hardware-encrypted USB, hardware + wallet. Not iCloud Notes. +6. **Generate keys on a machine you trust** — not a shared CI runner. + +## In these docs + +Code samples pass literal hex strings and short passwords inline for +readability. **That is not how to handle real secrets.** Replace inline +strings with values sourced from a secret manager, environment variable, +hardware wallet, or interactive prompt. diff --git a/docs/guides/addresses.md b/docs/guides/addresses.md deleted file mode 100644 index 51bc7ac5..00000000 --- a/docs/guides/addresses.md +++ /dev/null @@ -1,167 +0,0 @@ -# Addresses - -## Overview - -Kaspa addresses are encoded representations of public keys or script hashes. They include: - -- A **network prefix** (`kaspa:`, `kaspatest:`, `kaspadev:`, `kaspasim:`) -- A **version** indicating the address type (PubKey, PubKeyECDSA, ScriptHash) -- An **encoded payload** derived from the public key or script - -## Creating Addresses - -### From a String - -```python -from kaspa import Address - -# Parse an address string -address = Address("kaspa:qz0s9f5p7d3e2c4x8n1b6m9k0j2h4g5f3d7a8s9w0e1r2t3y4u5i6o7p8") - -# Get address components -print(f"Prefix: {address.prefix}") -print(f"Version: {address.version}") -print(f"Full: {address.to_string()}") -``` - -### From a Private Key - -```python -from kaspa import PrivateKey, NetworkType - -# Create from private key hex -private_key = PrivateKey("your-private-key-hex") - -# Derive Schnorr address (default) -address = private_key.to_address(NetworkType.Mainnet) -print(f"Schnorr address: {address.to_string()}") - -# Derive ECDSA address -ecdsa_address = private_key.to_address_ecdsa(NetworkType.Mainnet) -print(f"ECDSA address: {ecdsa_address.to_string()}") -``` - -### From a Public Key - -```python -from kaspa import PublicKey, NetworkType - -# Create from public key hex -public_key = PublicKey("02a1b2c3d4e5f6...") - -# Derive address -address = public_key.to_address(NetworkType.Mainnet) -print(f"Address: {address.to_string()}") -``` - -## Validating Addresses - -```python -from kaspa import Address - -address_str = "kaspa:qz..." - -# Static validation (returns bool) -if Address.validate(address_str): - address = Address(address_str) - print(f"Valid address: {address.to_string()}") -else: - print("Invalid address!") -``` - -## Address Types - -Kaspa supports several address versions: - -| Version | Description | -|---------|-------------| -| PubKey | Schnorr signature | -| PubKeyECDSA | ECDSA signature | -| ScriptHash | Pay-to-Script-Hash | - -```python -from kaspa import Address - -address = Address("kaspa:qz...") - -# Check the version -version = address.version -print(f"Address version: {version}") -``` - -## Network Prefixes - -Addresses include a prefix indicating the network: - -| Prefix | Network | Use | -|--------|---------|-----| -| `kaspa:` | Mainnet | Production | -| `kaspatest:` | Testnet | Testing | -| `kaspadev:` | Devnet | Development | -| `kaspasim:` | Simnet | Simulation | - -### Changing the Prefix - -```python -from kaspa import Address - -# Create mainnet address -address = Address("kaspa:qz...") - -# Change to testnet -address.prefix = "kaspatest" -print(f"Testnet address: {address.to_string()}") -``` - -## Address from Script - -Create an address from a script public key: - -```python -from kaspa import ScriptPublicKey, address_from_script_public_key, NetworkType - -# Create script public key -script_pubkey = ScriptPublicKey(0, "20a1b2c3...") - -# Convert to address -address = address_from_script_public_key(script_pubkey, NetworkType.Mainnet) -print(f"Address: {address.to_string()}") -``` - -## Script from Address - -Get the script public key for an address: - -```python -from kaspa import Address, pay_to_address_script - -address = Address("kaspa:qz...") - -# Get the locking script -script_pubkey = pay_to_address_script(address) -print(f"Script: {script_pubkey.script}") -``` - -## Multi-Signature Addresses - -Create a multi-signature address: - -```python -from kaspa import create_multisig_address, PublicKey, NetworkType - -# Gather public keys from all participants -pubkeys = [ - PublicKey("02key1..."), - PublicKey("02key2..."), - PublicKey("02key3..."), -] - -# Create 2-of-3 multisig address -multisig_address = create_multisig_address( - minimum_signatures=2, - keys=pubkeys, - network_type=NetworkType.Mainnet -) - -print(f"Multisig address: {multisig_address.to_string()}") -``` diff --git a/docs/guides/key-derivation.md b/docs/guides/key-derivation.md deleted file mode 100644 index d4269a56..00000000 --- a/docs/guides/key-derivation.md +++ /dev/null @@ -1,258 +0,0 @@ -# Key Derivation - -This guide covers hierarchical deterministic (HD) wallet key derivation in the Kaspa Python SDK. - -!!! danger "Security Warning" - **Handle Private Keys Securely** - - **These examples do not use proper private key/mnemonic/seed handling.** This is omitted for brevity. - - Never store your private keys in plain text, or directly in source code. Store securely offline. Anyone with access to this phrase has full control over your funds. - -## Derivation Path - -``` -m / purpose' / coin_type' / account' / change / address_index -``` - -See [Kaspa MDBook's page on derivation](https://kaspa-mdbook.aspectron.com/wallets/addresses.html) for more information. - -## Extended Keys - -### Extended Private Key (XPrv) - -```python -from kaspa import Mnemonic, XPrv - -# Generate from mnemonic -mnemonic = Mnemonic.random() -seed = mnemonic.to_seed() -xprv = XPrv(seed) - -# Access properties -print(f"XPrv: {xprv.xprv}") -print(f"Private key: {xprv.private_key}") -print(f"Depth: {xprv.depth}") -print(f"Chain code: {xprv.chain_code}") -``` - -### Extended Public Key (XPub) - -```python -from kaspa import XPrv, XPub - -# Derive XPub from XPrv -xprv = XPrv(seed) -xpub = xprv.to_xpub() - -# Access properties -print(f"XPub: {xpub.xpub}") -print(f"Depth: {xpub.depth}") -print(f"Chain code: {xpub.chain_code}") - -# Get public key -public_key = xpub.to_public_key() -``` - -## Manual Derivation - -### Deriving Child Keys - -```python -from kaspa import XPrv, DerivationPath - -xprv = XPrv(seed) - -# Derive by child number -child = xprv.derive_child(0) # Non-hardened -hardened_child = xprv.derive_child(0, hardened=True) - -# Derive by path string -account_key = xprv.derive_path("m/44'/111111'/0'") - -# Derive using DerivationPath object -path = DerivationPath("m/44'/111111'/0'/0/0") -derived = xprv.derive_path(path) -``` - -### Working with Derivation Paths - -```python -from kaspa import DerivationPath - -# Create a path -path = DerivationPath("m/44'/111111'/0'/0/0") - -# Check properties -print(f"Length: {path.length()}") -print(f"Is empty: {path.is_empty()}") -print(f"String: {path.to_string()}") - -# Get parent path -parent = path.parent() -print(f"Parent: {parent.to_string()}") - -# Extend path -path.push(1) # Add non-hardened child -path.push(0, hardened=True) # Add hardened child -``` - -## Key Generators - -### Private Key Generator - -```python -from kaspa import XPrv, PrivateKeyGenerator, NetworkType - -xprv = XPrv(seed) - -# Create generator for standard (non-multisig) wallet -key_gen = PrivateKeyGenerator( - xprv=xprv, - is_multisig=False, - account_index=0 -) - -# Generate receive addresses -for i in range(5): - private_key = key_gen.receive_key(i) - address = private_key.to_address(NetworkType.Mainnet) - print(f"Receive {i}: {address.to_string()}") - -# Generate change addresses -change_key = key_gen.change_key(0) -change_address = change_key.to_address(NetworkType.Mainnet) -print(f"Change: {change_address.to_string()}") -``` - -### Public Key Generator - -For watch-only wallets or when you only need addresses: - -```python -from kaspa import PublicKeyGenerator, NetworkType - -# Create from XPub string -pub_gen = PublicKeyGenerator.from_xpub("xpub...") - -# Or create from master XPrv -pub_gen = PublicKeyGenerator.from_master_xprv( - xprv=xprv_string, - is_multisig=False, - account_index=0 -) - -# Generate receive addresses -addresses = pub_gen.receive_addresses( - network_type=NetworkType.Mainnet, - start=0, - end=10 -) -for i, addr in enumerate(addresses): - print(f"Address {i}: {addr.to_string()}") - -# Get single address -single_addr = pub_gen.receive_address(NetworkType.Mainnet, 0) - -# Get as strings directly -addr_strings = pub_gen.receive_addresses_as_strings( - network_type=NetworkType.Mainnet, - start=0, - end=10 -) - -# Get public keys -pubkeys = pub_gen.receive_pubkeys(start=0, end=5) -pubkey_strings = pub_gen.receive_pubkeys_as_strings(start=0, end=5) -``` - -### Change Addresses - -Key generators provide both receive and change key paths: - -```python -from kaspa import PrivateKeyGenerator, PublicKeyGenerator, NetworkType - -# Private key generator -priv_gen = PrivateKeyGenerator(xprv_string, False, 0) -receive_key = priv_gen.receive_key(0) # m/.../0/0 -change_key = priv_gen.change_key(0) # m/.../1/0 - -# Public key generator -pub_gen = PublicKeyGenerator.from_xpub(xpub_string) -receive_addrs = pub_gen.receive_addresses(NetworkType.Mainnet, 0, 5) -change_addrs = pub_gen.change_addresses(NetworkType.Mainnet, 0, 5) -``` - -## Multi-Signature Wallets - -```python -from kaspa import PrivateKeyGenerator, PublicKeyGenerator - -# Each cosigner uses their own index -cosigner_0_gen = PrivateKeyGenerator( - xprv=xprv_string, - is_multisig=True, - account_index=0, - cosigner_index=0 -) - -cosigner_1_gen = PrivateKeyGenerator( - xprv=other_xprv_string, - is_multisig=True, - account_index=0, - cosigner_index=1 -) -``` - -## Account Types - -```python -from kaspa import AccountKind - -# Create account kind from string -bip32 = AccountKind("bip32") -legacy = AccountKind("legacy") -multisig = AccountKind("multisig") - -print(f"Account kind: {bip32.to_string()}") -``` - -## Complete Example: HD Wallet - -```python -from kaspa import ( - Mnemonic, XPrv, PrivateKeyGenerator, - PublicKeyGenerator, NetworkType -) - -# Create wallet from mnemonic -mnemonic = Mnemonic.random() -seed = mnemonic.to_seed() -master_xprv = XPrv(seed) - -# Derive account-level key -account_xprv = master_xprv.derive_path("m/44'/111111'/0'") -account_xpub = account_xprv.to_xpub() - -# Export XPub for watch-only wallet -xpub_export = account_xpub.to_str("kpub") -print(f"Watch-only XPub: {xpub_export}") - -# Full wallet with private keys -priv_gen = PrivateKeyGenerator(master_xprv, False, 0) - -# Watch-only wallet -pub_gen = PublicKeyGenerator.from_xpub(xpub_export) - -# Both generate the same addresses -for i in range(3): - # From private key generator - priv_addr = priv_gen.receive_key(i).to_address(NetworkType.Mainnet) - - # From public key generator - pub_addr = pub_gen.receive_address(NetworkType.Mainnet, i) - - assert priv_addr.to_string() == pub_addr.to_string() - print(f"Address {i}: {priv_addr.to_string()}") -``` diff --git a/docs/guides/message-signing.md b/docs/guides/message-signing.md deleted file mode 100644 index eb5c08fb..00000000 --- a/docs/guides/message-signing.md +++ /dev/null @@ -1,193 +0,0 @@ -# Message Signing - -This guide covers signing and verifying arbitrary messages with the Kaspa Python SDK. - -!!! danger "Security Warning" - **Handle Private Keys Securely** - - **These examples do not use proper private key/mnemonic/seed handling.** This is omitted for brevity. - - Never store your private keys in plain text, or directly in source code. Store securely offline. Anyone with access to this phrase has full control over your funds. - -## Overview - -Message signing allows you to: - -- **Prove ownership** of an address without revealing your private key -- **Sign data** for off-chain verification -- **Authenticate** actions or statements - -## Signing a Message - -```python -from kaspa import sign_message, PrivateKey - -# Your private key -private_key = PrivateKey("your-private-key-hex") - -# Message to sign -message = "Hello, I own this address!" - -# Sign the message -signature = sign_message(message, private_key) -print(f"Signature: {signature}") -``` - -### Deterministic Signing - -By default, signatures use auxiliary randomness for additional security. For deterministic signatures: - -```python -# Deterministic signature (same message + key = same signature) -signature = sign_message(message, private_key, no_aux_rand=True) -``` - -## Verifying a Signature - -```python -from kaspa import verify_message, PublicKey - -# The public key of the signer -public_key = PublicKey("02a1b2c3...") - -# Or derive from private key -public_key = private_key.to_public_key() - -# Verify the signature -message = "Hello, I own this address!" -signature = "signature-from-signing..." - -is_valid = verify_message(message, signature, public_key) - -if is_valid: - print("Signature is valid!") -else: - print("Invalid signature!") -``` - -## Complete Example - -```python -from kaspa import ( - Mnemonic, XPrv, PrivateKeyGenerator, - sign_message, verify_message, NetworkType -) - -# Create a wallet -mnemonic = Mnemonic.random() -xprv = XPrv(mnemonic.to_seed()) -key_gen = PrivateKeyGenerator(xprv, False, 0) - -# Get a keypair -private_key = key_gen.receive_key(0) -public_key = private_key.to_public_key() -address = private_key.to_address(NetworkType.Mainnet) - -print(f"Address: {address.to_string()}") - -# Sign a message -message = f"I control address {address.to_string()} on 2024-01-15" -signature = sign_message(message, private_key) -print(f"Signature: {signature}") - -# Verify the signature -is_valid = verify_message(message, signature, public_key) -print(f"Valid: {is_valid}") - -# Try with wrong message -wrong_message = "I control a different address" -is_valid_wrong = verify_message(wrong_message, signature, public_key) -print(f"Wrong message valid: {is_valid_wrong}") # False -``` - -## Use Cases - -### Proving Address Ownership - -```python -def prove_ownership(private_key, address, timestamp): - """Generate a proof of address ownership.""" - message = f"I own {address.to_string()} at {timestamp}" - signature = sign_message(message, private_key) - return { - "address": address.to_string(), - "message": message, - "signature": signature, - "timestamp": timestamp - } - -def verify_ownership(proof, public_key): - """Verify a proof of address ownership.""" - return verify_message( - proof["message"], - proof["signature"], - public_key - ) -``` - -### Signing Structured Data - -```python -import json -import hashlib - -def sign_json_data(data, private_key): - """Sign structured JSON data.""" - # Canonical JSON serialization - canonical = json.dumps(data, sort_keys=True, separators=(',', ':')) - - # Sign the serialized data - signature = sign_message(canonical, private_key) - - return { - "data": data, - "signature": signature - } - -def verify_json_data(signed_data, public_key): - """Verify signed JSON data.""" - canonical = json.dumps( - signed_data["data"], - sort_keys=True, - separators=(',', ':') - ) - - return verify_message( - canonical, - signed_data["signature"], - public_key - ) -``` - -### Authentication Token - -```python -import time - -def create_auth_token(private_key, address, validity_seconds=300): - """Create a time-limited authentication token.""" - expires = int(time.time()) + validity_seconds - message = f"auth:{address.to_string()}:{expires}" - signature = sign_message(message, private_key) - - return { - "address": address.to_string(), - "expires": expires, - "signature": signature - } - -def verify_auth_token(token, public_key): - """Verify an authentication token.""" - # Check expiration - if int(time.time()) > token["expires"]: - return False, "Token expired" - - # Verify signature - message = f"auth:{token['address']}:{token['expires']}" - is_valid = verify_message(message, token["signature"], public_key) - - if is_valid: - return True, "Valid" - else: - return False, "Invalid signature" -``` diff --git a/docs/guides/mnemonics.md b/docs/guides/mnemonics.md deleted file mode 100644 index 6887c3b7..00000000 --- a/docs/guides/mnemonics.md +++ /dev/null @@ -1,137 +0,0 @@ -# Mnemonics - -!!! danger "Security Warning" - **Handle Private Keys Securely** - - **These examples do not use proper private key/mnemonic/seed handling.** This is omitted for brevity. - - Never store your private keys in plain text, or directly in source code. Store securely offline. Anyone with access to this phrase has full control over your funds. - -## Overview - -Mnemonic phrases (also called seed phrases or recovery phrases) are human-readable representations of cryptographic seeds. The Kaspa SDK supports BIP-39 compatible mnemonics. - -## Generating a New Mnemonic - -```python -from kaspa import Mnemonic - -# Generate a random 24-word mnemonic (default) -mnemonic = Mnemonic.random() -print(f"Your seed phrase: {mnemonic.phrase}") - -# Generate with specific word count -mnemonic_12 = Mnemonic.random(word_count=12) # 12 words -mnemonic_24 = Mnemonic.random(word_count=24) # 24 words (recommended) -``` - -## Restoring from a Mnemonic - -```python -from kaspa import Mnemonic - -phrase = "abandon abandon abandon ... about" - -# Validate before use -if Mnemonic.validate(phrase): - mnemonic = Mnemonic(phrase) - print("Mnemonic restored successfully") -else: - print("Invalid mnemonic phrase!") -``` - -## Validation - -```python -from kaspa import Mnemonic, Language - -phrase = "word1 word2 word3 ..." - -# Basic validation -is_valid = Mnemonic.validate(phrase) -print(f"Valid: {is_valid}") - -# With specific language -is_valid_english = Mnemonic.validate(phrase, Language.English) -``` - -## Converting to Seed - -```python -from kaspa import Mnemonic, XPrv - -mnemonic = Mnemonic.random() - -# Convert to seed (without passphrase) -seed = mnemonic.to_seed() - -# Convert with optional passphrase for extra security -seed_with_passphrase = mnemonic.to_seed("my-secret-passphrase") - -# Use seed to create extended private key -xprv = XPrv(seed) -``` - -!!! info "Passphrase" - The passphrase (sometimes called "25th word") provides additional security. The same mnemonic with different passphrases produces different seeds. - -## Working with Entropy - -Access the underlying entropy: - -```python -from kaspa import Mnemonic - -mnemonic = Mnemonic.random() - -# Get entropy as hex string -entropy = mnemonic.entropy -print(f"Entropy: {entropy}") - -# Set new entropy (advanced use) -mnemonic.entropy = "new-entropy-hex" -``` - -## Language Support - -```python -from kaspa import Mnemonic, Language - -# Currently supported languages -mnemonic = Mnemonic.random() # Uses English by default - -# Specify language explicitly -mnemonic = Mnemonic(phrase, Language.English) -``` - -## Wallet Creation Example - -```python -from kaspa import ( - Mnemonic, XPrv, PrivateKeyGenerator, - NetworkType -) - -# Step 1: Generate mnemonic -mnemonic = Mnemonic.random() -print(f"Seed phrase: {mnemonic.phrase}") - -# Step 2: Convert to seed -seed = mnemonic.to_seed() - -# Step 3: Create extended private key -xprv = XPrv(seed) - -# Step 4: Create key generator -key_gen = PrivateKeyGenerator( - xprv=xprv, - is_multisig=False, - account_index=0 -) - -# Step 5: Derive addresses -for i in range(3): - private_key = key_gen.receive_key(i) - address = private_key.to_address(NetworkType.Mainnet) - print(f"Address {i}: {address.to_string()}") -``` diff --git a/docs/guides/rpc-client.md b/docs/guides/rpc-client.md deleted file mode 100644 index 2e7bac8d..00000000 --- a/docs/guides/rpc-client.md +++ /dev/null @@ -1,414 +0,0 @@ -# RPC Client - -## Overview - -The Kaspa Python SDK provides an asynchronous RPC client for communicating with Kaspa nodes via WebSocket. Features include: - -- **Automatic node discovery (PNN)** via Resolver. -- **Connection management** with reconnection support. -- **Full RPC API** coverage, including event subscriptions for real-time updates. - -## Quick Start - -```python -import asyncio -from kaspa import RpcClient, Resolver - -async def main(): - # Create client with resolver - client = RpcClient( - resolver=Resolver(), - network_id="mainnet" - ) - - # Connect - await client.connect() - print(f"Connected to: {client.url}") - - # Make RPC calls - info = await client.get_info() - print(f"Server info: {info}") - - # Disconnect - await client.disconnect() - -asyncio.run(main()) -``` - -## Connection Options - -### Using a Resolver - -The Resolver automatically finds available PNN nodes: - -```python -from kaspa import RpcClient, Resolver - -# Default resolver (uses public infrastructure) -resolver = Resolver() - -# Custom resolver URLs -resolver = Resolver(urls=["https://resolver1.kaspa.org"]) - -# With TLS configuration -resolver = Resolver(tls=True) - -client = RpcClient(resolver=resolver, network_id="mainnet") -``` - -### Direct Connection - -Connect directly to a known node: - -```python -from kaspa import RpcClient - -client = RpcClient( - url="wss://node.kaspa.org:17110", - network_id="mainnet", - encoding="borsh" # or "json" -) -``` - -### Connection Parameters - -```python -await client.connect( - block_async_connect=True, # Wait for connection - strategy="fallback", # Connection strategy - timeout_duration=30000, # Timeout in ms - retry_interval=1000, # Retry interval in ms -) -``` - -## Client Properties - -```python -# Check connection status -print(f"Connected: {client.is_connected}") - -# Get current URL -print(f"URL: {client.url}") - -# Get encoding -print(f"Encoding: {client.encoding}") - -# Get node ID -print(f"Node ID: {client.node_id}") - -# Get resolver -resolver = client.resolver -``` - -## RPC Methods - -### Network Information - -```python -# Get general info -info = await client.get_info() - -# Get block count -count = await client.get_block_count() -print(f"Blocks: {count['blockCount']}, Headers: {count['headerCount']}") - -# Get block DAG info -dag_info = await client.get_block_dag_info() -print(f"Network: {dag_info['networkName']}") -print(f"Block count: {dag_info['blockCount']}") - -# Get coin supply -supply = await client.get_coin_supply() -print(f"Circulating: {supply['circulatingSompi']}") - -# Get current network -network = await client.get_current_network() - -# Get sync status -sync = await client.get_sync_status() -print(f"Synced: {sync['isSynced']}") -``` - -### Balance and UTXOs - -```python -# Get balance for single address -balance = await client.get_balance_by_address({ - "address": "kaspa:qz..." -}) -print(f"Balance: {balance['balance']} sompi") - -# Get balances for multiple addresses -balances = await client.get_balances_by_addresses({ - "addresses": ["kaspa:qz...", "kaspa:qr..."] -}) - -# Get UTXOs -utxos = await client.get_utxos_by_addresses({ - "addresses": ["kaspa:qz..."] -}) -for entry in utxos.get("entries", []): - print(f"UTXO: {entry['outpoint']} = {entry['utxoEntry']['amount']}") -``` - -### Blocks - -```python -# Get specific block -block = await client.get_block({ - "hash": "block-hash-hex", - "includeTransactions": True -}) - -# Get multiple blocks -blocks = await client.get_blocks({ - "lowHash": "starting-hash", - "includeBlocks": True, - "includeTransactions": False -}) - -# Get block template (for mining) -template = await client.get_block_template({ - "payAddress": "kaspa:mining-address...", - "extraData": [] -}) -``` - -### Transactions - -```python -# Submit transaction -from kaspa import Transaction - -result = await client.submit_transaction({ - "transaction": tx.serialize_to_dict(), - "allowOrphan": False -}) -print(f"Transaction ID: {result['transactionId']}") - -# Get mempool entries -mempool = await client.get_mempool_entries({ - "includeOrphanPool": False, - "filterTransactionPool": True -}) - -# Get mempool entry by transaction ID -entry = await client.get_mempool_entry({ - "transactionId": "tx-id...", - "includeOrphanPool": False, - "filterTransactionPool": True -}) -``` - -### Fees - -```python -# Get fee estimate -fee = await client.get_fee_estimate() -print(f"Priority fee: {fee['estimate']['priorityBucket']}") - -# Experimental fee estimate with more detail -fee_exp = await client.get_fee_estimate_experimental({ - "verbose": True -}) -``` - -### Peer Management - -```python -# Get connected peers -peers = await client.get_connected_peer_info() - -# Get peer addresses -addresses = await client.get_peer_addresses() - -# Add peer -await client.add_peer({ - "peerAddress": "192.168.1.1:16111", - "isPermanent": False -}) - -# Ban/unban peer -await client.ban({"ip": "192.168.1.1"}) -await client.unban({"ip": "192.168.1.1"}) -``` - -### System - -```python -# Ping node -pong = await client.ping() - -# Get server info -server_info = await client.get_server_info() - -# Get system info -system_info = await client.get_system_info() - -# Get metrics -metrics = await client.get_metrics({ - "processMetrics": True, - "connectionMetrics": True, - "bandwidthMetrics": True, - "consensusMetrics": True, - "storageMetrics": False, - "customMetrics": False -}) -``` - -## Event Subscriptions - -Subscribe to real-time events. - -### Available Events - -| Event | Subscription Method | -|-------|---------------------| -| `utxos-changed` | `subscribe_utxos_changed()` | -| `block-added` | `subscribe_block_added()` | -| `virtual-chain-changed` | `subscribe_virtual_chain_changed()` | -| `virtual-daa-score-changed` | `subscribe_virtual_daa_score_changed()` | -| `sink-blue-score-changed` | `subscribe_sink_blue_score_changed()` | -| `finality-conflict` | `subscribe_finality_conflict()` | -| `finality-conflict-resolved` | `subscribe_finality_conflict_resolved()` | -| `new-block-template` | `subscribe_new_block_template()` | -| `pruning-point-utxo-set-override` | `subscribe_pruning_point_utxo_set_override()` | - - -### UTXO Changes - -```python -from kaspa import Address - -# Define callback -def on_utxo_change(event): - print(f"UTXO change: {event}") - -# Add listener -client.add_event_listener("utxos-changed", on_utxo_change) - -# Subscribe to addresses -await client.subscribe_utxos_changed([ - Address("kaspa:qz...") -]) - -# Later: unsubscribe -await client.unsubscribe_utxos_changed([ - Address("kaspa:qz...") -]) -``` - -### Block Events - -```python -def on_block_added(event): - print(f"New block: {event['block']['header']['hash']}") - -client.add_event_listener("block-added", on_block_added) -await client.subscribe_block_added() -``` - -### Virtual Chain Changes - -```python -def on_chain_change(event): - print(f"Chain updated: {event}") - -client.add_event_listener("virtual-chain-changed", on_chain_change) -await client.subscribe_virtual_chain_changed( - include_accepted_transaction_ids=True -) -``` - -### DAA Score Changes - -```python -def on_daa_change(event): - print(f"DAA score: {event['virtualDaaScore']}") - -client.add_event_listener("virtual-daa-score-changed", on_daa_change) -await client.subscribe_virtual_daa_score_changed() -``` - -### Managing Listeners - -```python -# Add listener with extra args -client.add_event_listener("block-added", callback, extra_arg) - -# Remove specific listener -client.remove_event_listener("block-added", callback) - -# Remove all listeners for an event -client.remove_event_listener("block-added") - -# Remove all listeners -client.remove_all_event_listeners() -``` - -## Complete Example: Wallet Monitor - -```python -import asyncio -from kaspa import RpcClient, Resolver, Address, sompi_to_kaspa - -class WalletMonitor: - def __init__(self, addresses): - self.addresses = [Address(a) for a in addresses] - self.client = RpcClient( - resolver=Resolver(), - network_id="mainnet" - ) - - async def start(self): - await self.client.connect() - - # Set up event handler - self.client.add_event_listener( - "utxos-changed", - self.on_utxo_change - ) - - # Subscribe to address changes - await self.client.subscribe_utxos_changed(self.addresses) - - # Get initial balances - await self.check_balances() - - print("Monitoring... Press Ctrl+C to stop") - - # Keep running - while True: - await asyncio.sleep(1) - - async def check_balances(self): - for addr in self.addresses: - result = await self.client.get_balance_by_address({ - "address": addr.to_string() - }) - balance = sompi_to_kaspa(result.get("balance", 0)) - print(f"{addr.to_string()[:20]}...: {balance} KAS") - - def on_utxo_change(self, event): - print(f"UTXO change detected!") - for added in event.get("added", []): - amount = sompi_to_kaspa(added["utxoEntry"]["amount"]) - print(f" + {amount} KAS") - for removed in event.get("removed", []): - amount = sompi_to_kaspa(removed["utxoEntry"]["amount"]) - print(f" - {amount} KAS") - - async def stop(self): - await self.client.disconnect() - -async def main(): - addresses = ["kaspa:qz..."] - monitor = WalletMonitor(addresses) - - try: - await monitor.start() - except KeyboardInterrupt: - await monitor.stop() - -asyncio.run(main()) -``` \ No newline at end of file diff --git a/docs/guides/transactions.md b/docs/guides/transactions.md deleted file mode 100644 index 293e8304..00000000 --- a/docs/guides/transactions.md +++ /dev/null @@ -1,361 +0,0 @@ -# Transactions - -This guide covers building, signing, and broadcasting transactions with the Kaspa Python SDK. - -!!! danger "Security Warning" - **Handle Private Keys Securely** - - **These examples do not use proper private key/mnemonic/seed handling.** This is omitted for brevity. - - Never store your private keys in plain text, or directly in source code. Store securely offline. Anyone with access to this phrase has full control over your funds. - -## Using the Generator - -The `Generator` class handles UTXO selection, fee calculation, and change management: - -```python -import asyncio -from kaspa import ( - RpcClient, Resolver, Generator, PaymentOutput, - Address, PrivateKey, NetworkId -) - -async def send_payment(): - # Connect to network - client = RpcClient(resolver=Resolver(), network_id="mainnet") - await client.connect() - - try: - # Your private key and address - private_key = PrivateKey("your-private-key-hex") - my_address = private_key.to_address("mainnet") - - # Fetch UTXOs - utxos = await client.get_utxos_by_addresses({ - "addresses": [my_address.to_string()] - }) - - # Define payment - recipient = Address("kaspa:recipient-address...") - amount = 500_000_000 # 5 KAS in sompi - - # Create generator - generator = Generator( - network_id=NetworkId("mainnet"), - entries=utxos["entries"], - change_address=my_address, - outputs=[PaymentOutput(recipient, amount)], - ) - - # Process transactions - for pending_tx in generator: - pending_tx.sign([private_key]) - tx_id = await pending_tx.submit(client) - print(f"Submitted: {tx_id}") - - # Get summary - summary = generator.summary() - print(f"Total fees: {summary.fees}") - print(f"Transactions: {summary.transactions}") - - finally: - await client.disconnect() - -asyncio.run(send_payment()) -``` - -## Generator Options - -```python -from kaspa import Generator, NetworkId, PaymentOutput - -generator = Generator( - # Required - network_id=NetworkId("mainnet"), - entries=utxo_entries, # List of UTXOs - change_address=my_address, # Where to send change - - # Optional - outputs=[payment1, payment2], # Payment outputs - payload=b"optional-data", # OP_RETURN data - priority_fee=1000, # Additional fee in sompi - priority_entries=priority_utxos, # UTXOs to use first - sig_op_count=1, # Signature operations per input - minimum_signatures=1, # For multisig estimation -) -``` - -## Estimating Transactions - -Transactions can be estimated prior to submission. - -```python -from kaspa import Generator, estimate_transactions - -# Using Generator.estimate() -generator = Generator( - network_id="mainnet", - entries=utxos, - change_address=my_address, - outputs=[PaymentOutput(recipient, amount)], -) - -summary = generator.estimate() -print(f"Estimated fee: {summary.fees} sompi") -print(f"Number of transactions: {summary.transactions}") -print(f"UTXOs consumed: {summary.utxos}") - -# Using standalone function -summary = estimate_transactions( - network_id="mainnet", - entries=utxos, - change_address=my_address, - outputs=[{"address": recipient, "amount": amount}], -) -``` - -## Pending Transactions - -The `PendingTransaction` represents a transaction ready for signing: - -```python -for pending_tx in generator: - # Transaction properties - print(f"ID: {pending_tx.id}") - print(f"Payment amount: {pending_tx.payment_amount}") - print(f"Change amount: {pending_tx.change_amount}") - print(f"Fee: {pending_tx.fee_amount}") - print(f"Mass: {pending_tx.mass}") - print(f"Type: {pending_tx.transaction_type}") - - # Get UTXOs being spent - utxo_refs = pending_tx.get_utxo_entries() - - # Get addresses involved - addresses = pending_tx.addresses() - - # Access underlying transaction - tx = pending_tx.transaction -``` - -## Signing Transactions - -### Simple Signing - -```python -# Sign with one or more private keys -pending_tx.sign([private_key]) - -# Or sign with multiple keys for multisig -pending_tx.sign([key1, key2, key3]) -``` - -### Per-Input Signing - -For more control, sign each input individually: - -```python -for i, utxo in enumerate(pending_tx.get_utxo_entries()): - pending_tx.sign_input(i, private_key) -``` - -### Custom Signature Scripts - -For advanced use cases (like multisig): - -```python -from kaspa import SighashType - -# Create signature -signature = pending_tx.create_input_signature( - input_index=0, - private_key=private_key, - sighash_type=SighashType.All -) - -# Set custom signature script -pending_tx.fill_input(0, signature_script_bytes) -``` - -## Manual Transaction Building - -Transactions can be built manually: - -```python -from kaspa import ( - Transaction, TransactionInput, TransactionOutput, - TransactionOutpoint, ScriptPublicKey, UtxoEntryReference, - sign_transaction -) - -# Create inputs from UTXOs -inputs = [] -for utxo in my_utxos: - outpoint = TransactionOutpoint( - transaction_id=utxo["outpoint"]["transactionId"], - index=utxo["outpoint"]["index"] - ) - tx_input = TransactionInput( - previous_outpoint=outpoint, - signature_script="", # Will be filled when signing - sequence=0, - sig_op_count=1, - utxo=UtxoEntryReference(utxo) - ) - inputs.append(tx_input) - -# Create outputs -outputs = [ - TransactionOutput( - value=amount, - script_public_key=pay_to_address_script(recipient) - ), - TransactionOutput( - value=change_amount, - script_public_key=pay_to_address_script(change_address) - ) -] - -# Build transaction -tx = Transaction( - version=0, - inputs=inputs, - outputs=outputs, - lock_time=0, - subnetwork_id="0000000000000000000000000000000000000000", - gas=0, - payload="", - mass=0 -) - -# Calculate and update mass -from kaspa import update_transaction_mass -update_transaction_mass("mainnet", tx) - -# Sign -signed_tx = sign_transaction(tx, [private_key], verify_sig=True) -``` - -## Transaction Mass and Fees - -Kaspa uses a mass-based fee model: - -```python -from kaspa import ( - calculate_transaction_mass, - calculate_transaction_fee, - calculate_storage_mass, - maximum_standard_transaction_mass -) - -# Get maximum allowed mass -max_mass = maximum_standard_transaction_mass() -print(f"Max mass: {max_mass}") - -# Calculate transaction mass -mass = calculate_transaction_mass("mainnet", tx) -print(f"Transaction mass: {mass}") - -# Calculate required fee -fee = calculate_transaction_fee("mainnet", tx) -print(f"Required fee: {fee} sompi") - -# Calculate storage mass component -storage_mass = calculate_storage_mass( - network_id="mainnet", - input_values=[1000000, 2000000], - output_values=[2500000, 400000] -) -``` - -## Submitting Transactions - -```python -# Using PendingTransaction -tx_id = await pending_tx.submit(client) - -# Manual submission -result = await client.submit_transaction({ - "transaction": tx.serialize_to_dict(), - "allowOrphan": False -}) -``` - -## Helper Functions - -### Create Single Transaction - -```python -from kaspa import create_transaction - -tx = create_transaction( - utxo_entry_source=utxos, - outputs=[{"address": "kaspa:...", "amount": 100000000}], - priority_fee=1000, - payload=None, - sig_op_count=1 -) -``` - -### Create Multiple Transactions - -```python -from kaspa import create_transactions - -result = create_transactions( - network_id="mainnet", - entries=utxos, - change_address=my_address, - outputs=[{"address": "kaspa:...", "amount": 100000000}], - priority_fee=1000, -) - -for pending in result["transactions"]: - pending.sign([private_key]) - # submit... - -print(f"Summary: {result['summary']}") -``` - -## Multi-Signature Transactions - -```python -from kaspa import ( - Generator, create_multisig_address, - PublicKey, NetworkType -) - -# Create multisig address (2-of-3) -pubkeys = [PublicKey(k) for k in [key1_pub, key2_pub, key3_pub]] -multisig_addr = create_multisig_address(2, pubkeys, NetworkType.Mainnet) - -# Build transaction spending from multisig -generator = Generator( - network_id="mainnet", - entries=multisig_utxos, - change_address=multisig_addr, - outputs=[PaymentOutput(recipient, amount)], - minimum_signatures=2, # For accurate mass calculation -) - -for pending_tx in generator: - # Collect signatures from 2 of 3 signers - pending_tx.sign([signer1_key, signer2_key]) - tx_id = await pending_tx.submit(client) -``` - -## Unit Conversions - -```python -from kaspa import kaspa_to_sompi, sompi_to_kaspa, sompi_to_kaspa_string_with_suffix - -# KAS to sompi -sompi = kaspa_to_sompi(1.5) # 150,000,000 sompi - -# Sompi to KAS -kas = sompi_to_kaspa(150000000) # 1.5 KAS - -# Formatted string -formatted = sompi_to_kaspa_string_with_suffix(150000000, "mainnet") -# "1.5 KAS" -``` diff --git a/docs/index.md b/docs/index.md index cd9d0c9c..c5508e4a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,24 +1,27 @@ # Kaspa Python SDK -This Python package, `kaspa`, provides an SDK for interacting with the Kaspa network from Python. +This Python package, `kaspa`, provides an SDK for interacting with the +Kaspa network from Python. This SDK provides features in the following main categories: -`kaspa` is a native extension module built from bindings to Rust and [rusty-kaspa](https://github.com/kaspanet/rusty-kaspa) source. [PyO3](https://pyo3.rs/) and [Maturin](https://www.maturin.rs/) are used to create bindings and build the extension module. More information on the inner workings can be found in the [Contributing section](contributing/index.md). +- [RPC Client](learn/rpc/overview.md) — RPC API for the Kaspa node using WebSockets. +- [Wallet SDK](learn/wallet-sdk/overview.md) — Bindings for wallet-related primitives such as key management, derivation, and transactions. +- [Managed Wallet](learn/wallet/overview.md) — A high-level, single Python class interface to the Rusty Kaspa Wallet API. This provides full wallet functionality in the single Python class: `Wallet`. -!!! warning "Beta Status" - This project is in beta status. +This project closely mirrors +[Kaspa's WASM SDK](https://kaspa.aspectron.org/docs/), while trying to +respect Python conventions. -This project very closely mirrors [Kaspa's WASM SDK](https://kaspa.aspectron.org/docs/), while trying to respect Python conventions. Feature parity with WASM SDK is a work in progress, not all features are available yet in Python. +## Bindings to Rusty Kaspa -This documentation site currently provides API reference and basic usage guides. General cryptocurrency concepts, development practices, and Kaspa specific concepts are not covered here. +**`kaspa` is a Python-native extension module built from bindings to Rust and +[rusty-kaspa](https://github.com/kaspanet/rusty-kaspa) source.** [PyO3](https://pyo3.rs/) and [Maturin](https://www.maturin.rs/) are used +to create bindings and build the extension module. -## Features +!!! info "As-thin-as-possible" + This project strives to provide as-thin-as-possible Python-compatible wrappers over rusty-kaspa source. Allowing Python developers leverage the features, stability, and security of rusty-kaspa directly, with minimal reimplementation in Python. -This SDK provides features in two primary categories: - -- **RPC Client** - Connect to Kaspa nodes via RPC. -- **Wallet Management** - Wallet related functionality (key management, derivation, addresses, transactions, etc.). - -Most features gaps with Kaspa WASM SDK exist around Wallet functionality. +More information on bindings approach and development notes can be found in the +[Contributing section](contributing/index.md). ## A (Very) Basic Example @@ -35,40 +38,27 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## Getting Started - -1. [Installation](getting-started/installation.md) - Set up the SDK in your environment -2. [Examples](getting-started/examples.md) - Build your first Kaspa application - -## Guides +## How the docs are organised
-- **[RPC Client](guides/rpc-client.md)** - Connect to Kaspa nodes - -- **[Transactions](guides/transactions.md)** - Build and sign transactions +- **[Getting Started](getting-started/installation.md)** + Install the SDK, run the first script, read the security note before + generating real keys. -- **[Addresses](guides/addresses.md)** - Create and validate Kaspa addresses +- **[Learn](learn/index.md)** + How the SDK is shaped, taught topic by topic. Connections, wallets, + derivation, transactions, the Kaspa concepts behind them. -- **[Mnemonics](guides/mnemonics.md)** - Generate and use seed phrases +- **[Examples](examples.md)** + Runnable scripts on GitHub covering RPC, wallet, transactions, + derivation, mnemonics, message signing, and addresses. -- **[Key Derivation](guides/key-derivation.md)** - HD wallet key generation - -- **[Message Signing](guides/message-signing.md)** - Sign and verify messages +- **[API Reference](reference/index.md)** + Every public class, method, and signature. Auto-generated.
-## API Reference - -For complete API documentation, see the [API Reference](reference/index.md). - ## License This project is licensed under the ISC License. - diff --git a/docs/learn/addresses.md b/docs/learn/addresses.md new file mode 100644 index 00000000..376ff8ca --- /dev/null +++ b/docs/learn/addresses.md @@ -0,0 +1,151 @@ +# Addresses + +A Kaspa address encodes a public key or script hash, the address +*version* (signature scheme or script type), and its network. The SDK +exposes them as [`Address`](../reference/Classes/Address.md) +instances. + +## Anatomy + +``` +kaspatest:qrxf48dgrdrm70rsk2nqf9p5xj4d4myrwq8mn3wvxcq8… +^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +prefix bech32-encoded version + payload + checksum +``` + +| Component | Source | +| --- | --- | +| Prefix | The network (see [Networks](networks.md)). | +| Version | One of `PubKey` (Schnorr), `PubKeyECDSA`, or `ScriptHash`. | +| Payload | The hash / public key bytes. | + +## Construct or parse + +```python +from kaspa import Address + +addr = Address("kaspa:qz...") +print(addr.prefix, addr.version, addr.to_string()) +``` + +`Address(string)` raises on malformed input. Use `Address.validate(s)` +to check first without raising: + +```python +if Address.validate(s): + addr = Address(s) +``` + +## From a key + +Schnorr (the default): + +```python +from kaspa import NetworkType, PrivateKey + +key = PrivateKey("<64-char hex>") +addr = key.to_address(NetworkType.Mainnet) +``` + +ECDSA: + +```python +ecdsa_addr = key.to_address_ecdsa(NetworkType.Mainnet) +``` + +From a public key: + +```python +from kaspa import PublicKey + +pub = PublicKey("02a1b2c3...") +addr = pub.to_address(NetworkType.Mainnet) +``` + +[`NetworkType`](../reference/Enums/NetworkType.md) (no suffix) is enough for address derivation: testnet-10 +and testnet-11 share the same `kaspatest:` prefix, so +`NetworkType.Testnet` produces the right address either way. Pass a +string or [`NetworkId`](../reference/Classes/NetworkId.md) only when an API needs to distinguish the two +testnets. + +## Versions + +| Version | Pays to | Used by | +| --- | --- | --- | +| `PubKey` | Schnorr public key | The SDK's default key derivation; every BIP32 address. | +| `PubKeyECDSA` | ECDSA public key | Keypair accounts created with `ecdsa=True`. | +| `ScriptHash` | A script hash (P2SH-style) | Multisig addresses; custom scripts. | + +```python +addr = Address("kaspa:qz...") +print(addr.version) # "PubKey" / "PubKeyECDSA" / "ScriptHash" +``` + +`addr.version` returns a string. The +[`AddressVersion`](../reference/Enums/AddressVersion.md) enum exists +if you'd rather pattern-match. + +## Re-encoding for a different network + +The `prefix` attribute is writable. Setting it re-encodes the bech32 +string with the new prefix and checksum, but **does not change the +underlying public-key or script-hash bytes** — it's a display-time +operation: + +```python +addr = Address("kaspa:qz...") +addr.prefix = "kaspatest" +print(addr.to_string()) # kaspatest:qz... +``` + +To get the genuine testnet address for a specific *key*, derive it +again under the testnet network: + +```python +key.to_address(NetworkType.Testnet) +``` + +## Scripts and addresses + +```python +from kaspa import ( + Address, NetworkType, + address_from_script_public_key, pay_to_address_script, +) + +# address → script (the lockup you put in a TransactionOutput) +spk = pay_to_address_script(Address("kaspa:qz...")) +print(spk.version, spk.script) + +# script → address +addr = address_from_script_public_key(spk, NetworkType.Mainnet) +``` + +[`address_from_script_public_key`](../reference/Functions/address_from_script_public_key.md) needs a [`NetworkType`](../reference/Enums/NetworkType.md) because the +script doesn't carry a prefix — you have to tell the decoder which +network you're displaying for. See +[Transactions → Outputs](transactions/outputs.md). + +## Multi-signature addresses + +Build a multi-signature address with [`create_multisig_address`](../reference/Functions/create_multisig_address.md): + +```python +from kaspa import create_multisig_address, NetworkType, PublicKey + +pubkeys = [ + PublicKey("<33-byte compressed-pubkey hex>"), + PublicKey("<33-byte compressed-pubkey hex>"), + PublicKey("<33-byte compressed-pubkey hex>"), +] +multi = create_multisig_address( + minimum_signatures=2, + keys=pubkeys, + network_type=NetworkType.Mainnet, +) +print(multi.to_string()) +``` + +For the full multisig spend flow (address creation, multi-cosigner +signing, submission), see +[`examples/transactions/multisig.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/transactions/multisig.py). diff --git a/docs/learn/index.md b/docs/learn/index.md new file mode 100644 index 00000000..a374dcb5 --- /dev/null +++ b/docs/learn/index.md @@ -0,0 +1,18 @@ +# Learn + +The Learn section on this site is grouped into the following main categories: + +- **Fundamentals** — [Networks](networks.md), [Addresses](addresses.md). +- **[RPC](rpc/overview.md)** — connect, call, subscribe. +- **[Wallet](wallet/overview.md)** — managed high-level API. +- **[Wallet SDK](wallet-sdk/overview.md)** — primitives the `Wallet` is built on. +- **[Transactions](transactions/overview.md)** — on-chain primitives. + +--- + +!!! danger "Secrets handling" + The secrets used in code snippets/examples throughout this site are for illustration only. + They do not implement full or proper secret handling. + Snippets throughout Learn use literal mnemonics, hex strings, and short + passwords for readability. **Never handle production secrets this way.** + See [Security](../getting-started/security.md). diff --git a/docs/learn/networks.md b/docs/learn/networks.md new file mode 100644 index 00000000..25515738 --- /dev/null +++ b/docs/learn/networks.md @@ -0,0 +1,64 @@ +# Networks + +The Kaspa community runs various public networks: a production mainnet and a few testnets. +Every SDK entry point that hits the chain +([`RpcClient`](../reference/Classes/RpcClient.md), +[`Wallet`](../reference/Classes/Wallet.md), +[`Address`](../reference/Classes/Address.md), +[derivation](wallet-sdk/derivation.md), etc.) needs a network identifier to pick the right +chain and address prefix. + +## The networks + +| Network | `network_id` | Address prefix | +| --- | --- | --- | +| Mainnet | `"mainnet"` | `kaspa:` | +| Testnet 10 | `"testnet-10"` | `kaspatest:` | +| Testnet 11 | `"testnet-11"` | `kaspatest:` | + +Operator-run **devnet** (`kaspadev:`) and **simnet** +(`kaspasim:`) also exist for private chains and simulators. + +## Using the identifier + +```python +from kaspa import RpcClient, Resolver + +client = RpcClient(resolver=Resolver(), network_id="testnet-10") +``` + +Most APIs accept the string form (`"mainnet"`, `"testnet-10"`). + +## `network_id` strings, `NetworkId`, and `NetworkType` + +Three forms turn up in different APIs: + +- **Plain strings** (`"testnet-10"`) — what most call sites accept. + Carries the suffix. +- **[`NetworkId`](../reference/Classes/NetworkId.md)** — typed, also + carries the suffix. Useful when you want to hold a value without + re-parsing it. Build with `NetworkId("testnet-10")`; read parts with + `.network_type` and `.suffix`. +- **[`NetworkType`](../reference/Enums/NetworkType.md)** — just the + base kind (`Mainnet`, `Testnet`, `Devnet`, `Simnet`). **No suffix.** + Sufficient anywhere only the address prefix matters (key-to-address + derivation, multisig address creation), since testnet-10 and + testnet-11 share the same `kaspatest:` prefix. Not enough to pick a + specific testnet for an RPC client. + +Rule of thumb: pass strings or [`NetworkId`](../reference/Classes/NetworkId.md) to anything that talks to +the chain; [`NetworkType`](../reference/Enums/NetworkType.md) is fine for derivation and address encoding. + +## What changes between networks + +- **Address prefix.** A key derived under `mainnet` produces + `kaspa:...`; the same key under `testnet-10` produces + `kaspatest:...`. See [Addresses](addresses.md). +- **Genesis and chain state.** Each network has its own UTXO set; funds + on one don't exist on another. +- **Resolver pool.** A + [`Resolver`](rpc/resolver.md) only returns nodes for the configured + `network_id`. +- **Maturity depths and block rate.** Coinbase maturity and block rate + differ by network; the SDK applies the right values automatically. + See [UTXO maturity](wallet/send-transaction.md#utxo-maturity). diff --git a/docs/learn/rpc/calls.md b/docs/learn/rpc/calls.md new file mode 100644 index 00000000..5fe32e31 --- /dev/null +++ b/docs/learn/rpc/calls.md @@ -0,0 +1,234 @@ +# Calls + +Once connected, every RPC method is `await client.(...)`. Most +take no arguments or a single dict, and return a dict (or list of +dicts) shaped like the rusty-kaspa wire protocol. + +This page is a brief tour of the available RPC calls, grouped by category. This is not an +exhaustive reference. For every call and its request/response model, +see [`RpcClient`](../../reference/Classes/RpcClient.md) in the API +Reference. + +## Network information + +```python +info = await client.get_info() +dag_info = await client.get_block_dag_info() +count = await client.get_block_count() +supply = await client.get_coin_supply() +network = await client.get_current_network() +``` + +`get_block_dag_info` returns: + +```python +{ + "network": "kaspa-mainnet", + "blockCount": 12345678, + "headerCount": 12345678, + "tipHashes": ["..."], + "difficulty": 1.23e15, + "pastMedianTime": 1700000000000, + "virtualParentHashes": ["..."], + # ...plus virtualDaaScore, sink, pruningPointHash, etc. +} +``` + +## Balances and UTXOs + +```python +balance = await client.get_balance_by_address({"address": "kaspa:qz..."}) +# {"balance": 100000000} + +balances = await client.get_balances_by_addresses({ + "addresses": ["kaspa:qz...", "kaspa:qr..."], +}) +# {"entries": [{"address": "kaspa:qz...", "balance": 100000000}, ...]} + +utxos = await client.get_utxos_by_addresses({"addresses": ["kaspa:qz..."]}) +for entry in utxos.get("entries", []): + print(entry["outpoint"], entry["utxoEntry"]["amount"]) +``` + +Balance amounts are in sompi (1 KAS = 100,000,000 sompi). + +Use [`get_utxos_by_addresses`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.get_utxos_by_addresses) for one-shot queries or polling. For +continuous tracking, subscribe to +[`utxos-changed`](subscriptions.md#available-events), or use +[`UtxoContext`](../../reference/Classes/UtxoContext.md) for per-address tracking +on top of that subscription (see [UTXO Context](../wallet-sdk/utxo-context.md)). + +## Blocks + +```python +block = await client.get_block({"hash": "...", "includeTransactions": True}) +blocks = await client.get_blocks({ + "lowHash": "...", + "includeBlocks": True, + "includeTransactions": False, +}) +template = await client.get_block_template({ + "payAddress": "kaspa:...", + "extraData": [], +}) +``` + +`get_block` returns `{"block": {...}}` where the inner block has +`header`, `transactions`, and `verboseData` keys; verbose data adds the +block hash, child hashes, and merge-set info. + +## Virtual chain + +Walk the selected-parent chain forward from a known block. Useful for +indexers and confirmation tracking — every accepted transaction id +passes through this stream. + +```python +chain = await client.get_virtual_chain_from_block({ + "startHash": "...", + "includeAcceptedTransactionIds": True, + "minConfirmationCount": 10, # optional; skip blocks with fewer confirmations +}) + +for h in chain["addedChainBlockHashes"]: + print("+", h) +for h in chain["removedChainBlockHashes"]: + print("-", h) +for entry in chain["acceptedTransactionIds"]: + print(entry["acceptingBlockHash"], entry["acceptedTransactionIds"]) +``` + +`addedChainBlockHashes` and `removedChainBlockHashes` give the chain +delta from `startHash` to the current sink. With +`includeAcceptedTransactionIds=True`, each entry maps an accepting +block hash to the transaction ids it accepted. + +For a streaming version, subscribe to +[`virtual-chain-changed`](subscriptions.md#virtual-chain-progression). + +### `get_virtual_chain_from_block_v2` + +**Prefer V2 for new code.** It supersedes the V1 call above: it swaps +the boolean flag for a verbosity level and returns richer per-block +data. V1 is kept for backward compatibility. + +```python +chain = await client.get_virtual_chain_from_block_v2({ + "startHash": "...", + "dataVerbosityLevel": "Low", # "None" | "Low" | "High" | "Full" + "minConfirmationCount": 10, # optional +}) + +for entry in chain["chainBlockAcceptedTransactions"]: + header = entry.get("chainBlockHeader") # populated at higher verbosity + txs = entry.get("acceptedTransactions", []) + print(len(txs), "txs accepted under header:", header) +``` + +Verbosity levels: + +- `"None"` — chain delta only (`addedChainBlockHashes`, + `removedChainBlockHashes`). +- `"Low"` — adds accepted-transaction ids per chain block. +- `"High"` — adds the chain block header and per-transaction metadata. +- `"Full"` — full headers and full transactions. + +Pick the lowest level that meets your needs; higher verbosity costs +bandwidth and node CPU. + +## Transactions and mempool + +```python +result = await client.submit_transaction({ + "transaction": signed_tx, # required: a Transaction instance, NOT a dict + "allowOrphan": False, +}) +# {"transactionId": "..."} + +mempool = await client.get_mempool_entries({ + "includeOrphanPool": False, + "filterTransactionPool": True, +}) +entry = await client.get_mempool_entry({ + "transactionId": "...", + "includeOrphanPool": False, + "filterTransactionPool": True, +}) +``` + +[`submit_transaction`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.submit_transaction) is the one call where the request dict embeds a +real Python object: the `transaction` value must be a +[`Transaction`](../../reference/Classes/Transaction.md) instance +(passing a dict raises). Every other call on this page is dict-in, +dict-out. + +If you have a +[`PendingTransaction`](../../reference/Classes/PendingTransaction.md) +from the [Transaction Generator](../wallet-sdk/tx-generator.md), +prefer [`pending_tx.submit(client)`](../../reference/Classes/PendingTransaction.md#kaspa.PendingTransaction.submit) — it serialises and submits in one +call. See [Submission](../transactions/submission.md) for the full +flow and `allowOrphan` semantics. + +## Fees + +```python +fee = await client.get_fee_estimate() +# { +# "estimate": { +# "priorityBucket": {"feerate": 1.0, "estimatedSeconds": 1.0}, +# "normalBuckets": [...], +# "lowBuckets": [...], +# } +# } + +fee_x = await client.get_fee_estimate_experimental({"verbose": True}) +``` + +See [Mass & Fees](../transactions/mass-and-fees.md) for how these +estimates feed into transaction construction. + +## Peers + +```python +peers = await client.get_connected_peer_info() +addresses = await client.get_peer_addresses() + +await client.add_peer({"peerAddress": "192.168.1.1:16111", "isPermanent": False}) +await client.ban({"ip": "192.168.1.1"}) +await client.unban({"ip": "192.168.1.1"}) +``` + +These are administrative — for node operators, not clients. + +## System + +```python +await client.ping() +sync = await client.get_sync_status() +server = await client.get_server_info() +system = await client.get_system_info() +metrics = await client.get_metrics({ + "processMetrics": True, + "connectionMetrics": True, + "bandwidthMetrics": True, + "consensusMetrics": True, + "storageMetrics": False, + "customMetrics": False, +}) +``` + +## Errors + +Protocol-level failures (invalid address, malformed request, node-side +errors) raise a plain `Exception` — see +[Errors in the overview](overview.md#errors) for the full picture. +Connection-level failures retry automatically (see +[Connecting → Reconnects](connecting.md#reconnects)). + +## Where to next + +- [Subscriptions](subscriptions.md) — server-pushed notifications. +- [Wallet → Send Transaction](../wallet/send-transaction.md) — the + managed [`Wallet`](../../reference/Classes/Wallet.md) wraps [`submit_transaction`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.submit_transaction) with sensible defaults. +- [Wallet SDK → Transaction Generator](../wallet-sdk/tx-generator.md) — + build the transactions you submit via [`submit_transaction`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.submit_transaction). diff --git a/docs/learn/rpc/connecting.md b/docs/learn/rpc/connecting.md new file mode 100644 index 00000000..0aed6120 --- /dev/null +++ b/docs/learn/rpc/connecting.md @@ -0,0 +1,122 @@ +# Connecting + +[`RpcClient.connect()`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.connect) +opens the WebSocket; +[`disconnect()`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.disconnect) +closes it. While connected, every RPC method is callable and +notifications stream in. + +You can connect directly to a node URL, or let the SDK discover one for +you via [`Resolver`](../../reference/Classes/Resolver.md) (covered on its own [page](resolver.md)). + +## Connecting to a known node + +Pass a URL directly. See [Networks](../networks.md) for the canonical +wRPC ports per network: + +```python +import asyncio +from kaspa import RpcClient + +async def main(): + client = RpcClient( + url="ws://node.example.com:17110", + network_id="mainnet", + encoding="borsh", # or "json" + ) + await client.connect() + try: + info = await client.get_block_dag_info() + print(info["blockCount"]) + finally: + await client.disconnect() + +asyncio.run(main()) +``` + +## Connecting via Resolver + +Skip the URL and let the SDK pick a Public Node Network (PNN) node for +the network you want: + +```python +from kaspa import Resolver, RpcClient + +client = RpcClient(resolver=Resolver(), network_id="mainnet") +await client.connect() +``` + +See [Resolver](resolver.md) for discovery details and when to run your +own node instead. + +## URL schemes + +- `ws://` — plaintext WebSocket +- `wss://` — TLS WebSocket + +## Connection options + +[`connect()`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.connect) takes a few behavioural overrides: + +```python +await client.connect( + block_async_connect=True, # default + strategy="retry", # default + url="ws://node.example.com:17110", + timeout_duration=30000, + retry_interval=1000, +) +``` + +- `block_async_connect` — `True` (default) makes `connect()` await + until the socket is open. Set to `False` to return immediately and + let the connection complete in the background; check + `client.is_connected` or listen for the `connect` event to know when + it's ready. +- `strategy` — `"retry"` (default) loops until a connection succeeds, + pausing `retry_interval` between attempts; `"fallback"` returns on + the first failure. `timeout_duration` caps each individual attempt + (not the overall wall-clock); under `"retry"` there is no overall + ceiling. Applies to both URL-based and [`Resolver`](../../reference/Classes/Resolver.md)-driven clients. +- `url` — overrides the constructor URL for this attempt only. Lets you + retarget a long-lived client without rebuilding it. +- `timeout_duration` — per-attempt ceiling, in milliseconds. +- `retry_interval` — delay between attempts, in milliseconds. + +## Inspecting the live client + +```python +print(client.is_connected) # bool +print(client.url) # resolved or supplied node URL, or None +print(client.encoding) # "borsh" or "json" +print(client.node_id) # resolver-supplied node UID; None for direct URLs +print(client.resolver) # the Resolver instance, or None +``` + +## Encoding: Borsh vs JSON + +Borsh (the default) is a compact binary format used natively by the +node. The constructor accepts either the string form (`encoding="borsh"`, +`encoding="json"`) or the [`Encoding`](../../reference/Enums/Encoding.md) +enum (`Encoding.Borsh`, `Encoding.Json`) — pick whichever your codebase +prefers and stick to it. + +Use `"json"` only to inspect raw frames in a tool that doesn't speak +Borsh, or when targeting a node that doesn't support it. + +## Reconnects + +If the WebSocket drops mid-session, the client reconnects on its own. +Calls made *during* the gap raise; calls made *after* a successful +reconnect work normally. To stop reconnect attempts, call +[`disconnect()`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.disconnect) — or use `strategy="fallback"`, which gives up after +one failed reconnect instead of looping. To track disruptions, listen +for the `connect` and `disconnect` events +(see [Subscriptions](subscriptions.md#available-events)). + +## Where to next + +- [Resolver](resolver.md) — node discovery details. +- [Calls](calls.md) — what to do once `is_connected` is `True`. +- [Subscriptions](subscriptions.md) — real-time notifications, including + connection-state events. diff --git a/docs/learn/rpc/overview.md b/docs/learn/rpc/overview.md new file mode 100644 index 00000000..c4b08fea --- /dev/null +++ b/docs/learn/rpc/overview.md @@ -0,0 +1,107 @@ +# RPC + +Kaspa nodes expose an RPC API. This SDK provides +[`RpcClient`](../../reference/Classes/RpcClient.md) for interacting with the RPC API. One class for +connection management, request/response calls, and event subscriptions. + +## An example + +A complete script — connect to a public mainnet node, fetch DAG state, +disconnect: + +```python +import asyncio +from kaspa import Resolver, RpcClient + +async def main(): + client = RpcClient(resolver=Resolver(), network_id="mainnet") + await client.connect() + try: + info = await client.get_block_dag_info() + print(f"network={info['network']} blocks={info['blockCount']}") + finally: + await client.disconnect() + +asyncio.run(main()) +``` + +## Overview + +[`RpcClient`](../../reference/Classes/RpcClient.md) is an async WebSocket client. Each instance: + +- Connects to one node at a time. +- Reconnects automatically if the socket drops. +- Uses Borsh encoding by default — compact and faster to parse than + JSON. Pass `encoding="json"` for the JSON wire format. See the + [`Encoding`](../../reference/Enums/Encoding.md) enum. + +[`RpcClient`](../../reference/Classes/RpcClient.md) is **not** an async context manager — wrap calls in +`try/finally` (or your own helper) to guarantee [`disconnect()`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.disconnect) runs. + +## Two ways to point a client at a node + +```python +# Resolver: let the SDK pick a public node for the network you want +client = RpcClient(resolver=Resolver(), network_id="mainnet") + +# Direct URL: a known node you control or trust +client = RpcClient(url="wss://node.example.com:17110", network_id="mainnet") +``` + +See [Resolver](resolver.md) for node discovery and +[Connecting](connecting.md) for the connection lifecycle. + +## Naming conventions + +Two styles meet at this API and the seam is worth knowing about up +front: + +- **Method names are Python snake_case**: `get_block_dag_info`, + `subscribe_utxos_changed`. +- **Request and response dict keys are camelCase**: `includeTransactions`, + `addedChainBlockHashes`, `payAddress`. + +The camelCase keys mirror the rusty-kaspa wire protocol. Misspelled or +snake_cased keys (`include_transactions`) raise a `KeyError` at the +binding layer rather than being silently ignored. The one historical +exception is `submit_transaction`'s `allow_orphan`, which is accepted +with a `DeprecationWarning` — prefer `allowOrphan`. + +Request and response shapes are `TypedDict`s in the bundled type +stubs (e.g. `GetBlockDagInfoResponse`), so an IDE will autocomplete +the camelCase keys for you. + +## Errors + +[`RpcClient`](../../reference/Classes/RpcClient.md) raises a plain `Exception` for protocol-, network-, and +validation-level failures (the binding layer doesn't currently expose +typed RPC error subclasses). Catch broadly and inspect the message: + +```python +try: + balance = await client.get_balance_by_address({"address": addr}) +except Exception as exc: + print("rpc call failed:", exc) +``` + +Connection drops are handled automatically — see +[Connecting → Reconnects](connecting.md#reconnects). The typed +exception classes under [`kaspa.exceptions`](../../reference/SUMMARY.md) +([`WalletRpcError`](../../reference/Exceptions/WalletRpcError.md), +[`WalletNotConnectedError`](../../reference/Exceptions/WalletNotConnectedError.md), etc.) come from the wallet layer, not raw +[`RpcClient`](../../reference/Classes/RpcClient.md) calls. + +## RPC methods + +- **[Calls](calls.md)** — request/response RPCs for network info, + balances, blocks, mempool, fees, and peers. +- **[Subscriptions](subscriptions.md)** — node-pushed notifications for + UTXO changes, new blocks, virtual chain updates, and DAA score + changes. Each subscription pairs with an event listener. + +## Where to next + +- [Connecting](connecting.md) — connect, disconnect, retries, encoding. +- [Resolver](resolver.md) — node discovery. +- [Calls](calls.md) — RPC method catalog. +- [Subscriptions](subscriptions.md) — real-time notifications. diff --git a/docs/learn/rpc/resolver.md b/docs/learn/rpc/resolver.md new file mode 100644 index 00000000..15e3f303 --- /dev/null +++ b/docs/learn/rpc/resolver.md @@ -0,0 +1,101 @@ +# Resolver + +A [`Resolver`](../../reference/Classes/Resolver.md) finds a public Kaspa +node so you don't need a URL up front. Pass one to +[`RpcClient`](../../reference/Classes/RpcClient.md) instead of `url=` +and `client.connect()` picks a live node from the Public Node Network +(PNN). + +For most apps, this is all you need: + +```python +from kaspa import Resolver, RpcClient + +client = RpcClient(resolver=Resolver(), network_id="mainnet") +await client.connect() +``` + +**For security-critical applications, you should connect to your own +node.** + +`network_id` selects the network — `"mainnet"` or a testnet +(e.g. `"testnet-10"`). It takes a string or a +[`NetworkId`](../../reference/Classes/NetworkId.md); see +[Networks](../networks.md) for the full list. Not every testnet has PNN +nodes. + +## Constructor options + +[`Resolver`](../../reference/Classes/Resolver.md) accepts a few keyword arguments at construction time: + +```python +# Default +resolver = Resolver() + +# Require a TLS-capable node (wss://) +resolver = Resolver(tls=True) + +# Point at your own resolver fleet (advanced — see "Under the hood") +resolver = Resolver(urls=["https://resolver1.example.org"]) +``` + +- `tls=True` — restrict to `wss://` nodes. Default `False` allows any + reachable node. +- `urls=` — replaces the default resolver-service list with your own + (see [Under the hood](#under-the-hood)). + +## Querying the resolver directly + +You can fetch a URL without constructing an [`RpcClient`](../../reference/Classes/RpcClient.md): + +```python +from kaspa import Resolver + +resolver = Resolver() + +url = await resolver.get_url("borsh", "mainnet") +descriptor = await resolver.get_node("borsh", "mainnet") +``` + +Both arguments accept the string form shown above or the typed +equivalents (`Encoding.Borsh`, `NetworkId("mainnet")`) — see +[`Encoding`](../../reference/Enums/Encoding.md) and +[`NetworkId`](../../reference/Classes/NetworkId.md). + +[`get_url`](../../reference/Classes/Resolver.md#kaspa.Resolver.get_url) returns a +WebSocket URL ready for [`RpcClient(url=...)`](../../reference/Classes/RpcClient.md). +[`get_node`](../../reference/Classes/Resolver.md#kaspa.Resolver.get_node) returns a +dict with the node's `uid`, `url`, and other metadata. + +## Under the hood + +You don't need any of this to use `Resolver` — it's here for anyone +running their own infrastructure or debugging connectivity. + +A [`Resolver`](../../reference/Classes/Resolver.md) doesn't open WebSockets or hold Kaspa node URLs. It holds +a list of *resolver service* HTTP endpoints (see +[aspectron/kaspa-resolver](https://github.com/aspectron/kaspa-resolver)) +that track live PNN nodes and load-balance across them. + +On [`get_url`](../../reference/Classes/Resolver.md#kaspa.Resolver.get_url) / [`get_node`](../../reference/Classes/Resolver.md#kaspa.Resolver.get_node) (called internally by [`client.connect()`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.connect)): + +1. Pick a configured resolver-service URL at random. +2. `GET {url}/v2/kaspa/{network_id}/{tls_or_any}/wrpc/{encoding}`. +3. Parse the response as a node descriptor and return the URL. +4. On failure, try the next service; raise if all fail. + +The default `Resolver()` ships with the public resolver-service list +embedded in the SDK (sourced from +[`Resolvers.toml`](https://github.com/kaspanet/rusty-kaspa/blob/master/rpc/wrpc/client/Resolvers.toml) +in `kaspa-wrpc-client`). `Resolver(urls=...)` replaces that list — +useful for a private node cluster behind your own resolver fleet. +`resolver.urls()` returns the configured list, or an empty list when +using the embedded defaults (concrete URLs are hidden so they can +rotate without breaking SDK consumers). + +## Where to next + +- [Connecting](connecting.md) — direct URLs, retry/timeout options, + encoding. +- [Calls](calls.md) — what to do once connected. +- [Subscriptions](subscriptions.md) — real-time notifications. diff --git a/docs/learn/rpc/subscriptions.md b/docs/learn/rpc/subscriptions.md new file mode 100644 index 00000000..53afeb5a --- /dev/null +++ b/docs/learn/rpc/subscriptions.md @@ -0,0 +1,245 @@ +# Subscriptions + +Subscriptions let +[`RpcClient`](../../reference/Classes/RpcClient.md) receive a live feed +of node events. The node pushes events as they happen; the client +invokes the callbacks you registered. + +## The two-step pattern + +Every subscription has two parts: + +1. **A listener** — a Python callback registered via + [`add_event_listener("", callback)`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.add_event_listener). +2. **A subscription** — `await client.subscribe_(...)` tells the + node to start streaming. + +Both halves are required. A listener with no subscription receives +nothing; a subscription with no listener silently drops events. + +```python +def on_utxo_change(event): + print("UTXO change:", event) + +client.add_event_listener("utxos-changed", on_utxo_change) +await client.subscribe_utxos_changed([Address("kaspa:qz...")]) +``` + +## Available events + +Each `subscribe_*` has a matching `unsubscribe_*` with the same +argument shape. + +| Event name | Subscribe call | Arguments | Event payload | +| --- | --- | --- | --- | +| `utxos-changed` | [`subscribe_utxos_changed`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.subscribe_utxos_changed) | `addresses: list[Address]` | [`UtxosChangedEvent`](../../reference/TypedDicts/UtxosChangedEvent.md) | +| `block-added` | [`subscribe_block_added`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.subscribe_block_added) | — | [`BlockAddedEvent`](../../reference/TypedDicts/BlockAddedEvent.md) | +| `virtual-chain-changed` | [`subscribe_virtual_chain_changed`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.subscribe_virtual_chain_changed) | `include_accepted_transaction_ids: bool` | [`VirtualChainChangedEvent`](../../reference/TypedDicts/VirtualChainChangedEvent.md) | +| `virtual-daa-score-changed` | [`subscribe_virtual_daa_score_changed`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.subscribe_virtual_daa_score_changed) | — | [`VirtualDaaScoreChangedEvent`](../../reference/TypedDicts/VirtualDaaScoreChangedEvent.md) | +| `sink-blue-score-changed` | [`subscribe_sink_blue_score_changed`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.subscribe_sink_blue_score_changed) | — | [`SinkBlueScoreChangedEvent`](../../reference/TypedDicts/SinkBlueScoreChangedEvent.md) | +| `finality-conflict` | [`subscribe_finality_conflict`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.subscribe_finality_conflict) | — | [`FinalityConflictEvent`](../../reference/TypedDicts/FinalityConflictEvent.md) | +| `finality-conflict-resolved` | [`subscribe_finality_conflict_resolved`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.subscribe_finality_conflict_resolved) | — | [`FinalityConflictResolvedEvent`](../../reference/TypedDicts/FinalityConflictResolvedEvent.md) | +| `new-block-template` | [`subscribe_new_block_template`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.subscribe_new_block_template) | — | [`NewBlockTemplateEvent`](../../reference/TypedDicts/NewBlockTemplateEvent.md) | +| `pruning-point-utxo-set-override` | [`subscribe_pruning_point_utxo_set_override`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.subscribe_pruning_point_utxo_set_override) | — | [`PruningPointUtxoSetOverrideEvent`](../../reference/TypedDicts/PruningPointUtxoSetOverrideEvent.md) | + +Event names also map to the +[`NotificationEvent`](../../reference/Enums/NotificationEvent.md) enum +if you prefer typed variants over kebab-case strings. + +The client also emits two control events that don't require a +`subscribe_*` call — just register a listener: + +| Event name | Fires when | Event payload | +| --- | --- | --- | +| `connect` | The WebSocket has connected (including after a reconnect). | [`ConnectEvent`](../../reference/TypedDicts/ConnectEvent.md) | +| `disconnect` | The WebSocket has dropped. | [`DisconnectEvent`](../../reference/TypedDicts/DisconnectEvent.md) | + +Use these to track connection state without polling `client.is_connected`. + +### Listening to all events + +Pass the special `"all"` event name (or `NotificationEvent.All`) to +register one callback for every notification — node-pushed events plus +`connect` / `disconnect`. You still need `subscribe_*` for any +node-pushed event you want to receive; `"all"` only multiplexes +delivery, it doesn't subscribe on your behalf. + +```python +def on_any(event): + print(event["type"], event) + +client.add_event_listener("all", on_any) +await client.subscribe_block_added() +await client.subscribe_virtual_daa_score_changed() +``` + +## Event payload shape + +Every callback receives a `dict` with a `"type"` key naming the +event. The remaining keys depend on the event. + +### utxos-changed + +[`UtxosChangedEvent`](../../reference/TypedDicts/UtxosChangedEvent.md) +is the only event that does *not* nest its body under `"data"` — it's +flattened so callbacks can read `event["added"]` directly. The +`"added"` and `"removed"` lists hold +[`RpcUtxosByAddressesEntry`](../../reference/TypedDicts/RpcUtxosByAddressesEntry.md) +items. + +The `"type"` value is the PascalCase variant name (`"UtxosChanged"`, +`"BlockAdded"`, …) — the kebab-case form (`"utxos-changed"`, +`"block-added"`, …) is only used when registering listeners. + +```python +{ + "type": "UtxosChanged", + "added": [ + { + "address": "kaspa:qz...", + "outpoint": {"transactionId": "...", "index": 0}, + "utxoEntry": { + "amount": 100000000, + "scriptPublicKey": {"version": 0, "script": "..."}, + "blockDaaScore": 123456789, + "isCoinbase": False, + }, + }, + ], + "removed": [], +} +``` + +### All other node-pushed events + +A `"data"` key holds the notification body. Each event has a wrapper +TypedDict (e.g. +[`BlockAddedEvent`](../../reference/TypedDicts/BlockAddedEvent.md)) and +a body TypedDict (e.g. +[`RpcBlockAddedNotification`](../../reference/TypedDicts/RpcBlockAddedNotification.md)). +See the [Available events](#available-events) table for the full list. +For example, a `virtual-daa-score-changed` callback receives: + +```python +{ + "type": "VirtualDaaScoreChanged", + "data": { + "virtualDaaScore": 123456789, + }, +} +``` + +### connect / disconnect + +[`ConnectEvent`](../../reference/TypedDicts/ConnectEvent.md) and +[`DisconnectEvent`](../../reference/TypedDicts/DisconnectEvent.md) carry +a single `"rpc"` key holding the node URL as a string: + +```python +{"type": "connect", "rpc": "wss://node.example.com:17110"} +``` + +The bundled +[`Notification`](../../reference/Classes/Notification.md) class wraps +notifications internally; for callback type hints, use the per-event +TypedDicts above. + +## Examples + +### Watching addresses for UTXO changes + +Pass [`Address`](../../reference/Classes/Address.md) instances (or +strings parsed by `Address(...)`): + +```python +from kaspa import Address + +addresses = [Address("kaspa:qz...")] + +def on_change(event): + for added in event.get("added", []): + print("+", added["utxoEntry"]["amount"]) + for removed in event.get("removed", []): + print("-", removed["utxoEntry"]["amount"]) + +client.add_event_listener("utxos-changed", on_change) +await client.subscribe_utxos_changed(addresses) + +# ... later ... +await client.unsubscribe_utxos_changed(addresses) +``` + +To watch a managed-wallet account instead of raw addresses, use the +[`Wallet`](../../reference/Classes/Wallet.md)'s `Balance` and `Maturity` events — see +[Wallet → Events](../wallet/events.md). + +### Block events + +```python +def on_block(event): + print("new block:", event["data"]["block"]["header"]["hash"]) + +client.add_event_listener("block-added", on_block) +await client.subscribe_block_added() +``` + +### Virtual chain progression + +```python +def on_chain(event): + data = event["data"] + print("added:", data["addedChainBlockHashes"]) + print("removed:", data["removedChainBlockHashes"]) + +client.add_event_listener("virtual-chain-changed", on_chain) +await client.subscribe_virtual_chain_changed(include_accepted_transaction_ids=True) +``` + +With `include_accepted_transaction_ids=True`, the payload doubles as a +confirmation feed — every accepted transaction id appears in +`event["data"]["acceptedTransactionIds"]`. For the one-shot equivalent, +see [`get_virtual_chain_from_block`](calls.md#virtual-chain). + +### Connection state + +```python +def on_connect(event): + print("connected to", event["rpc"]) + +def on_disconnect(event): + print("disconnected from", event["rpc"]) + +client.add_event_listener("connect", on_connect) +client.add_event_listener("disconnect", on_disconnect) +``` + +No `subscribe_*` is needed — the client emits these itself when the +WebSocket transitions. + +## Listener bookkeeping + +```python +client.add_event_listener("block-added", callback) # add +client.add_event_listener("block-added", callback, extra) # forward extra arg + +client.remove_event_listener("block-added", callback) # remove specific +client.remove_event_listener("block-added") # remove all for event +client.remove_all_event_listeners() # remove all globally +``` + +Listeners outlive a single subscription cycle. Re-subscribing after an +unsubscribe does *not* re-fire previously delivered events. To catch +up, do a one-shot +[`get_utxos_by_addresses`](calls.md#balances-and-utxos) (or equivalent) +before re-subscribing. + +## Where to next + +- [Calls](calls.md) — the request/response side of the API. +- [`RpcClient`](../../reference/Classes/RpcClient.md) — full + `subscribe_*` / `unsubscribe_*` / [`add_event_listener`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.add_event_listener) reference. +- [`UtxoProcessor`](../../reference/Classes/UtxoProcessor.md) (see [UTXO Processor](../wallet-sdk/utxo-processor.md)) and + [`UtxoContext`](../../reference/Classes/UtxoContext.md) (see [UTXO Context](../wallet-sdk/utxo-context.md)) — higher-level UTXO + tracking built on `utxos-changed`. +- [Wallet → Events](../wallet/events.md) — the managed [`Wallet`](../../reference/Classes/Wallet.md)'s + higher-level event surface. diff --git a/docs/learn/transactions/inputs.md b/docs/learn/transactions/inputs.md new file mode 100644 index 00000000..25976d81 --- /dev/null +++ b/docs/learn/transactions/inputs.md @@ -0,0 +1,85 @@ +# Inputs + +A transaction's inputs say *which UTXOs are being spent*. Each input +points at a previous output by `(transaction_id, index)` and carries +the script that proves the spender is allowed to claim it. + +## Types involved + +``` +TransactionInput + previous_outpoint: TransactionOutpoint(transaction_id, index) + signature_script (filled at sign time) + sequence + sig_op_count + utxo: UtxoEntryReference # optional, but you almost always want it set +``` + +- **[`TransactionOutpoint`](../../reference/Classes/TransactionOutpoint.md)** — `(transaction_id, index)`. The pointer + to the output being spent. +- **[`UtxoEntryReference`](../../reference/Classes/UtxoEntryReference.md)** — a cached copy of the *spent output*: its + amount, lockup script, block DAA score, and coinbase flag. +- **`signature_script`** — the unlocking script. Empty (`""`) at + build time; filled when you sign. See [Signing](signing.md). +- **`sequence`** — sequence number. Leave at `0` unless you have a + specific protocol-level reason. +- **`sig_op_count`** — number of signature operations this input + performs (`1` for Schnorr/ECDSA, `>1` for multisig). Feeds into + mass calculation. + +## Build an input + +From a UTXO dict returned by [`client.get_utxos_by_addresses(...)`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.get_utxos_by_addresses): + +```python +from kaspa import TransactionInput, TransactionOutpoint, UtxoEntryReference + +resp = await client.get_utxos_by_addresses({"addresses": [my_address]}) +utxo = resp["entries"][0] + +inp = TransactionInput( + previous_outpoint=TransactionOutpoint( + transaction_id=utxo["outpoint"]["transactionId"], + index=utxo["outpoint"]["index"], + ), + signature_script="", # filled at sign time + sequence=0, + sig_op_count=1, + utxo=UtxoEntryReference(utxo), +) +``` + +## Why inputs carry a UtxoEntryReference + +Kaspa signs over the spent output's amount and lockup, not just the +outpoint. The SDK can't sign correctly without that context, so +`TransactionInput.utxo` *attaches* it directly — no node round-trip +needed. + +Consequences: + +- Forgetting `utxo=...` when building manually breaks signing. +- A signed transaction can move between processes (offline signer, + co-signer, relay) without the receiver needing the source node — + every input carries what's needed. +- The [`Generator`](../../reference/Classes/Generator.md) handles this for you. Pass [`UtxoEntryReference`](../../reference/Classes/UtxoEntryReference.md)s (or + a [`UtxoContext`](../../reference/Classes/UtxoContext.md) — see [UTXO Context](../wallet-sdk/utxo-context.md)) and it picks and + wraps inputs internally. + +## Selecting which UTXOs to spend + +The [`Generator`](../../reference/Classes/Generator.md) (see [Transaction Generator](../wallet-sdk/tx-generator.md)) handles +selection. When building manually, sum input values until the total +covers `outputs + fee`, then route the remainder to a change output. +See [Outputs → Change](outputs.md#change-outputs). + +## Reading inputs back + +```python +for inp in tx.inputs: + print(inp.previous_outpoint.transaction_id, inp.previous_outpoint.index) + print(inp.signature_script_as_hex) # hex string after signing, None before + print(inp.sig_op_count, inp.sequence) + if inp.utxo: + print(inp.utxo.amount, inp.utxo.script_public_key) +``` diff --git a/docs/learn/transactions/mass-and-fees.md b/docs/learn/transactions/mass-and-fees.md new file mode 100644 index 00000000..68fbe4af --- /dev/null +++ b/docs/learn/transactions/mass-and-fees.md @@ -0,0 +1,122 @@ +# Mass & fees + +Kaspa uses a **mass-based fee model**. Every transaction has a *mass* +— a number derived from its byte size, compute cost, and a storage +component tied to input and output values. The required fee is +`mass × fee_rate`, where `fee_rate` is the network's current rate. + +## The two kinds of mass + +- **Compute / size mass** — from the transaction's serialized size + and signature operations. +- **Storage mass** — from input and output *values*, specifically to + discourage UTXO-set bloat (many tiny outputs from one large input). + +A transaction's overall mass combines the two; you don't compute the +parts separately for normal use. + +## Compute mass for a transaction + +The relevant helpers: +[`calculate_transaction_mass`](../../reference/Functions/calculate_transaction_mass.md), +[`update_transaction_mass`](../../reference/Functions/update_transaction_mass.md), +[`maximum_standard_transaction_mass`](../../reference/Functions/maximum_standard_transaction_mass.md): + +```python +from kaspa import ( + calculate_transaction_mass, + update_transaction_mass, + maximum_standard_transaction_mass, +) + +mass = calculate_transaction_mass("mainnet", tx) +print(mass) +print(maximum_standard_transaction_mass()) # protocol upper bound +``` + +[`calculate_transaction_mass(network_id, tx)`](../../reference/Functions/calculate_transaction_mass.md) returns the mass without +mutating the transaction. To write it onto the transaction itself +(required before signing or serializing): + +```python +ok = update_transaction_mass("mainnet", tx) +print(tx.mass, ok) # ok is False if mass exceeds the standard limit +``` + +!!! warning "Order matters" + Run [`update_transaction_mass`](../../reference/Functions/update_transaction_mass.md) after inputs and outputs are + settled but **before** signing or serializing. Mass is part of + the signed payload — sign first and you'll be signing over + `mass=0`. + +For multisig estimation, both calls take an optional +`minimum_signatures` to size the signature script correctly: + +```python +update_transaction_mass("mainnet", tx, minimum_signatures=2) +``` + +## Storage mass on its own + +[`calculate_storage_mass`](../../reference/Functions/calculate_storage_mass.md) computes only the storage component: + +```python +from kaspa import calculate_storage_mass + +storage_mass = calculate_storage_mass( + network_id="mainnet", + input_values=[1_000_000, 2_000_000], + output_values=[2_500_000, 400_000], +) +``` + +Useful for sizing change outputs: if a tiny change output pushes +storage mass through the roof, fold it into the fee instead. + +## Fees + +```python +from kaspa import calculate_transaction_fee + +fee = calculate_transaction_fee("mainnet", tx) +print(fee) # required fee in sompi, or None if mass exceeds the standard limit +``` + +[`calculate_transaction_fee`](../../reference/Functions/calculate_transaction_fee.md) returns the minimum required fee at the +network's current rate, or `None` if the transaction's mass exceeds +[`maximum_standard_transaction_mass()`](../../reference/Functions/maximum_standard_transaction_mass.md). Split the inputs across +multiple transactions in that case. + +## Querying the fee rate + +The network exposes a fee estimator over RPC — see +[RPC → Calls → Fees](../rpc/calls.md#fees) and +[`get_fee_estimate`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.get_fee_estimate): + +```python +estimate = await client.get_fee_estimate({}) +print(estimate["estimate"]["priorityBucket"]["feerate"]) +print(estimate["estimate"]["normalBuckets"]) +print(estimate["estimate"]["lowBuckets"]) +``` + +Each bucket carries a `feerate` (sompi-per-gram-of-mass) and an +`estimatedSeconds` for time-to-confirmation at that rate. Pick a +bucket by how much you care about latency, multiply by mass, and +you have a fee. + +The [`Wallet`](../../reference/Classes/Wallet.md) wraps this as +[`fee_rate_estimate()`](../../reference/Classes/Wallet.md#kaspa.Wallet.fee_rate_estimate) (see [the fee model](../wallet/send-transaction.md#fee-model)) and +the [`Generator`](../../reference/Classes/Generator.md) picks a sensible default if you don't pass `fee_rate=` +explicitly. + +## When to set fees explicitly + +The [`Generator`](../../reference/Classes/Generator.md) (see [Transaction Generator](../wallet-sdk/tx-generator.md)) picks a fee rate, +computes mass, and folds the leftover into change. Override only: + +- **`fee_rate=`** — when you have a specific sompi-per-gram in mind. +- **`priority_fee=`** — to add a flat surcharge on top. +- **Manual path** — when sizing change around the fee yourself. + +For typical sends, the defaults are fine. diff --git a/docs/learn/transactions/metadata.md b/docs/learn/transactions/metadata.md new file mode 100644 index 00000000..b484722d --- /dev/null +++ b/docs/learn/transactions/metadata.md @@ -0,0 +1,84 @@ +# Metadata fields + +Beyond inputs and outputs, a +[`Transaction`](../../reference/Classes/Transaction.md) carries six +fields that affect how it's interpreted on-chain. Most take defaults +— this page documents what they are so you know what to leave alone +and what to set deliberately. + +```python +Transaction( + version=0, + inputs=..., + outputs=..., + lock_time=0, + subnetwork_id="0000000000000000000000000000000000000000", + gas=0, + payload="", + mass=0, +) +``` + +## `version` + +Transaction format version. Use `0` — the only currently-defined +version on Kaspa. + +## `lock_time` + +Earliest moment a transaction is allowed into a block, encoded as a +DAA-score threshold. `0` means "no lock" — what you want unless +building a time-locked construct (e.g. a refund branch). + +## `subnetwork_id` + +The subnetwork the transaction belongs to. Most transactions live on +the default subnetwork — id all zeros: + +```python +subnetwork_id="0000000000000000000000000000000000000000" +``` + +Non-default IDs are reserved for protocol-level transaction kinds +(coinbase, etc.) that you generally don't construct from the SDK. + +## `gas` + +`0` on the default subnetwork. Reserved for subnetwork transactions +with a compute-cost component. + +## `payload` + +Arbitrary bytes attached to the transaction. The closest analog in +Bitcoin terms is `OP_RETURN`-style data, but `payload` lives at the +transaction level, not inside a script. The SDK accepts a hex +string, raw bytes, or a list of byte values: + +```python +Transaction(..., payload="68656c6c6f", ...) # hex string +Transaction(..., payload=b"hello", ...) # raw bytes +``` + +Use cases: + +- **Application-level metadata** riding alongside a payment (invoice + ID, memo, reference number). +- **Protocol-level data** for systems built on top of Kaspa + transactions. + +Payload bytes are hashed into the transaction ID and signed over, +but they don't bind the transaction to anything off-chain on their +own. + +The [`Generator`](../../reference/Classes/Generator.md) (see [Transaction Generator](../wallet-sdk/tx-generator.md)) accepts `payload=` directly: + +```python +Generator(..., payload=b"invoice-12345") +``` + +## `mass` + +The transaction's mass. `0` at construction; populate with +[`update_transaction_mass(network_id, tx)`](../../reference/Functions/update_transaction_mass.md) after inputs and outputs +are finalized and **before** signing or serializing — mass is part +of the signed payload. See [Mass & fees](mass-and-fees.md). diff --git a/docs/learn/transactions/outputs.md b/docs/learn/transactions/outputs.md new file mode 100644 index 00000000..7f47c8c5 --- /dev/null +++ b/docs/learn/transactions/outputs.md @@ -0,0 +1,107 @@ +# Outputs + +A transaction's outputs are the new UTXOs it creates. Each carries a +value (in sompi) and a *locking script* — the conditions a future +spender must satisfy. + +## Types involved + +``` +TransactionOutput + value (sompi) + script_public_key: ScriptPublicKey + +ScriptPublicKey + version (int) + script (hex bytes — the lockup conditions) +``` + +[`TransactionOutput`](../../reference/Classes/TransactionOutput.md) +pairs the amount with the script that locks it. +[`ScriptPublicKey`](../../reference/Classes/ScriptPublicKey.md) is the +script itself: a version byte plus the encoded program a future +spender must satisfy. + +## Build an output + +Pay-to-address is the common case. Build the lockup script with +[`pay_to_address_script`](../../reference/Functions/pay_to_address_script.md): + +```python +from kaspa import Address, TransactionOutput, pay_to_address_script + +recipient = Address("kaspa:qz...") +out = TransactionOutput( + value=500_000_000, # 5 KAS in sompi + script_public_key=pay_to_address_script(recipient), +) +``` + +For the inverse — recovering the address an output pays to — use +[`address_from_script_public_key`](../../reference/Functions/address_from_script_public_key.md). It needs a network argument because +the script doesn't carry the prefix: + +```python +from kaspa import NetworkType, address_from_script_public_key + +addr = address_from_script_public_key(out.script_public_key, NetworkType.Mainnet) +``` + +## Pay-to-script-hash + +For multisig and other custom scripts, the locking side uses a script +hash. See +[`examples/transactions/multisig.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/transactions/multisig.py) +for the full P2SH flow (address creation, multi-cosigner signing, +submission). Build the lockup with [`pay_to_script_hash_script`](../../reference/Functions/pay_to_script_hash_script.md): + +```python +from kaspa import pay_to_script_hash_script + +spk = pay_to_script_hash_script(redeem_script_bytes) +out = TransactionOutput(value=amount, script_public_key=spk) +``` + +## Change outputs + +When selected inputs sum to more than `outputs + fee`, the leftover +goes to a change output you control: + +```python +outputs = [ + TransactionOutput(value=amount, script_public_key=pay_to_address_script(recipient)), + TransactionOutput(value=change_amount, script_public_key=pay_to_address_script(change_addr)), +] +``` + +The [`Generator`](../../reference/Classes/Generator.md) (see [Transaction Generator](../wallet-sdk/tx-generator.md)) computes +`change_amount` for you (`selected_total − outputs − fee`) and writes +change last. Manually, do the arithmetic yourself — and re-check +after [`update_transaction_mass`](../../reference/Functions/update_transaction_mass.md) if the fee shifted. + +If a tiny change output would inflate storage mass more than it's +worth, fold it into the fee instead. See +[Mass & fees](mass-and-fees.md#storage-mass-on-its-own) for sizing. + +## Sompi vs KAS + +Every value in the transaction API is a **sompi int**. +`1 KAS = 100_000_000 sompi`. Convert with [`kaspa_to_sompi`](../../reference/Functions/kaspa_to_sompi.md) and [`sompi_to_kaspa`](../../reference/Functions/sompi_to_kaspa.md): + +```python +from kaspa import kaspa_to_sompi, sompi_to_kaspa + +kaspa_to_sompi(1.5) # 150_000_000 +sompi_to_kaspa(150_000_000) # 1.5 +``` + +Convert only at the UI boundary — don't store KAS as a float. + +## Reading outputs back + +```python +for out in tx.outputs: + print(out.value) # sompi + print(out.script_public_key.version) + print(out.script_public_key.script) # hex +``` diff --git a/docs/learn/transactions/overview.md b/docs/learn/transactions/overview.md new file mode 100644 index 00000000..2dcb1128 --- /dev/null +++ b/docs/learn/transactions/overview.md @@ -0,0 +1,109 @@ +# Transactions + +A Kaspa transaction has the same shape as on any UTXO chain: a list +of inputs (each spending a previous output), a list of outputs, and +a few metadata fields. The SDK exposes the underlying types — +[`Transaction`](../../reference/Classes/Transaction.md), +[`TransactionInput`](../../reference/Classes/TransactionInput.md), +[`TransactionOutput`](../../reference/Classes/TransactionOutput.md), +[`TransactionOutpoint`](../../reference/Classes/TransactionOutpoint.md), +[`UtxoEntryReference`](../../reference/Classes/UtxoEntryReference.md) +— and helpers that build, sign, mass, and serialise them. + +!!! tip "Most callers don't need this section" + The [Transaction Generator](../wallet-sdk/tx-generator.md) (and + the managed [`Wallet`](../../reference/Classes/Wallet.md) (see [Wallet](../wallet/send-transaction.md)) on top of it) + handles UTXO selection, mass, signing, and submission for you. + Reach for the primitives below only when you need custom lockup + scripts, exact input ordering, payload data, or offline signing. + +## Anatomy + +``` +Transaction + version, lock_time, subnetwork_id, gas, payload, mass + inputs: [TransactionInput, ...] + outputs: [TransactionOutput, ...] + +TransactionInput + previous_outpoint: TransactionOutpoint(transaction_id, index) + signature_script (filled at sign time) + sequence + sig_op_count + utxo: UtxoEntryReference + +TransactionOutput + value (sompi) + script_public_key (lockup script) +``` + +What sets Kaspa apart from a Bitcoin-shaped chain: + +- **Inputs carry their own UTXO context** via [`UtxoEntryReference`](../../reference/Classes/UtxoEntryReference.md), so + the signer doesn't have to re-fetch the spent output for its amount + and lockup. See [Inputs](inputs.md). +- **Mass replaces "byte size × rate"** as the fee model. Compute mass + on the transaction (including a storage component derived from + input and output values), then multiply by the prevailing fee rate. + See [Mass & fees](mass-and-fees.md). +- **The atomic unit is the sompi**: `1 KAS = 100_000_000 sompi`. + Every amount in the transaction surface is a sompi int. See + [`kaspa_to_sompi`](../../reference/Functions/kaspa_to_sompi.md) and + [`sompi_to_kaspa`](../../reference/Functions/sompi_to_kaspa.md). + +## End-to-end (manual path) + +This walks the manual flow using +[`sign_transaction`](../../reference/Functions/sign_transaction.md), +[`pay_to_address_script`](../../reference/Functions/pay_to_address_script.md), +and +[`update_transaction_mass`](../../reference/Functions/update_transaction_mass.md): + +```python +from kaspa import ( + Transaction, TransactionInput, TransactionOutput, TransactionOutpoint, + UtxoEntryReference, sign_transaction, pay_to_address_script, + update_transaction_mass, +) + +resp = await client.get_utxos_by_addresses({"addresses": [my_address]}) +my_utxos = resp["entries"] + +inputs = [ + TransactionInput( + previous_outpoint=TransactionOutpoint( + transaction_id=u["outpoint"]["transactionId"], + index=u["outpoint"]["index"], + ), + signature_script="", # filled at sign time + sequence=0, + sig_op_count=1, + utxo=UtxoEntryReference(u), + ) + for u in my_utxos +] + +outputs = [ + TransactionOutput(value=amount, script_public_key=pay_to_address_script(recipient)), + TransactionOutput(value=change_amount, script_public_key=pay_to_address_script(change_addr)), +] + +tx = Transaction( + version=0, inputs=inputs, outputs=outputs, + lock_time=0, + subnetwork_id="0000000000000000000000000000000000000000", + gas=0, payload="", mass=0, +) + +update_transaction_mass("mainnet", tx) # mass is signed over — fill before signing +signed = sign_transaction(tx, [private_key], verify_sig=True) + +await client.submit_transaction({ + "transaction": signed, + "allowOrphan": False, +}) +``` + +This is what the [`Generator`](../../reference/Classes/Generator.md) (see [Transaction Generator](../wallet-sdk/tx-generator.md)) does +internally — it picks UTXOs, computes mass, signs, and yields one or +more ready-to-submit [`PendingTransaction`](../../reference/Classes/PendingTransaction.md)s. diff --git a/docs/learn/transactions/scripts.md b/docs/learn/transactions/scripts.md new file mode 100644 index 00000000..dc397c5b --- /dev/null +++ b/docs/learn/transactions/scripts.md @@ -0,0 +1,210 @@ +# Scripts + +A [`ScriptPublicKey`](../../reference/Classes/ScriptPublicKey.md) is the lock on every output. Most of the time it's +generated for you — [`pay_to_address_script`](../../reference/Functions/pay_to_address_script.md) builds the standard +pay-to-pubkey lock, and both the +[`Generator`](../../reference/Classes/Generator.md) (see [Transaction Generator](../wallet-sdk/tx-generator.md)) and the high-level +[`Wallet`](../../reference/Classes/Wallet.md) (see [Wallet](../wallet/overview.md)) API use it under the hood. This page +is for the cases where you need a non-standard lock: multisig redeem +scripts, KRC‑20 / inscription envelopes, time-locked spends, covenant +prototypes. + +The fluent builder is +[`ScriptBuilder`](../../reference/Classes/ScriptBuilder.md); the +opcode set lives in +[`Opcodes`](../../reference/Enums/Opcodes.md). + +## When you'd reach for this + +- **Multisig.** Building the redeem script behind an `M`-of-`N` + pay-to-script-hash address. Worked example: + [`examples/transactions/multisig.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/transactions/multisig.py). +- **KRC‑20 / inscription envelopes.** Embedding token-protocol JSON + in a commit-reveal script. Worked example: + [`examples/transactions/krc20_deploy.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/transactions/krc20_deploy.py). +- **Custom locking conditions.** Time-locked or hash-locked spends + and covenant prototypes. Advanced — you should already know what + opcodes you need. + +## Building a script + +[`ScriptBuilder`](../../reference/Classes/ScriptBuilder.md) is a chained-builder: every method returns the +builder so you can compose a script in one expression. + +```python +from kaspa import Opcodes, ScriptBuilder + +script = ScriptBuilder()\ + .add_data(pubkey_xonly_hex)\ + .add_op(Opcodes.OpCheckSig) + +print(script.to_string()) # hex of the assembled script +``` + +The four input families: + +- **[`add_op(op)`](../../reference/Classes/ScriptBuilder.md) / [`add_ops([op, ...])`](../../reference/Classes/ScriptBuilder.md)** — push a single opcode or a + sequence. `op` is an [`Opcodes`](../../reference/Enums/Opcodes.md) enum member or its integer value. +- **[`add_data(bytes_or_hex)`](../../reference/Classes/ScriptBuilder.md)** — push raw bytes (signatures, public + keys, payloads). The builder picks the right `OP_PUSHDATA*` variant + for the size automatically. Accepts a hex string, `bytes`, or a + list of ints. +- **[`add_i64(n)`](../../reference/Classes/ScriptBuilder.md)** — push a signed integer. Used for `M` and `N` in + multisig, and for sequence / lock-time scalars. +- **[`add_lock_time(daa_score)`](../../reference/Classes/ScriptBuilder.md) / [`add_sequence(seq)`](../../reference/Classes/ScriptBuilder.md)** — push + DAA-score and sequence values, for time-locked spends. + +For the full opcode catalog see the +[`Opcodes`](../../reference/Enums/Opcodes.md) reference. + +## Wrapping in P2SH + +The output side commits to the *hash* of the redeem script, not the +script itself. [`create_pay_to_script_hash_script`](../../reference/Classes/ScriptBuilder.md) produces the +locking +[`ScriptPublicKey`](../../reference/Classes/ScriptPublicKey.md); +[`pay_to_script_hash_script`](../../reference/Functions/pay_to_script_hash_script.md) +is the equivalent free function when you have the redeem-script bytes +directly: + +```python +from kaspa import address_from_script_public_key + +spk = script.create_pay_to_script_hash_script() +p2sh_address = address_from_script_public_key(spk, "testnet-10") +``` + +Place `spk` on a [`TransactionOutput`](../../reference/Classes/TransactionOutput.md), or share `p2sh_address` so +funds can be sent to it. See [`address_from_script_public_key`](../../reference/Functions/address_from_script_public_key.md) for the network argument. + +When *spending* a P2SH output, the input's `signature_script` must +reveal the redeem script *and* a satisfying signature. For the +single-signature case, the convenience helper writes the witness for +you ([`create_input_signature`](../../reference/Classes/PendingTransaction.md), [`encode_pay_to_script_hash_signature_script`](../../reference/Classes/ScriptBuilder.md), [`fill_input`](../../reference/Classes/PendingTransaction.md)): + +```python +sig = pending.create_input_signature(input_index=0, private_key=key) +witness = script.encode_pay_to_script_hash_signature_script(sig) +pending.fill_input(0, witness) +``` + +[`pay_to_script_hash_signature_script(redeem_script, signature)`](../../reference/Functions/pay_to_script_hash_signature_script.md) +is the equivalent functional form when you no longer have the original +builder in scope. For multisig — where the witness needs more than +one signature — you build the `signature_script` manually with +[`ScriptBuilder`](../../reference/Classes/ScriptBuilder.md); see the example linked below. + +## Two real shapes + +### Multisig redeem (M-of-N) + +[`create_multisig_address`](../../reference/Functions/create_multisig_address.md) produces the same lockup as a one-shot helper; this section +shows what's happening underneath: + +```python +redeem = ScriptBuilder()\ + .add_i64(2)\ + .add_data(pub_a.to_x_only_public_key().to_string())\ + .add_data(pub_b.to_x_only_public_key().to_string())\ + .add_data(pub_c.to_x_only_public_key().to_string())\ + .add_i64(3)\ + .add_op(Opcodes.OpCheckMultiSig) + +spk = redeem.create_pay_to_script_hash_script() +``` + +This is a 2-of-3 Schnorr multisig: integer `M`, the public keys +([`XOnlyPublicKey`](../../reference/Classes/XOnlyPublicKey.md) for Schnorr), integer `N`, then [`Opcodes.OpCheckMultiSig`](../../reference/Enums/Opcodes.md). ECDSA +multisig uses [`Opcodes.OpCheckMultiSigECDSA`](../../reference/Enums/Opcodes.md) and full (compressed) [`PublicKey`](../../reference/Classes/PublicKey.md)s +instead. The mass calculator needs to know about the multiple +signatures the input will eventually hold — pass `sig_op_count=N` per +input and `minimum_signatures=M` to the +[`Generator`](../../reference/Classes/Generator.md) (see [Transaction Generator](../wallet-sdk/tx-generator.md)). See +[Signing → Multisig](signing.md#multisig-and-sig_op_count) for how +those fields feed mass. + +The spending side (collecting `M` signatures and packing them into +each input's `signature_script`) is non-trivial. The full flow is in +[`examples/transactions/multisig.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/transactions/multisig.py). + +### KRC‑20 / inscription envelope + +```python +import json + +script = ScriptBuilder()\ + .add_data(pub.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(payload, separators=(",", ":")).encode())\ + .add_op(Opcodes.OpEndIf) +``` + +The `OpFalse` / `OpIf` block is unreachable execution — a +conventional way to embed protocol data without affecting whether +the script can be satisfied. The reveal-stage input later spends a +P2SH output committing to this script, putting the embedded payload +on-chain. + +The two-stage commit/reveal flow itself (fund the P2SH address in a +commit transaction, then spend it back to yourself in a reveal +transaction once the commit is mature) lives in +[`examples/transactions/krc20_deploy.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/transactions/krc20_deploy.py). +The managed [`Wallet`](../../reference/Classes/Wallet.md) wraps this as +[`accounts_commit_reveal`](../../reference/Classes/Wallet.md) / +[`accounts_commit_reveal_manual`](../../reference/Classes/Wallet.md), keyed by a +[`CommitRevealAddressKind`](../../reference/Enums/CommitRevealAddressKind.md). + +## Inspecting an unknown script + +When you hold a [`ScriptPublicKey`](../../reference/Classes/ScriptPublicKey.md) from somewhere else — a UTXO +returned by [`get_utxos_by_addresses`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.get_utxos_by_addresses), an output read off-chain — the +classification predicates +([`is_script_pay_to_pubkey`](../../reference/Functions/is_script_pay_to_pubkey.md), +[`is_script_pay_to_pubkey_ecdsa`](../../reference/Functions/is_script_pay_to_pubkey_ecdsa.md), +[`is_script_pay_to_script_hash`](../../reference/Functions/is_script_pay_to_script_hash.md)) +tell you which lockup family it is: + +```python +from kaspa import ( + is_script_pay_to_pubkey, + is_script_pay_to_pubkey_ecdsa, + is_script_pay_to_script_hash, +) + +script_bytes = utxo.script_public_key.script +if is_script_pay_to_pubkey(script_bytes): + ... # Schnorr P2PK +elif is_script_pay_to_pubkey_ecdsa(script_bytes): + ... # ECDSA P2PK +elif is_script_pay_to_script_hash(script_bytes): + ... # P2SH — needs the redeem script to spend +``` + +Use them to pick the signing path, filter UTXOs the wallet can +actually spend, or audit a transaction's outputs. + +## SighashType and advanced flows + +Scripts and sighash variants interact when you write protocols that +intentionally let signed transactions be amended. +[`SighashType.All`](../../reference/Enums/SighashType.md) — the +default — commits to every input and every output and is the only +one ordinary scripts should use. The `_None`, `Single`, and +`*AnyOneCanPay` variants exist for coinjoins and partial co-signing +flows; don't reach for them without a spec to follow. See +[Signing → SighashType](signing.md#sighashtype). + +## What this page didn't cover + +- The full multisig orchestration (deriving cosigner keys, exchanging + partial signatures, submission): + [`examples/transactions/multisig.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/transactions/multisig.py). +- Commit-reveal mechanics (two-stage submission, fee budget, the + maturity gate between commit and reveal): + [`examples/transactions/krc20_deploy.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/transactions/krc20_deploy.py). +- The full opcode catalog: + [`Opcodes`](../../reference/Enums/Opcodes.md) reference. diff --git a/docs/learn/transactions/serialization.md b/docs/learn/transactions/serialization.md new file mode 100644 index 00000000..752c8f54 --- /dev/null +++ b/docs/learn/transactions/serialization.md @@ -0,0 +1,59 @@ +# Serialization + +The transaction-shaped types — +[`Transaction`](../../reference/Classes/Transaction.md), +[`TransactionInput`](../../reference/Classes/TransactionInput.md), +[`TransactionOutput`](../../reference/Classes/TransactionOutput.md), +[`TransactionOutpoint`](../../reference/Classes/TransactionOutpoint.md), +[`UtxoEntryReference`](../../reference/Classes/UtxoEntryReference.md) +— support `to_dict()` and `from_dict()`. The dict shape matches the +wRPC wire format the node accepts and produces. + +## Round-tripping + +```python +tx_dict = tx.to_dict() +restored = Transaction.from_dict(tx_dict) +assert restored == tx +``` + +[`to_dict()`](../../reference/Classes/Transaction.md) returns a fresh Python dict — modifying it doesn't +mutate the source object. [`from_dict()`](../../reference/Classes/Transaction.md) raises on malformed input +(missing required keys, wrong types, invalid values). + +## What the dict looks like + +```python +{ + "id": "ab12...", # transaction id, hex + "version": 0, + "inputs": [{ "previousOutpoint": {...}, "signatureScript": "...", "sequence": 0, "sigOpCount": 1 }, ...], + "outputs": [{ "value": 500000000, "scriptPublicKey": {"version": 0, "script": "..."} }, ...], + "lockTime": 0, + "subnetworkId": "0000000000000000000000000000000000000000", + "gas": 0, + "payload": "", # hex string + "mass": 12345, +} +``` + +Field names use camelCase (wRPC convention), unlike the snake_case +Python class attributes. + +## When you need this + +Within a single process, you rarely need to round-trip — pass typed +objects around. The dict form earns its place at process boundaries: + +- **Offline signing** — build on an online machine, serialize, sign + on an air-gapped one, serialize again, send back, submit. The dict + is the natural transport. +- **Co-signer flows** — multisig where each cosigner signs in turn. + Each step ships a dict; the next signer reads, signs, and forwards. +- **Persistence** — saving a pending transaction to disk or a queue. + Store the dict (as JSON), not the typed object. + +For submission itself you can pass either a [`Transaction`](../../reference/Classes/Transaction.md) or a dict +to [`client.submit_transaction({"transaction": ...})`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.submit_transaction); the dict form +is only required when the transaction has already been serialized +elsewhere. See [Submission](submission.md). diff --git a/docs/learn/transactions/signing.md b/docs/learn/transactions/signing.md new file mode 100644 index 00000000..6fb24f7b --- /dev/null +++ b/docs/learn/transactions/signing.md @@ -0,0 +1,114 @@ +# Signing + +Signing fills each input's `signature_script` with proof that the +spender controls the key the UTXO is locked to. Kaspa defaults to +**Schnorr** signatures over secp256k1; ECDSA is supported for inputs +locked to ECDSA addresses. Multisig inputs combine multiple +signatures under a script-hash lockup. + +For Schnorr vs ECDSA on the addressing side, see +[Addresses → Versions](../addresses.md#versions). + +## Sign a manually built transaction + +Run [`update_transaction_mass`](../../reference/Functions/update_transaction_mass.md) first, then [`sign_transaction`](../../reference/Functions/sign_transaction.md): + +```python +from kaspa import sign_transaction, update_transaction_mass + +update_transaction_mass("mainnet", tx) # mass is signed over — fill first +signed = sign_transaction(tx, [private_key], verify_sig=True) +``` + +[`sign_transaction(tx, signers, verify_sig)`](../../reference/Functions/sign_transaction.md): + +- `signers` — a list of + [`PrivateKey`](../../reference/Classes/PrivateKey.md). The signer + for each input is inferred from the input's UTXO lockup; pass every + key any input needs. +- `verify_sig=True` — verify each signature after writing it. Raises + on mismatch (a corrupt input or wrong key). Cheap insurance during + development; disable in performance-sensitive paths once you trust + the inputs. + +Changing inputs, outputs, or mass *after* signing invalidates the +signature — sign last. + +## Sign a generator-produced PendingTransaction + +A [`PendingTransaction`](../../reference/Classes/PendingTransaction.md) yielded by [`Generator`](../../reference/Classes/Generator.md) (see [Transaction Generator](../wallet-sdk/tx-generator.md)) signs in one call: + +```python +for pending in gen: + pending.sign([key]) # all inputs at once + await pending.submit(client) +``` + +For multisig, hand in every cosigner's key: + +```python +pending.sign([key1, key2, key3]) +``` + +For per-input control: + +```python +for i in range(len(pending.get_utxo_entries())): + pending.sign_input(i, key_for(i)) +``` + +[`sign_input`](../../reference/Classes/PendingTransaction.md) is the right surface when different inputs need different +signers (mixed-key wallets, partially-co-signed flows). + +## SighashType + +The hash that gets signed describes *which parts of the transaction* +the signature commits to. +[`SighashType.All`](../../reference/Enums/SighashType.md) is the +default and the only one most code should use — it signs every input +and every output. + +The other variants (`_None`, `Single`, and the `*AnyOneCanPay` +flavors) exist for advanced collaborative flows like coinjoins, +where cosigners add inputs or outputs after one party signs. If +you don't have a specific protocol reason, leave it at `All`. + +## Build a signature without filling the input + +When you need raw signature bytes — e.g. to send to a co-signer for +aggregation — use [`create_input_signature`](../../reference/Functions/create_input_signature.md): + +```python +from kaspa import SighashType, create_input_signature + +sig_hex = create_input_signature( + tx, + input_index=0, + private_key=key, + sighash_type=SighashType.All, +) +``` + +The same method exists on [`PendingTransaction`](../../reference/Classes/PendingTransaction.md) +([`pending.create_input_signature(...)`](../../reference/Classes/PendingTransaction.md)); write the resulting script +back with [`pending.fill_input(...)`](../../reference/Classes/PendingTransaction.md). + +## Multisig and sig_op_count + +Two fields interact with mass when you sign: + +- **`sig_op_count`** on each input — number of signature ops the + input actually performs. `1` for a single-key spend, `M` for an + `M`-of-`N` multisig. +- **`minimum_signatures`** passed to + [`update_transaction_mass(..., minimum_signatures=M)`](../../reference/Functions/update_transaction_mass.md) and + [`calculate_transaction_mass`](../../reference/Functions/calculate_transaction_mass.md) — tells the mass calculator how big + the filled-in signature script will be. + +Wrong values yield wrong mass — the transaction is rejected (mass +too low) or pays more fee than it needs (mass too high). The +[`Generator`](../../reference/Classes/Generator.md) handles this when you pass `sig_op_count` and +`minimum_signatures` to the constructor. + +For the full multisig flow, see +[`examples/transactions/multisig.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/transactions/multisig.py). diff --git a/docs/learn/transactions/submission.md b/docs/learn/transactions/submission.md new file mode 100644 index 00000000..b8bc6287 --- /dev/null +++ b/docs/learn/transactions/submission.md @@ -0,0 +1,83 @@ +# Submission & confirmation + +Submitting a signed transaction hands it to a node, which gossips it +to the network and includes it in a block when capacity allows. Two +surfaces: [`pending.submit(client)`](../../reference/Classes/PendingTransaction.md#kaspa.PendingTransaction.submit) for transactions produced by the +[`Generator`](../../reference/Classes/Generator.md) (see [Transaction Generator](../wallet-sdk/tx-generator.md)), and +[`client.submit_transaction(...)`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.submit_transaction) (see [RPC → Calls](../rpc/calls.md#transactions-and-mempool)) +for everything else. + +## From a PendingTransaction + +```python +tx_id = await pending.submit(client) +print(tx_id) +``` + +[`pending.submit`](../../reference/Classes/PendingTransaction.md#kaspa.PendingTransaction.submit) calls [`submit_transaction`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.submit_transaction) for you. The right path +for [`Generator`](../../reference/Classes/Generator.md)-built transactions — including the managed +[`Wallet`](../../reference/Classes/Wallet.md) (see [Send Transaction](../wallet/send-transaction.md)), which is built on the +[`Generator`](../../reference/Classes/Generator.md). + +## Manual submission + +```python +result = await client.submit_transaction({ + "transaction": signed_tx, + "allowOrphan": False, +}) +print(result["transactionId"]) +``` + +The request takes: + +- **`transaction`** — a signed [`Transaction`](../../reference/Classes/Transaction.md) (or its dict form via + [`Transaction.to_dict()`](../../reference/Classes/Transaction.md) when shipping through another system; see + [Serialization](serialization.md)). +- **`allowOrphan`** — whether to keep the transaction in the mempool + when an input hasn't been seen yet (e.g. submitting a chain out of + order). Default `False` unless you know you're submitting a chain. + +## What "submitted" means + +Submission is *acceptance into the mempool*, not confirmation. The +return value is the transaction ID; the transaction is now eligible +for inclusion in a block. + +A Kaspa transaction lifecycle has three observable states: + +- **In mempool** — accepted by the node, waiting for inclusion. ID + returned from [`submit_transaction`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.submit_transaction). +- **Virtual-chain accepted** — included in a block that's part of the + canonical DAG ordering. Surfaces via the + [`virtual-chain-changed`](../rpc/subscriptions.md#virtual-chain-progression) + notification. +- **Mature** — confirmed past the maturity threshold; new UTXOs are + spendable. Surfaces via a `Maturity` event on the managed + [`Wallet`](../../reference/Classes/Wallet.md) (see [Wallet → Events](../wallet/events.md)) or the + [`UtxoProcessor`](../../reference/Classes/UtxoProcessor.md) (see [UTXO Processor](../wallet-sdk/utxo-processor.md)). + +The right gate for confirmation is the `Maturity` event for the +specific transaction, not "wait N seconds" and not the first time it +appears in a block. + +## Failures and retries + +[`submit_transaction`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.submit_transaction) raises if the node rejects the transaction. +Common reasons: + +- **`fee too low`** — recompute mass with [`update_transaction_mass`](../../reference/Functions/update_transaction_mass.md) + *after* any input/output change, then re-sign. +- **`orphan`** — an input references a transaction the node hasn't + seen. Wait for the parent to land, or set `allowOrphan=True` when + intentionally submitting a chain. +- **`already in mempool`** — the same `transaction_id` is already + pending. Safe to ignore. +- **`mass exceeded`** — the transaction is over + [`maximum_standard_transaction_mass()`](../../reference/Functions/maximum_standard_transaction_mass.md). Split the inputs across + multiple transactions; the [`Generator`](../../reference/Classes/Generator.md) does this automatically. + +A virtual-chain-accepted transaction *can* be reorged out — at which +point its outputs are no longer mature. The SDK surfaces this as a +`Reorg` event; see +[Wallet → Events](../wallet/events.md). diff --git a/docs/learn/wallet-sdk/derivation.md b/docs/learn/wallet-sdk/derivation.md new file mode 100644 index 00000000..1298c8e0 --- /dev/null +++ b/docs/learn/wallet-sdk/derivation.md @@ -0,0 +1,112 @@ +# Derivation + +Once you have an [`XPrv`](../../reference/Classes/XPrv.md) (see +[Key Management](key-management.md)), derivation produces every other +key the wallet uses. Kaspa follows BIP-44 with coin type `111111`: + +``` +m / 44' / 111111' / account' / chain / address_index +``` + +A trailing `'` denotes a *hardened* level — derived from the parent's +private key, so the corresponding `xpub` cannot derive its children. +Non-hardened (no `'`) levels can be derived from the `xpub` alone, +which is what makes watch-only wallets possible. `chain` is `0` for +receive addresses, `1` for change. + +See the [Kaspa MDBook page on +derivation](https://kaspa-mdbook.aspectron.com/wallets/addresses.html) +for protocol-level details. + +## `PrivateKeyGenerator` + +For "give me address `i`" derivation, use +[`PrivateKeyGenerator`](../../reference/Classes/PrivateKeyGenerator.md). +It walks the full BIP-44 path for you: + +```python +from kaspa import PrivateKeyGenerator, NetworkType + +gen = PrivateKeyGenerator( + xprv=xprv, # accepts XPrv or its xprv-string form + is_multisig=False, + account_index=0, +) + +for i in range(5): + key = gen.receive_key(i) # m/44'/111111'/0'/0/i + addr = key.to_address(NetworkType.Mainnet) + print(i, addr.to_string()) + +change = gen.change_key(0) # m/44'/111111'/0'/1/0 +``` + +`xprv` accepts either an [`XPrv`](../../reference/Classes/XPrv.md) instance or its xprv string form; +[`NetworkType`](../../reference/Enums/NetworkType.md) selects the address prefix. + +## `PublicKeyGenerator` — watch-only + +When you only need addresses (no signing), +[`PublicKeyGenerator`](../../reference/Classes/PublicKeyGenerator.md) +derives them from an [`xpub`](../../reference/Classes/XPub.md): + +```python +from kaspa import PublicKeyGenerator, NetworkType + +pub = PublicKeyGenerator.from_xpub("xpub...") + +addr = pub.receive_address(NetworkType.Mainnet, 0) +addrs = pub.receive_addresses(NetworkType.Mainnet, start=0, end=10) +keys = pub.receive_pubkeys(start=0, end=5) +``` + +A public-only generator can be built from an existing [`XPrv`](../../reference/Classes/XPrv.md): + +```python +pub = PublicKeyGenerator.from_master_xprv( + xprv=xprv_string, + is_multisig=False, + account_index=0, +) +``` + +`change_addresses(...)` and the `*_as_strings` variants are also +available. + +## Manual derivation + +For non-standard paths or one-off derivations, drop down to the +extended-key APIs directly. Most users won't need this — reach for +[`PrivateKeyGenerator`](../../reference/Classes/PrivateKeyGenerator.md) first. + +```python +from kaspa import DerivationPath, Mnemonic, XPrv + +xprv = XPrv(Mnemonic.random().to_seed()) + +print(xprv.xprv, xprv.depth, xprv.chain_code) + +# Public counterpart — useful for watch-only wallets. +xpub = xprv.to_xpub() + +# Direct derivation +child = xprv.derive_child(0) # non-hardened +hardened = xprv.derive_child(0, hardened=True) +account_xprv = xprv.derive_path("m/44'/111111'/0'") +leaf = xprv.derive_path(DerivationPath("m/44'/111111'/0'/0/0")) +``` + +[`DerivationPath`](../../reference/Classes/DerivationPath.md) is +mutable — handy for walking a chain incrementally with `push`, +`parent`, `length`, etc. See +[`examples/derivation.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/derivation.py) +for runnable derivation snippets. + +## Where to next + +- [UTXO Processor](utxo-processor.md) — set up the live event pipeline + for the addresses you derived. +- [Transaction Generator](tx-generator.md) — sign and submit using + these keys. +- [Wallet → Accounts](../wallet/accounts.md) — the managed Wallet's + account API uses these primitives internally. diff --git a/docs/learn/wallet-sdk/key-management.md b/docs/learn/wallet-sdk/key-management.md new file mode 100644 index 00000000..bfe90fdc --- /dev/null +++ b/docs/learn/wallet-sdk/key-management.md @@ -0,0 +1,122 @@ +# Key Management + +Everything on this page is BIP-39 compatible. The SDK gives you +[`Mnemonic`](../../reference/Classes/Mnemonic.md) for the +human-readable phrase, the seed bytes it produces, and +[`XPrv`](../../reference/Classes/XPrv.md) for the master extended +private key from that seed. From an [`XPrv`](../../reference/Classes/XPrv.md) you [derive](derivation.md) child keys. + +Read [Security](../../getting-started/security.md) before generating a +real mnemonic. + +## Generate a mnemonic + +[`Mnemonic.random()`](../../reference/Classes/Mnemonic.md) generates a fresh phrase: + +```python +from kaspa import Mnemonic + +m = Mnemonic.random() # 24 words, default +m12 = Mnemonic.random(word_count=12) # 12 words +print(m.phrase) +``` + +## Restore from a mnemonic + +```python +from kaspa import Mnemonic + +phrase = "abandon abandon abandon ... about" + +if Mnemonic.validate(phrase): + m = Mnemonic(phrase) +else: + raise ValueError("invalid mnemonic") +``` + +[`Mnemonic.validate(phrase)`](../../reference/Classes/Mnemonic.md) checks word membership, length, and the +BIP-39 checksum, and returns a bool. The [`Mnemonic(phrase)`](../../reference/Classes/Mnemonic.md) constructor +raises on the same conditions, so the explicit [`validate()`](../../reference/Classes/Mnemonic.md) call is +only useful when you want to surface a friendlier error than the +underlying exception. + +## Validation + +```python +from kaspa import Mnemonic, Language + +ok = Mnemonic.validate(phrase) # English assumed +ok_en = Mnemonic.validate(phrase, Language.English) # explicit +``` + +The [`Language`](../../reference/Enums/Language.md) enum names the BIP-39 wordlist (English by default). + +## Convert to a seed + +```python +from kaspa import Mnemonic, XPrv + +m = Mnemonic.random() +seed = m.to_seed() # 64-byte BIP-39 seed +seed_with_passphrase = m.to_seed("25th-word") # different seed; same mnemonic + +xprv = XPrv(seed) +``` + +!!! info "Passphrase" + The optional passphrase (the "25th word") changes the seed. The + same mnemonic with different passphrases produces different + wallets. An attacker who recovers the mnemonic alone gets nothing + without the passphrase. + +## Inspect entropy + +The `entropy` property exposes the underlying random bits as a hex +string — the raw input the BIP-39 phrase encodes: + +```python +m = Mnemonic.random() +print(m.entropy) # hex +m.entropy = "" # advanced; rebuilds the phrase +``` + +You rarely need to set `entropy` directly. Two cases that come up: +re-creating a `Mnemonic` from entropy emitted by another tool, and +debugging a vector mismatch against a third-party implementation. + +## Languages + +```python +from kaspa import Mnemonic, Language + +m = Mnemonic(phrase, Language.English) +``` + +English is the default and what every Kaspa example uses. Other +BIP-39 wordlists exist on the enum but are rarely used — if you don't +know you need a non-English wordlist, use English. + +## Hex private keys ([`PrivateKey`](../../reference/Classes/PrivateKey.md)) + +For one-key accounts (a single secp256k1 secret, no derivation), +skip the mnemonic entirely: + +```python +from kaspa import PrivateKey + +key = PrivateKey("<64-char hex>") +addr = key.to_address("testnet-10") +``` + +The wallet's keypair accounts use the same 64-char hex string. When +calling [`prv_key_data_create`](../../reference/Classes/Wallet.md#kaspa.Wallet.prv_key_data_create), pass it as `secret` with +[`kind=PrvKeyDataVariantKind.SecretKey`](../../reference/Enums/PrvKeyDataVariantKind.md) — see +[Wallet → Accounts → Keypair accounts](../wallet/accounts.md#keypair-accounts). + +## Where to next + +- [Derivation](derivation.md) — turn the `XPrv` into addresses. +- [Transaction Generator](tx-generator.md) — sign a transaction with a + key you derived. +- [Security](../../getting-started/security.md) — secret-handling rules + before any of the above touches mainnet. diff --git a/docs/learn/wallet-sdk/overview.md b/docs/learn/wallet-sdk/overview.md new file mode 100644 index 00000000..d6303f44 --- /dev/null +++ b/docs/learn/wallet-sdk/overview.md @@ -0,0 +1,87 @@ +# Wallet SDK + +The **Wallet SDK** is the set of primitives the managed +[`Wallet`](../../reference/Classes/Wallet.md) (see [Wallet](../wallet/overview.md)) is built on — and the toolkit you +reach for whenever you don't want a managed wallet at all (custom +signers, indexers, watch-only flows, hot-path services that keep +everything in memory). + +This page is your section index. Every page below is independently +useful; read in order if you're new. + +## What lives here + +| Page | What it covers | +| --- | --- | +| [Key Management](key-management.md) | [`Mnemonic`](../../reference/Classes/Mnemonic.md), BIP-39 seed, [`XPrv`](../../reference/Classes/XPrv.md), hex import/export. | +| [Derivation](derivation.md) | [`PrivateKeyGenerator`](../../reference/Classes/PrivateKeyGenerator.md), [`PublicKeyGenerator`](../../reference/Classes/PublicKeyGenerator.md), BIP-44 paths. | +| [UTXO Processor](utxo-processor.md) | The engine: opens a node listener and dispatches events to contexts. | +| [UTXO Context](utxo-context.md) | Per-address UTXO tracking on top of a processor. | +| [Transaction Generator](tx-generator.md) | UTXO selection, fees, signing, submission. | + +## End-to-end without a managed wallet + +This is the canonical "primitives only" flow — a mnemonic, a derived +address, live UTXO tracking, and a signed-and-submitted send. No +`Wallet`, no on-disk file. + +```python +import asyncio +from kaspa import ( + Generator, Mnemonic, NetworkId, NetworkType, PaymentOutput, + PrivateKeyGenerator, Resolver, RpcClient, UtxoContext, UtxoProcessor, + XPrv, +) + +async def main(): + network = NetworkId("testnet-10") + + # 1. Key material + xprv = XPrv(Mnemonic("<24-word phrase>").to_seed()) + keys = PrivateKeyGenerator(xprv=xprv, is_multisig=False, account_index=0) + receive_key = keys.receive_key(0) + receive_addr = receive_key.to_address(NetworkType.Testnet) + + # 2. Connect and start a processor + client = RpcClient(resolver=Resolver(), network_id="testnet-10") + await client.connect() + try: + processor = UtxoProcessor(client, network) + await processor.start() + + # 3. Track the address — seeds the mature set and subscribes + context = UtxoContext(processor) + await context.track_addresses([receive_addr]) + + # 4. Build, sign, submit + gen = Generator( + network_id=network, + entries=context, # mature UTXOs flow from here + change_address=receive_addr, + outputs=[PaymentOutput(receive_addr, 100_000_000)], # 1 KAS + ) + for pending in gen: + pending.sign([receive_key]) + print("submitted:", await pending.submit(client)) + + await processor.stop() + finally: + await client.disconnect() + +asyncio.run(main()) +``` + +For the same flow with the managed [`Wallet`](../../reference/Classes/Wallet.md) (mnemonic stored on disk, +account state persisted), see +[Wallet → Send Transaction](../wallet/send-transaction.md). + +## Where to next + +- [Key Management](key-management.md) — start here if you have only a + mnemonic or hex private key. +- [Derivation](derivation.md) — turn an `XPrv` into addresses. +- [UTXO Processor](utxo-processor.md) — set up the live UTXO event + pipeline. +- [UTXO Context](utxo-context.md) — track UTXOs for a specific address + set. +- [Transaction Generator](tx-generator.md) — build, sign, and submit. diff --git a/docs/learn/wallet-sdk/tx-generator.md b/docs/learn/wallet-sdk/tx-generator.md new file mode 100644 index 00000000..7ba795d2 --- /dev/null +++ b/docs/learn/wallet-sdk/tx-generator.md @@ -0,0 +1,237 @@ +# Transaction Generator + +The [`Generator`](../../reference/Classes/Generator.md) is the SDK's +built-in coin selector and fee calculator. You hand it UTXOs, a change +address, and the outputs you want; it picks inputs, computes mass and +fees, and yields one or more +[`PendingTransaction`](../../reference/Classes/PendingTransaction.md)s +ready to sign and submit. + +## Idiomatic: feed it a `UtxoContext` + +In a long-running process, build a [`UtxoContext`](../../reference/Classes/UtxoContext.md) (see [UTXO Context](utxo-context.md)) +once and pass it as `entries`. The generator pulls the current mature +set on iteration — no manual UTXO snapshot, no stale data. [`PaymentOutput`](../../reference/Classes/PaymentOutput.md) and [`NetworkId`](../../reference/Classes/NetworkId.md) describe the destination. + +```python +gen = Generator( + network_id=NetworkId("mainnet"), + entries=context, # UtxoContext + change_address=my_addr, + outputs=[PaymentOutput(recipient, 100_000_000)], # 1 KAS +) +for pending in gen: + pending.sign([key]) + print("submitted:", await pending.submit(client)) +``` + +A [`Generator`](../../reference/Classes/Generator.md) is *iterable* — when the input set is too large for one +transaction's mass budget, it yields a chain of consolidating +transactions followed by the final payment. Loop and [`submit`](../../reference/Classes/PendingTransaction.md#kaspa.PendingTransaction.submit) each. + +For the full processor → context → generator wiring, see +[Wallet SDK → Overview](overview.md#end-to-end-without-a-managed-wallet). + +## One-shot: raw UTXO list + +Without a context (one-shot scripts, ad-hoc tools), pass the entries +list straight from +[`get_utxos_by_addresses`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.get_utxos_by_addresses) (see [RPC → Calls](../rpc/calls.md#balances-and-utxos)): + +```python +import asyncio +from kaspa import ( + RpcClient, Resolver, Generator, PaymentOutput, + PrivateKey, NetworkId, +) + +async def main(): + client = RpcClient(resolver=Resolver(), network_id="mainnet") + await client.connect() + try: + key = PrivateKey("<64-char hex>") + my_addr = key.to_address("mainnet") + + utxos = await client.get_utxos_by_addresses({ + "addresses": [my_addr.to_string()], + }) + + gen = Generator( + network_id=NetworkId("mainnet"), + entries=utxos["entries"], + change_address=my_addr, + outputs=[PaymentOutput(my_addr, 100_000_000)], # 1 KAS + ) + for pending in gen: + pending.sign([key]) + print("submitted:", await pending.submit(client)) + + print(gen.summary().fees, gen.summary().transactions) + finally: + await client.disconnect() + +asyncio.run(main()) +``` + +## Constructor options + +```python +gen = Generator( + network_id=NetworkId("mainnet"), + entries=utxos, # list[UtxoEntry] OR a UtxoContext + change_address=my_addr, + outputs=[PaymentOutput(recipient, amount)], + + # optional + payload=b"optional-data", # OP_RETURN-equivalent payload + priority_fee=1000, # extra fee in sompi + priority_entries=priority, # UTXOs to consume first + sig_op_count=1, # signature ops per input + minimum_signatures=1, # for multisig mass estimation + fee_rate=2.0, # explicit sompi/gram override +) +``` + +`entries` accepts a [`UtxoContext`](../../reference/Classes/UtxoContext.md) directly — pass +the context and it consumes from the mature set without you copying +the list. + +## Estimate before signing + +Two entry points, same answer. Use [`gen.estimate()`](../../reference/Classes/Generator.md) if you already +have a [`Generator`](../../reference/Classes/Generator.md); use [`estimate_transactions()`](../../reference/Functions/estimate_transactions.md) to quote a hypothetical +send without constructing one. + +```python +# You already have a Generator — most common case. +summary = gen.estimate() +print(summary.fees, summary.transactions, summary.utxos) + +# Standalone — no Generator yet. +from kaspa import estimate_transactions + +summary = estimate_transactions( + network_id="mainnet", + entries=utxos, + change_address=my_addr, + outputs=[{"address": recipient, "amount": amount}], +) +``` + +[`estimate()`](../../reference/Classes/Generator.md) doesn't consume the generator — you can iterate it for +real afterwards. + +## Pending transactions + +Each yielded item exposes the proposed transaction's metadata: + +```python +for pending in gen: + print(pending.id, pending.fee_amount, pending.mass) + print(pending.payment_amount, pending.change_amount, pending.transaction_type) + inputs = pending.get_utxo_entries() + addrs = pending.addresses() + raw_tx = pending.transaction +``` + +Use this to surface fee / mass to a user before they authorize a +signature. + +## Signing + +The everyday path: sign every input with one or more keys. + +```python +pending.sign([key]) +pending.sign([key1, key2, key3]) # multisig +``` + +### Advanced: per-input and custom scripts + +Reach for these only when single-call signing isn't enough — usually +mixed-key sets, hardware-signer integrations, or non-standard scripts. +See [`examples/transactions/multisig.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/transactions/multisig.py) +for a full multisig run. + +[`SighashType`](../../reference/Enums/SighashType.md) selects which parts of the transaction the signature commits to: + +```python +from kaspa import SighashType + +# Per-input control with different keys +for i, _ in enumerate(pending.get_utxo_entries()): + pending.sign_input(i, key) + +# Custom signature scripts +sig = pending.create_input_signature( + input_index=0, + private_key=key, + sighash_type=SighashType.All, +) +pending.fill_input(0, custom_script_bytes) +``` + +## Submit + +```python +tx_id = await pending.submit(client) + +# Or manually: +result = await client.submit_transaction({ + "transaction": pending.transaction, + "allowOrphan": False, +}) +``` + +[`pending.submit(client)`](../../reference/Classes/PendingTransaction.md#kaspa.PendingTransaction.submit) is the right path. The manual route is for +round-tripping the transaction through another system before +submission. See +[Transactions → Submission](../transactions/submission.md) for the +`allowOrphan` semantics and confirmation states. + +## One-shot helpers + +Two free functions for when the loop-and-submit pattern is more code +than you need: + +- **[`create_transaction`](../../reference/Functions/create_transaction.md)** (singular) — builds a single + [`Transaction`](../../reference/Classes/Transaction.md). Use it when you know the input set fits in one tx. +- **[`create_transactions`](../../reference/Functions/create_transactions.md)** (plural) — wraps [`Generator`](../../reference/Classes/Generator.md) end-to-end + and returns `{"transactions": [...], "summary":`[`GeneratorSummary`](../../reference/Classes/GeneratorSummary.md)`}`, + matching the chain you'd get from iterating [`Generator`](../../reference/Classes/Generator.md) yourself. + +```python +from kaspa import create_transaction, create_transactions + +# Singular — one transaction. +tx = create_transaction( + utxo_entry_source=utxos, + outputs=[{"address": "kaspa:...", "amount": 100_000_000}], # 1 KAS + priority_fee=1000, +) + +# Plural — Generator-equivalent. +result = create_transactions( + network_id="mainnet", + entries=utxos, + change_address=my_addr, + outputs=[{"address": "kaspa:...", "amount": 100_000_000}], # 1 KAS + priority_fee=1000, +) +for pending in result["transactions"]: + pending.sign([key]) + await pending.submit(client) + +print(result["summary"]) +``` + +## Where to next + +- [UTXO Context](utxo-context.md) — what to pass as `entries` in a + long-running process. +- [Wallet → Send Transaction](../wallet/send-transaction.md) — the + managed [`Wallet`](../../reference/Classes/Wallet.md) wraps [`Generator`](../../reference/Classes/Generator.md) with sensible defaults. +- [Transactions → Submission](../transactions/submission.md) — + `allowOrphan` semantics, confirmation states. +- [Examples](../../examples.md) — runnable scripts including a full + multisig flow. diff --git a/docs/learn/wallet-sdk/utxo-context.md b/docs/learn/wallet-sdk/utxo-context.md new file mode 100644 index 00000000..bca460a1 --- /dev/null +++ b/docs/learn/wallet-sdk/utxo-context.md @@ -0,0 +1,107 @@ +# UTXO Context + +A [`UtxoContext`](../../reference/Classes/UtxoContext.md) tracks UTXOs +for a fixed set of addresses. It's bound to a +[`UtxoProcessor`](../../reference/Classes/UtxoProcessor.md) (see [UTXO Processor](utxo-processor.md)) and fed by it: as the processor +receives notifications from the node, it routes changes to whichever +contexts have registered the relevant addresses. The context exposes +the resulting UTXO set, balance, and mature/pending splits. + +The managed [`Wallet`](../../reference/Classes/Wallet.md) (see [Wallet](../wallet/overview.md)) creates one [`UtxoContext`](../../reference/Classes/UtxoContext.md) +per activated account internally. + +[`UtxoContext`](../../reference/Classes/UtxoContext.md) can be used when you want UTXO tracking outside of [`Wallet`](../../reference/Classes/Wallet.md). + +## Build one + +```python +from kaspa import NetworkId, Resolver, RpcClient, UtxoContext, UtxoProcessor + +client = RpcClient(resolver=Resolver(), network_id="testnet-10") +await client.connect() + +processor = UtxoProcessor(client, NetworkId("testnet-10")) +await processor.start() + +context = UtxoContext(processor) +await context.track_addresses(["kaspatest:qr0lr4ml..."]) +``` + +`UtxoContext(processor, id=...)` accepts an optional 32-byte hex id; +omitted ids are generated. Set an explicit id when you need the +context to be addressable across reconnects. + +## What it exposes + +```python +print(context.is_active) # bool — see "Lifecycle" below +print(context.balance) # Balance | None +print(context.balance_strings) # BalanceStrings | None (formatted) +print(context.mature_length) # int — number of spendable UTXOs + +mature = context.mature_range(from_=0, to=10) # list[UtxoEntryReference] +pending = context.pending() # list[UtxoEntryReference] +``` + +`balance` is `None` until the first notification arrives; after that +it's a [`Balance(mature, pending, outgoing)`](../../reference/Classes/Balance.md) in sompi +([`balance_strings`](../../reference/Classes/BalanceStrings.md) returns the formatted form); +[`mature_range`](../../reference/Classes/UtxoContext.md) and [`pending`](../../reference/Classes/UtxoContext.md) return [`UtxoEntryReference`](../../reference/Classes/UtxoEntryReference.md)s. + +## Lifecycle + +A [`UtxoContext`](../../reference/Classes/UtxoContext.md) has no separate active/inactive state of its own — +`context.is_active` mirrors the bound processor. Implications: + +- The context's address set and in-memory UTXOs persist as long as + the Python object lives, even if the processor is stopped or the + socket dropped. On reconnect the processor re-registers them + automatically. +- [`track_addresses(addresses, current_daa_score=None)`](../../reference/Classes/UtxoContext.md) adds addresses, + subscribes the processor to `UtxosChanged` for them, and seeds the + mature set via a single [`get_utxos_by_addresses`](../../reference/Classes/RpcClient.md#kaspa.RpcClient.get_utxos_by_addresses) RPC. Pass + `current_daa_score` only if you need the maturity classification of + the seeded UTXOs to use a DAA score other than the processor's + current one — for example, when reconstructing balances at a + specific historical point. +- [`unregister_addresses([...])`](../../reference/Classes/UtxoContext.md) removes those addresses and stops the + matching `UtxosChanged` subscription. UTXOs they contributed remain + in the in-memory set until evicted by a notification or by [`clear()`](../../reference/Classes/UtxoContext.md). +- [`clear()`](../../reference/Classes/UtxoContext.md) unregisters every tracked address (unsubscribing from the + node) and drops every cached UTXO. The context is reusable — + [`track_addresses`](../../reference/Classes/UtxoContext.md) again to rehydrate it. + +```python +await context.track_addresses(["kaspatest:..."]) +await context.unregister_addresses(["kaspatest:..."]) +await context.clear() +``` + +[`track_addresses`](../../reference/Classes/UtxoContext.md) accepts [`Address`](../../reference/Classes/Address.md) instances or their string forms. + +## Use as `Generator` input + +The [`Generator`](../../reference/Classes/Generator.md) (see [Transaction Generator](tx-generator.md)) accepts a [`UtxoContext`](../../reference/Classes/UtxoContext.md) +as its `entries` argument: + +```python +from kaspa import Generator, PaymentOutput + +gen = Generator( + entries=context, # mature UTXOs come from here + change_address=my_addr, + outputs=[PaymentOutput(recipient, 100_000_000)], +) +``` + +This avoids snapshotting the UTXO list yourself — the generator pulls +the current mature set when it iterates. + +## Where to next + +- [Transaction Generator](tx-generator.md) — sending using a + `UtxoContext` as input. +- [UTXO Processor](utxo-processor.md) — the engine the context is + bound to. +- [Wallet → Architecture](../wallet/architecture.md) — how the managed + Wallet uses `UtxoContext`s internally. diff --git a/docs/learn/wallet-sdk/utxo-processor.md b/docs/learn/wallet-sdk/utxo-processor.md new file mode 100644 index 00000000..113619b2 --- /dev/null +++ b/docs/learn/wallet-sdk/utxo-processor.md @@ -0,0 +1,130 @@ +# UTXO Processor + +A [`UtxoProcessor`](../../reference/Classes/UtxoProcessor.md) is the +engine that drives live UTXO tracking: it owns the wRPC subscription +and dispatches address-scoped UTXO events to one or more +[`UtxoContext`](../../reference/Classes/UtxoContext.md)s (see [UTXO Context](utxo-context.md)). The managed +[`Wallet`](../../reference/Classes/Wallet.md) (see [Wallet](../wallet/overview.md)) builds one internally; otherwise, you +build one and bind contexts to it. + +**Read this page first**, then [UTXO Context](utxo-context.md). + +## Construction + +Build a [`UtxoProcessor`](../../reference/Classes/UtxoProcessor.md) on top of an [`RpcClient`](../../reference/Classes/RpcClient.md) and a [`NetworkId`](../../reference/Classes/NetworkId.md): + +```python +from kaspa import NetworkId, Resolver, RpcClient, UtxoProcessor + +client = RpcClient(resolver=Resolver(), network_id="testnet-10") +await client.connect() + +processor = UtxoProcessor(client, NetworkId("testnet-10")) +await processor.start() + +# ...later... +await processor.stop() +await client.disconnect() +``` + +`start()` runs a short handshake against the node: + +1. Calls `get_server_info`, validates RPC version + network match, + bails with `UtxoIndexNotEnabled` if the node has no UTXO index. +2. Opens a single wRPC listener and subscribes to + `VirtualDaaScoreChanged` only — **no** `UtxosChanged` subscription + yet. UTXO subscriptions are opened later, per-address, when a + [`UtxoContext`](../../reference/Classes/UtxoContext.md) calls `track_addresses(...)`. +3. Emits `utxo-proc-start`. + +`stop()` is the matching shutdown. Without `start()`, bound contexts +stay empty. + +## Properties + +| Property | Meaning | +| --- | --- | +| `processor.rpc` | The [`RpcClient`](../../reference/Classes/RpcClient.md) it's reading from. | +| `processor.network_id` | The [`NetworkId`](../../reference/Classes/NetworkId.md) it was constructed with. | +| `processor.is_active` | `True` after `start()`, `False` after `stop()` or before. | + +## Events + +The processor has its own event surface — a smaller, lower-level +cousin of the +[managed Wallet's events](../wallet/events.md). + +```python +def on_event(event): + print(event["type"], event.get("data")) + +# Single event +processor.add_event_listener("maturity", on_event) + +# A list of events — supported here (the wallet's listener takes one +# event at a time) +processor.add_event_listener( + ["utxo-proc-start", "utxo-proc-stop", "pending", "maturity", + "reorg", "stasis", "discovery", "balance", "utxo-proc-error", "error"], + on_event, +) + +# Every event +processor.add_event_listener("all", on_event) +``` + +| Event | When it fires | +| --- | --- | +| `utxo-proc-start` / `utxo-proc-stop` | Processor entered / left the active state. | +| `pending` | A UTXO landed for a tracked address but isn't mature yet. | +| `maturity` | A previously-pending UTXO crossed the maturity depth. | +| `reorg`, `stasis` | A UTXO was unwound or coinbase-locked. | +| `discovery` | A scan-time discovery hit. | +| `balance` | A bound [`UtxoContext`](../../reference/Classes/UtxoContext.md)'s balance changed. | +| `utxo-proc-error`, `error` | Something went wrong. | + +[`UtxoProcessorEvent`](../../reference/Enums/UtxoProcessorEvent.md) +is the enum form if you prefer typed values over kebab strings. + +## Reconnects + +The underlying [`RpcClient`](../../reference/Classes/RpcClient.md) reconnects automatically when the WebSocket +drops. The processor reacts: + +- On disconnect: emits `utxo-proc-stop`. Bound contexts stay alive + — their address sets and in-memory UTXOs are kept. +- On the next successful reconnect: re-runs the handshake, re-opens + the DAA-score subscription, and re-registers `UtxosChanged` for + every address that's still tracked across all bound contexts. + Re-emits `utxo-proc-start`. + +You don't need to rebuild contexts on reconnect. Gate work that needs +fresh state on `processor.is_active` (or on `utxo-proc-start`). + +## Coordinating with `asyncio` + +Listener callbacks may run on a background thread. To signal an +`asyncio.Event` from one, bridge through the loop: + +```python +loop = asyncio.get_running_loop() +got_start = asyncio.Event() + +def on_event(event): + if event.get("type") == "utxo-proc-start": + loop.call_soon_threadsafe(got_start.set) + +processor.add_event_listener("utxo-proc-start", on_event) +await processor.start() +await got_start.wait() +``` + +This is the same pattern the managed [`Wallet`](../../reference/Classes/Wallet.md) uses internally. + +## Where to next + +- [UTXO Context](utxo-context.md) — bind a context to this processor. +- [Transaction Generator](tx-generator.md) — pass a bound context as + `entries`. +- [Wallet → Sync State](../wallet/sync-state.md) — the same handshake + surfaced one level up, with the full `SyncState` payload reference. diff --git a/docs/learn/wallet/accounts.md b/docs/learn/wallet/accounts.md new file mode 100644 index 00000000..956f9cd1 --- /dev/null +++ b/docs/learn/wallet/accounts.md @@ -0,0 +1,173 @@ +# Accounts + +A wallet can hold multiple accounts of mixed kinds, each backed by one +[private key data entry](private-keys.md). + +## Account kinds + +| Kind | Backing private key data | Address derivation | +| --- | --- | --- | +| `bip32` | `Mnemonic` | HD path `m/44'/111111'/'//` | +| `keypair` | `SecretKey` | One address per account (Schnorr or ECDSA) | +| `multisig`, `bip32watch`, `legacy` | — | Specialised; not covered here. | + +## Surface + +| Method | Purpose | +| --- | --- | +| [`accounts_enumerate()`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_enumerate) | List [`AccountDescriptor`](../../reference/Classes/AccountDescriptor.md) for every account. | +| [`accounts_get(id)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_get) | Fetch a single descriptor. | +| [`accounts_create_bip32(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_create_bip32) | Create a new HD account at a given `account_index`. | +| [`accounts_create_keypair(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_create_keypair) | Create a single-key account. | +| [`accounts_import_bip32(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_import_bip32) | Same as create_bip32, but runs an address-discovery scan first. | +| [`accounts_import_keypair(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_import_keypair) | Same as create_keypair (discovery is a no-op for a single address). | +| [`accounts_rename(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_rename) | Update an account's name. | +| [`accounts_activate([ids])`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_activate) | Begin UTXO tracking for the given accounts (or all). | +| [`accounts_ensure_default(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_ensure_default) | Ensure a default `bip32` account exists, creating one if needed. | + +[`accounts_enumerate`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_enumerate), [`accounts_get`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_get), and the create/import methods +return [`AccountDescriptor`](../../reference/Classes/AccountDescriptor.md) objects (with attribute access). For +deriving the next address on an existing account, see +[Addresses](addresses.md). For sending, see +[Send Transaction](send-transaction.md). + +## BIP32 accounts + +```python +prv_key_id = await wallet.prv_key_data_create( + wallet_secret=wallet_secret, + secret="<24-word mnemonic>", + kind=PrvKeyDataVariantKind.Mnemonic, + name="demo-mnemonic-key", +) + +acct = await wallet.accounts_create_bip32( + wallet_secret=wallet_secret, + prv_key_data_id=prv_key_id, + account_name="demo-acct-0", + account_index=0, # omit to use the next free index +) +``` + +A second account from the same mnemonic only changes `account_index`: + +```python +acct1 = await wallet.accounts_create_bip32( + wallet_secret=wallet_secret, + prv_key_data_id=prv_key_id, + account_index=1, +) +``` + +### Inspect + +```python +for a in await wallet.accounts_enumerate(): + print(a.account_id, a.kind, a.account_name, a.balance) + print(" receive:", a.receive_address) + print(" change: ", a.change_address) +``` + +[`AccountDescriptor`](../../reference/Classes/AccountDescriptor.md) +exposes `kind`, `account_id`, `account_name`, `balance`, +`prv_key_data_ids`, `receive_address` / `change_address`, and (HD +only) `account_index`, `xpub_keys`, `ecdsa`, `receive_address_index`, +`change_address_index`. `get_addresses()` returns every derived +address. + +### Import vs. create + +[`accounts_import_bip32`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_import_bip32) runs an address-discovery scan before adding +the account, so previously-funded addresses are recognised as used. +Use it when restoring a known-used mnemonic; use +[`accounts_create_bip32`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_create_bip32) for fresh accounts. + +## Keypair accounts + +A keypair account holds one secp256k1 key and produces one address. +No derivation tree — no "next address", no `account_index`. Use one +when you have a single secret to manage alongside other accounts in +the same wallet, or when moving an existing standalone key into +managed storage. + +A keypair account is backed by a `SecretKey`-variant +[private key data entry](private-keys.md): + +```python +from kaspa import PrivateKey, PrvKeyDataVariantKind + +# 64-char hex secp256k1 secret +secret_hex = PrivateKey(...).to_string() + +secret_pkd = await wallet.prv_key_data_create( + wallet_secret=wallet_secret, + secret=secret_hex, + kind=PrvKeyDataVariantKind.SecretKey, + name="demo-secret-key", +) + +kp = await wallet.accounts_create_keypair( + wallet_secret=wallet_secret, + prv_key_data_id=secret_pkd, + ecdsa=False, # False = Schnorr (default), True = ECDSA + account_name="keypair-acct", +) +``` + +`ecdsa` selects the signature scheme. Schnorr is the modern default +and what almost every caller wants. `ecdsa=True` produces an ECDSA +address — only choose it when you need compatibility with a tool or +hardware that requires ECDSA addresses on Kaspa. + +### Keypair descriptor shape + +| Field | Value | +| --- | --- | +| `kind` | `"keypair"` | +| `account_id` | stable id | +| `receive_address` | the one address | +| `change_address` | the *same* address — no separate change chain | +| `account_index`, `xpub_keys`, `receive_address_index`, `change_address_index` | `None` | +| `ecdsa` | reflects the constructor flag | + +[`accounts_create_new_address`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_create_new_address) raises on a keypair account. +[`accounts_import_keypair`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_import_keypair) is the same as [`accounts_create_keypair`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_create_keypair) — +the address-discovery scan is a no-op for a single address. + +## Activate + +Accounts must be activated before they emit balance events or accept +sends. [`accounts_activate`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_activate) requires a connected wRPC client and a synced +wallet — see [Sync State](sync-state.md). + +```python +await wallet.accounts_activate([acct.account_id]) +# or, activate every account: +await wallet.accounts_activate() +``` + +## Ensure-default + +Use [`accounts_ensure_default`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_ensure_default) and the +[`AccountKind`](../../reference/Classes/AccountKind.md) helper: + +```python +from kaspa import AccountKind + +acct = await wallet.accounts_ensure_default( + wallet_secret=wallet_secret, + account_kind=AccountKind("bip32"), + mnemonic_phrase=None, # generate a fresh mnemonic if creating +) +``` + +Returns the default `bip32` account if there is one, otherwise creates +one (generating a fresh mnemonic when `mnemonic_phrase` is `None`). +Only `bip32` is supported; other kinds raise. + +## Where to next + +- [Addresses](addresses.md) — derive new receive / change addresses on an + existing account. +- [Send Transaction](send-transaction.md) — outgoing flows. +- [Events](events.md) — `Balance`, `Pending`, and `Maturity` events. diff --git a/docs/learn/wallet/addresses.md b/docs/learn/wallet/addresses.md new file mode 100644 index 00000000..ee801637 --- /dev/null +++ b/docs/learn/wallet/addresses.md @@ -0,0 +1,70 @@ +# Addresses + +A BIP32 account derives [`Address`](../../reference/Classes/Address.md) instances. The wallet records two +indices per account — one for receive, one for change. +[`accounts_create_new_address`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_create_new_address) advances them. Keypair accounts have a +single fixed address; calling [`accounts_create_new_address`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_create_new_address) on one +raises. + +For the lower-level [`Address`](../../reference/Classes/Address.md) primitive (parsing, encoding, script conversion), +see [Fundamentals → Addresses](../addresses.md). + +## Read the current addresses + +[`AccountDescriptor`](../../reference/Classes/AccountDescriptor.md) already carries the most recent of each: + +```python +acct = await wallet.accounts_get(account_id=acct.account_id) +print(acct.receive_address) # for the next-to-receive index +print(acct.change_address) # for the next-to-spend-from-as-change index +print(acct.receive_address_index) # int, BIP32 only +print(acct.change_address_index) # int, BIP32 only +``` + +[`get_addresses()`](../../reference/Classes/AccountDescriptor.md) returns every derived address on the account — the +right choice for re-subscribing UTXO notifications across all of them. + +## Derive the next address + +[`accounts_create_new_address`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_create_new_address) takes a [`NewAddressKind`](../../reference/Enums/NewAddressKind.md) and returns the next derived address: + +```python +from kaspa import NewAddressKind + +next_recv = await wallet.accounts_create_new_address( + account_id=acct.account_id, + kind=NewAddressKind.Receive, +) +next_change = await wallet.accounts_create_new_address( + account_id=acct.account_id, + kind=NewAddressKind.Change, +) +``` + +The index used is the descriptor's `receive_address_index` or +`change_address_index` *before* the call; afterwards the descriptor's +counter advances by one. Newly derived addresses register +automatically with the account's [`UtxoContext`](../../reference/Classes/UtxoContext.md), so funds sent to them +appear in the next sync. + +## Receive vs. change + +- **Receive** addresses are what you hand out to 3rd parties. +- **Change** addresses are where the wallet returns leftover funds. + +To sweep a UTXO set and leave leftover on a *fresh* change address, +advance the change index first — see [Sweep Funds](sweep.md). + +## Address discovery on import + +[`accounts_import_bip32`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_import_bip32) walks the receive and change chains for +addresses that have ever held a UTXO and bumps the indices +accordingly. That's what lets a restored wallet "remember" addresses +it previously handed out. Without it, `next_recv` would silently +re-issue an already-used address. + +## Where to next + +- [Send Transaction](send-transaction.md) — sending to an address. +- [Events](events.md) — events that fire when a derived address + receives funds. diff --git a/docs/learn/wallet/architecture.md b/docs/learn/wallet/architecture.md new file mode 100644 index 00000000..78d0e6f7 --- /dev/null +++ b/docs/learn/wallet/architecture.md @@ -0,0 +1,43 @@ +# Architecture + +The `Wallet` class is a system of cooperating components. Knowing some of the underlying mechanics is useful for development. + +This is a very brief, high-level overview. + +## The pieces + +```mermaid +flowchart TD + Wallet["Wallet
lifecycle, file storage, accounts"] + RpcClient["RpcClient"] + UtxoProcessor["UtxoProcessor"] + Ctx0["UtxoContext
account 0"] + Ctx1["UtxoContext
account 1"] + + Wallet -- owns --> RpcClient + Wallet -- owns --> UtxoProcessor + RpcClient -- pushes notifications --> UtxoProcessor + UtxoProcessor -- routes per-account --> Ctx0 + UtxoProcessor -- routes per-account --> Ctx1 +``` + +| Component | Job | +| --- | --- | +| **[`Wallet`](../../reference/Classes/Wallet.md)** | Lifecycle, on-disk file storage, account list, event multiplexer. The object your code interacts with. | +| **[`RpcClient`](../../reference/Classes/RpcClient.md)** | The wRPC connection. Used internally for calls and as the source of node-pushed notifications. | +| **[`UtxoProcessor`](../../reference/Classes/UtxoProcessor.md)** | Subscribes to virtual-chain / UTXO notifications, tracks `synced` state, routes UTXO changes to the right `UtxoContext`. | +| **[`UtxoContext`](../../reference/Classes/UtxoContext.md)** | One per activated account. Holds tracked addresses, per-state balance (`mature`, `pending`, `outgoing`), and the mature UTXO set the coin selector pulls from. | + +The wallet does not poll the node for UTXO state. It is fed by the +processor from notifications — see [Sync State](sync-state.md) for +what gates that flow. + +## Where to next + +- [Lifecycle](lifecycle.md) — the state machine and boot sequence. +- [Sync State](sync-state.md) — node IBD vs. processor readiness. +- [Send Transaction → UTXO maturity](send-transaction.md#utxo-maturity) — Pending / Mature / Outgoing + states and why `accounts_get_utxos` can return `[]`. +- [Events](events.md) — the live event surface. +- [Transaction History](transaction-history.md) — stored records and + annotation. diff --git a/docs/learn/wallet/errors.md b/docs/learn/wallet/errors.md new file mode 100644 index 00000000..a80ef3e5 --- /dev/null +++ b/docs/learn/wallet/errors.md @@ -0,0 +1,28 @@ +# Errors + +The wallet's errors live in `kaspa.exceptions`. Most are +self-explanatory; a few are common enough that they're worth knowing +ahead of time. The full list is in +[`kaspa.exceptions`](../../reference/SUMMARY.md). + +## Common errors + +| Error | Triggered by | Fix | +| --- | --- | --- | +| [`WalletAlreadyExistsError`](../../reference/Exceptions/WalletAlreadyExistsError.md) | [`wallet_create`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_create)`(filename=..., overwrite_wallet_storage=False)` when the file exists. | Catch and call [`wallet_open(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_open) instead, or pass `overwrite_wallet_storage=True` (clobbers). See [Lifecycle → create-or-open](lifecycle.md#create-or-open-pattern). | +| [`WalletNotOpenError`](../../reference/Exceptions/WalletNotOpenError.md) | Any `prv_key_data_*`, `accounts_*`, `transactions_*` call before [`wallet_create`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_create) / [`wallet_open`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_open). | Open the wallet first. | +| [`WalletNotConnectedError`](../../reference/Exceptions/WalletNotConnectedError.md) | [`accounts_activate(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_activate) without a connected wRPC client. | `await wallet.`[`connect`](../../reference/Classes/Wallet.md#kaspa.Wallet.connect)`(...)` first. | +| [`WalletNotSyncedError`](../../reference/Exceptions/WalletNotSyncedError.md) | [`accounts_send`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_send), [`accounts_estimate`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_estimate), [`accounts_get_utxos`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_get_utxos), [`accounts_transfer`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_transfer) before the processor finishes its sync handshake. | Wait on [`wallet.is_synced`](../../reference/Classes/Wallet.md#kaspa.Wallet.is_synced) (poll or `SyncState` listener) — see [Sync State](sync-state.md). | +| [`WalletInsufficientFundsError`](../../reference/Exceptions/WalletInsufficientFundsError.md) | [`accounts_send`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_send) / [`accounts_transfer`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_transfer) when the mature balance can't cover `outputs + fees`. | Wait for pending UTXOs to mature (`Maturity` [event](events.md)), reduce amount, or lower priority fee. | +| `"Invalid prv key data kind, supported types are Mnemonic and SecretKey"` | [`prv_key_data_create`](../../reference/Classes/Wallet.md#kaspa.Wallet.prv_key_data_create)`(kind=`[`PrvKeyDataVariantKind`](../../reference/Enums/PrvKeyDataVariantKind.md)`.Bip39Seed)` or `kind=ExtendedPrivateKey`. | Use `Mnemonic` or `SecretKey`. The other two enum variants are not implemented upstream — see [Private Keys](private-keys.md#variants). | +| `UtxoIndexNotEnabled` ([event](events.md), not exception) | Connecting to a kaspad without the UTXO index. The processor refuses to proceed. | Point at a node that has UTXO indexing enabled. | +| `RuntimeError: cannot change network while connected` | [`wallet.set_network_id(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.set_network_id) after [`connect()`](../../reference/Classes/Wallet.md#kaspa.Wallet.connect). | `await wallet.`[`disconnect()`](../../reference/Classes/Wallet.md#kaspa.Wallet.disconnect) → [`set_network_id(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.set_network_id) → `await wallet.`[`connect()`](../../reference/Classes/Wallet.md#kaspa.Wallet.connect). | +| `RuntimeError: cannot derive address on a keypair account` | [`accounts_create_new_address(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_create_new_address) on a keypair account. | Keypair accounts have one fixed address; read it from [`descriptor.receive_address`](../../reference/Classes/AccountDescriptor.md). See [Keypair accounts](accounts.md#keypair-accounts). | + +## Where to next + +- [Lifecycle](lifecycle.md) — preconditions and ordering rules. +- [Sync State](sync-state.md) — what the sync gate is and how to wait + on it. +- [`kaspa.exceptions` reference](../../reference/SUMMARY.md) — the + full set of named exceptions. diff --git a/docs/learn/wallet/events.md b/docs/learn/wallet/events.md new file mode 100644 index 00000000..5cf39eec --- /dev/null +++ b/docs/learn/wallet/events.md @@ -0,0 +1,190 @@ +# Events + +The wallet emits events for every state change the node pushes +through. Register Python callbacks on the wallet; its event +multiplexer forwards relevant events to them, and you react — update +a UI, trigger the next send, log a maturity, handle a reorg. + +For the "what happened in this account?" view that reads stored +transaction records, see [Transaction History](transaction-history.md). + +!!! warning "Callbacks must be synchronous" + The dispatcher invokes callbacks via `callback(*args, event, **kwargs)` — + a direct synchronous call. If you pass an `async def` function, it + returns a coroutine that is **never awaited**: the body silently does + not run. Use a regular `def` and offload async work with + `asyncio.create_task(...)` from inside the callback. + +## Listener API + +The wallet exposes [`add_event_listener`](../../reference/Classes/Wallet.md#kaspa.Wallet.add_event_listener) and [`remove_event_listener`](../../reference/Classes/Wallet.md#kaspa.Wallet.remove_event_listener): + +```python +def add_event_listener(event, callback, *args, **kwargs) -> None +def remove_event_listener(event, callback=None) -> None +``` + +- `event` — a + [`WalletEventType`](../../reference/Enums/WalletEventType.md), its + kebab-case string name (`"balance"`, `"sync-state"`), or `"all"` / + [`WalletEventType.All`](../../reference/Enums/WalletEventType.md) for every event. +- `callback` — invoked as `callback(*args, event, **kwargs)`. Must be + synchronous (see warning above). +- `args` / `kwargs` — forwarded verbatim to every invocation. Handy + for routing context (account id, channel) without closures. +- [`remove_event_listener(event)`](../../reference/Classes/Wallet.md#kaspa.Wallet.remove_event_listener) with no callback clears every + listener for that event. With `"all"` and no callback, clears every + listener globally. + +## A minimal subscriber + +```python +from kaspa import Resolver, Wallet, WalletEventType + +def on_event(event): + print(event["type"], event.get("data")) + +wallet = Wallet(network_id="testnet-10", resolver=Resolver()) +wallet.add_event_listener(WalletEventType.All, on_event) + +await wallet.start() +await wallet.connect() +``` + +Each event is a dict with at least a `type` key (the kebab-case kind +name, e.g. `"balance"`, `"sync-state"`, `"fee-rate"`) and an optional +`data` payload specific to that event. + +## Event taxonomy + +| Group | Events | +| --- | --- | +| Connection | `Connect`, `Disconnect`, `ServerStatus`, `UtxoIndexNotEnabled` | +| Wallet file | `WalletList`, `WalletStart`, `WalletHint`, `WalletOpen`, `WalletCreate`, `WalletReload`, `WalletClose`, `WalletError` | +| Key & account state | `PrvKeyDataCreate`, `AccountCreate`, `AccountActivation`, `AccountDeactivation`, `AccountSelection`, `AccountUpdate` | +| Sync & runtime | `SyncState`, `UtxoProcStart`, `UtxoProcStop`, `UtxoProcError`, `DaaScoreChange`, `Metrics`, `FeeRate` | +| UTXO movement | `Pending`, `Maturity`, `Reorg`, `Stasis`, `Discovery`, `Balance` | +| Catch-all | `All`, `Error` | + +## Event payloads + +Every event is `{"type": , "data": }`. The `data` +shapes below come from `kaspa-wallet-core::events`. Field names that +appear in `camelCase` are camel-cased on the wire; `snake_case` fields +are passed through as-is. + +### UTXO movement + +| Event | `data` fields | +| --- | --- | +| `Balance` | `balance: {mature, pending, outgoing, mature_utxo_count, pending_utxo_count, stasis_utxo_count} \| None`, `id: ` (see [`Balance`](../../reference/Classes/Balance.md)) | +| `Pending` | `record: TransactionRecord` — UTXO seen, not yet mature. | +| `Maturity` | `record: TransactionRecord` — UTXO crossed the maturity threshold; spendable. | +| `Reorg` | `record: TransactionRecord` — pending UTXO unwound by a reorg. | +| `Stasis` | `record: TransactionRecord` — coinbase output unwound during stasis. Safe to ignore. | +| `Discovery` | `record: TransactionRecord` — UTXO discovered during the initial scan of an account. When using the runtime [`Wallet`](../../reference/Classes/Wallet.md), you can usually rely on the [transaction history](transaction-history.md) instead. | + +A `TransactionRecord` carries `id`, `unixtimeMsec`, `value`, +`binding`, `blockDaaScore`, `network`, `data` (the per-kind +transaction body), and optional `note` / `metadata` strings. + +### Sync & runtime + +| Event | `data` fields | +| --- | --- | +| `SyncState` | `syncState: {type, data}` — see [Sync State → payloads](sync-state.md#reading-syncstate-payloads). | +| `UtxoProcStart` | *(no data)* | +| `UtxoProcStop` | *(no data)* | +| `UtxoProcError` | `message: str` | +| `DaaScoreChange` | `currentDaaScore: int` | +| `Metrics` | `networkId: str`, `metrics: MetricsUpdate` | +| `FeeRate` | `priority: {feerate, seconds}`, `normal: {...}`, `low: {...}` | + +### Key & account state + +| Event | `data` fields | +| --- | --- | +| `PrvKeyDataCreate` | `prvKeyDataInfo:`[`PrvKeyDataInfo`](../../reference/Classes/PrvKeyDataInfo.md) | +| `AccountCreate` | `accountDescriptor:`[`AccountDescriptor`](../../reference/Classes/AccountDescriptor.md) | +| `AccountActivation` | `ids: list[`[`AccountId`](../../reference/Classes/AccountId.md)`]` | +| `AccountDeactivation` | `ids: list[`[`AccountId`](../../reference/Classes/AccountId.md)`]` | +| `AccountSelection` | `id:`[`AccountId`](../../reference/Classes/AccountId.md)`\| None` | +| `AccountUpdate` | `accountDescriptor:`[`AccountDescriptor`](../../reference/Classes/AccountDescriptor.md) — fires when a new address is generated, etc. | + +### Wallet file + +| Event | `data` fields | +| --- | --- | +| `WalletList` | `walletDescriptors: list[`[`WalletDescriptor`](../../reference/Classes/WalletDescriptor.md)`]` | +| `WalletStart` | *(no data)* — fires once after [`start()`](../../reference/Classes/Wallet.md#kaspa.Wallet.start). | +| `WalletHint` | `hint: str \| None` — anti-phishing hint stored on the wallet file. | +| `WalletOpen` | `walletDescriptor:`[`WalletDescriptor`](../../reference/Classes/WalletDescriptor.md)`\| None`, `accountDescriptors: list[`[`AccountDescriptor`](../../reference/Classes/AccountDescriptor.md)`] \| None` | +| `WalletCreate` | `walletDescriptor:`[`WalletDescriptor`](../../reference/Classes/WalletDescriptor.md), `storageDescriptor: StorageDescriptor` | +| `WalletReload` | `walletDescriptor:`[`WalletDescriptor`](../../reference/Classes/WalletDescriptor.md)`\| None`, `accountDescriptors: list[`[`AccountDescriptor`](../../reference/Classes/AccountDescriptor.md)`] \| None` | +| `WalletClose` | *(no data)* | +| `WalletError` | `message: str` | + +### Connection + +| Event | `data` fields | +| --- | --- | +| `Connect` | `networkId: str`, `url: str \| None` | +| `Disconnect` | `networkId: str`, `url: str \| None` | +| `ServerStatus` | `networkId: str`, `serverVersion: str`, `isSynced: bool`, `url: str \| None` | +| `UtxoIndexNotEnabled` | `url: str \| None` | + +## When each event fires + +Common subscriptions: + +- **`SyncState`** — progress while the [`UtxoProcessor`](../../reference/Classes/UtxoProcessor.md) catches up. + Pair with [`wallet.is_synced`](../../reference/Classes/Wallet.md#kaspa.Wallet.is_synced) — see [Sync State](sync-state.md). +- **`Balance`** — fires when a [`UtxoContext`](../../reference/Classes/UtxoContext.md) balance changes. The + right signal for live UI updates. +- **`Pending`** — a new UTXO landed for a tracked address but isn't + yet spendable. +- **`Maturity`** — a previously-pending UTXO crossed the maturity + depth and is now spendable. The strongest gate for "send-then-wait" + flows — don't trigger the next [`accounts_send`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_send) on `Pending` alone. +- **`Reorg`** / **`Stasis`** — a UTXO was unwound or coinbase-locked. + Defensive code for high-value flows. +- **`AccountActivation`** / **`AccountDeactivation`** — react to + [`accounts_activate`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_activate) / [`wallet_close`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_close). + +## Targeted subscriptions + +```python +wallet.add_event_listener("balance", on_balance) +wallet.add_event_listener("maturity", on_maturity) +wallet.add_event_listener(WalletEventType.SyncState, on_sync) +``` + +To pass context to a generic callback: + +```python +wallet.add_event_listener("balance", on_change, account.account_id, label="primary") +# callback receives: on_change(account.account_id, event, label="primary") +``` + +## Cleanup + +Listeners outlive the wallet's open file but not its runtime. Pair a +permanent registration with an explicit removal on shutdown, or use +`"all"` to clear in one call via [`remove_event_listener`](../../reference/Classes/Wallet.md#kaspa.Wallet.remove_event_listener): + +```python +wallet.remove_event_listener(WalletEventType.All) +await wallet.stop() +``` + +## Where to next + +- [Transaction History](transaction-history.md) — stored records, + notes, and metadata. +- [Send Transaction](send-transaction.md) — `Maturity` as the right + wait condition. +- [Architecture](architecture.md) — what's actually generating these + events. +- [Lifecycle](lifecycle.md) — when each event group fires. +- [Wallet SDK → UTXO Processor](../wallet-sdk/utxo-processor.md) — + the lower-level event surface beneath the managed wallet. diff --git a/docs/learn/wallet/lifecycle.md b/docs/learn/wallet/lifecycle.md new file mode 100644 index 00000000..40043833 --- /dev/null +++ b/docs/learn/wallet/lifecycle.md @@ -0,0 +1,224 @@ +# Lifecycle + +A `Wallet` moves through five states. Each transition is async and +ordered. + +```mermaid +stateDiagram-v2 + [*] --> Constructed: Wallet(...) + Constructed --> Started: start() + Started --> Connected: connect() + Connected --> Open: wallet_create() / wallet_open() + Open --> Active: accounts_activate() + Active --> Open: wallet_close() + Open --> Started: disconnect() + Started --> [*]: stop() +``` + +## States and transitions + +| Step | Method | Effect | +| --- | --- | --- | +| Construct | [`Wallet(network_id, encoding, url, resolver)`](../../reference/Classes/Wallet.md#kaspa.Wallet) | Builds the local file store and an internal wRPC client. No I/O. | +| Start | [`await wallet.start()`](../../reference/Classes/Wallet.md#kaspa.Wallet.start) | Boots the `UtxoProcessor`, the wRPC notifier, and the event-dispatch task. | +| Connect | [`await wallet.connect(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.connect) | Connects the wRPC client to a node (via `resolver` or explicit `url`). | +| Open | [`await wallet.wallet_create(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_create) / [`wallet_open(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_open) | Decrypts and loads a wallet file; secrets become available in memory. | +| Activate | [`await wallet.accounts_activate([ids])`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_activate) | Begins UTXO tracking and event emission for the chosen accounts. | +| Close | [`await wallet.wallet_close()`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_close) | Releases the open wallet; activated accounts stop tracking. | +| Disconnect | [`await wallet.disconnect()`](../../reference/Classes/Wallet.md#kaspa.Wallet.disconnect) | Drops the wRPC connection; the wallet remains started. | +| Stop | [`await wallet.stop()`](../../reference/Classes/Wallet.md#kaspa.Wallet.stop) | Tears down the runtime and event task. | + +## Properties + +| Property | Type | Meaning | +| --- | --- | --- | +| [`wallet.rpc`](../../reference/Classes/Wallet.md#kaspa.Wallet.rpc) | [`RpcClient`](../../reference/Classes/RpcClient.md) | The underlying wRPC client. Use for direct node calls. | +| [`wallet.is_open`](../../reference/Classes/Wallet.md#kaspa.Wallet.is_open) | `bool` | `True` between `wallet_open` / `wallet_create` and `wallet_close`. | +| [`wallet.is_synced`](../../reference/Classes/Wallet.md#kaspa.Wallet.is_synced) | `bool` | `True` once the `UtxoProcessor` has caught up. See [Sync State](sync-state.md). | +| [`wallet.descriptor`](../../reference/Classes/Wallet.md#kaspa.Wallet.descriptor) | `WalletDescriptor \| None` | Metadata for the open wallet, or `None` when closed. | + +## Construct + +Constructing a [`Wallet`](../../reference/Classes/Wallet.md) does no +I/O. It builds the local file store and an internal wRPC client. + +```python +from kaspa import Resolver, Wallet + +wallet = Wallet( + network_id="testnet-10", # required in practice + resolver=Resolver(), # discover a public node + # url=... # OR a known node URL + # encoding="borsh", # default; "json" also accepted +) +``` + +| Argument | Required | Notes | +| --- | --- | --- | +| `network_id` | effectively yes | `"mainnet"`, `"testnet-10"`, `"testnet-11"`. Drives both resolver query and address encoding. May be omitted at construction and supplied later via `set_network_id`. | +| `resolver` | one of | A [`Resolver`](../rpc/resolver.md) instance. | +| `url` | resolver/url | A known wRPC URL (`wss://node.example:17110`). Skip the resolver if set. | +| `encoding` | optional | `"borsh"` (default) or `"json"`. Borsh is right for almost everything. | + +Addresses derived from this wallet are encoded for `network_id`, and +the resolver only returns nodes on that network. + +### Switching networks + +`set_network_id` raises if the wallet is currently connected: + +```python +await wallet.disconnect() +wallet.set_network_id("mainnet") +await wallet.connect() +``` + +Switching network does not invalidate the file store, but BIP32 +account *addresses* are network-specific — a key created under +`testnet-10` produces different addresses than the same key under +`mainnet`. + +### Storage location + +Wallet files live in the SDK's local store (under `~/.kaspa/` by +default). The folder is created on first write. The current `Wallet` +constructor does not expose a per-instance override; the location is +fixed for the process. + +## Start and connect + +`start()` boots the runtime. `connect()` attaches the wRPC client to a +node. + +```python +await wallet.start() +await wallet.connect(strategy="fallback", timeout_duration=5_000) +``` + +`start()` without `connect()` leaves the runtime running but unable to +reach the node. `connect()` without a prior `start()` leaves the +wallet runtime unstarted, so account activation and event dispatch +never function. + +### Connect options + +`connect()` takes the same options as +[`RpcClient.connect`](../rpc/connecting.md#connection-options) — +`block_async_connect`, `strategy`, `url`, `timeout_duration`, +`retry_interval`. Pass `url=` to override the resolver-discovered node +for one connection. + +### Sync gate + +[`connect()`](../../reference/Classes/Wallet.md#kaspa.Wallet.connect) resolves as soon as the WebSocket is up — not when the +processor has caught up. UTXO-dependent calls +([`AccountDescriptor.balance`](../../reference/Classes/AccountDescriptor.md), [`accounts_get_utxos`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_get_utxos), [`accounts_send`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_send)) +wait on [`wallet.is_synced`](../../reference/Classes/Wallet.md#kaspa.Wallet.is_synced). Quick polling form for scripts: + +```python +while not wallet.is_synced: + await asyncio.sleep(0.5) +``` + +For the event-driven pattern and the node-vs-processor breakdown, see +[Sync State](sync-state.md). + +## Open a wallet file + +A wallet file is a single encrypted file on disk. Only one is open at +a time per `Wallet` instance. + +```python +created = await wallet.wallet_create( + wallet_secret="example-secret", + filename="demo", + overwrite_wallet_storage=False, + title="demo", + user_hint="example", +) +``` + +- `filename` — on-disk basename; omit for the SDK default. +- `overwrite_wallet_storage=False` — raises [`WalletAlreadyExistsError`](../../reference/Exceptions/WalletAlreadyExistsError.md) + if the file exists; pass `True` to overwrite. +- `user_hint` — stored alongside the file as a recoverable password + hint. + +To open an existing file: + +```python +opened = await wallet.wallet_open( + wallet_secret="example-secret", + account_descriptors=True, + filename="demo", +) +``` + +`account_descriptors=True` returns the account list in the response so +you can pick which to activate without a follow-up +[`accounts_enumerate()`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_enumerate). + +### Create-or-open pattern + +```python +from kaspa.exceptions import WalletAlreadyExistsError + +try: + await wallet.wallet_create( + wallet_secret=secret, + filename="demo", + overwrite_wallet_storage=False, + ) +except WalletAlreadyExistsError: + await wallet.wallet_open( + wallet_secret=secret, + account_descriptors=True, + filename="demo", + ) +``` + +For listing, exporting, importing, renaming, and re-encrypting wallet +files, see [Wallet Files](wallet-files.md). + +## Reload + +[`wallet_reload(reactivate)`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_reload) reboots the account runtime from cached +wallet data without disk I/O. Pass `reactivate=True` to resume +previously active accounts. A `WalletReload` [event](events.md) fires either way. +Useful after upstream account-state changes; you usually don't need +it. + +## Close, disconnect, stop + +```python +await wallet.wallet_close() # release the open file; secrets leave memory +await wallet.disconnect() # drop the wRPC link; runtime stays alive +await wallet.stop() # tear down the runtime and event task +``` + +`wallet_close` does not stop the runtime — pair it with `stop()` on +shutdown. Skipping `stop()` leaks the notification task; skipping +`disconnect()` leaves the WebSocket open. + +## Ordering rules + +!!! warning "Preconditions" + - [`start()`](../../reference/Classes/Wallet.md#kaspa.Wallet.start) must precede [`connect()`](../../reference/Classes/Wallet.md#kaspa.Wallet.connect), [`wallet_create()`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_create), and [`wallet_open()`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_open). + - [`wallet_create()`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_create) / [`wallet_open()`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_open) may run before or after + [`connect()`](../../reference/Classes/Wallet.md#kaspa.Wallet.connect), but [`accounts_activate()`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_activate) requires the wRPC client to + be connected and the wallet to be synced (see [Sync State](sync-state.md)). + - [`set_network_id()`](../../reference/Classes/Wallet.md#kaspa.Wallet.set_network_id) raises if the wRPC client is currently + connected — [`disconnect()`](../../reference/Classes/Wallet.md#kaspa.Wallet.disconnect) first, change the network, then + [`connect()`](../../reference/Classes/Wallet.md#kaspa.Wallet.connect) again. + - [`wallet_close()`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_close) does not stop the runtime; pair it with [`stop()`](../../reference/Classes/Wallet.md#kaspa.Wallet.stop) + on shutdown. + +## Where to next + +- [Wallet Files](wallet-files.md) — enumerate, export, import, + rename, change secret. +- [Private Keys](private-keys.md) — the next step after creating a + wallet. +- [Accounts](accounts.md) — derive accounts from stored key data. +- [Sync State](sync-state.md) — node IBD vs. processor readiness. +- [Errors](errors.md) — common exceptions and their fixes. diff --git a/docs/learn/wallet/overview.md b/docs/learn/wallet/overview.md new file mode 100644 index 00000000..827a1f5f --- /dev/null +++ b/docs/learn/wallet/overview.md @@ -0,0 +1,88 @@ +# Wallet + +The [`Wallet`](../../reference/Classes/Wallet.md) class is the SDK's +high-level managed wallet. It leverages primitives in +[Wallet SDK](../wallet-sdk/overview.md) (and other rusty-kaspa components) to provide the following features: + +- Persistent encrypted on-disk storage for keys and account metadata +- Multi-account management across BIP32 and keypair accounts +- Event bus for chain notifications (balance, maturity, reorg) +- Built-in send, transfer, and sweep flows +- Address derivation and discovery +- Transaction history tracking + +## Runnable example + +Generates a fresh testnet mnemonic, opens a new wallet file, derives a +BIP32 account, waits for sync, prints the receive address, and tears +down cleanly. No external funds required to run. + +```python +import asyncio +from kaspa import ( + Mnemonic, + PrvKeyDataVariantKind, + Resolver, + Wallet, +) + +async def main(): + wallet = Wallet(network_id="testnet-10", resolver=Resolver()) + await wallet.start() + await wallet.connect(strategy="fallback", timeout_duration=5_000) + + while not wallet.is_synced: + await asyncio.sleep(0.5) + + await wallet.wallet_create( + wallet_secret="example-secret", + filename="demo", + title="demo", + ) + + mnemonic = Mnemonic.random() + prv_key_id = await wallet.prv_key_data_create( + wallet_secret="example-secret", + secret=mnemonic.phrase, + kind=PrvKeyDataVariantKind.Mnemonic, + ) + account = await wallet.accounts_create_bip32( + wallet_secret="example-secret", + prv_key_data_id=prv_key_id, + account_index=0, + ) + await wallet.accounts_activate([account.account_id]) + + print("receive address:", account.receive_address) + + await wallet.wallet_close() + await wallet.disconnect() + await wallet.stop() + +asyncio.run(main()) +``` + +Re-running raises [`WalletAlreadyExistsError`](../../reference/Exceptions/WalletAlreadyExistsError.md) on [`wallet_create`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_create) — +switch to [`wallet_open`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_open), or pass `overwrite_wallet_storage=True`. See +[Lifecycle](lifecycle.md). + +For a full send / sweep / history flow, see +[`examples/wallet/transactions.py`](https://github.com/kaspanet/kaspa-python-sdk/blob/main/examples/wallet/transactions.py). + +## How this section is laid out + +- [Architecture](architecture.md) — [`Wallet`](../../reference/Classes/Wallet.md) / [`UtxoProcessor`](../../reference/Classes/UtxoProcessor.md) / + [`UtxoContext`](../../reference/Classes/UtxoContext.md) and how notifications flow. +- [Lifecycle](lifecycle.md) — the state machine and ordering rules. +- [Wallet Files](wallet-files.md) — enumerate, export, import, + rename, change secret. +- [Private Keys](private-keys.md) and [Accounts](accounts.md) — + populating the wallet (BIP32 and keypair). +- [Addresses](addresses.md) — derive and inspect addresses. +- [Send Transaction](send-transaction.md), [Sweep Funds](sweep.md) — + outgoing flows, including the fee model and UTXO maturity. +- [Sync State](sync-state.md) — node IBD vs. processor readiness. +- [Events](events.md) — live event subscriptions. +- [Transaction History](transaction-history.md) — stored records and + annotation APIs. +- [Errors](errors.md) — common exceptions and their fixes. diff --git a/docs/learn/wallet/private-keys.md b/docs/learn/wallet/private-keys.md new file mode 100644 index 00000000..728e3b5b --- /dev/null +++ b/docs/learn/wallet/private-keys.md @@ -0,0 +1,95 @@ +# Private Keys + +A *private key data* entry is the encrypted secret that backs one or +more accounts. A wallet file holds zero or more private key data +entries; each account references exactly one by +[`PrvKeyDataId`](../../reference/Classes/PrvKeyDataId.md). + +## Variants + +[`PrvKeyDataVariantKind`](../../reference/Enums/PrvKeyDataVariantKind.md) +selects the format of `secret` passed to [`prv_key_data_create`](../../reference/Classes/Wallet.md#kaspa.Wallet.prv_key_data_create). The +enum exposes four variants, but only two are accepted by the upstream +wallet today: + +| Variant | `secret` format | Typical source | Status | +| --- | --- | --- | --- | +| `Mnemonic` | BIP-39 phrase (12 or 24 words) | New wallets, [`Mnemonic.random(...)`](../../reference/Classes/Mnemonic.md) | Supported | +| `SecretKey` | 64-char hex secp256k1 key | Single-key (keypair) accounts | Supported | +| `Bip39Seed` | Hex-encoded BIP-39 seed | Pre-derived seeds from another tool | **Not supported upstream** — [`prv_key_data_create`](../../reference/Classes/Wallet.md#kaspa.Wallet.prv_key_data_create) raises | +| `ExtendedPrivateKey` | xprv string | Migrating an existing HD wallet | **Not supported upstream** — [`prv_key_data_create`](../../reference/Classes/Wallet.md#kaspa.Wallet.prv_key_data_create) raises | + +The two unsupported variants fall through to a `_` arm in +`kaspa-wallet-core`'s `create_prv_key_data` and surface as +`"Invalid prv key data kind, supported types are Mnemonic and SecretKey"`. +Use `Mnemonic` for HD wallets and `SecretKey` for single-key accounts. + +## Surface + +| Method | Purpose | +| --- | --- | +| [`prv_key_data_create(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.prv_key_data_create) | Encrypt and store a new entry; returns its [`PrvKeyDataId`](../../reference/Classes/PrvKeyDataId.md). | +| [`prv_key_data_enumerate()`](../../reference/Classes/Wallet.md#kaspa.Wallet.prv_key_data_enumerate) | List [`PrvKeyDataInfo`](../../reference/Classes/PrvKeyDataInfo.md) for every stored entry. | +| [`prv_key_data_get(secret, id)`](../../reference/Classes/Wallet.md#kaspa.Wallet.prv_key_data_get) | Fetch metadata for a single entry. | + +The wallet must be open. The actual secret never leaves the wallet — +only its metadata is returned. + +## Create + +```python +from kaspa import PrvKeyDataVariantKind + +prv_key_id = await wallet.prv_key_data_create( + wallet_secret=wallet_secret, + secret="", + kind=PrvKeyDataVariantKind.Mnemonic, + payment_secret=None, # optional second factor + name="demo-key", +) +``` + +Returns a [`PrvKeyDataId`](../../reference/Classes/PrvKeyDataId.md). + +`payment_secret` layers a second password on top of `wallet_secret`. +Every operation that decrypts this entry (account creation, signing, +export) must supply it. Use `None` for single-password wallets. + +## Enumerate and inspect + +```python +for info in await wallet.prv_key_data_enumerate(): + print(info.id, info.name, info.is_encrypted) +``` + +Returns `list[PrvKeyDataInfo]`. +[`PrvKeyDataInfo`](../../reference/Classes/PrvKeyDataInfo.md) exposes: + +- `id: PrvKeyDataId` — stable identifier for account creation. +- `name: str | None` — the label set at creation time. +- `is_encrypted: bool` — `True` if a `payment_secret` is required. + +[`prv_key_data_get(wallet_secret, id)`](../../reference/Classes/Wallet.md#kaspa.Wallet.prv_key_data_get) returns the same metadata for +one entry, raising if the id is unknown. + +## Using a private key data entry + +[`PrvKeyDataId`](../../reference/Classes/PrvKeyDataId.md) links a +private key data entry to the accounts derived from it: + +```python +descriptor = await wallet.accounts_create_bip32( + wallet_secret=wallet_secret, + prv_key_data_id=prv_key_id, + account_index=0, +) +``` + +A single private key data entry can back many accounts — common for +BIP32 wallets where multiple account indices share one mnemonic. See +[Accounts](accounts.md) and [`accounts_create_bip32`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_create_bip32). + +## Where to next + +- [Accounts](accounts.md) — derive BIP32 and keypair accounts from a + private key data entry. diff --git a/docs/learn/wallet/send-transaction.md b/docs/learn/wallet/send-transaction.md new file mode 100644 index 00000000..71fe69a4 --- /dev/null +++ b/docs/learn/wallet/send-transaction.md @@ -0,0 +1,211 @@ +# Send Transaction + +Outgoing flows from an activated account. Every method on this page +requires an open wallet, a connected wRPC client, an activated source +account, and `wallet.is_synced == True` — see +[Sync State](sync-state.md). + +## Surface + +| Method | Returns | Purpose | +| --- | --- | --- | +| [`accounts_estimate(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_estimate) | [`GeneratorSummary`](../../reference/Classes/GeneratorSummary.md) | Dry-run a send without submitting. | +| [`accounts_send(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_send) | [`GeneratorSummary`](../../reference/Classes/GeneratorSummary.md) | Sign and submit a send. | +| [`accounts_transfer(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_transfer) | [`GeneratorSummary`](../../reference/Classes/GeneratorSummary.md) | Internal transfer between two accounts in the same wallet. | +| [`accounts_get_utxos(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_get_utxos) | `list[dict]` | Snapshot of an account's tracked UTXOs (post-sync). | +| [`fee_rate_estimate()`](../../reference/Classes/Wallet.md#kaspa.Wallet.fee_rate_estimate) | `dict` | Current low / normal / priority fee rates from the node. | +| [`fee_rate_poller_enable(seconds)`](../../reference/Classes/Wallet.md#kaspa.Wallet.fee_rate_poller_enable) / [`_disable()`](../../reference/Classes/Wallet.md#kaspa.Wallet.fee_rate_poller_disable) | — | Background fee-rate refresh. | + +For sweeping (consolidating every UTXO), see [Sweep Funds](sweep.md). + +## Send a single output + +[`accounts_send`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_send) takes a [`Fees`](../../reference/Classes/Fees.md) object with a [`FeeSource`](../../reference/Enums/FeeSource.md) and a list of [`PaymentOutput`](../../reference/Classes/PaymentOutput.md): + +```python +from kaspa import Fees, FeeSource, PaymentOutput + +result = await wallet.accounts_send( + wallet_secret=wallet_secret, + account_id=account.account_id, + priority_fee_sompi=Fees(0, FeeSource.SenderPays), + destination=[PaymentOutput("kaspatest:...", 100_000_000)], # 1 KAS +) +print(result.final_transaction_id, result.fees, result.final_amount) +``` + +[`Fees(0, ...)`](../../reference/Classes/Fees.md) is fine here: with priority `0` the wallet still pays +the network minimum (mass × `fee_rate`). See +[Fee model](#fee-model) below. + +## Multi-output send + +A single `destination` list of N outputs becomes one transaction with +N + 1 outputs (the +1 is the change return). + +```python +outputs = [PaymentOutput(addr, 100_000_000) for addr in addresses] +result = await wallet.accounts_send( + wallet_secret=wallet_secret, + account_id=account.account_id, + priority_fee_sompi=Fees(0, FeeSource.SenderPays), + destination=outputs, +) +``` + +## Estimate before sending + +```python +estimate = await wallet.accounts_estimate( + account_id=account.account_id, + priority_fee_sompi=Fees(0, FeeSource.SenderPays), + destination=outputs, +) +print(estimate.fees, estimate.final_amount, estimate.utxos) +``` + +[`accounts_estimate`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_estimate) and [`accounts_send`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_send) take the same arguments. +Estimating first surfaces fees and UTXO selection before signing. + +## `GeneratorSummary` fields + +Both [`accounts_estimate`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_estimate) and [`accounts_send`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_send) return a +[`GeneratorSummary`](../../reference/Classes/GeneratorSummary.md): + +| Field | Type | Meaning | +| --- | --- | --- | +| `network_id` | `str` | Network the summary applies to. | +| `network_type` | `str` | Network type identifier. | +| `utxos` | `int` | Total inputs consumed across the chain. | +| `mass` | `int` | Total transaction mass. | +| `fees` | `int` | Aggregate fee paid in sompi (network + priority). | +| `transactions` | `int` | Number of transactions generated. | +| `stages` | `int` | Number of compounding stages. | +| `final_amount` | `int \| None` | Final output amount in sompi (None if not yet generated). | +| `final_transaction_id` | `str \| None` | ID of the final transaction in the chain. | + +For most sends the chain is one transaction; large sweeps produce +multi-transaction chains (see [Sweep Funds](sweep.md)). + +## Fee model + +A submitted transaction pays: + +``` +total_fee = network_fee + priority_fee +network_fee = mass * fee_rate +``` + +Both arguments are total sompi values, never per-gram on the Python +side: + +| Argument | Type | What it controls | +| --- | --- | --- | +| `priority_fee_sompi` | [`Fees`](../../reference/Classes/Fees.md)`(amount_sompi, source)` | A flat top-up in sompi above the network minimum. `0` is a valid (minimum-priority) value. | +| `fee_rate` | `float \| None` | Network rate in sompi per gram. `None` resolves to the node's suggested rate. | + +[`FeeSource`](../../reference/Enums/FeeSource.md) decides who absorbs the fee: + +- **`FeeSource.SenderPays`** — fee is added on top of the destination + amount. Standard sends. +- **`FeeSource.ReceiverPays`** — fee is deducted from the destination + amount. Used to sweep an exact balance with no leftover change (see + [Sweep Funds](sweep.md)). + +See [Mass & Fees](../transactions/mass-and-fees.md) for the underlying +mass model. + +```python +rates = await wallet.fee_rate_estimate() +# {"priority": {"feerate": ..., "seconds": ...}, +# "normal": {"feerate": ..., "seconds": ...}, +# "low": {"feerate": ..., "seconds": ...}} +``` + +[`fee_rate_estimate`](../../reference/Classes/Wallet.md#kaspa.Wallet.fee_rate_estimate) returns a one-shot dict. + +For latency-sensitive flows, run a background poller via [`fee_rate_poller_enable`](../../reference/Classes/Wallet.md#kaspa.Wallet.fee_rate_poller_enable) / +[`fee_rate_poller_disable`](../../reference/Classes/Wallet.md#kaspa.Wallet.fee_rate_poller_disable): + +```python +wallet.fee_rate_poller_enable(15) # refresh every 15 seconds +# ... later ... +wallet.fee_rate_poller_disable() +``` + +## Internal transfers + +Funds moved between two accounts in the **same wallet** are spendable +immediately on transaction acceptance — no maturity wait: + +```python +await wallet.accounts_transfer( + wallet_secret=wallet_secret, + source_account_id=src.account_id, + destination_account_id=dst.account_id, + transfer_amount_sompi=500_000_000, +) +``` + +Use [`accounts_transfer`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_transfer) for in-wallet movement; use [`accounts_send`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_send) +for external addresses. + +## UTXO maturity + +Every UTXO the +[`UtxoProcessor`](../../reference/Classes/UtxoProcessor.md) sees moves +through three states: + +- **Pending** — seen, but confirmation depth is below the maturity + threshold. Counted in [`Balance.pending`](../../reference/Classes/Balance.md). *Not* spendable. +- **Mature** — confirmed deeply enough to spend. Counted in + [`Balance.mature`](../../reference/Classes/Balance.md). Returned by [`accounts_get_utxos`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_get_utxos). Selectable. +- **Outgoing** — locked because the wallet just spent it in a + transaction it generated. Counted in [`Balance.outgoing`](../../reference/Classes/Balance.md) until the + spend matures or is reorged out. + +The default thresholds (mainnet and testnet-10): + +| | DAA score depth | +| --- | --- | +| User transactions → Mature | 100 | +| Coinbase transactions → Mature | 1000 | +| Coinbase stasis (UTXOs hidden) | 500 | + +Override per network with +[`UtxoProcessor.set_user_transaction_maturity_period_daa(network_id, value)`](../../reference/Classes/UtxoProcessor.md) +and the matching coinbase setter; defaults usually want to stay. + +### Why `accounts_get_utxos` can return `[]` + +[`accounts_get_utxos`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_get_utxos) reads the in-memory [`UtxoContext`](../../reference/Classes/UtxoContext.md). It returns +`[]` when: + +1. The wallet isn't synced yet — see [Sync State](sync-state.md). +2. The account hasn't been activated. +3. No notification for a funding tx has reached the processor yet. + +Each UTXO is a `dict` — read amounts as `u["amount"]`, not +`u.amount`. + +### Waiting for confirmations + +Sends submit immediately, but spent UTXOs need to mature before the +next [`accounts_send`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_send) will see them. Two correct waits, both via +[Events](events.md): + +- **Pending** — fires when a UTXO lands but isn't yet spendable. + Useful for UI. +- **Maturity** — fires when a UTXO crosses the maturity depth and is + spendable. The right gate for "send → wait → send again" flows. + +Polling [`accounts_get_utxos`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_get_utxos) works for one-shot scripts; a `Maturity` +listener is the production pattern. + +## Where to next + +- [Sweep Funds](sweep.md) — consolidating every UTXO. +- [Events](events.md) — `Pending`, `Maturity`, and listener + registration. +- [Wallet SDK → Transaction Generator](../wallet-sdk/tx-generator.md) — + the lower-level primitive `accounts_send` is built on. diff --git a/docs/learn/wallet/sweep.md b/docs/learn/wallet/sweep.md new file mode 100644 index 00000000..c691f0ce --- /dev/null +++ b/docs/learn/wallet/sweep.md @@ -0,0 +1,74 @@ +# Sweep Funds + +A sweep consolidates every UTXO in an account into one address. Two +patterns; the difference is whether you want any leftover change. + +Both patterns require a synced wallet — [`accounts_get_utxos`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_get_utxos) returns +`[]` until then. See [Sync State](sync-state.md). + +## Pattern 1: sweep to your own change address + +Omit `destination` entirely. The wallet routes the full sweepable +balance to the account's current change address — no external +recipient. Useful for collapsing a long UTXO history into a single +mature output before a high-volume send flow. + +```python +from kaspa import Fees, FeeSource + +await wallet.accounts_send( + wallet_secret=wallet_secret, + account_id=account.account_id, + priority_fee_sompi=Fees(0, FeeSource.SenderPays), + destination=None, +) +``` + +[`FeeSource.SenderPays`](../../reference/Enums/FeeSource.md) works here because the change return absorbs the fee — +there's no external recipient to subtract from. [`Fees(0, ...)`](../../reference/Classes/Fees.md) keeps +priority at the network minimum; raise it to bid for faster +inclusion. + +## Pattern 2: sweep an exact balance to a fresh address + +To leave the account at zero with the destination receiving *the exact +aggregate balance minus fees*, use [`FeeSource.ReceiverPays`](../../reference/Enums/FeeSource.md): + +```python +from kaspa import Fees, FeeSource, PaymentOutput + +utxos = await wallet.accounts_get_utxos(account_id=account.account_id) +total = sum(u["amount"] for u in utxos) + +await wallet.accounts_send( + wallet_secret=wallet_secret, + account_id=account.account_id, + priority_fee_sompi=Fees(0, FeeSource.ReceiverPays), + destination=[PaymentOutput(sweep_address, total)], +) +``` + +[`accounts_get_utxos`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_get_utxos) returns `list[dict]`; each UTXO's amount is +`u["amount"]` (sompi). + +The destination amount is the *gross* balance; [`FeeSource.ReceiverPays`](../../reference/Enums/FeeSource.md) deducts +the network fee from it before broadcasting. The result: no change +output, no dust. + +## Big sweeps come back as multiple transactions + +If the input set is too large for one transaction's mass budget, the +underlying [`Generator`](../../reference/Classes/Generator.md) (see [Transaction Generator](../wallet-sdk/tx-generator.md)) produces a +series of transactions: each consolidates some UTXOs into a single +intermediate output, and the final transaction sends the aggregate to +the destination. [`accounts_send`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_send) returns the final +[`GeneratorSummary`](../../reference/Classes/GeneratorSummary.md); +`summary.transactions` reports how many were submitted. Watch the +[`Maturity` event](events.md) to know when the chain has +caught up — only the final output is what you'd hand off downstream. + +## Where to next + +- [Send Transaction](send-transaction.md) — non-sweep sends and the + fee model. +- [Events](events.md) — gating "wait until swept" on `Maturity`. diff --git a/docs/learn/wallet/sync-state.md b/docs/learn/wallet/sync-state.md new file mode 100644 index 00000000..714ec783 --- /dev/null +++ b/docs/learn/wallet/sync-state.md @@ -0,0 +1,160 @@ +# Sync State + +"Sync" in the managed wallet covers two distinct layers: + +1. **Node sync** — has the kaspad you're connected to finished its + own initial block download (IBD)? +2. **Processor sync** — has the wallet's embedded + [`UtxoProcessor`](../../reference/Classes/UtxoProcessor.md) + finished registering subscriptions and confirmed the node is in a + usable state? + +`accounts_*` calls (balances, UTXO snapshots, sends) wait on (2), +which is itself gated on (1). Treating them as one gives the right +answer most of the time but obscurs *what* the wallet is waiting for. + +## Node sync state + +A node still in IBD doesn't have all blocks/UTXOs and can't answer +wallet RPC calls authoritatively. Two surfaces report this: + +- **`ServerStatus`** [event](events.md) — emitted once after [`connect()`](../../reference/Classes/Wallet.md#kaspa.Wallet.connect), right + after the initial `get_server_info` handshake. Payload includes + `isSynced` (the node-side flag), `networkId`, `serverVersion`, and + `url`. +- **`SyncState`** [event](events.md) with an *IBD substate* — see + [Reading SyncState payloads](#reading-syncstate-payloads). + +If the node is missing its UTXO index entirely, the processor +short-circuits with a **`UtxoIndexNotEnabled`** [event](events.md) and refuses to +proceed — the only fix is to point at a node that has it. + +## Processor sync state + +After [`connect()`](../../reference/Classes/Wallet.md#kaspa.Wallet.connect), the wallet's [`UtxoProcessor`](../../reference/Classes/UtxoProcessor.md) runs a short handshake +against the node: + +1. One `get_server_info` round trip — validates RPC API version, + network-ID match, and that the node has its UTXO index enabled + (otherwise it emits `UtxoIndexNotEnabled` and stops). Reads the + node's `is_synced` flag and current `virtualDaaScore`. Emits + `ServerStatus`. +2. Registers a single wRPC listener and subscribes to + `VirtualDaaScoreChanged`. No `UtxosChanged` subscription is opened + at this stage — the processor doesn't know about any addresses yet. +3. Tracks the node's IBD progress (the `proof` / `headers` / `blocks` + / `utxo-sync` substates flow through here), then flips ready. + +Per-address `UtxosChanged` subscriptions and the initial UTXO seed +happen later, inside [`accounts_activate`](../../reference/Classes/Wallet.md#kaspa.Wallet.accounts_activate): each account's +[`UtxoContext`](../../reference/Classes/UtxoContext.md) issues a single +`get_utxos_by_addresses` call to populate its mature set and starts a +`UtxosChanged` subscription scoped to its addresses. After that the +context is updated purely from streamed notifications — there's no +periodic re-poll. + +- **[`wallet.is_synced`](../../reference/Classes/Wallet.md#kaspa.Wallet.is_synced)** — `True` once the handshake above completes. + This is the flag every `accounts_*` call effectively waits on. +- **`SyncState`** [event](events.md) with a *terminal substate* — `Synced` when + the processor flips ready, `NotSynced` when it falls back (e.g. on + reconnect). +- **`UtxoProcStart`** / **`UtxoProcStop`** — fire when the processor + itself starts and stops. Useful as bookends in event logs. + +The processor cannot be synced if the node isn't, so [`wallet.is_synced`](../../reference/Classes/Wallet.md#kaspa.Wallet.is_synced) +is the single condition you actually need to gate work on. The +node-level signals are useful for *reporting progress*, not for +unblocking calls. + +## Reading SyncState payloads + +The `SyncState` event carries one substate per emission. Payload +shape: + +```python +{ + "type": "sync-state", + "data": { + "syncState": { + "type": "", + "data": { ... }, # variant-specific + }, + }, +} +``` + +The substates fall into three groups: + +| Group | `type` | `data` fields | Layer | +| --- | --- | --- | --- | +| IBD progress | `proof` | `level: int` | Node | +| | `headers` | `headers: int, progress: int` | Node | +| | `blocks` | `blocks: int, progress: int` | Node | +| | `utxo-sync` | `chunks: int, total: int` | Node | +| | `trust-sync` | `processed: int, total: int` | Node | +| Resync | `utxo-resync` | *(none)* | Node | +| Terminal | `not-synced` | *(none)* | Processor | +| | `synced` | *(none)* | Processor | + +The IBD substates make it easy to drive a progress bar: + +```python +def on_event(event): + if event["type"] != "sync-state": + return + state = event["data"]["syncState"] + kind = state["type"] + if kind == "headers": + print(f"headers {state['data']['headers']:,} ({state['data']['progress']}%)") + elif kind == "blocks": + print(f"blocks {state['data']['blocks']:,} ({state['data']['progress']}%)") + elif kind == "synced": + print("processor ready") +``` + +## A staged wait + +To surface node IBD separately from processor readiness: + +Drive `node_synced` and `processor_ready` events with [`add_event_listener`](../../reference/Classes/Wallet.md#kaspa.Wallet.add_event_listener) and the [`WalletEventType`](../../reference/Enums/WalletEventType.md) enum: + +```python +import asyncio +from kaspa import Resolver, Wallet, WalletEventType + +node_synced = asyncio.Event() +processor_ready = asyncio.Event() + +def on_event(event): + t = event["type"] + if t == "server-status" and event["data"]["isSynced"]: + node_synced.set() + elif t == "sync-state" and event["data"]["syncState"]["type"] == "synced": + processor_ready.set() + +wallet = Wallet(network_id="testnet-10", resolver=Resolver()) +wallet.add_event_listener(WalletEventType.All, on_event) + +await wallet.start() +await wallet.connect() +await node_synced.wait() +await processor_ready.wait() +assert wallet.is_synced +``` + +For most scripts the polling form in +[Lifecycle → Sync gate](lifecycle.md#sync-gate) is enough. + +## Reconnects and `Disconnect` + +A `Disconnect` [event](events.md) flips [`wallet.is_synced`](../../reference/Classes/Wallet.md#kaspa.Wallet.is_synced) back to `False`; the +processor re-runs its handshake on the next `Connect` and re-emits a +fresh `ServerStatus` plus `SyncState` chain. Long-running listeners +should treat the gate as *re-arming*, not one-shot — gate every +`accounts_*` batch on [`is_synced`](../../reference/Classes/Wallet.md#kaspa.Wallet.is_synced), not on a once-set flag. + +## Where to next + +- [Architecture](architecture.md) — where the processor and RPC + client sit in the component graph. +- [Events](events.md) — the full event taxonomy these signals live in. diff --git a/docs/learn/wallet/transaction-history.md b/docs/learn/wallet/transaction-history.md new file mode 100644 index 00000000..bc1fbce5 --- /dev/null +++ b/docs/learn/wallet/transaction-history.md @@ -0,0 +1,76 @@ +# Transaction History + +The wallet stores a record of every transaction that touched each +account, with optional user-supplied note and metadata strings. + +For the live event stream (`Balance`, `Pending`, `Maturity`, etc.), +see [Events](events.md). + +## Fetching a window + +[`transactions_data_get`](../../reference/Classes/Wallet.md#kaspa.Wallet.transactions_data_get) returns a paged window of records: + +```python +data = await wallet.transactions_data_get( + account_id=account.account_id, + network_id="testnet-10", + start=0, + end=20, +) +``` + +- `account_id` — the account whose history you want. +- `network_id` — history is stored per network; pass the same + identifier the wallet is connected to. +- `start` / `end` — half-open paging window into the stored history + (newest first). +- `filter` *(optional)* — list of + [`TransactionKind`](../../reference/Enums/TransactionKind.md) values + (or kebab strings) to keep; everything else is dropped. + +Returns a dict containing the matching `TransactionRecord`s. Each +record carries `id`, `unixtimeMsec`, `value`, `binding`, +`blockDaaScore`, `network`, `data` (the per-kind transaction body), +and the optional `note` / `metadata` strings. + +## Annotating records + +Notes and metadata are free-form strings stored alongside the record +in the wallet file, not on chain. Update them with +[`transactions_replace_note`](../../reference/Classes/Wallet.md#kaspa.Wallet.transactions_replace_note) and +[`transactions_replace_metadata`](../../reference/Classes/Wallet.md#kaspa.Wallet.transactions_replace_metadata): + +```python +await wallet.transactions_replace_note( + account_id=account.account_id, + network_id="testnet-10", + transaction_id=tx_id, + note="rent", +) +await wallet.transactions_replace_metadata( + account_id=account.account_id, + network_id="testnet-10", + transaction_id=tx_id, + metadata='{"tag": "ops"}', +) +``` + +Pass `None` (or omit) to clear the existing value. If you want +structured metadata, encode it yourself (JSON, msgpack, etc.) — the +wallet stores opaque strings. + +## Stored records vs. live events + +The history APIs read what's already on disk; they don't reach out to +the node. New records land on disk as the +[`UtxoProcessor`](../../reference/Classes/UtxoProcessor.md) processes +notifications, so for "react when something happens" flows use the +[event](events.md) surface and only call [`transactions_data_get`](../../reference/Classes/Wallet.md#kaspa.Wallet.transactions_data_get) when +you need the persisted view (UI history pane, audit, export). + +## Where to next + +- [Events](events.md) — live `Balance` / `Pending` / `Maturity` etc. +- [Send Transaction](send-transaction.md) — outgoing flows that + populate this history. +- [Wallet Files](wallet-files.md) — where the records are persisted. diff --git a/docs/learn/wallet/wallet-files.md b/docs/learn/wallet/wallet-files.md new file mode 100644 index 00000000..538d7f40 --- /dev/null +++ b/docs/learn/wallet/wallet-files.md @@ -0,0 +1,97 @@ +# Wallet Files + +A wallet file is a single encrypted file on disk. Only one is open at +a time per `Wallet` instance. Creating, opening, and closing a file is +covered in [Lifecycle](lifecycle.md); this page covers the +file-management surface that runs *around* the open file: listing +what's on disk, backing up, restoring, renaming, and rotating the +password. + +## Surface + +| Method | Returns | Purpose | +| --- | --- | --- | +| [`wallet_enumerate()`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_enumerate) | `list[`[`WalletDescriptor`](../../reference/Classes/WalletDescriptor.md)`]` | List every wallet file in the store. | +| [`wallet_export(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_export) | `str` (hex) | Dump the encrypted payload as hex. | +| [`wallet_import(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_import) | `dict` | Materialise a previously exported payload as a new file. | +| [`wallet_change_secret(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_change_secret) | — | Re-encrypt the open file with a new password. | +| [`wallet_rename(...)`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_rename) | — | Update the title (and/or filename — see warning below). | + +All methods require [`start()`](../../reference/Classes/Wallet.md#kaspa.Wallet.start); none require a wRPC connection. +[`wallet_change_secret`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_change_secret) and [`wallet_rename`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_rename) operate on the currently +open file. + +## Enumerate + +```python +descriptors = await wallet.wallet_enumerate() +for d in descriptors: + print(d.filename, d.title) +``` + +[`WalletDescriptor`](../../reference/Classes/WalletDescriptor.md) is a typed object — use attribute access. Available +before any wallet is opened, useful for a wallet picker UI. + +## Export and import + +```python +hex_payload = await wallet.wallet_export( + wallet_secret="example-secret", + include_transactions=True, +) +# ... transfer hex_payload to another machine, then: +imported = await wallet.wallet_import( + wallet_secret="example-secret", + wallet_data=hex_payload, +) +new_filename = imported["walletDescriptor"]["filename"] +await wallet.wallet_open( + wallet_secret="example-secret", + account_descriptors=True, + filename=new_filename, +) +``` + +[`wallet_import`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_import) is the only file-API method that returns a dict — +read the new filename via `imported["walletDescriptor"]["filename"]`. + +The exported payload is borsh-serialized and remains encrypted with +`wallet_secret`; private key material never leaves memory in the +clear. [`wallet_import`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_import) writes a new file in the store and returns its +descriptor — you still need to [`wallet_open`](../../reference/Classes/Wallet.md#kaspa.Wallet.wallet_open) it. + +## Change secret + +```python +await wallet.wallet_change_secret( + old_wallet_secret="example-secret", + new_wallet_secret="new-secret", +) +``` + +Re-encrypts the open file in place. The wallet stays open under the +new secret; subsequent `wallet_open` calls must use the new password. + +## Rename + +```python +await wallet.wallet_rename( + wallet_secret="example-secret", + title="renamed wallet", +) +``` + +!!! warning "Rename `title` only — `filename` is broken upstream" + Passing `filename` triggers an upstream rusty-kaspa bug: the new path + is resolved relative to the process cwd (not the wallet store folder) + and the `.wallet` extension is not appended. The renamed file ends up + at `./` and the in-memory store starts pointing at that bare + path. Until fixed upstream, leave `filename=None`. + +## Where to next + +- [Lifecycle](lifecycle.md) — create, open, close, and the rest of + the state machine. +- [Private Keys](private-keys.md) — populate the open wallet with key + data. +- [Accounts](accounts.md) — derive accounts from stored key data. diff --git a/docs/llms.txt b/docs/llms.txt index 3afb641e..349c8951 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -21,8 +21,7 @@ Docs are versioned (base URL requires version path like `/latest/` or `/dev/`). ## Documentation - [API Reference](https://kaspanet.github.io/kaspa-python-sdk/latest/reference/): Full API documentation -- [Examples](https://kaspanet.github.io/kaspa-python-sdk/latest/getting-started/examples/): Code examples -- [Guides](https://kaspanet.github.io/kaspa-python-sdk/latest/guides/): Feature guides for RPC, transactions, addresses, etc. +- [Examples](https://kaspanet.github.io/kaspa-python-sdk/latest/examples/): Runnable example scripts on GitHub - [Source](https://github.com/kaspanet/kaspa-python-sdk): GitHub repository ## Optional diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 07f9217f..ea6043e0 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,5 +1,5 @@ -/* Style navigation section headers to stand out more */ -.md-nav--primary .md-nav__item--section > .md-nav__link { +/* Top-level section headers (e.g. tabs like LEARN) — uppercase teal */ +.md-nav--primary > .md-nav__list > .md-nav__item--section > .md-nav__link { font-weight: 700; color: var(--md-primary-fg-color); text-transform: uppercase; @@ -9,15 +9,39 @@ opacity: 0.8; } -/* Add a subtle top border to separate sections */ -.md-nav--primary .md-nav__item--section { +/* Top-level section dividers */ +.md-nav--primary > .md-nav__list > .md-nav__item--section { border-top: 1px solid var(--md-default-fg-color--lightest); padding-top: 0.6em; margin-top: 0.6em; } -/* Remove border from the first section */ -.md-nav--primary .md-nav__item--section:first-child { +.md-nav--primary > .md-nav__list > .md-nav__item--section:first-child { + border-top: none; + margin-top: 0; + padding-top: 0; +} + +/* Nested section headers (e.g. RPC, Wallet, Transactions inside Learn) — + bold + slightly muted, not uppercase, so hierarchy reads clearly */ +.md-nav--primary .md-nav .md-nav__item--section > .md-nav__link { + font-weight: 700; + color: var(--md-default-fg-color); + font-size: 0.75rem; + text-transform: none; + letter-spacing: 0; + margin-top: 0.8em; + opacity: 0.85; +} + +/* Lighter divider above nested sections */ +.md-nav--primary .md-nav .md-nav__item--section { + border-top: 1px dashed var(--md-default-fg-color--lightest); + padding-top: 0.4em; + margin-top: 0.4em; +} + +.md-nav--primary .md-nav .md-nav__item--section:first-child { border-top: none; margin-top: 0; padding-top: 0; diff --git a/kaspa_rpc.pyi b/kaspa_rpc.pyi index 506bdff6..7819c0ea 100644 --- a/kaspa_rpc.pyi +++ b/kaspa_rpc.pyi @@ -7,7 +7,7 @@ Long term, attempts should be made to auto generate. """ from enum import Enum -from typing import TypedDict +from typing import Literal, TypedDict # ============================================================================= @@ -962,3 +962,128 @@ class SubmitTransactionReplacementResponse(TypedDict): transactionId: str replacedTransaction: RpcTransaction + +# ============================================================================= +# Notification bodies (the value of the `data` field on subscription events) +# ============================================================================= + +class RpcBlockAddedNotification(TypedDict): + """Body of a `BlockAdded` event (the value of `event["data"]`).""" + block: RpcBlock + + +class RpcVirtualChainChangedNotification(TypedDict): + """Body of a `VirtualChainChanged` event.""" + removedChainBlockHashes: list[str] + addedChainBlockHashes: list[str] + acceptedTransactionIds: list[RpcAcceptedTransactionIds] + + +class RpcFinalityConflictNotification(TypedDict): + """Body of a `FinalityConflict` event.""" + violatingBlockHash: str + + +class RpcFinalityConflictResolvedNotification(TypedDict): + """Body of a `FinalityConflictResolved` event.""" + finalityBlockHash: str + + +class RpcSinkBlueScoreChangedNotification(TypedDict): + """Body of a `SinkBlueScoreChanged` event.""" + sinkBlueScore: int + + +class RpcVirtualDaaScoreChangedNotification(TypedDict): + """Body of a `VirtualDaaScoreChanged` event.""" + virtualDaaScore: int + + +class RpcPruningPointUtxoSetOverrideNotification(TypedDict): + """Body of a `PruningPointUtxoSetOverride` event. The body is empty.""" + pass + + +class RpcNewBlockTemplateNotification(TypedDict): + """Body of a `NewBlockTemplate` event. The body is empty.""" + pass + + +# ============================================================================= +# Subscription event wrappers (the dict passed to `add_event_listener` callbacks) +# +# Note: the `type` field is the PascalCase variant name as emitted by the +# bindings (e.g. `"BlockAdded"`), even though the kebab-case form (`"block-added"`) +# is what `add_event_listener` accepts when registering listeners. +# ============================================================================= + +class BlockAddedEvent(TypedDict): + """Callback payload for the `block-added` subscription.""" + type: Literal["BlockAdded"] + data: RpcBlockAddedNotification + + +class VirtualChainChangedEvent(TypedDict): + """Callback payload for the `virtual-chain-changed` subscription.""" + type: Literal["VirtualChainChanged"] + data: RpcVirtualChainChangedNotification + + +class FinalityConflictEvent(TypedDict): + """Callback payload for the `finality-conflict` subscription.""" + type: Literal["FinalityConflict"] + data: RpcFinalityConflictNotification + + +class FinalityConflictResolvedEvent(TypedDict): + """Callback payload for the `finality-conflict-resolved` subscription.""" + type: Literal["FinalityConflictResolved"] + data: RpcFinalityConflictResolvedNotification + + +class UtxosChangedEvent(TypedDict): + """Callback payload for the `utxos-changed` subscription. + + Unlike the other notification events, the body is flattened onto the + event itself rather than nested under a `data` key. + """ + type: Literal["UtxosChanged"] + added: list[RpcUtxosByAddressesEntry] + removed: list[RpcUtxosByAddressesEntry] + + +class SinkBlueScoreChangedEvent(TypedDict): + """Callback payload for the `sink-blue-score-changed` subscription.""" + type: Literal["SinkBlueScoreChanged"] + data: RpcSinkBlueScoreChangedNotification + + +class VirtualDaaScoreChangedEvent(TypedDict): + """Callback payload for the `virtual-daa-score-changed` subscription.""" + type: Literal["VirtualDaaScoreChanged"] + data: RpcVirtualDaaScoreChangedNotification + + +class PruningPointUtxoSetOverrideEvent(TypedDict): + """Callback payload for the `pruning-point-utxo-set-override` subscription.""" + type: Literal["PruningPointUtxoSetOverride"] + data: RpcPruningPointUtxoSetOverrideNotification + + +class NewBlockTemplateEvent(TypedDict): + """Callback payload for the `new-block-template` subscription.""" + type: Literal["NewBlockTemplate"] + data: RpcNewBlockTemplateNotification + + +class ConnectEvent(TypedDict): + """Emitted by `RpcClient` whenever the underlying WebSocket connects.""" + type: Literal["connect"] + rpc: str + + +class DisconnectEvent(TypedDict): + """Emitted by `RpcClient` whenever the underlying WebSocket disconnects.""" + type: Literal["disconnect"] + rpc: str + diff --git a/mkdocs.yml b/mkdocs.yml index 81a571da..1780d046 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,7 +70,11 @@ plugins: markdown_extensions: - admonition - pymdownx.details - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.highlight: anchor_linenums: true line_spans: __span @@ -107,17 +111,53 @@ extra: copyright: Copyright © 2022-2026 Kaspa developers nav: - - Home: index.md - Getting Started: + - index.md - Installation: getting-started/installation.md - - Examples: getting-started/examples.md - - Guides: - - RPC Client: guides/rpc-client.md - - Addresses: guides/addresses.md - - Transactions: guides/transactions.md - - Mnemonics: guides/mnemonics.md - - Key Derivation: guides/key-derivation.md - - Message Signing: guides/message-signing.md + - Security: getting-started/security.md + - Learn: + - Overview: learn/index.md + - Fundamentals: + - Networks: learn/networks.md + - Addresses: learn/addresses.md + - RPC: + - Overview: learn/rpc/overview.md + - Connecting: learn/rpc/connecting.md + - Resolver: learn/rpc/resolver.md + - Calls: learn/rpc/calls.md + - Subscriptions: learn/rpc/subscriptions.md + - Wallet: + - Overview: learn/wallet/overview.md + - Architecture: learn/wallet/architecture.md + - Lifecycle: learn/wallet/lifecycle.md + - Wallet Files: learn/wallet/wallet-files.md + - Private Keys: learn/wallet/private-keys.md + - Accounts: learn/wallet/accounts.md + - Addresses: learn/wallet/addresses.md + - Send Transaction: learn/wallet/send-transaction.md + - Sweep Funds: learn/wallet/sweep.md + - Sync State: learn/wallet/sync-state.md + - Events: learn/wallet/events.md + - Transaction History: learn/wallet/transaction-history.md + - Errors: learn/wallet/errors.md + - Wallet SDK: + - Overview: learn/wallet-sdk/overview.md + - Key Management: learn/wallet-sdk/key-management.md + - Derivation: learn/wallet-sdk/derivation.md + - UTXO Processor: learn/wallet-sdk/utxo-processor.md + - UTXO Context: learn/wallet-sdk/utxo-context.md + - Transaction Generator: learn/wallet-sdk/tx-generator.md + - Transactions: + - Overview: learn/transactions/overview.md + - Metadata Fields: learn/transactions/metadata.md + - Inputs: learn/transactions/inputs.md + - Outputs: learn/transactions/outputs.md + - Mass & Fees: learn/transactions/mass-and-fees.md + - Signing: learn/transactions/signing.md + - Scripts: learn/transactions/scripts.md + - Submission: learn/transactions/submission.md + - Serialization: learn/transactions/serialization.md + - Examples: examples.md - API Reference: reference/ - Contributing: - Overview: contributing/index.md diff --git a/python/kaspa/__init__.pyi b/python/kaspa/__init__.pyi index 203bd04d..abc4076f 100644 --- a/python/kaspa/__init__.pyi +++ b/python/kaspa/__init__.pyi @@ -4713,7 +4713,7 @@ Long term, attempts should be made to auto generate. """ from enum import Enum -from typing import TypedDict +from typing import Literal, TypedDict # ============================================================================= @@ -5666,4 +5666,129 @@ class SubmitTransactionResponse(TypedDict): class SubmitTransactionReplacementResponse(TypedDict): """Response from submit_transaction_replacement.""" transactionId: str - replacedTransaction: RpcTransaction \ No newline at end of file + replacedTransaction: RpcTransaction + + +# ============================================================================= +# Notification bodies (the value of the `data` field on subscription events) +# ============================================================================= + +class RpcBlockAddedNotification(TypedDict): + """Body of a `BlockAdded` event (the value of `event["data"]`).""" + block: RpcBlock + + +class RpcVirtualChainChangedNotification(TypedDict): + """Body of a `VirtualChainChanged` event.""" + removedChainBlockHashes: list[str] + addedChainBlockHashes: list[str] + acceptedTransactionIds: list[RpcAcceptedTransactionIds] + + +class RpcFinalityConflictNotification(TypedDict): + """Body of a `FinalityConflict` event.""" + violatingBlockHash: str + + +class RpcFinalityConflictResolvedNotification(TypedDict): + """Body of a `FinalityConflictResolved` event.""" + finalityBlockHash: str + + +class RpcSinkBlueScoreChangedNotification(TypedDict): + """Body of a `SinkBlueScoreChanged` event.""" + sinkBlueScore: int + + +class RpcVirtualDaaScoreChangedNotification(TypedDict): + """Body of a `VirtualDaaScoreChanged` event.""" + virtualDaaScore: int + + +class RpcPruningPointUtxoSetOverrideNotification(TypedDict): + """Body of a `PruningPointUtxoSetOverride` event. The body is empty.""" + pass + + +class RpcNewBlockTemplateNotification(TypedDict): + """Body of a `NewBlockTemplate` event. The body is empty.""" + pass + + +# ============================================================================= +# Subscription event wrappers (the dict passed to `add_event_listener` callbacks) +# +# Note: the `type` field is the PascalCase variant name as emitted by the +# bindings (e.g. `"BlockAdded"`), even though the kebab-case form (`"block-added"`) +# is what `add_event_listener` accepts when registering listeners. +# ============================================================================= + +class BlockAddedEvent(TypedDict): + """Callback payload for the `block-added` subscription.""" + type: Literal["BlockAdded"] + data: RpcBlockAddedNotification + + +class VirtualChainChangedEvent(TypedDict): + """Callback payload for the `virtual-chain-changed` subscription.""" + type: Literal["VirtualChainChanged"] + data: RpcVirtualChainChangedNotification + + +class FinalityConflictEvent(TypedDict): + """Callback payload for the `finality-conflict` subscription.""" + type: Literal["FinalityConflict"] + data: RpcFinalityConflictNotification + + +class FinalityConflictResolvedEvent(TypedDict): + """Callback payload for the `finality-conflict-resolved` subscription.""" + type: Literal["FinalityConflictResolved"] + data: RpcFinalityConflictResolvedNotification + + +class UtxosChangedEvent(TypedDict): + """Callback payload for the `utxos-changed` subscription. + + Unlike the other notification events, the body is flattened onto the + event itself rather than nested under a `data` key. + """ + type: Literal["UtxosChanged"] + added: list[RpcUtxosByAddressesEntry] + removed: list[RpcUtxosByAddressesEntry] + + +class SinkBlueScoreChangedEvent(TypedDict): + """Callback payload for the `sink-blue-score-changed` subscription.""" + type: Literal["SinkBlueScoreChanged"] + data: RpcSinkBlueScoreChangedNotification + + +class VirtualDaaScoreChangedEvent(TypedDict): + """Callback payload for the `virtual-daa-score-changed` subscription.""" + type: Literal["VirtualDaaScoreChanged"] + data: RpcVirtualDaaScoreChangedNotification + + +class PruningPointUtxoSetOverrideEvent(TypedDict): + """Callback payload for the `pruning-point-utxo-set-override` subscription.""" + type: Literal["PruningPointUtxoSetOverride"] + data: RpcPruningPointUtxoSetOverrideNotification + + +class NewBlockTemplateEvent(TypedDict): + """Callback payload for the `new-block-template` subscription.""" + type: Literal["NewBlockTemplate"] + data: RpcNewBlockTemplateNotification + + +class ConnectEvent(TypedDict): + """Emitted by `RpcClient` whenever the underlying WebSocket connects.""" + type: Literal["connect"] + rpc: str + + +class DisconnectEvent(TypedDict): + """Emitted by `RpcClient` whenever the underlying WebSocket disconnects.""" + type: Literal["disconnect"] + rpc: str \ No newline at end of file