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