From 8e7e11c9dc2e8ece3428d9f4f7d0546fc7f4b007 Mon Sep 17 00:00:00 2001 From: smartgoo Date: Wed, 22 Apr 2026 16:54:28 -0400 Subject: [PATCH 01/15] export import example --- examples/wallet/export_import.py | 76 ++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 examples/wallet/export_import.py diff --git a/examples/wallet/export_import.py b/examples/wallet/export_import.py new file mode 100644 index 00000000..94c26bbc --- /dev/null +++ b/examples/wallet/export_import.py @@ -0,0 +1,76 @@ +"""Export, import and reload, a wallet. +""" + +import argparse +import asyncio +from pathlib import Path + +from kaspa import Resolver, Wallet + +from shared import NETWORK_ID, WALLET_SECRET, open_or_create_wallet + +TITLE = "wallet export import demo" +FILENAME = "-".join(TITLE.split(" ")) + + +async def main(rpc_url: str | None): + # ------------------------------------------------------------------ + # Construct and start wallet + # ------------------------------------------------------------------ + if rpc_url: + wallet = Wallet(network_id=NETWORK_ID, url=rpc_url) + else: + wallet = Wallet(network_id=NETWORK_ID, resolver=Resolver()) + + await wallet.start() + print("Wallet started") + + # ------------------------------------------------------------------ + # Open existing wallet file or create new + # ------------------------------------------------------------------ + await open_or_create_wallet(wallet, FILENAME, title=TITLE) + + # ------------------------------------------------------------------ + # Export -> close -> import -> open + # ------------------------------------------------------------------ + exported = await wallet.wallet_export(WALLET_SECRET, True) + print(f"Wallet exported: {exported[:32]}... ({len(exported)} hex chars)") + + await wallet.wallet_close() + print("Wallet closed") + + # Delete on-disk wallet files so the import process + # can create from the prior exported hex + wallet_dir = Path.home() / ".kaspa" + wallet_path = wallet_dir / f"{FILENAME}.wallet" + wallet_path.unlink() + print("Wallet deleted (to simulate import from scratch)") + + # No transactions under this wallet, so, nothing to delete + # transactions_path = wallet_dir / f"{FILENAME}.transactions" + # transactions_path.rmdir() + # print(f"Removed transactions dir at {wallet_path}") + + # `wallet_import` writes a fresh file derived from the payload's + imported = await wallet.wallet_import(WALLET_SECRET, exported) + print(f"Wallet imported (from the prior exported hex): {imported}") + imported_filename = imported["walletDescriptor"]["filename"] + + reopened = await wallet.wallet_open(WALLET_SECRET, True, imported_filename) + print(f"Reopened imported wallet ({imported_filename}): {reopened}") + + # ------------------------------------------------------------------ + # Wind down. + # ------------------------------------------------------------------ + await wallet.wallet_close() + print("Wallet closed") + + await wallet.stop() + print("Wallet stopped") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--rpc-url", default=None, help="RPC URL (defaults to Resolver)") + args = parser.parse_args() + asyncio.run(main(args.rpc_url)) From 0719caafa5fdf86d4d399c3f98462f65147472ff Mon Sep 17 00:00:00 2001 From: smartgoo Date: Wed, 22 Apr 2026 16:56:16 -0400 Subject: [PATCH 02/15] wallet example utils --- examples/wallet/shared.py | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 examples/wallet/shared.py diff --git a/examples/wallet/shared.py b/examples/wallet/shared.py new file mode 100644 index 00000000..1492f195 --- /dev/null +++ b/examples/wallet/shared.py @@ -0,0 +1,60 @@ +"""Shared demo variables and bootstrap helper for the wallet examples. + +All four wallet examples import the same mnemonic, secret, and network id +from this module so they derive addresses from a single deterministic seed. + +Each example uses its own `FILENAME` so the on-disk wallets stay +independent. +""" + +from kaspa import PrvKeyDataVariantKind, Wallet + +FIXED_MNEMONIC_PHRASE = ( + "abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon abandon abandon art" +) +WALLET_SECRET = "example-wallet-secret" +NETWORK_ID = "testnet-10" + + +async def open_or_create_wallet(wallet: Wallet, filename: str, *, title: str) -> str: + """Open an existing demo wallet or create a fresh one. + + On the first run this stores the shared demo mnemonic and derives a + BIP32 account at index 0. On subsequent runs it just reopens the + persisted wallet. + """ + if await wallet.exists(filename): + w = await wallet.wallet_open(WALLET_SECRET, True, filename) + print(f"Opened existing wallet file `{filename}`: {w}") + return (await wallet.accounts_enumerate())[0].account_id + + created = await wallet.wallet_create( + wallet_secret=WALLET_SECRET, + filename=filename, + overwrite_wallet_storage=False, + title=title, + user_hint="example", + ) + print(f"Created new wallet file {filename!r}: {created}") + + prv_key_id = await wallet.prv_key_data_create( + wallet_secret=WALLET_SECRET, + secret=FIXED_MNEMONIC_PHRASE, + kind=PrvKeyDataVariantKind.Mnemonic, + payment_secret=None, + name="demo-key", + ) + print(f"Created PrvKeyDataId: {prv_key_id}") + + descriptor = await wallet.accounts_create_bip32( + wallet_secret=WALLET_SECRET, + prv_key_data_id=prv_key_id, + payment_secret=None, + account_name="demo-acct", + account_index=0, + ) + print(f"Created BIP32 account: {descriptor}") + + return descriptor.account_id From 732c600d7208cde0e8257f861f16820609564f68 Mon Sep 17 00:00:00 2001 From: smartgoo Date: Wed, 22 Apr 2026 17:11:30 -0400 Subject: [PATCH 03/15] wallet creation example --- examples/wallet/creation.py | 109 ++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 examples/wallet/creation.py diff --git a/examples/wallet/creation.py b/examples/wallet/creation.py new file mode 100644 index 00000000..37f1f901 --- /dev/null +++ b/examples/wallet/creation.py @@ -0,0 +1,109 @@ +"""Create (or open) a deterministic wallet and demonstrate persistence. + +Reruns reuse the wallet file written on the first run, so `wallet.exists()` +flips to True and `wallet_open` replaces `wallet_create`. The fixed mnemonic +means the BIP32 account derives the same addresses every time. +""" + +import argparse +import asyncio + +from kaspa import PrvKeyDataVariantKind, Resolver, Wallet +from kaspa.exceptions import WalletAlreadyExistsError + +from shared import FIXED_MNEMONIC_PHRASE, NETWORK_ID, WALLET_SECRET + +FILENAME = "wallet-creation-demo" +TITLE = "wallet creation demo" + + +async def main(rpc_url: str | None): + # ------------------------------------------------------------------ + # Construct wallet + # ------------------------------------------------------------------ + if rpc_url: + wallet = Wallet(network_id=NETWORK_ID, url=rpc_url) + else: + wallet = Wallet(network_id=NETWORK_ID, resolver=Resolver()) + + print("Initialized Wallet instance properties:") + print(f" - rpc: {wallet.rpc}") + print(f" - is_open: {wallet.is_open}") + print(f" - descriptor: {wallet.descriptor}") + + # ------------------------------------------------------------------ + # Start wallet runtime + # ------------------------------------------------------------------ + await wallet.start() + print("Wallet started") + + wallets = await wallet.wallet_enumerate() + print("Wallets on disk:") + for w in wallets: + print(f" - {w}") + + # ------------------------------------------------------------------ + # Create wallet (catch and fall back to open if it already exists) + # ------------------------------------------------------------------ + try: + created = await wallet.wallet_create( + wallet_secret=WALLET_SECRET, + filename=FILENAME, + overwrite_wallet_storage=False, + title=TITLE, + user_hint="example", + ) + print(f"Created new wallet file {FILENAME!r}: {created}") + + prv_key_id = await wallet.prv_key_data_create( + wallet_secret=WALLET_SECRET, + secret=FIXED_MNEMONIC_PHRASE, + kind=PrvKeyDataVariantKind.Mnemonic, + payment_secret=None, + name="demo-key", + ) + print(f"Created PrvKeyDataId: {prv_key_id}") + + descriptor = await wallet.accounts_create_bip32( + wallet_secret=WALLET_SECRET, + prv_key_data_id=prv_key_id, + payment_secret=None, + account_name="demo-acct", + account_index=0, + ) + print(f"Created BIP32 account: {descriptor}") + except WalletAlreadyExistsError: + print(f"Wallet with filename `{FILENAME}` already exists") + opened = await wallet.wallet_open(WALLET_SECRET, True, FILENAME) + print(f"Opened existing wallet {FILENAME}: {opened}") + + print("Opened Wallet instance properties:") + print(f" - is_open: {wallet.is_open}") + print(f" - descriptor: {wallet.descriptor}") + + # ------------------------------------------------------------------ + # Close and reopen (just to show persistence) + # ------------------------------------------------------------------ + await wallet.wallet_close() + print("Closed Wallet instance properties:") + print(f" - is_open: {wallet.is_open}") + print(f" - descriptor: {wallet.descriptor}") + + reopened = await wallet.wallet_open(WALLET_SECRET, True, FILENAME) + print(f"Reopened wallet: {reopened}") + + await wallet.wallet_close() + print("Wallet closed") + + # ------------------------------------------------------------------ + # Stop wallet runtime + # ------------------------------------------------------------------ + await wallet.stop() + print("Wallet stopped") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--rpc-url", default=None, help="RPC URL (defaults to Resolver)") + args = parser.parse_args() + asyncio.run(main(args.rpc_url)) From 7b81f78c7822d4f01bc4b23e669dbad7e758fd42 Mon Sep 17 00:00:00 2001 From: smartgoo Date: Thu, 23 Apr 2026 17:06:59 -0400 Subject: [PATCH 04/15] fix accounts_get return value --- python/kaspa/__init__.pyi | 2 +- src/wallet/core/wallet.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/python/kaspa/__init__.pyi b/python/kaspa/__init__.pyi index 2b854c37..6c0bc6f1 100644 --- a/python/kaspa/__init__.pyi +++ b/python/kaspa/__init__.pyi @@ -3238,7 +3238,7 @@ class Wallet: Args: account_ids: Optional list of hex-encoded account ids. If None, activates all accounts. """ - def accounts_get(self, account_id: AccountId | str) -> None: + def accounts_get(self, account_id: AccountId | str) -> AccountDescriptor: r""" Verify that an account exists in the open wallet. diff --git a/src/wallet/core/wallet.rs b/src/wallet/core/wallet.rs index b18899fe..f3ee188e 100644 --- a/src/wallet/core/wallet.rs +++ b/src/wallet/core/wallet.rs @@ -1195,7 +1195,7 @@ impl PyWallet { /// /// Args: /// account_id: Hex-encoded id of the account to look up. - #[gen_stub(override_return_type(type_repr = "None"))] + #[gen_stub(override_return_type(type_repr = "AccountDescriptor"))] pub fn accounts_get<'py>( &self, py: Python<'py>, @@ -1207,9 +1207,9 @@ impl PyWallet { let wallet = self.wallet().clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - wallet.accounts_get_call(request).await.into_py_result()?; + let resp = wallet.accounts_get_call(request).await.into_py_result()?; - Ok(()) + Ok(PyAccountDescriptor::from(resp.account_descriptor)) }) } From 4d23e3c7f88252028089676ad3d26d0b6f12550f Mon Sep 17 00:00:00 2001 From: smartgoo Date: Thu, 23 Apr 2026 20:26:39 -0400 Subject: [PATCH 05/15] wallet examples wip --- examples/wallet.py | 506 -------------------------------- examples/wallet/accounts.py | 218 ++++++++++++++ examples/wallet/shared.py | 29 +- examples/wallet/transactions.py | 193 ++++++++++++ 4 files changed, 429 insertions(+), 517 deletions(-) delete mode 100644 examples/wallet.py create mode 100644 examples/wallet/accounts.py create mode 100644 examples/wallet/transactions.py diff --git a/examples/wallet.py b/examples/wallet.py deleted file mode 100644 index 2f67ffa9..00000000 --- a/examples/wallet.py +++ /dev/null @@ -1,506 +0,0 @@ -import argparse -import asyncio - -from kaspa import ( - AccountKind, - AccountsDiscoveryKind, - CommitRevealAddressKind, - Fees, - Mnemonic, - NetworkId, - NewAddressKind, - PrvKeyDataVariantKind, - Resolver, - TransactionKind, - Wallet, - WalletEventType, -) - -WALLET_SECRET = "walletSecret" -NEW_WALLET_SECRET = "walletSecretNew" -FILENAME = "wallet_demo" -NETWORK_ID = "testnet-10" - - -def _on_event(event): - print(f" [event callback]: {event.get('type', '')}") - - -async def main(rpc_url: str | None): - # ------------------------------------------------------------------ - # 1. Construct the Wallet - # ------------------------------------------------------------------ - if rpc_url: - wallet = Wallet(network_id=NETWORK_ID, url=rpc_url) - else: - wallet = Wallet(network_id=NETWORK_ID, resolver=Resolver()) - - print("--- Wallet.rpc") - print(wallet.rpc) - print() - - print("--- Wallet.is_open") - print(wallet.is_open) - print() - - print("--- Wallet.is_synced") - print(wallet.is_synced) - print() - - print("--- Wallet.descriptor") - print(wallet.descriptor) - print() - - print("--- Wallet.exists(FILENAME)") - print(await wallet.exists(FILENAME)) - print() - - # ------------------------------------------------------------------ - # 2. Start wallet runtime & add event listener - # ------------------------------------------------------------------ - print("--- Wallet.start()") - print(await wallet.start()) - print() - - print("--- Wallet.connect()") - print(await wallet.connect(url=rpc_url, strategy="fallback", timeout_duration=5000)) - print() - - print("--- add_event_listener('all')") - print(wallet.add_event_listener(WalletEventType.All, _on_event)) - print() - - print("--- set_network_id(NETWORK_ID)") - print(wallet.set_network_id(NetworkId(NETWORK_ID))) - print() - - # ------------------------------------------------------------------ - # 3. wallet_* fns: enumerate -> create -> open. - # ------------------------------------------------------------------ - print("--- wallet_enumerate()") - print(await wallet.wallet_enumerate()) - print() - - print("--- wallet_create()") - print(await wallet.wallet_create( - wallet_secret=WALLET_SECRET, - filename=FILENAME, - overwrite_wallet_storage=True, - title="full walkthrough", - user_hint="example", - )) - print() - - print("--- Wallet.is_open [after create]") - print(wallet.is_open) - print() - - print("--- Wallet.descriptor [after create]") - print(wallet.descriptor) - print() - - # ------------------------------------------------------------------ - # 4. prv_key_data_* fns: create an entry, then list/fetch by id. - # ------------------------------------------------------------------ - print("--- prv_key_data_create(Mnemonic)") - mnemonic_prv_key_id = await wallet.prv_key_data_create( - wallet_secret=WALLET_SECRET, - secret=Mnemonic.random().phrase, - kind=PrvKeyDataVariantKind.Mnemonic, - payment_secret=None, - name="walkthrough-mnemonic-key", - ) - print(mnemonic_prv_key_id) - print() - - # accounts_create_keypair / accounts_import_keypair require a SecretKey - # variant prv_key_data; mnemonic-backed entries fail upstream with - # "Sectet key is required". - print("--- prv_key_data_create(SecretKey)") - secret_key_prv_key_id = await wallet.prv_key_data_create( - wallet_secret=WALLET_SECRET, - secret="a" * 32, - kind=PrvKeyDataVariantKind.SecretKey, - payment_secret=None, - name="walkthrough-secret-key", - ) - print(secret_key_prv_key_id) - print() - - print("--- prv_key_data_enumerate()") - prv_key_infos = await wallet.prv_key_data_enumerate() - print(prv_key_infos) - print() - - print("--- prv_key_data_get(mnemonic id)") - print(await wallet.prv_key_data_get( - wallet_secret=WALLET_SECRET, prv_key_data_id=mnemonic_prv_key_id - )) - print() - - print("--- prv_key_data_get(secret-key id)") - print(await wallet.prv_key_data_get( - wallet_secret=WALLET_SECRET, prv_key_data_id=secret_key_prv_key_id - )) - print() - - # ------------------------------------------------------------------ - # 5. accounts_* fns: create -> enumerate -> get -> rename -> activate. - # ------------------------------------------------------------------ - print("--- accounts_create_bip32()") - bip32_descriptor = await wallet.accounts_create_bip32( - wallet_secret=WALLET_SECRET, - prv_key_data_id=mnemonic_prv_key_id, - payment_secret=None, - account_name="bip32-acct", - account_index=None, - ) - print(bip32_descriptor) - print() - - account_id = bip32_descriptor.account_id - - print("--- accounts_create_keypair()") - print(await wallet.accounts_create_keypair( - wallet_secret=WALLET_SECRET, - prv_key_data_id=secret_key_prv_key_id, - ecdsa=False, - account_name="kp-acct", - )) - print() - - print("--- accounts_import_bip32()") - print(await wallet.accounts_import_bip32( - wallet_secret=WALLET_SECRET, - prv_key_data_id=mnemonic_prv_key_id, - payment_secret=None, - account_name="imported-bip32", - account_index=None, - )) - print() - - print("--- accounts_import_keypair()") - try: - print(await wallet.accounts_import_keypair( - wallet_secret=WALLET_SECRET, - prv_key_data_id=secret_key_prv_key_id, - ecdsa=False, - account_name="imported-kp", - )) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - print("--- accounts_ensure_default(Bip32)") - print(await wallet.accounts_ensure_default( - wallet_secret=WALLET_SECRET, - account_kind=AccountKind("bip32"), - payment_secret=None, - mnemonic_phrase=Mnemonic.random().phrase, - )) - print() - - print("--- accounts_enumerate()") - print(await wallet.accounts_enumerate()) - print() - - print("--- accounts_get(account_id)") - print(await wallet.accounts_get(account_id)) - print() - - print("--- accounts_rename()") - print(await wallet.accounts_rename( - wallet_secret=WALLET_SECRET, - account_id=account_id, - name="renamed-bip32", - )) - print() - - print("--- accounts_activate([account_id])") - print(await wallet.accounts_activate([account_id])) - print() - - # ------------------------------------------------------------------ - # 6. Address derivation. - # ------------------------------------------------------------------ - print("--- accounts_create_new_address(Receive)") - receive_address = await wallet.accounts_create_new_address( - account_id, NewAddressKind.Receive - ) - print(receive_address) - print() - - print("--- accounts_create_new_address(Change)") - print(await wallet.accounts_create_new_address(account_id, NewAddressKind.Change)) - print() - - # ------------------------------------------------------------------ - # 6b. Wait for testnet-10 funds at the receive address before - # proceeding to UTXO/spend paths. - # ------------------------------------------------------------------ - print(f"\n>>> Send testnet-10 funds to: {receive_address}") - print(">>> Faucet: https://faucet.kaspanet.io/") - print(">>> Waiting for funds...") - while True: - utxos = await wallet.accounts_get_utxos(account_id=account_id) - if utxos: - print(f">>> Funds detected: {len(utxos)} UTXO(s)") - print() - break - await asyncio.sleep(5) - - # ------------------------------------------------------------------ - # 7. accounts_discovery (offline-friendly: scans a fresh mnemonic). - # ------------------------------------------------------------------ - print("--- accounts_discovery(Bip44)") - print(await wallet.accounts_discovery( - discovery_kind=AccountsDiscoveryKind.Bip44, - address_scan_extent=10, - account_scan_extent=1, - bip39_mnemonic=Mnemonic.random().phrase, - bip39_passphrase=None, - )) - print() - - # ------------------------------------------------------------------ - # 8. UTXO + spend paths. These need an RPC connection and (for sends) - # funded UTXOs; they will surface their own errors when offline. - # ------------------------------------------------------------------ - print("--- accounts_get_utxos(account_id)") - try: - print(await wallet.accounts_get_utxos(account_id=account_id)) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - print("--- accounts_estimate(change-only, fee=0)") - try: - print(await wallet.accounts_estimate( - account_id=account_id, - priority_fee_sompi=Fees(0, None), - fee_rate=None, - payload=None, - destination=None, - )) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - print("--- accounts_send(change-only)") - try: - print(await wallet.accounts_send( - wallet_secret=WALLET_SECRET, - account_id=account_id, - priority_fee_sompi=Fees(0, None), - payment_secret=None, - fee_rate=None, - payload=None, - destination=None, - )) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - print("--- accounts_transfer(self -> self)") - try: - print(await wallet.accounts_transfer( - wallet_secret=WALLET_SECRET, - source_account_id=account_id, - destination_account_id=account_id, - transfer_amount_sompi=1, - payment_secret=None, - fee_rate=None, - priority_fee_sompi=None, - )) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - print("--- accounts_commit_reveal()") - try: - print(await wallet.accounts_commit_reveal( - wallet_secret=WALLET_SECRET, - account_id=account_id, - address_type=CommitRevealAddressKind.Receive, - address_index=0, - script_sig=b"\x00", - commit_amount_sompi=1, - reveal_fee_sompi=1, - payment_secret=None, - fee_rate=None, - payload=None, - )) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - print("--- accounts_commit_reveal_manual()") - try: - print(await wallet.accounts_commit_reveal_manual( - wallet_secret=WALLET_SECRET, - account_id=account_id, - script_sig=b"\x00", - reveal_fee_sompi=1, - payment_secret=None, - fee_rate=None, - payload=None, - start_destination=None, - end_destination=None, - )) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - # ------------------------------------------------------------------ - # 9. transactions_*: read history, then attempt note/metadata edits. - # The replace_* calls expect a real transaction id; we use a - # placeholder so they exercise the code path. - # ------------------------------------------------------------------ - print("--- transactions_data_get()") - try: - print(await wallet.transactions_data_get( - account_id=account_id, - network_id=NetworkId(NETWORK_ID), - start=0, - end=10, - filter=[TransactionKind.Incoming], - )) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - placeholder_tx_id = "0" * 64 - - print("--- transactions_replace_note()") - try: - print(await wallet.transactions_replace_note( - account_id=account_id, - network_id=NetworkId(NETWORK_ID), - transaction_id=placeholder_tx_id, - note="walkthrough note", - )) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - print("--- transactions_replace_metadata()") - try: - print(await wallet.transactions_replace_metadata( - account_id=account_id, - network_id=NetworkId(NETWORK_ID), - transaction_id=placeholder_tx_id, - metadata="walkthrough metadata", - )) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - # ------------------------------------------------------------------ - # 10. batch / flush / retain_context / get_status / address book. - # ------------------------------------------------------------------ - print("--- batch()") - print(await wallet.batch()) - print() - - print("--- flush()") - print(await wallet.flush(WALLET_SECRET)) - print() - - print("--- retain_context()") - print(await wallet.retain_context("walkthrough-context", b"payload-bytes")) - print() - - print("--- get_status()") - print(await wallet.get_status(None)) - print() - - print("--- address_book_enumerate()") - print(await wallet.address_book_enumerate()) - print() - - # ------------------------------------------------------------------ - # 11. fee_rate_*: needs an RPC connection. - # ------------------------------------------------------------------ - print("--- fee_rate_estimate()") - try: - print(await wallet.fee_rate_estimate()) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - print("--- fee_rate_poller_enable(5)") - try: - print(await wallet.fee_rate_poller_enable(5)) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - print("--- fee_rate_poller_disable()") - try: - print(await wallet.fee_rate_poller_disable()) - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() - - # ------------------------------------------------------------------ - # 12. Wind down accounts. - # ------------------------------------------------------------------ - # accounts_deactivate hangs due to an upstream rk bug - # skipped for now - # print("--- accounts_deactivate([account_id])") - # print(await wallet.accounts_deactivate([account_id])) - # print() - - # ------------------------------------------------------------------ - # 13. Export, close, import, reopen, change secret, rename, reload. - # ------------------------------------------------------------------ - print("--- wallet_export()") - exported = await wallet.wallet_export(WALLET_SECRET, True) - print(exported) - print() - - print("--- wallet_close()") - print(await wallet.wallet_close()) - print() - - print("--- wallet_import()") - print(await wallet.wallet_import(WALLET_SECRET, exported)) - print() - - print("--- wallet_open()") - print(await wallet.wallet_open(WALLET_SECRET, True, FILENAME)) - print() - - print("--- wallet_reload(True)") - print(await wallet.wallet_reload(True)) - print() - - print("--- wallet_change_secret()") - print(await wallet.wallet_change_secret(WALLET_SECRET, NEW_WALLET_SECRET)) - print() - - print("--- wallet_rename(title only)") - print(await wallet.wallet_rename(NEW_WALLET_SECRET, "walkthrough renamed", None)) - print() - - print("--- wallet_close() [final]") - print(await wallet.wallet_close()) - print() - - # ------------------------------------------------------------------ - # 14. Wind down listeners and runtime. - # ------------------------------------------------------------------ - print("--- remove_event_listener('all')") - print(wallet.remove_event_listener(WalletEventType.All)) - print() - - print("--- Wallet.stop()") - print(await wallet.stop()) - print() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("--rpc-url", default=None, help="RPC URL (defaults to Resolver)") - args = parser.parse_args() - asyncio.run(main(args.rpc_url)) diff --git a/examples/wallet/accounts.py b/examples/wallet/accounts.py new file mode 100644 index 00000000..bdd65fc3 --- /dev/null +++ b/examples/wallet/accounts.py @@ -0,0 +1,218 @@ +"""Manage BIP32 and keypair accounts in one wallet file. + +A wallet file holds N accounts of any mix of variants, each backed by an +entry in the wallet's `prv_key_data` store. This example exercises both +account types in the same file: + +* **BIP32 (HD-derived)** — the default. One mnemonic backs unlimited + accounts, each at a different `account_index` on Kaspa's BIP44 path + `m/44'/111111'/{account_index}'`. Receive addresses live under + `.../0/i`, change addresses under `.../1/i`. Each account derives its + addresses on demand by walking the HD tree. The address at a given + index is deterministic (same mnemonic → same bytes), but + `accounts_create_new_address` advances the account's stored index on + every call — so the printed addresses shift forward each run. + +* **Keypair (non-HD)** — one pre-existing secp256k1 private key wrapped + as an account. One account, one address, no derivation path. Use when + importing a raw key from an external source (paper wallet, another + tool, etc.) into the wallet file. + +Both types coexist: the final `accounts_enumerate` prints them +side-by-side in the same on-disk wallet. +""" + +import argparse +import asyncio + +from kaspa import ( + AccountKind, + AccountsDiscoveryKind, + NewAddressKind, + PrvKeyDataVariantKind, + Resolver, + Wallet, +) +from kaspa.exceptions import WalletAccountAlreadyExistsError + +from shared import ( + FIXED_MNEMONIC_PHRASE, + NETWORK_ID, + WALLET_SECRET, + open_or_create_wallet, +) + +FILENAME = "wallet_accounts_demo" +# 32 UTF-8 bytes used directly as a secp256k1 scalar (see wallet-core's +# SecretKey variant handling in prv_key_data_create). In real use this +# would be a key you generated or imported, not a literal placeholder. +FIXED_SECRET_KEY_BYTES = "a" * 32 + +# Kaspa's SLIP-44 coin type. Combined with BIP44's purpose (44'), every +# HD account lives at m/44'/111111'/{account_index}'. +KASPA_COIN_TYPE = 111111 + + +def bip32_account_path(account_index: int) -> str: + return f"m/44'/{KASPA_COIN_TYPE}'/{account_index}'" + + +def bip32_address_path(account_index: int, change: bool, address_index: int) -> str: + chain = 1 if change else 0 + return f"{bip32_account_path(account_index)}/{chain}/{address_index}" + + +async def main(rpc_url: str | None): + # ------------------------------------------------------------------ + # Construct and start + # ------------------------------------------------------------------ + if rpc_url: + wallet = Wallet(network_id=NETWORK_ID, url=rpc_url) + else: + wallet = Wallet(network_id=NETWORK_ID, resolver=Resolver()) + + await wallet.start() + print("Wallet started\n") + + account_id = await open_or_create_wallet( + wallet, FILENAME, title="accounts and addresses demo" + ) + print(f"account_id = {account_id}\n") + + # ================================================================== + # BIP32 accounts (HD-derived from the wallet mnemonic) + # ================================================================== + print("Wallet accounts:") + for account in await wallet.accounts_enumerate(): + print(f" - {account}") + print() + + account = await wallet.accounts_get(account_id) + print(f"Account details: {account}\n") + # Default account sits at BIP44 index 0 on Kaspa's coin type. + print(f"Derivation path (account 0): {bip32_account_path(0)}\n") + + await wallet.accounts_activate([account_id]) + print(f"Activated account {account_id}\n") + + # Derive new receive/change addresses from the default BIP32 account. + # Receive addresses live under `.../0/i`, change under `.../1/i`; `i` + # is the per-chain index the account advances on each call. + new_receive = await wallet.accounts_create_new_address(account_id, NewAddressKind.Receive) + print(f"New receive address: {new_receive} (under {bip32_account_path(0)}/0/i)\n") + + new_change = await wallet.accounts_create_new_address(account_id, NewAddressKind.Change) + print(f"New change address: {new_change} (under {bip32_account_path(0)}/1/i)\n") + + # Create a *second* BIP32 account at account_index=1, reusing the + # same prv_key_data_id as account 0 (same mnemonic). Different HD + # subtree → completely different addresses from account 0. + mnemonic_prv_id = next( + info.id for info in await wallet.prv_key_data_enumerate() if info.name == "demo-key" + ) + try: + second_bip32 = await wallet.accounts_create_bip32( + wallet_secret=WALLET_SECRET, + prv_key_data_id=mnemonic_prv_id, + payment_secret=None, + account_name="demo-acct-1", + account_index=1, + ) + print(f"Created second BIP32 account (index=1): {second_bip32}\n") + acct1_id = second_bip32.account_id + except WalletAccountAlreadyExistsError: + print("Second BIP32 account (index=1) already exists\n") + # Find it by elimination — the BIP32 account that isn't our + # default. Robust to renames (unlike a name-based lookup). + bip32_kind = AccountKind("bip32") + acct1_id = next( + a.account_id + for a in await wallet.accounts_enumerate() + if a.kind == bip32_kind and a.account_id != account_id + ) + print(f"Derivation path (account 1): {bip32_account_path(1)}\n") + + # Derive a receive address under account 1 to show the tree diverges + # from account 0 despite sharing the same mnemonic. Same caveat as + # above: the receive-chain index advances on every run. + acct1_receive = await wallet.accounts_create_new_address(acct1_id, NewAddressKind.Receive) + print(f"Account 1 receive address: {acct1_receive} (under {bip32_account_path(1)}/0/i)\n") + + # BIP44 discovery: given a mnemonic, derive accounts at + # m/44'/111111'/{i}' for i in [0, account_scan_extent) and ask the + # node whether any derived addresses have on-chain history. Returns + # the highest account index that had activity. RPC is required for + # the activity check, so we connect just for this call. + await wallet.connect(url=rpc_url, strategy="fallback", timeout_duration=5000) + print("RPC connected (for BIP44 discovery)\n") + discovered = await wallet.accounts_discovery( + discovery_kind=AccountsDiscoveryKind.Bip44, + address_scan_extent=10, + account_scan_extent=1, + bip39_mnemonic=FIXED_MNEMONIC_PHRASE, + bip39_passphrase=None, + ) + print(f"BIP44 discovery result (last index with activity): {discovered}\n") + await wallet.disconnect() + print("RPC disconnected\n") + + # ================================================================== + # Section 2 — Keypair account (single pre-existing key, non-HD) + # + # One secp256k1 private key → one account → one address. No HD tree, + # no account_index. Use when importing a key from outside the wallet. + # ================================================================== + existing = next( + (info for info in await wallet.prv_key_data_enumerate() if info.name == "demo-secret-key"), + None, + ) + if existing is None: + secret_key_prv_id = await wallet.prv_key_data_create( + wallet_secret=WALLET_SECRET, + secret=FIXED_SECRET_KEY_BYTES, + kind=PrvKeyDataVariantKind.SecretKey, + payment_secret=None, + name="demo-secret-key", + ) + else: + secret_key_prv_id = existing.id + + try: + keypair_account = await wallet.accounts_create_keypair( + wallet_secret=WALLET_SECRET, + prv_key_data_id=secret_key_prv_id, + ecdsa=False, + account_name="kp-acct", + ) + print(f"Created keypair account: {keypair_account}\n") + except WalletAccountAlreadyExistsError: + print("Keypair account already exists\n") + + # ================================================================== + # Section 3 — Both types live side-by-side in the same wallet file + # ================================================================== + print("Private key data entries:") + for info in await wallet.prv_key_data_enumerate(): + print(f" - {info}") + print() + + print("Final account list (BIP32 and keypair side-by-side):") + for account in await wallet.accounts_enumerate(): + print(f" - {account}") + print() + + # ------------------------------------------------------------------ + # Wind down + # ------------------------------------------------------------------ + await wallet.wallet_close() + print("Wallet closed\n") + + await wallet.stop() + print("Wallet stopped\n") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--rpc-url", default=None, help="RPC URL (defaults to Resolver)") + args = parser.parse_args() + asyncio.run(main(args.rpc_url)) diff --git a/examples/wallet/shared.py b/examples/wallet/shared.py index 1492f195..8683b340 100644 --- a/examples/wallet/shared.py +++ b/examples/wallet/shared.py @@ -1,13 +1,15 @@ """Shared demo variables and bootstrap helper for the wallet examples. -All four wallet examples import the same mnemonic, secret, and network id -from this module so they derive addresses from a single deterministic seed. +All wallet examples import the same mnemonic, secret, and network id from +this module so they derive addresses from a single deterministic seed. Each example uses its own `FILENAME` so the on-disk wallets stay independent. """ -from kaspa import PrvKeyDataVariantKind, Wallet +from kaspa import AccountKind, PrvKeyDataVariantKind, Wallet + +BIP32_KIND = AccountKind("bip32") FIXED_MNEMONIC_PHRASE = ( "abandon abandon abandon abandon abandon abandon abandon abandon " @@ -21,14 +23,19 @@ async def open_or_create_wallet(wallet: Wallet, filename: str, *, title: str) -> str: """Open an existing demo wallet or create a fresh one. - On the first run this stores the shared demo mnemonic and derives a - BIP32 account at index 0. On subsequent runs it just reopens the - persisted wallet. + First run: creates the wallet, stores the shared demo mnemonic under + the name "demo-key", and creates a BIP32 account at index 0. + Subsequent runs: reopens the file and returns the same account via + `accounts_ensure_default`, which is idempotent. """ if await wallet.exists(filename): - w = await wallet.wallet_open(WALLET_SECRET, True, filename) - print(f"Opened existing wallet file `{filename}`: {w}") - return (await wallet.accounts_enumerate())[0].account_id + opened = await wallet.wallet_open(WALLET_SECRET, True, filename) + print(f"Opened existing wallet file {filename!r}: {opened}\n") + descriptor = await wallet.accounts_ensure_default( + wallet_secret=WALLET_SECRET, + account_kind=BIP32_KIND, + ) + return descriptor.account_id created = await wallet.wallet_create( wallet_secret=WALLET_SECRET, @@ -46,7 +53,7 @@ async def open_or_create_wallet(wallet: Wallet, filename: str, *, title: str) -> payment_secret=None, name="demo-key", ) - print(f"Created PrvKeyDataId: {prv_key_id}") + print(f"Created PrvKeyDataId: {prv_key_id}\n") descriptor = await wallet.accounts_create_bip32( wallet_secret=WALLET_SECRET, @@ -55,6 +62,6 @@ async def open_or_create_wallet(wallet: Wallet, filename: str, *, title: str) -> account_name="demo-acct", account_index=0, ) - print(f"Created BIP32 account: {descriptor}") + print(f"Created BIP32 account: {descriptor}\n") return descriptor.account_id diff --git a/examples/wallet/transactions.py b/examples/wallet/transactions.py new file mode 100644 index 00000000..09a1a8e6 --- /dev/null +++ b/examples/wallet/transactions.py @@ -0,0 +1,193 @@ +"""Send, transfer, estimate, and inspect transactions for a funded wallet. + +Requires testnet-10 funds. On the first run the script pauses at the receive +address and polls `accounts_get_utxos` until funds appear; on later runs the +existing UTXO set is picked up immediately. +""" + +import argparse +import asyncio + +from kaspa import ( + Fees, + NetworkId, + Resolver, + TransactionKind, + Wallet, + WalletEventType, +) + +from shared import NETWORK_ID, WALLET_SECRET, open_or_create_wallet + +FILENAME = "wallet_transactions_demo" + + +def _on_balance(event): + print(f" [balance event]: {event.get('type', '')}") + + +async def main(rpc_url: str | None): + # ------------------------------------------------------------------ + # 1. Construct, start, connect (RPC is required for everything below). + # ------------------------------------------------------------------ + if rpc_url: + wallet = Wallet(network_id=NETWORK_ID, url=rpc_url) + else: + wallet = Wallet(network_id=NETWORK_ID, resolver=Resolver()) + + await wallet.start() + print("Wallet started") + + await wallet.connect(url=rpc_url, strategy="fallback", timeout_duration=5000) + print("RPC connected") + + wallet.add_event_listener(WalletEventType.Balance, _on_balance) + + # ------------------------------------------------------------------ + # 2. Idempotent bootstrap — see `shared.py`. + # ------------------------------------------------------------------ + account_id = await open_or_create_wallet( + wallet, FILENAME, title="transactions demo" + ) + print(f"account_id = {account_id}") + print() + + await wallet.accounts_activate([account_id]) + print(f"Activated account {account_id}") + print() + + # ------------------------------------------------------------------ + # 3. Pick up (or derive) a receive address and wait until it is funded. + # ------------------------------------------------------------------ + account = await wallet.accounts_get(account_id) + receive_address = account.receive_address + print(f"receive_address = {receive_address}") + print() + + print(">>> Waiting for testnet-10 funds at the receive address above...") + while True: + utxos = await wallet.accounts_get_utxos(account_id=account_id) + if utxos: + print(f">>> Funds detected: {len(utxos)} UTXO(s)") + print() + break + await asyncio.sleep(5) + + # ------------------------------------------------------------------ + # 4. Fee-rate helpers. + # ------------------------------------------------------------------ + fee_estimate = await wallet.fee_rate_estimate() + print(f"Fee rate estimate: {fee_estimate}") + print() + + await wallet.fee_rate_poller_enable(5) + print("Fee-rate poller enabled (5s interval)") + + await wallet.fee_rate_poller_disable() + print("Fee-rate poller disabled") + print() + + # ------------------------------------------------------------------ + # 5. UTXOs, estimate, send (change-only: no destination), self-transfer. + # ------------------------------------------------------------------ + utxos_for_account = await wallet.accounts_get_utxos(account_id=account_id) + print(f"UTXOs for account: {utxos_for_account}") + print() + + estimate = await wallet.accounts_estimate( + account_id=account_id, + priority_fee_sompi=Fees(0, None), + fee_rate=None, + payload=None, + destination=None, + ) + print(f"Change-only estimate (fee=0): {estimate}") + print() + + send_result = await wallet.accounts_send( + wallet_secret=WALLET_SECRET, + account_id=account_id, + priority_fee_sompi=Fees(0, None), + payment_secret=None, + fee_rate=None, + payload=None, + destination=None, + ) + print(f"Change-only send: {send_result}") + print() + + transfer_result = await wallet.accounts_transfer( + wallet_secret=WALLET_SECRET, + source_account_id=account_id, + destination_account_id=account_id, + transfer_amount_sompi=1, + payment_secret=None, + fee_rate=None, + priority_fee_sompi=None, + ) + print(f"Self-transfer (1 sompi): {transfer_result}") + print() + + # ------------------------------------------------------------------ + # 6. Transaction history. The replace_* calls need a real tx id; we + # use a placeholder so the code path is still exercised. + # ------------------------------------------------------------------ + try: + incoming = await wallet.transactions_data_get( + account_id=account_id, + network_id=NetworkId(NETWORK_ID), + start=0, + end=10, + filter=[TransactionKind.Incoming], + ) + print(f"Incoming transactions: {incoming}") + except Exception as exc: + print(f"{type(exc).__name__}: {exc}") + print() + + placeholder_tx_id = "0" * 64 + + try: + await wallet.transactions_replace_note( + account_id=account_id, + network_id=NetworkId(NETWORK_ID), + transaction_id=placeholder_tx_id, + note="demo note", + ) + print("Replaced note on placeholder tx") + except Exception as exc: + print(f"{type(exc).__name__}: {exc}") + print() + + try: + await wallet.transactions_replace_metadata( + account_id=account_id, + network_id=NetworkId(NETWORK_ID), + transaction_id=placeholder_tx_id, + metadata="demo metadata", + ) + print("Replaced metadata on placeholder tx") + except Exception as exc: + print(f"{type(exc).__name__}: {exc}") + print() + + # ------------------------------------------------------------------ + # 7. Wind down. + # ------------------------------------------------------------------ + wallet.remove_event_listener(WalletEventType.Balance) + + await wallet.wallet_close() + print("Wallet closed") + + await wallet.disconnect() + print("RPC disconnected") + + await wallet.stop() + print("Wallet stopped") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--rpc-url", default=None, help="RPC URL (defaults to Resolver)") + args = parser.parse_args() + asyncio.run(main(args.rpc_url)) From 9e7609fe51cc359618db98acd901aef8b8529232 Mon Sep 17 00:00:00 2001 From: smartgoo Date: Sat, 25 Apr 2026 07:05:02 -0400 Subject: [PATCH 06/15] expot import example clean up --- examples/wallet/export_import.py | 66 ++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/examples/wallet/export_import.py b/examples/wallet/export_import.py index 94c26bbc..3418ecb6 100644 --- a/examples/wallet/export_import.py +++ b/examples/wallet/export_import.py @@ -1,13 +1,18 @@ -"""Export, import and reload, a wallet. +"""Example showing export, import and reload, a wallet. """ import argparse import asyncio from pathlib import Path -from kaspa import Resolver, Wallet +from kaspa import PrvKeyDataVariantKind, Resolver, Wallet +from kaspa.exceptions import WalletAlreadyExistsError -from shared import NETWORK_ID, WALLET_SECRET, open_or_create_wallet +from shared import ( + FIXED_MNEMONIC_PHRASE, + NETWORK_ID, + WALLET_SECRET +) TITLE = "wallet export import demo" FILENAME = "-".join(TITLE.split(" ")) @@ -23,50 +28,79 @@ async def main(rpc_url: str | None): wallet = Wallet(network_id=NETWORK_ID, resolver=Resolver()) await wallet.start() - print("Wallet started") + print("Wallet started\n") # ------------------------------------------------------------------ - # Open existing wallet file or create new + # Open existing wallet or create new # ------------------------------------------------------------------ - await open_or_create_wallet(wallet, FILENAME, title=TITLE) + try: + created = await wallet.wallet_create( + wallet_secret=WALLET_SECRET, + filename=FILENAME, + overwrite_wallet_storage=False, + title=TITLE, + user_hint="example" + ) + print(f"Created new wallet file {FILENAME}: {created}\n") + + prv_key_id = await wallet.prv_key_data_create( + wallet_secret=WALLET_SECRET, + secret=FIXED_MNEMONIC_PHRASE, + kind=PrvKeyDataVariantKind.Mnemonic, + payment_secret=None, + name="demo-key" + ) + + account_descriptor = await wallet.accounts_create_bip32( + wallet_secret=WALLET_SECRET, + prv_key_data_id=prv_key_id, + payment_secret=None, + account_name="demo-acct", + account_index=0 + ) + print(f"Created BIP32 account: {account_descriptor}\n") + except WalletAlreadyExistsError: + print(f"Wallet with filename {FILENAME} already exists\n") + opened = await wallet.wallet_open(WALLET_SECRET, True, FILENAME) + print(f"Opened existing wallet {FILENAME}: {opened}\n") # ------------------------------------------------------------------ - # Export -> close -> import -> open + # Export -> close -> delete original -> import as new -> open # ------------------------------------------------------------------ exported = await wallet.wallet_export(WALLET_SECRET, True) - print(f"Wallet exported: {exported[:32]}... ({len(exported)} hex chars)") + print(f"Wallet exported ({len(exported)} hex chars): {exported}\n") await wallet.wallet_close() - print("Wallet closed") + print("Wallet closed\n") # Delete on-disk wallet files so the import process # can create from the prior exported hex wallet_dir = Path.home() / ".kaspa" wallet_path = wallet_dir / f"{FILENAME}.wallet" wallet_path.unlink() - print("Wallet deleted (to simulate import from scratch)") + print("Wallet deleted (to simulate fresh import)\n") # No transactions under this wallet, so, nothing to delete # transactions_path = wallet_dir / f"{FILENAME}.transactions" # transactions_path.rmdir() # print(f"Removed transactions dir at {wallet_path}") - # `wallet_import` writes a fresh file derived from the payload's + # `wallet_import` creates new wallet file imported = await wallet.wallet_import(WALLET_SECRET, exported) - print(f"Wallet imported (from the prior exported hex): {imported}") + print(f"Wallet imported (from the prior exported hex): {imported}\n") imported_filename = imported["walletDescriptor"]["filename"] reopened = await wallet.wallet_open(WALLET_SECRET, True, imported_filename) - print(f"Reopened imported wallet ({imported_filename}): {reopened}") + print(f"Reopened imported wallet ({imported_filename}): {reopened}\n") # ------------------------------------------------------------------ - # Wind down. + # Wind down # ------------------------------------------------------------------ await wallet.wallet_close() - print("Wallet closed") + print("Wallet closed\n") await wallet.stop() - print("Wallet stopped") + print("Wallet stopped\n") if __name__ == "__main__": From bd30ea6436e03961337b47853cdd57450e885db2 Mon Sep 17 00:00:00 2001 From: smartgoo Date: Sat, 25 Apr 2026 11:48:17 -0400 Subject: [PATCH 07/15] fix secret handlign in prv_key_data_create when creating secret key based prv key data --- python/kaspa/__init__.pyi | 4 +++- src/wallet/core/wallet.rs | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/python/kaspa/__init__.pyi b/python/kaspa/__init__.pyi index 6c0bc6f1..a9eb8b9f 100644 --- a/python/kaspa/__init__.pyi +++ b/python/kaspa/__init__.pyi @@ -3098,7 +3098,9 @@ class Wallet: Args: wallet_secret: Password for the open wallet. - secret: The secret material (mnemonic phrase, hex seed, or extended key). + secret: The secret value. For Mnemonic, the BIP39 phrase. For + Bip39Seed and ExtendedPrivateKey, the encoded string. For + SecretKey, a 64-character hex-encoded 32-byte secp256k1 key. kind: The variant kind of `secret` (Mnemonic, Bip39Seed, ExtendedPrivateKey, or SecretKey). payment_secret: Optional additional secret used to encrypt the entry. name: Optional human-readable name for the entry. diff --git a/src/wallet/core/wallet.rs b/src/wallet/core/wallet.rs index f3ee188e..55ee4f0b 100644 --- a/src/wallet/core/wallet.rs +++ b/src/wallet/core/wallet.rs @@ -14,6 +14,7 @@ use crate::{ account::{descriptor::PyAccountDescriptor, kind::PyAccountKind}, api::message::{PyAccountsDiscoveryKind, PyCommitRevealAddressKind, PyNewAddressKind}, deterministic::PyAccountId, + error::PyWalletFasterHexError, events::PyWalletEventType, storage::{ interface::PyWalletDescriptor, @@ -26,6 +27,7 @@ use crate::{ use ahash::AHashMap; use futures::{FutureExt, select}; use kaspa_addresses::Address; +use kaspa_utils::hex::FromHex; use kaspa_wallet_core::{ api::*, error::Error as NativeError, @@ -700,7 +702,9 @@ impl PyWallet { /// /// Args: /// wallet_secret: Password for the open wallet. - /// secret: The secret material (mnemonic phrase, hex seed, or extended key). + /// secret: The secret value. For Mnemonic, the BIP39 phrase. For + /// Bip39Seed and ExtendedPrivateKey, the encoded string. For + /// SecretKey, a 64-character hex-encoded secp256k1 key. /// kind: The variant kind of `secret` (Mnemonic, Bip39Seed, ExtendedPrivateKey, or SecretKey). /// payment_secret: Optional additional secret used to encrypt the entry. /// name: Optional human-readable name for the entry. @@ -719,12 +723,20 @@ impl PyWallet { payment_secret: Option, name: Option, ) -> PyResult> { + let secret = match kind { + PyPrvKeyDataVariantKind::SecretKey => { + let bytes = Vec::from_hex(&secret) + .map_err(|err| PyWalletFasterHexError::new_err(err.to_string()))?; + Secret::from(bytes) + } + _ => secret.into(), + }; let request = PrvKeyDataCreateRequest { wallet_secret: wallet_secret.into(), prv_key_data_args: PrvKeyDataCreateArgs::new( name, payment_secret.map(Secret::from), - secret.into(), + secret, kind.into(), ), }; From 825b36d029b7fd1268550faa9187d6ead6b0053b Mon Sep 17 00:00:00 2001 From: smartgoo Date: Sat, 25 Apr 2026 12:53:25 -0400 Subject: [PATCH 08/15] expose missing account desriptor properties to python --- docs/CHANGELOG.md | 4 +- python/kaspa/__init__.pyi | 46 ++++++++++- src/address.rs | 6 ++ src/wallet/core/account/descriptor.rs | 106 ++++++++++++++++++++++++-- src/wallet/core/storage/keydata.rs | 6 ++ tests/unit/test_wallet_types.py | 5 +- tests/wallet_helpers.py | 8 +- 7 files changed, 161 insertions(+), 20 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c4252532..204a65cc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,7 +2,7 @@ ### Added - Class `Wallet` exposed to Python, providing the full rusty-kaspa wallet API: lifecycle (`start`, `stop`, `connect`, `disconnect`, `set_network_id`, `get_status`), wallet file operations (`wallet_enumerate`, `wallet_create`, `wallet_open`, `wallet_close`, `wallet_reload`, `wallet_rename`, `wallet_change_secret`, `wallet_export`, `wallet_import`), private key data (`prv_key_data_enumerate`, `prv_key_data_create`, `prv_key_data_remove`, `prv_key_data_get`), accounts (`accounts_enumerate`, `accounts_create_bip32`, `accounts_create_keypair`, `accounts_import_bip32`, `accounts_import_keypair`, `accounts_rename`, `accounts_discovery`, `accounts_ensure_default`, `accounts_activate`, `accounts_get`, `accounts_create_new_address`, `accounts_get_utxos`), spending (`accounts_estimate`, `accounts_send`, `accounts_transfer`, `accounts_commit_reveal`, `accounts_commit_reveal_manual`), transaction history (`transactions_data_get`, `transactions_replace_note`, `transactions_replace_metadata`), storage (`batch`, `flush`, `retain_context`, `address_book_enumerate`), fee rate (`fee_rate_estimate`, `fee_rate_poller_enable`, `fee_rate_poller_disable`), and event listeners (`add_event_listener`, `remove_event_listener`). -- Class `AccountDescriptor` exposed to Python. Contains account metadata including kind, ID, name, balance, and addresses. +- Class `AccountDescriptor` exposed to Python. Contains account metadata including kind, ID, name, balance, and addresses, and account kind specific properties: `account_index`, `xpub_keys`, `ecdsa`, `receive_address_index`, and `change_address_index` (each `None` when the property is not applicable to the account kind). - Class `AccountId` exposed to Python. Hex-encoded identifier for a wallet account. - Class `PrvKeyDataId` exposed to Python. Hex-encoded identifier for a private key data entry. Constructible from a hex string and accepted interchangeably with `str` by `Wallet` methods. - Class `WalletDescriptor` exposed to Python. Contains wallet metadata including title and filename. @@ -18,7 +18,7 @@ - Enum `FeeSource` exposed to Python. Indicates who pays the transaction fee: sender or receiver. - Enum `AccountKind` exposed to Python. Represents the account type (legacy, bip32, multisig, keypair, etc.). - Wallet-specific exception classes populated into the `kaspa.exceptions` submodule, covering the rusty-kaspa wallet error variants (e.g. `WalletInsufficientFundsError`, `WalletAccountNotFoundError`, `WalletNotSyncedError`, etc.). -- Example `examples/wallet.py` demonstrating end-to-end wallet usage. +- Examples under `examples/wallet/` demonstrating wallet usage - Pytest options `--network-id` and `--rpc-url` for targeting integration tests at a specific network / node. ### Changed diff --git a/python/kaspa/__init__.pyi b/python/kaspa/__init__.pyi index a9eb8b9f..2a4c4226 100644 --- a/python/kaspa/__init__.pyi +++ b/python/kaspa/__init__.pyi @@ -29,15 +29,55 @@ class AccountDescriptor: The current balance of the account, or None if not yet known. """ @property - def receive_address(self) -> typing.Optional[builtins.str]: + def prv_key_data_ids(self) -> typing.Optional[builtins.list[PrvKeyDataId]]: + r""" + The associated prv key data ids + """ + @property + def receive_address(self) -> typing.Optional[Address]: r""" The current receive address as a string, or None if not available. """ @property - def change_address(self) -> typing.Optional[builtins.str]: + def change_address(self) -> typing.Optional[Address]: r""" The current change address as a string, or None if not available. """ + @property + def account_index(self) -> typing.Optional[builtins.int]: + r""" + The BIP44 account index. None for account kinds that are not derived from + an account index (e.g. keypair accounts). + """ + @property + def xpub_keys(self) -> typing.Optional[builtins.list[builtins.str]]: + r""" + Extended public keys associated with the account, formatted as strings. + None for account kinds that don't expose xpubs (e.g. keypair accounts). + """ + @property + def ecdsa(self) -> typing.Optional[builtins.bool]: + r""" + Whether the account uses ECDSA signatures (vs Schnorr). None if not applicable. + """ + @property + def receive_address_index(self) -> typing.Optional[builtins.int]: + r""" + Number of receive addresses derived so far for this account. The next + receive address generated by `accounts_create_new_address` will be at this + index. None for account kinds without HD derivation. + """ + @property + def change_address_index(self) -> typing.Optional[builtins.int]: + r""" + Number of change addresses derived so far for this account. The next + change address generated by `accounts_create_new_address` will be at this + index. None for account kinds without HD derivation. + """ + def get_addresses(self) -> typing.Optional[builtins.list[Address]]: + r""" + All addresses of the account + """ def __repr__(self) -> builtins.str: r""" The string representation. @@ -3100,7 +3140,7 @@ class Wallet: wallet_secret: Password for the open wallet. secret: The secret value. For Mnemonic, the BIP39 phrase. For Bip39Seed and ExtendedPrivateKey, the encoded string. For - SecretKey, a 64-character hex-encoded 32-byte secp256k1 key. + SecretKey, a 64-character hex-encoded secp256k1 key. kind: The variant kind of `secret` (Mnemonic, Bip39Seed, ExtendedPrivateKey, or SecretKey). payment_secret: Optional additional secret used to encrypt the entry. name: Optional human-readable name for the entry. diff --git a/src/address.rs b/src/address.rs index 79411585..d1201738 100644 --- a/src/address.rs +++ b/src/address.rs @@ -159,6 +159,12 @@ impl From
for PyAddress { } } +impl From<&Address> for PyAddress { + fn from(value: &Address) -> Self { + PyAddress(value.clone()) + } +} + impl From for Address { fn from(value: PyAddress) -> Address { value.0 diff --git a/src/wallet/core/account/descriptor.rs b/src/wallet/core/account/descriptor.rs index ce7187f9..2896733b 100644 --- a/src/wallet/core/account/descriptor.rs +++ b/src/wallet/core/account/descriptor.rs @@ -1,8 +1,15 @@ -use crate::wallet::core::{ - account::kind::PyAccountKind, deterministic::PyAccountId, utxo::balance::PyBalance, +use crate::{ + address::PyAddress, + wallet::core::{ + account::kind::PyAccountKind, deterministic::PyAccountId, storage::keydata::PyPrvKeyDataId, + utxo::balance::PyBalance, + }, }; use kaspa_utils::hex::ToHex; -use kaspa_wallet_core::account::descriptor::AccountDescriptor; +use kaspa_wallet_core::{ + account::descriptor::{AccountDescriptor, AccountDescriptorProperty, AccountDescriptorValue}, + storage::AssocPrvKeyDataIds, +}; use pyo3::prelude::*; use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; @@ -38,16 +45,101 @@ impl PyAccountDescriptor { self.0.balance.clone().map(PyBalance::from) } + /// The associated prv key data ids + #[getter] + pub fn get_prv_key_data_ids(&self) -> Option> { + match &self.0.prv_key_data_ids { + AssocPrvKeyDataIds::None => None, + AssocPrvKeyDataIds::Single(prv_key_id) => Some(vec![PyPrvKeyDataId::from(prv_key_id)]), + AssocPrvKeyDataIds::Multiple(prv_key_ids) => { + Some(prv_key_ids.iter().map(PyPrvKeyDataId::from).collect()) + } + } + } + /// The current receive address as a string, or None if not available. #[getter] - pub fn get_receive_address(&self) -> Option { - self.0.receive_address.as_ref().map(|a| a.to_string()) + pub fn get_receive_address(&self) -> Option { + self.0.receive_address.as_ref().map(PyAddress::from) } /// The current change address as a string, or None if not available. #[getter] - pub fn get_change_address(&self) -> Option { - self.0.change_address.as_ref().map(|a| a.to_string()) + pub fn get_change_address(&self) -> Option { + self.0.change_address.as_ref().map(PyAddress::from) + } + + /// All addresses of the account + pub fn get_addresses(&self) -> Option> { + self.0 + .addresses + .as_ref() + .map(|addrs| addrs.iter().map(PyAddress::from).collect()) + } + + /// The BIP44 account index. None for account kinds that are not derived from + /// an account index (e.g. keypair accounts). + #[getter] + pub fn get_account_index(&self) -> Option { + match self + .0 + .properties + .get(&AccountDescriptorProperty::AccountIndex) + { + Some(AccountDescriptorValue::U64(v)) => Some(*v), + _ => None, + } + } + + /// Extended public keys associated with the account, formatted as strings. + /// None for account kinds that don't expose xpubs (e.g. keypair accounts). + #[getter] + pub fn get_xpub_keys(&self) -> Option> { + match self.0.properties.get(&AccountDescriptorProperty::XpubKeys) { + Some(AccountDescriptorValue::XPubKeys(keys)) => { + Some(keys.iter().map(|k| k.to_string(None)).collect()) + } + _ => None, + } + } + + /// Whether the account uses ECDSA signatures (vs Schnorr). None if not applicable. + #[getter] + pub fn get_ecdsa(&self) -> Option { + match self.0.properties.get(&AccountDescriptorProperty::Ecdsa) { + Some(AccountDescriptorValue::Bool(v)) => Some(*v), + _ => None, + } + } + + /// Number of receive addresses derived so far for this account. The next + /// receive address generated by `accounts_create_new_address` will be at this + /// index. None for account kinds without HD derivation. + #[getter] + pub fn get_receive_address_index(&self) -> Option { + match self + .0 + .properties + .get(&AccountDescriptorProperty::DerivationMeta) + { + Some(AccountDescriptorValue::AddressDerivationMeta(v)) => Some(v.receive()), + _ => None, + } + } + + /// Number of change addresses derived so far for this account. The next + /// change address generated by `accounts_create_new_address` will be at this + /// index. None for account kinds without HD derivation. + #[getter] + pub fn get_change_address_index(&self) -> Option { + match self + .0 + .properties + .get(&AccountDescriptorProperty::DerivationMeta) + { + Some(AccountDescriptorValue::AddressDerivationMeta(v)) => Some(v.change()), + _ => None, + } } /// The string representation. diff --git a/src/wallet/core/storage/keydata.rs b/src/wallet/core/storage/keydata.rs index abb98595..52e85dae 100644 --- a/src/wallet/core/storage/keydata.rs +++ b/src/wallet/core/storage/keydata.rs @@ -111,6 +111,12 @@ impl From for PyPrvKeyDataId { } } +impl From<&PrvKeyDataId> for PyPrvKeyDataId { + fn from(value: &PrvKeyDataId) -> Self { + Self(*value) + } +} + impl From for PrvKeyDataId { fn from(value: PyPrvKeyDataId) -> Self { value.0 diff --git a/tests/unit/test_wallet_types.py b/tests/unit/test_wallet_types.py index 7326e1d6..8c38373a 100644 --- a/tests/unit/test_wallet_types.py +++ b/tests/unit/test_wallet_types.py @@ -13,6 +13,7 @@ AccountId, AccountKind, AccountsDiscoveryKind, + Address, CommitRevealAddressKind, Mnemonic, NewAddressKind, @@ -241,8 +242,8 @@ async def test_descriptor_properties(self, open_wallet): assert isinstance(descriptor.kind, AccountKind) assert descriptor.account_name == "acct-desc" assert descriptor.balance is None - assert isinstance(descriptor.receive_address, str) - assert isinstance(descriptor.change_address, str) + assert isinstance(descriptor.receive_address, Address) + assert isinstance(descriptor.change_address, Address) async def test_descriptor_repr(self, open_wallet): """Test __repr__ includes the account kind and id.""" diff --git a/tests/wallet_helpers.py b/tests/wallet_helpers.py index fd0f1f59..39b5da7f 100644 --- a/tests/wallet_helpers.py +++ b/tests/wallet_helpers.py @@ -11,12 +11,8 @@ from tests.conftest import TEST_WALLET_SECRET -# The SecretKey variant of PrvKeyDataVariantKind passes the `secret` String -# straight to `Secret::from(secret)` (see `prv_key_data_create` in -# `src/wallet/core/wallet.rs`), so upstream wallet-core reads its UTF-8 -# bytes directly as the secp256k1 key material — NOT as a hex string. -# 32 repeated "a" chars = 32 bytes of 0x61, a valid secp256k1 scalar. -TEST_SECRET_KEY_RAW = "a" * 32 +# 64-char hex string = 32 bytes, a valid secp256k1 scalar. +TEST_SECRET_KEY_RAW = "a" * 64 async def create_mnemonic_key( From f20f7293b582ea46fcbfff95849577b8484e52bb Mon Sep 17 00:00:00 2001 From: smartgoo Date: Sat, 25 Apr 2026 13:45:41 -0400 Subject: [PATCH 09/15] accounts example --- examples/wallet/accounts.py | 315 +++++++++++++++++++++--------------- 1 file changed, 181 insertions(+), 134 deletions(-) diff --git a/examples/wallet/accounts.py b/examples/wallet/accounts.py index bdd65fc3..d2c75ea5 100644 --- a/examples/wallet/accounts.py +++ b/examples/wallet/accounts.py @@ -1,64 +1,55 @@ -"""Manage BIP32 and keypair accounts in one wallet file. - -A wallet file holds N accounts of any mix of variants, each backed by an -entry in the wallet's `prv_key_data` store. This example exercises both -account types in the same file: - -* **BIP32 (HD-derived)** — the default. One mnemonic backs unlimited - accounts, each at a different `account_index` on Kaspa's BIP44 path - `m/44'/111111'/{account_index}'`. Receive addresses live under - `.../0/i`, change addresses under `.../1/i`. Each account derives its - addresses on demand by walking the HD tree. The address at a given - index is deterministic (same mnemonic → same bytes), but - `accounts_create_new_address` advances the account's stored index on - every call — so the printed addresses shift forward each run. - -* **Keypair (non-HD)** — one pre-existing secp256k1 private key wrapped - as an account. One account, one address, no derivation path. Use when - importing a raw key from an external source (paper wallet, another - tool, etc.) into the wallet file. - -Both types coexist: the final `accounts_enumerate` prints them -side-by-side in the same on-disk wallet. +"""Example showing both BIP32 and keypair account types in one wallet. + +A wallet can hold N accounts any type, each backed by an +entry in the wallet's `prv_key_data` store. + +This example shows creation of both account types in the same file: + +- BIP32 (HD-derived): One mnemonic backs unlimited accounts, +each at a different `account_index` on Kaspa's BIP44 path +m/44'/111111'/{account_index}' + +- Keypair (non-HD): One secp256k1 private key wrapped + as an account. One account, one address, no derivation path. + +Both types coexist ins ame wallet in this example """ import argparse import asyncio from kaspa import ( - AccountKind, - AccountsDiscoveryKind, NewAddressKind, + PrivateKey, PrvKeyDataVariantKind, Resolver, Wallet, ) -from kaspa.exceptions import WalletAccountAlreadyExistsError +from kaspa.exceptions import WalletAlreadyExistsError from shared import ( FIXED_MNEMONIC_PHRASE, NETWORK_ID, WALLET_SECRET, - open_or_create_wallet, ) -FILENAME = "wallet_accounts_demo" -# 32 UTF-8 bytes used directly as a secp256k1 scalar (see wallet-core's -# SecretKey variant handling in prv_key_data_create). In real use this -# would be a key you generated or imported, not a literal placeholder. -FIXED_SECRET_KEY_BYTES = "a" * 32 +TITLE = "example wallet accounts" +FILENAME = "-".join(TITLE.split(" ")) -# Kaspa's SLIP-44 coin type. Combined with BIP44's purpose (44'), every -# HD account lives at m/44'/111111'/{account_index}'. -KASPA_COIN_TYPE = 111111 +PRIVATE_KEY = PrivateKey("389840d7696e89c38856a066175e8e92697f0cf182b854c883237a50acaf1f69") def bip32_account_path(account_index: int) -> str: - return f"m/44'/{KASPA_COIN_TYPE}'/{account_index}'" + return f"m/44'/111111'/{account_index}'" -def bip32_address_path(account_index: int, change: bool, address_index: int) -> str: - chain = 1 if change else 0 +def bip32_address_path(account_index: int, address_type: str, address_index: int) -> str: + if address_type == "receive": + chain = 0 + elif address_type == "change": + chain = 1 + else: + raise ValueError(f"address_type must be 'receive' or 'change', got {address_type!r}") return f"{bip32_account_path(account_index)}/{chain}/{address_index}" @@ -66,6 +57,11 @@ async def main(rpc_url: str | None): # ------------------------------------------------------------------ # Construct and start # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tInitialize & Create/Open Wallet") + print("-" * 100) + if rpc_url: wallet = Wallet(network_id=NETWORK_ID, url=rpc_url) else: @@ -74,123 +70,167 @@ async def main(rpc_url: str | None): await wallet.start() print("Wallet started\n") - account_id = await open_or_create_wallet( - wallet, FILENAME, title="accounts and addresses demo" - ) - print(f"account_id = {account_id}\n") + # ------------------------------------------------------------------ + # Open existing wallet or create new + # ------------------------------------------------------------------ + try: + # Create wallet + created = await wallet.wallet_create( + wallet_secret=WALLET_SECRET, + filename=FILENAME, + overwrite_wallet_storage=False, + title=TITLE, + user_hint="example" + ) + print(f"Created new wallet file {FILENAME}: {created}\n") + except WalletAlreadyExistsError: + # Open existing wallet + print(f"Wallet with filename {FILENAME} already exists\n") + opened = await wallet.wallet_open(WALLET_SECRET, True, FILENAME) + print(f"Opened existing wallet {FILENAME}: {opened}\n") + + # Enumerate and print all accounts + all_accounts = await wallet.accounts_enumerate() + print("Wallet's existing accounts:") + for account in all_accounts: + print(f" - {account}") + print() - # ================================================================== + # ------------------------------------------------------------------ # BIP32 accounts (HD-derived from the wallet mnemonic) - # ================================================================== - print("Wallet accounts:") - for account in await wallet.accounts_enumerate(): - print(f" - {account}") + # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tBIP32 accounts (HD-derived from the wallet mnemonic)") + print("-" * 100) + + # Try to find existing demo account at index 0 first. If it exists, we can + # pull the mnemonic prv key id from the account descriptor + account_0 = next((a for a in all_accounts if a.account_name == "demo-acct-0"), None) + if account_0 is None: + # New wallet - need to create the mnemonic prv key, then the account + mnemonic_prv_key_id = await wallet.prv_key_data_create( + wallet_secret=WALLET_SECRET, + secret=FIXED_MNEMONIC_PHRASE, + kind=PrvKeyDataVariantKind.Mnemonic, + payment_secret=None, + name="demo-mnemonic-key", + ) + account_0 = await wallet.accounts_create_bip32( + wallet_secret=WALLET_SECRET, + prv_key_data_id=mnemonic_prv_key_id, + payment_secret=None, + account_name="demo-acct-0", + account_index=0, + ) + print(f"Created BIP32 account (index=0): {account_0}\n") + else: + mnemonic_prv_key_id = account_0.prv_key_data_ids[0] + print(f"Using existing BIP32 account (index=0): {account_0}\n") + + print(f"Account {account_0.account_index}") + print(f" - derivation path: {bip32_account_path(account_0.account_index)}") + print(f" - xpub_keys: {account_0.xpub_keys}") + print(f" - existing address counts: receive={account_0.receive_address_index}, change={account_0.change_address_index}\n") + + # Derive new receive addresses for the BIP32 account_0 + print(f"Account {account_0.account_index} - deriving RECEIVE addresses:") + recv_start = account_0.receive_address_index + for i in range(5): + addr_idx = recv_start + i + rec_addr = await wallet.accounts_create_new_address(account_0.account_id, NewAddressKind.Receive) + print(f" - path {bip32_address_path(account_0.account_index, 'receive', addr_idx)} - {rec_addr}") print() - account = await wallet.accounts_get(account_id) - print(f"Account details: {account}\n") - # Default account sits at BIP44 index 0 on Kaspa's coin type. - print(f"Derivation path (account 0): {bip32_account_path(0)}\n") - - await wallet.accounts_activate([account_id]) - print(f"Activated account {account_id}\n") - - # Derive new receive/change addresses from the default BIP32 account. - # Receive addresses live under `.../0/i`, change under `.../1/i`; `i` - # is the per-chain index the account advances on each call. - new_receive = await wallet.accounts_create_new_address(account_id, NewAddressKind.Receive) - print(f"New receive address: {new_receive} (under {bip32_account_path(0)}/0/i)\n") - - new_change = await wallet.accounts_create_new_address(account_id, NewAddressKind.Change) - print(f"New change address: {new_change} (under {bip32_account_path(0)}/1/i)\n") - - # Create a *second* BIP32 account at account_index=1, reusing the - # same prv_key_data_id as account 0 (same mnemonic). Different HD - # subtree → completely different addresses from account 0. - mnemonic_prv_id = next( - info.id for info in await wallet.prv_key_data_enumerate() if info.name == "demo-key" - ) - try: - second_bip32 = await wallet.accounts_create_bip32( + # Derive new change addresses for the BIP32 account_0 + print(f"Account {account_0.account_index} - deriving CHANGE addresses:") + change_start = account_0.change_address_index + for i in range(5): + addr_idx = change_start + i + change_addr = await wallet.accounts_create_new_address(account_0.account_id, NewAddressKind.Change) + print(f" - path {bip32_address_path(account_0.account_index, 'change', addr_idx)} - {change_addr}") + print() + + # Account at index 1 — same mnemonic, just a different account_index. + account_1 = next((a for a in all_accounts if a.account_name == "demo-acct-1"), None) + if account_1 is None: + account_1 = await wallet.accounts_create_bip32( wallet_secret=WALLET_SECRET, - prv_key_data_id=mnemonic_prv_id, + prv_key_data_id=mnemonic_prv_key_id, payment_secret=None, account_name="demo-acct-1", account_index=1, ) - print(f"Created second BIP32 account (index=1): {second_bip32}\n") - acct1_id = second_bip32.account_id - except WalletAccountAlreadyExistsError: - print("Second BIP32 account (index=1) already exists\n") - # Find it by elimination — the BIP32 account that isn't our - # default. Robust to renames (unlike a name-based lookup). - bip32_kind = AccountKind("bip32") - acct1_id = next( - a.account_id - for a in await wallet.accounts_enumerate() - if a.kind == bip32_kind and a.account_id != account_id - ) - print(f"Derivation path (account 1): {bip32_account_path(1)}\n") - - # Derive a receive address under account 1 to show the tree diverges - # from account 0 despite sharing the same mnemonic. Same caveat as - # above: the receive-chain index advances on every run. - acct1_receive = await wallet.accounts_create_new_address(acct1_id, NewAddressKind.Receive) - print(f"Account 1 receive address: {acct1_receive} (under {bip32_account_path(1)}/0/i)\n") - - # BIP44 discovery: given a mnemonic, derive accounts at - # m/44'/111111'/{i}' for i in [0, account_scan_extent) and ask the - # node whether any derived addresses have on-chain history. Returns - # the highest account index that had activity. RPC is required for - # the activity check, so we connect just for this call. - await wallet.connect(url=rpc_url, strategy="fallback", timeout_duration=5000) - print("RPC connected (for BIP44 discovery)\n") - discovered = await wallet.accounts_discovery( - discovery_kind=AccountsDiscoveryKind.Bip44, - address_scan_extent=10, - account_scan_extent=1, - bip39_mnemonic=FIXED_MNEMONIC_PHRASE, - bip39_passphrase=None, - ) - print(f"BIP44 discovery result (last index with activity): {discovered}\n") - await wallet.disconnect() - print("RPC disconnected\n") - - # ================================================================== - # Section 2 — Keypair account (single pre-existing key, non-HD) - # - # One secp256k1 private key → one account → one address. No HD tree, - # no account_index. Use when importing a key from outside the wallet. - # ================================================================== - existing = next( - (info for info in await wallet.prv_key_data_enumerate() if info.name == "demo-secret-key"), - None, - ) - if existing is None: - secret_key_prv_id = await wallet.prv_key_data_create( + print(f"Created BIP32 account (index=1): {account_1}\n") + else: + print(f"Using existing BIP32 account (index=1): {account_1}\n") + + print(f"Account {account_1.account_index}") + print(f" - derivation path: {bip32_account_path(account_1.account_index)}") + print(f" - xpub_keys: {account_1.xpub_keys}") + print(f" - existing address counts: receive={account_1.receive_address_index}, change={account_1.change_address_index}\n") + + # Derive new receive addresses for the BIP32 account_1 + print(f"Account {account_1.account_index} - deriving RECEIVE addresses:") + recv_start = account_1.receive_address_index + for i in range(5): + addr_idx = recv_start + i + rec_addr = await wallet.accounts_create_new_address(account_1.account_id, NewAddressKind.Receive) + print(f" - path {bip32_address_path(account_1.account_index, 'receive', addr_idx)} - {rec_addr}") + print() + + # Derive new change addresses for the BIP32 account_1 + print(f"Account {account_1.account_index} - deriving CHANGE addresses:") + change_start = account_1.change_address_index + for i in range(5): + addr_idx = change_start + i + change_addr = await wallet.accounts_create_new_address(account_1.account_id, NewAddressKind.Change) + print(f" - path {bip32_address_path(account_1.account_index, 'change', addr_idx)} - {change_addr}") + print() + + # ------------------------------------------------------------------ + # Keypair account (single key/account/address, non-HD) + # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tKeypair account (single key/account/address, non-HD)") + print("-" * 100) + + keypair_account = next((a for a in all_accounts if a.account_name == "keypair-acct"), None) + if keypair_account is None: + secret_key_prv_key_id = await wallet.prv_key_data_create( wallet_secret=WALLET_SECRET, - secret=FIXED_SECRET_KEY_BYTES, + secret=PRIVATE_KEY.to_string(), kind=PrvKeyDataVariantKind.SecretKey, payment_secret=None, name="demo-secret-key", ) - else: - secret_key_prv_id = existing.id - - try: keypair_account = await wallet.accounts_create_keypair( wallet_secret=WALLET_SECRET, - prv_key_data_id=secret_key_prv_id, + prv_key_data_id=secret_key_prv_key_id, ecdsa=False, - account_name="kp-acct", + account_name="keypair-acct", ) print(f"Created keypair account: {keypair_account}\n") - except WalletAccountAlreadyExistsError: - print("Keypair account already exists\n") + else: + print(f"Using existing keypair account: {keypair_account}\n") + + # Keypair accounts are not HD-derived: account_index/xpub_keys/derivation + # counts are all None. ecdsa is True/False depending on creation choice. + print(f" - account_index: {keypair_account.account_index}") + print(f" - xpub_keys: {keypair_account.xpub_keys}") + print(f" - ecdsa: {keypair_account.ecdsa}") + print(f" - receive_address_index: {keypair_account.receive_address_index}") + print(f" - change_address_index: {keypair_account.change_address_index}\n") + + # ------------------------------------------------------------------ + # Both types live side-by-side in the same wallet file + # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tBoth BIP32 and Keypair types live side-by-side in the same wallet file") + print("-" * 100) - # ================================================================== - # Section 3 — Both types live side-by-side in the same wallet file - # ================================================================== print("Private key data entries:") for info in await wallet.prv_key_data_enumerate(): print(f" - {info}") @@ -199,11 +239,18 @@ async def main(rpc_url: str | None): print("Final account list (BIP32 and keypair side-by-side):") for account in await wallet.accounts_enumerate(): print(f" - {account}") + print(f" account_index={account.account_index} ecdsa={account.ecdsa} " + f"derivation=(receive={account.receive_address_index}, change={account.change_address_index})") print() # ------------------------------------------------------------------ # Wind down # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tWind down") + print("-" * 100) + await wallet.wallet_close() print("Wallet closed\n") From ab7fb0369cefef8bb03bb4a10c5007c105c9bced Mon Sep 17 00:00:00 2001 From: smartgoo Date: Sat, 25 Apr 2026 13:59:17 -0400 Subject: [PATCH 10/15] consistent example styling --- examples/wallet/creation.py | 74 ++++++++++++++++++++++---------- examples/wallet/export_import.py | 37 +++++++++++++--- 2 files changed, 81 insertions(+), 30 deletions(-) diff --git a/examples/wallet/creation.py b/examples/wallet/creation.py index 37f1f901..38910fc7 100644 --- a/examples/wallet/creation.py +++ b/examples/wallet/creation.py @@ -13,39 +13,50 @@ from shared import FIXED_MNEMONIC_PHRASE, NETWORK_ID, WALLET_SECRET -FILENAME = "wallet-creation-demo" -TITLE = "wallet creation demo" +TITLE = "example wallet creation" +FILENAME = "-".join(TITLE.split(" ")) async def main(rpc_url: str | None): # ------------------------------------------------------------------ - # Construct wallet + # Construct and start wallet # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tConstruct and start wallet") + print("-" * 100) + if rpc_url: wallet = Wallet(network_id=NETWORK_ID, url=rpc_url) else: wallet = Wallet(network_id=NETWORK_ID, resolver=Resolver()) - print("Initialized Wallet instance properties:") + await wallet.start() + print("Wallet started\n") + + print("Newly initialized wallet instance's properties:") print(f" - rpc: {wallet.rpc}") print(f" - is_open: {wallet.is_open}") print(f" - descriptor: {wallet.descriptor}") + print() # ------------------------------------------------------------------ - # Start wallet runtime + # Open existing wallet or create new # ------------------------------------------------------------------ - await wallet.start() - print("Wallet started") + print() + print("-" * 100) + print("\tOpen existing wallet or create new") + print("-" * 100) + # Enumerate wallets on disk wallets = await wallet.wallet_enumerate() print("Wallets on disk:") for w in wallets: print(f" - {w}") + print() - # ------------------------------------------------------------------ - # Create wallet (catch and fall back to open if it already exists) - # ------------------------------------------------------------------ try: + # Create wallet created = await wallet.wallet_create( wallet_secret=WALLET_SECRET, filename=FILENAME, @@ -53,8 +64,9 @@ async def main(rpc_url: str | None): title=TITLE, user_hint="example", ) - print(f"Created new wallet file {FILENAME!r}: {created}") + print(f"Created new wallet file {FILENAME}: {created}\n") + # Create mnemonic type prv key data for wallet prv_key_id = await wallet.prv_key_data_create( wallet_secret=WALLET_SECRET, secret=FIXED_MNEMONIC_PHRASE, @@ -62,8 +74,9 @@ async def main(rpc_url: str | None): payment_secret=None, name="demo-key", ) - print(f"Created PrvKeyDataId: {prv_key_id}") + print(f"Created PrvKeyDataId: {prv_key_id}\n") + # Create first account descriptor = await wallet.accounts_create_bip32( wallet_secret=WALLET_SECRET, prv_key_data_id=prv_key_id, @@ -71,35 +84,50 @@ async def main(rpc_url: str | None): account_name="demo-acct", account_index=0, ) - print(f"Created BIP32 account: {descriptor}") + print(f"Created BIP32 account: {descriptor}\n") except WalletAlreadyExistsError: - print(f"Wallet with filename `{FILENAME}` already exists") + # Open existing wallet + print(f"Wallet with filename {FILENAME} already exists\n") opened = await wallet.wallet_open(WALLET_SECRET, True, FILENAME) - print(f"Opened existing wallet {FILENAME}: {opened}") + print(f"Opened existing wallet {FILENAME}: {opened}\n") - print("Opened Wallet instance properties:") + print("Opened wallet instance's properties:") + print(f" - rpc: {wallet.rpc}") print(f" - is_open: {wallet.is_open}") print(f" - descriptor: {wallet.descriptor}") + print() # ------------------------------------------------------------------ - # Close and reopen (just to show persistence) + # Close and reopen (to show persistence) # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tClose and reopen (to show persistence)") + print("-" * 100) + await wallet.wallet_close() print("Closed Wallet instance properties:") + print(f" - rpc: {wallet.rpc}") print(f" - is_open: {wallet.is_open}") print(f" - descriptor: {wallet.descriptor}") + print() reopened = await wallet.wallet_open(WALLET_SECRET, True, FILENAME) - print(f"Reopened wallet: {reopened}") - - await wallet.wallet_close() - print("Wallet closed") + print(f"Reopened wallet: {reopened}\n") # ------------------------------------------------------------------ - # Stop wallet runtime + # Wind down # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tWind down") + print("-" * 100) + + await wallet.wallet_close() + print("Wallet closed\n") + await wallet.stop() - print("Wallet stopped") + print("Wallet stopped\n") if __name__ == "__main__": diff --git a/examples/wallet/export_import.py b/examples/wallet/export_import.py index 3418ecb6..cc716bab 100644 --- a/examples/wallet/export_import.py +++ b/examples/wallet/export_import.py @@ -1,5 +1,4 @@ -"""Example showing export, import and reload, a wallet. -""" +"""Example showing wallet export, import, and reload.""" import argparse import asyncio @@ -11,10 +10,10 @@ from shared import ( FIXED_MNEMONIC_PHRASE, NETWORK_ID, - WALLET_SECRET + WALLET_SECRET, ) -TITLE = "wallet export import demo" +TITLE = "example wallet export import" FILENAME = "-".join(TITLE.split(" ")) @@ -22,6 +21,11 @@ async def main(rpc_url: str | None): # ------------------------------------------------------------------ # Construct and start wallet # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tConstruct and start wallet") + print("-" * 100) + if rpc_url: wallet = Wallet(network_id=NETWORK_ID, url=rpc_url) else: @@ -33,33 +37,42 @@ async def main(rpc_url: str | None): # ------------------------------------------------------------------ # Open existing wallet or create new # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tOpen existing wallet or create new") + print("-" * 100) + try: + # Create wallet created = await wallet.wallet_create( wallet_secret=WALLET_SECRET, filename=FILENAME, overwrite_wallet_storage=False, title=TITLE, - user_hint="example" + user_hint="example", ) print(f"Created new wallet file {FILENAME}: {created}\n") + # Create first prv key data for wallet prv_key_id = await wallet.prv_key_data_create( wallet_secret=WALLET_SECRET, secret=FIXED_MNEMONIC_PHRASE, kind=PrvKeyDataVariantKind.Mnemonic, payment_secret=None, - name="demo-key" + name="demo-key", ) + # Create first account account_descriptor = await wallet.accounts_create_bip32( wallet_secret=WALLET_SECRET, prv_key_data_id=prv_key_id, payment_secret=None, account_name="demo-acct", - account_index=0 + account_index=0, ) print(f"Created BIP32 account: {account_descriptor}\n") except WalletAlreadyExistsError: + # Open existing wallet print(f"Wallet with filename {FILENAME} already exists\n") opened = await wallet.wallet_open(WALLET_SECRET, True, FILENAME) print(f"Opened existing wallet {FILENAME}: {opened}\n") @@ -67,6 +80,11 @@ async def main(rpc_url: str | None): # ------------------------------------------------------------------ # Export -> close -> delete original -> import as new -> open # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tExport, close, delete, import, reopen") + print("-" * 100) + exported = await wallet.wallet_export(WALLET_SECRET, True) print(f"Wallet exported ({len(exported)} hex chars): {exported}\n") @@ -96,6 +114,11 @@ async def main(rpc_url: str | None): # ------------------------------------------------------------------ # Wind down # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tWind down") + print("-" * 100) + await wallet.wallet_close() print("Wallet closed\n") From 8ff73a793169dca8698e70c66846e91cd2d98e7a Mon Sep 17 00:00:00 2001 From: smartgoo Date: Sun, 26 Apr 2026 06:29:55 -0400 Subject: [PATCH 11/15] add payment output constructor and frompyobject --- python/kaspa/__init__.pyi | 10 +++++++++- src/wallet/core/tx/payment.rs | 30 ++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/python/kaspa/__init__.pyi b/python/kaspa/__init__.pyi index 2a4c4226..c1a7095a 100644 --- a/python/kaspa/__init__.pyi +++ b/python/kaspa/__init__.pyi @@ -908,8 +908,16 @@ class PaymentOutput: A payment destination with address and amount. Represents a single output in a transaction, specifying where funds - should be sent and how much. Used with Generator and create_transactions. + should be sent and how much. """ + def __new__(cls, address: Address, amount: builtins.int) -> PaymentOutput: + r""" + Create a new Payment Output. + + Args: + address: The address to send this output to. + amount: The amount, in sompi, to send on this output. + """ def __eq__(self, other: PaymentOutput) -> builtins.bool: r""" Equality comparison. diff --git a/src/wallet/core/tx/payment.rs b/src/wallet/core/tx/payment.rs index 9aaf61c1..9781ed1a 100644 --- a/src/wallet/core/tx/payment.rs +++ b/src/wallet/core/tx/payment.rs @@ -11,15 +11,25 @@ use crate::address::PyAddress; /// A payment destination with address and amount. /// /// Represents a single output in a transaction, specifying where funds -/// should be sent and how much. Used with Generator and create_transactions. +/// should be sent and how much. #[gen_stub_pyclass] -#[pyclass(name = "PaymentOutput")] +#[pyclass(name = "PaymentOutput", skip_from_py_object)] #[derive(Clone)] pub struct PyPaymentOutput(PaymentOutput); #[gen_stub_pymethods] #[pymethods] impl PyPaymentOutput { + /// Create a new Payment Output. + /// + /// Args: + /// address: The address to send this output to. + /// amount: The amount, in sompi, to send on this output. + #[new] + fn new(address: PyAddress, amount: u64) -> Self { + Self(PaymentOutput::new(address.into(), amount)) + } + /// Equality comparison. /// /// Args: @@ -69,3 +79,19 @@ impl TryFrom<&Bound<'_, PyDict>> for PyPaymentOutput { Ok(Self(inner)) } } + +impl<'py> FromPyObject<'_, 'py> for PyPaymentOutput { + type Error = PyErr; + + fn extract(obj: Borrowed<'_, 'py, PyAny>) -> Result { + if let Ok(output) = obj.cast::() { + Ok(output.borrow().clone()) + } else if let Ok(dict) = obj.cast::() { + Ok(PyPaymentOutput::try_from(&*dict)?) + } else { + Err(PyException::new_err( + "PaymentOutput must be an instance of `PaymentOutput` or compatible `dict`", + )) + } + } +} From acd62f5803787ccb374a5ed0635a47881a15f2b3 Mon Sep 17 00:00:00 2001 From: smartgoo Date: Sun, 26 Apr 2026 08:24:11 -0400 Subject: [PATCH 12/15] balance class repr method --- python/kaspa/__init__.pyi | 7 +++++++ src/wallet/core/utxo/balance.rs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/python/kaspa/__init__.pyi b/python/kaspa/__init__.pyi index c1a7095a..58b9ba83 100644 --- a/python/kaspa/__init__.pyi +++ b/python/kaspa/__init__.pyi @@ -302,6 +302,13 @@ class Balance: r""" Number of stasis (coinbase) UTXOs. """ + def __repr__(self) -> builtins.str: + r""" + The detailed string representation. + + Returns: + str: The Balance as a repr string. + """ @typing.final class BalanceStrings: diff --git a/src/wallet/core/utxo/balance.rs b/src/wallet/core/utxo/balance.rs index 0d362cd7..f5673d78 100644 --- a/src/wallet/core/utxo/balance.rs +++ b/src/wallet/core/utxo/balance.rs @@ -47,6 +47,22 @@ impl PyBalance { pub fn get_stasis_utxo_count(&self) -> usize { self.0.stasis_utxo_count } + + /// The detailed string representation. + /// + /// Returns: + /// str: The Balance as a repr string. + pub fn __repr__(&self) -> String { + format!( + "Balance(mature={}, pending={}, outgoing={}, mature_utxo_count={}, pending_utxo_count={}, stasis_utxo_count={})", + self.0.mature, + self.0.pending, + self.0.outgoing, + self.0.mature_utxo_count, + self.0.pending_utxo_count, + self.0.stasis_utxo_count, + ) + } } impl From for PyBalance { From 922e8ec61053deaef507897a8ed0d4d139f888e5 Mon Sep 17 00:00:00 2001 From: smartgoo Date: Sun, 26 Apr 2026 08:36:40 -0400 Subject: [PATCH 13/15] changelog --- docs/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 204a65cc..b4cd4f58 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,7 +7,7 @@ - Class `PrvKeyDataId` exposed to Python. Hex-encoded identifier for a private key data entry. Constructible from a hex string and accepted interchangeably with `str` by `Wallet` methods. - Class `WalletDescriptor` exposed to Python. Contains wallet metadata including title and filename. - Class `PrvKeyDataInfo` exposed to Python. Holds private key data info including ID, name, and encryption status. -- Class `PaymentOutput` exposed to Python. Represents a transaction output with a destination address and amount. +- Class `PaymentOutput` is now constructible via `PaymentOutput(address, amount)`, and accepted as either an instance or a `{"address": ..., "amount": ...}` dict wherever the bindings take a `PaymentOutput`. - Class `Fees` exposed to Python. Specifies transaction fees as an absolute sompi amount with an optional fee source. - Enum `WalletEventType` exposed to Python. Enumerates wallet event types such as connection, account, and transaction events. - Enum `AccountsDiscoveryKind` exposed to Python. Specifies the account discovery method (e.g. Bip44). @@ -27,6 +27,7 @@ - `build-dev` script builds with `--strip` for smaller artifacts. - `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. ### Fixed - `AccountDescriptor.__repr__` now correctly renders optional fields. From 377fdc28d09b086e2239acbcee551e443b079f5a Mon Sep 17 00:00:00 2001 From: smartgoo Date: Sun, 26 Apr 2026 08:36:54 -0400 Subject: [PATCH 14/15] wallet transaction example --- examples/wallet/shared.py | 57 +---- examples/wallet/transactions.py | 383 +++++++++++++++++++++++--------- 2 files changed, 284 insertions(+), 156 deletions(-) diff --git a/examples/wallet/shared.py b/examples/wallet/shared.py index 8683b340..a56e0855 100644 --- a/examples/wallet/shared.py +++ b/examples/wallet/shared.py @@ -1,13 +1,7 @@ -"""Shared demo variables and bootstrap helper for the wallet examples. - -All wallet examples import the same mnemonic, secret, and network id from -this module so they derive addresses from a single deterministic seed. - -Each example uses its own `FILENAME` so the on-disk wallets stay -independent. +"""Shared variables for the wallet examples. """ -from kaspa import AccountKind, PrvKeyDataVariantKind, Wallet +from kaspa import AccountKind BIP32_KIND = AccountKind("bip32") @@ -18,50 +12,3 @@ ) WALLET_SECRET = "example-wallet-secret" NETWORK_ID = "testnet-10" - - -async def open_or_create_wallet(wallet: Wallet, filename: str, *, title: str) -> str: - """Open an existing demo wallet or create a fresh one. - - First run: creates the wallet, stores the shared demo mnemonic under - the name "demo-key", and creates a BIP32 account at index 0. - Subsequent runs: reopens the file and returns the same account via - `accounts_ensure_default`, which is idempotent. - """ - if await wallet.exists(filename): - opened = await wallet.wallet_open(WALLET_SECRET, True, filename) - print(f"Opened existing wallet file {filename!r}: {opened}\n") - descriptor = await wallet.accounts_ensure_default( - wallet_secret=WALLET_SECRET, - account_kind=BIP32_KIND, - ) - return descriptor.account_id - - created = await wallet.wallet_create( - wallet_secret=WALLET_SECRET, - filename=filename, - overwrite_wallet_storage=False, - title=title, - user_hint="example", - ) - print(f"Created new wallet file {filename!r}: {created}") - - prv_key_id = await wallet.prv_key_data_create( - wallet_secret=WALLET_SECRET, - secret=FIXED_MNEMONIC_PHRASE, - kind=PrvKeyDataVariantKind.Mnemonic, - payment_secret=None, - name="demo-key", - ) - print(f"Created PrvKeyDataId: {prv_key_id}\n") - - descriptor = await wallet.accounts_create_bip32( - wallet_secret=WALLET_SECRET, - prv_key_data_id=prv_key_id, - payment_secret=None, - account_name="demo-acct", - account_index=0, - ) - print(f"Created BIP32 account: {descriptor}\n") - - return descriptor.account_id diff --git a/examples/wallet/transactions.py b/examples/wallet/transactions.py index 09a1a8e6..559ffeb6 100644 --- a/examples/wallet/transactions.py +++ b/examples/wallet/transactions.py @@ -1,189 +1,370 @@ -"""Send, transfer, estimate, and inspect transactions for a funded wallet. +"""Example showing transactions via wallet. -Requires testnet-10 funds. On the first run the script pauses at the receive -address and polls `accounts_get_utxos` until funds appear; on later runs the -existing UTXO set is picked up immediately. +Requires a live testnet-10 RPC node (passed via `--rpc-url`, or reachable +via the public `Resolver`) and testnet-10 funds sent to the printed +account_0 receive address. + +Funds are sent between two BIP32 accounts derived from the same mnemonic. + +- Wait at `account_0`'s receive address until incoming UTXOs are visible on + testnet-10. +- Derive five receive addresses on `account_1` and send to all five in a + single transaction (six outputs: 5 destinations + 1 change). +- Wait for `account_1`'s to receive prior tx. Derive a fresh receive address + on `account_1`, then sweep every UTXO into that single address. +- Print the resulting transaction history and balances for both accounts. """ import argparse import asyncio from kaspa import ( + FeeSource, Fees, NetworkId, + NewAddressKind, + PaymentOutput, + PrvKeyDataVariantKind, Resolver, TransactionKind, Wallet, - WalletEventType, +) +from kaspa.exceptions import WalletAlreadyExistsError + +from shared import ( + FIXED_MNEMONIC_PHRASE, + NETWORK_ID, + WALLET_SECRET, ) -from shared import NETWORK_ID, WALLET_SECRET, open_or_create_wallet +TITLE = "example wallet transactions" +FILENAME = "-".join(TITLE.split(" ")) -FILENAME = "wallet_transactions_demo" +# Per-destination amount for the multi-output send +PER_OUTPUT_SOMPI = 100_000_000 # 1 KAS +NUM_DESTINATIONS = 5 -def _on_balance(event): - print(f" [balance event]: {event.get('type', '')}") +async def _wait_for_utxos(wallet, account_id, label, min_count=1): + while True: + utxos = await wallet.accounts_get_utxos(account_id=account_id) + descriptor = await wallet.accounts_get(account_id) + print(f" utxos={len(utxos)} balance={descriptor.balance}") + if len(utxos) >= min_count: + print(f">>> {label}: {len(utxos)} UTXO(s) available\n") + return utxos + await asyncio.sleep(1) async def main(rpc_url: str | None): # ------------------------------------------------------------------ - # 1. Construct, start, connect (RPC is required for everything below). + # Construct and start wallet # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tConstruct and start wallet") + print("-" * 100) + if rpc_url: wallet = Wallet(network_id=NETWORK_ID, url=rpc_url) else: wallet = Wallet(network_id=NETWORK_ID, resolver=Resolver()) await wallet.start() - print("Wallet started") + print("Wallet started\n") await wallet.connect(url=rpc_url, strategy="fallback", timeout_duration=5000) - print("RPC connected") + print("RPC connected\n") - wallet.add_event_listener(WalletEventType.Balance, _on_balance) + while not wallet.is_synced: + await asyncio.sleep(0.5) + print("Wallet synced\n") # ------------------------------------------------------------------ - # 2. Idempotent bootstrap — see `shared.py`. + # Open existing wallet or create new (with two BIP32 accounts) # ------------------------------------------------------------------ - account_id = await open_or_create_wallet( - wallet, FILENAME, title="transactions demo" - ) - print(f"account_id = {account_id}") print() + print("-" * 100) + print("\tOpen existing wallet or create new") + print("-" * 100) - await wallet.accounts_activate([account_id]) - print(f"Activated account {account_id}") - print() + try: + created = await wallet.wallet_create( + wallet_secret=WALLET_SECRET, + filename=FILENAME, + overwrite_wallet_storage=False, + title=TITLE, + user_hint="example", + ) + print(f"Created new wallet file {FILENAME}: {created}\n") + except WalletAlreadyExistsError: + print(f"Wallet with filename {FILENAME} already exists\n") + opened = await wallet.wallet_open(WALLET_SECRET, True, FILENAME) + print(f"Opened existing wallet {FILENAME}: {opened}\n") + + accounts = await wallet.accounts_enumerate() + + account_0 = next((a for a in accounts if a.account_name == "demo-acct-0"), None) + if account_0 is None: + prv_key_id = await wallet.prv_key_data_create( + wallet_secret=WALLET_SECRET, + secret=FIXED_MNEMONIC_PHRASE, + kind=PrvKeyDataVariantKind.Mnemonic, + payment_secret=None, + name="demo-key", + ) + account_0 = await wallet.accounts_create_bip32( + wallet_secret=WALLET_SECRET, + prv_key_data_id=prv_key_id, + payment_secret=None, + account_name="demo-acct-0", + account_index=0, + ) + print(f"Created BIP32 account (index=0): {account_0}\n") + else: + prv_key_id = account_0.prv_key_data_ids[0] + print(f"Using existing BIP32 account (index=0): {account_0}\n") + + account_1 = next((a for a in accounts if a.account_name == "demo-acct-1"), None) + if account_1 is None: + account_1 = await wallet.accounts_create_bip32( + wallet_secret=WALLET_SECRET, + prv_key_data_id=prv_key_id, + payment_secret=None, + account_name="demo-acct-1", + account_index=1, + ) + print(f"Created BIP32 account (index=1): {account_1}\n") + else: + print(f"Using existing BIP32 account (index=1): {account_1}\n") + + print(f"account_0.account_id = {account_0.account_id}") + print(f"account_1.account_id = {account_1.account_id}\n") + + await wallet.accounts_activate([account_0.account_id, account_1.account_id]) + print(f"Activated accounts {account_0.account_id}, {account_1.account_id}\n") # ------------------------------------------------------------------ - # 3. Pick up (or derive) a receive address and wait until it is funded. + # Derive 5 receive addresses on account_1 (multi-output destinations) # ------------------------------------------------------------------ - account = await wallet.accounts_get(account_id) - receive_address = account.receive_address - print(f"receive_address = {receive_address}") print() - - print(">>> Waiting for testnet-10 funds at the receive address above...") - while True: - utxos = await wallet.accounts_get_utxos(account_id=account_id) - if utxos: - print(f">>> Funds detected: {len(utxos)} UTXO(s)") - print() - break - await asyncio.sleep(5) + print("-" * 100) + print("\tDerive 5 receive addresses on account_1") + print("-" * 100) + + destination_addresses = [] + for i in range(NUM_DESTINATIONS): + addr = await wallet.accounts_create_new_address( + account_1.account_id, NewAddressKind.Receive + ) + destination_addresses.append(addr) + print(f" - destination {i}: {addr}") + print() # ------------------------------------------------------------------ - # 4. Fee-rate helpers. + # Wait for testnet-10 funds at account_0 # ------------------------------------------------------------------ - fee_estimate = await wallet.fee_rate_estimate() - print(f"Fee rate estimate: {fee_estimate}") print() + print("-" * 100) + print("\tWait for testnet-10 funds at account_0") + print("-" * 100) + + account_0_descriptor = await wallet.accounts_get(account_0.account_id) + receive_address = account_0_descriptor.receive_address + print(f"account_0 receive_address = {receive_address}\n") - await wallet.fee_rate_poller_enable(5) - print("Fee-rate poller enabled (5s interval)") + print(">>> Send testnet-10 funds to the receive address above. Script will continue a few seconds after funds arrive.") + await _wait_for_utxos(wallet, account_0.account_id, "account_0 funded") - await wallet.fee_rate_poller_disable() - print("Fee-rate poller disabled") + # ------------------------------------------------------------------ + # Fee-rate estimate + # ------------------------------------------------------------------ print() + print("-" * 100) + print("\tFee-rate estimate") + print("-" * 100) + + fee_estimate = await wallet.fee_rate_estimate() + print(f"Fee rate estimate: {fee_estimate}\n") # ------------------------------------------------------------------ - # 5. UTXOs, estimate, send (change-only: no destination), self-transfer. + # Multi-output send: account_0 -> 5 receive addresses on account_1 + # (one transaction, 6 outputs: 5 destinations + 1 change) # ------------------------------------------------------------------ - utxos_for_account = await wallet.accounts_get_utxos(account_id=account_id) - print(f"UTXOs for account: {utxos_for_account}") print() + print("-" * 100) + print("\tMulti-output send (account_0 -> 5 destinations on account_1)") + print("-" * 100) + + outputs = [PaymentOutput(addr, PER_OUTPUT_SOMPI) for addr in destination_addresses] + print(f"Built {len(outputs)} PaymentOutput(s) of {PER_OUTPUT_SOMPI} sompi each\n") - estimate = await wallet.accounts_estimate( - account_id=account_id, - priority_fee_sompi=Fees(0, None), + multi_estimate = await wallet.accounts_estimate( + account_id=account_0.account_id, + priority_fee_sompi=Fees(0, FeeSource.SenderPays), fee_rate=None, payload=None, - destination=None, + destination=outputs, ) - print(f"Change-only estimate (fee=0): {estimate}") - print() + print(f"Multi-output estimate: {multi_estimate}\n") - send_result = await wallet.accounts_send( + multi_send = await wallet.accounts_send( wallet_secret=WALLET_SECRET, - account_id=account_id, - priority_fee_sompi=Fees(0, None), + account_id=account_0.account_id, + priority_fee_sompi=Fees(0, FeeSource.SenderPays), payment_secret=None, fee_rate=None, payload=None, - destination=None, + destination=outputs, ) - print(f"Change-only send: {send_result}") + print(f"Multi-output send: {multi_send}") + print(f" - final_transaction_id: {multi_send.final_transaction_id}") + print(f" - aggregate fees: {multi_send.fees} sompi") + print(f" - final_amount: {multi_send.final_amount} sompi\n") + + # ------------------------------------------------------------------ + # Wait for account_1 to see all 5 incoming UTXOs + # ------------------------------------------------------------------ print() + print("-" * 100) + print("\tWait for account_1 UTXOs to settle") + print("-" * 100) - transfer_result = await wallet.accounts_transfer( + await _wait_for_utxos( + wallet, account_1.account_id, "account_1 received", min_count=NUM_DESTINATIONS + ) + + # ------------------------------------------------------------------ + # Sweep account_1: consolidate every UTXO into one fresh address. + # Fees(0, FeeSource.ReceiverPays) deducts the network fee from the + # destination amount, leaving zero change. + # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tSweep account_1 into a fresh receive address") + print("-" * 100) + + sweep_address = await wallet.accounts_create_new_address( + account_1.account_id, NewAddressKind.Receive + ) + print(f"sweep destination: {sweep_address}\n") + + account_1_utxos = await wallet.accounts_get_utxos(account_id=account_1.account_id) + sweep_total = sum(u["amount"] for u in account_1_utxos) + print(f"account_1 total: {sweep_total} sompi across {len(account_1_utxos)} UTXO(s)\n") + + sweep_send = await wallet.accounts_send( wallet_secret=WALLET_SECRET, - source_account_id=account_id, - destination_account_id=account_id, - transfer_amount_sompi=1, + account_id=account_1.account_id, + priority_fee_sompi=Fees(0, FeeSource.ReceiverPays), payment_secret=None, fee_rate=None, - priority_fee_sompi=None, + payload=None, + destination=[PaymentOutput(sweep_address, sweep_total)], ) - print(f"Self-transfer (1 sompi): {transfer_result}") - print() + print(f"Sweep send: {sweep_send}") + print(f" - final_transaction_id: {sweep_send.final_transaction_id}") + print(f" - aggregate fees: {sweep_send.fees} sompi") + print(f" - final_amount: {sweep_send.final_amount} sompi\n") # ------------------------------------------------------------------ - # 6. Transaction history. The replace_* calls need a real tx id; we - # use a placeholder so the code path is still exercised. + # Transaction history (both accounts) # ------------------------------------------------------------------ - try: - incoming = await wallet.transactions_data_get( - account_id=account_id, - network_id=NetworkId(NETWORK_ID), + print() + print("-" * 100) + print("\tTransaction history") + print("-" * 100) + + network_id = NetworkId(NETWORK_ID) + + for label, acct_id in ( + ("account_0", account_0.account_id), + ("account_1", account_1.account_id), + ): + history = await wallet.transactions_data_get( + account_id=acct_id, + network_id=network_id, start=0, - end=10, - filter=[TransactionKind.Incoming], + end=20, + filter=[TransactionKind.Outgoing, TransactionKind.Incoming], ) - print(f"Incoming transactions: {incoming}") - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() + print(f"{label} history:") + print(f" - {history}\n") + + sweep_tx_id = sweep_send.final_transaction_id + await wallet.transactions_replace_note( + account_id=account_1.account_id, + network_id=network_id, + transaction_id=sweep_tx_id, + note="demo sweep", + ) + print(f"Replaced note on sweep tx {sweep_tx_id}\n") - placeholder_tx_id = "0" * 64 + await wallet.transactions_replace_metadata( + account_id=account_1.account_id, + network_id=network_id, + transaction_id=sweep_tx_id, + metadata="demo metadata", + ) + print(f"Replaced metadata on sweep tx {sweep_tx_id}\n") - try: - await wallet.transactions_replace_note( - account_id=account_id, - network_id=NetworkId(NETWORK_ID), - transaction_id=placeholder_tx_id, - note="demo note", - ) - print("Replaced note on placeholder tx") - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") + # ------------------------------------------------------------------ + # Account / address balances + # + # Wait for the sweep output to mature so per-address counts reflect + # the post-sweep state instead of pending/outgoing. + # ------------------------------------------------------------------ print() + print("-" * 100) + print("\tAccount / address balances") + print("-" * 100) - try: - await wallet.transactions_replace_metadata( - account_id=account_id, - network_id=NetworkId(NETWORK_ID), - transaction_id=placeholder_tx_id, - metadata="demo metadata", - ) - print("Replaced metadata on placeholder tx") - except Exception as exc: - print(f"{type(exc).__name__}: {exc}") - print() + await _wait_for_utxos( + wallet, account_1.account_id, "account_1 sweep output matured" + ) + + for label, acct in (("account_0", account_0), ("account_1", account_1)): + descriptor = await wallet.accounts_get(acct.account_id) + balance = descriptor.balance + print(f"{label} ({acct.account_id})") + if balance is not None: + print( + f" - balance: mature={balance.mature} pending={balance.pending} " + f"outgoing={balance.outgoing} " + f"(mature_utxos={balance.mature_utxo_count}, " + f"pending_utxos={balance.pending_utxo_count})" + ) + else: + print(" - balance: None") + + addresses = descriptor.get_addresses() or [] + for addr in addresses: + addr_utxos = await wallet.accounts_get_utxos( + account_id=acct.account_id, + addresses=[addr], + ) + if not addr_utxos: + continue + total = sum(u["amount"] for u in addr_utxos) + print(f" - {addr}: {total} sompi across {len(addr_utxos)} UTXO(s)") + print() # ------------------------------------------------------------------ - # 7. Wind down. + # Wind down # ------------------------------------------------------------------ - wallet.remove_event_listener(WalletEventType.Balance) + print() + print("-" * 100) + print("\tWind down") + print("-" * 100) await wallet.wallet_close() - print("Wallet closed") + print("Wallet closed\n") await wallet.disconnect() - print("RPC disconnected") + print("RPC disconnected\n") await wallet.stop() - print("Wallet stopped") + print("Wallet stopped\n") if __name__ == "__main__": From 9dd955f3f9a826de0b574657f0c04ec8f47dd171 Mon Sep 17 00:00:00 2001 From: smartgoo Date: Sun, 26 Apr 2026 09:00:30 -0400 Subject: [PATCH 15/15] wallet fn doc comment clean up --- python/kaspa/__init__.pyi | 94 ++++++++++++++------- src/wallet/core/wallet.rs | 166 +++++++++++++++++++++++++------------- 2 files changed, 172 insertions(+), 88 deletions(-) diff --git a/python/kaspa/__init__.pyi b/python/kaspa/__init__.pyi index 58b9ba83..203bd04d 100644 --- a/python/kaspa/__init__.pyi +++ b/python/kaspa/__init__.pyi @@ -3002,15 +3002,16 @@ class Wallet: r""" Start the wallet runtime and event notification task. - Spawns the background task that dispatches wallet events to any - registered Python listeners. Must be called before opening a wallet. + Boots the underlying `UtxoProcessor`, the wRPC client notifier, and + the background task that dispatches wallet events to registered + Python listeners. Must be called before opening a wallet. """ def stop(self) -> None: r""" Stop the wallet runtime and event notification task. Tears down the background notification task and stops the wallet's - internal services. Should be called before disposing of the Wallet. + `UtxoProcessor`. """ def connect(self, block_async_connect: typing.Optional[builtins.bool] = None, strategy: typing.Optional[builtins.str] = None, url: typing.Optional[builtins.str] = None, timeout_duration: typing.Optional[builtins.int] = None, retry_interval: typing.Optional[builtins.int] = None) -> None: r""" @@ -3055,7 +3056,8 @@ class Wallet: network_id: The NetworkId (or string) to bind the wallet to. Raises: - Exception: If the wallet rejects the network change. + Exception: If the wRPC client is currently connected. Disconnect + before changing networks. """ def wallet_enumerate(self) -> list[WalletDescriptor]: r""" @@ -3096,10 +3098,16 @@ class Wallet: """ def wallet_reload(self, reactivate: builtins.bool) -> None: r""" - Reload the wallet from disk. + Reboot the wallet's account runtime without re-reading from disk. + + Stops every active account, cleans up the `UtxoProcessor`, and emits + a `WalletReload` event. The cached wallet data is reused as-is; the + store is not re-opened. Args: - reactivate: If True, re-activate previously active accounts after reload. + reactivate: If True, re-`start()` previously active accounts so + they resume UTXO discovery and balance tracking. If False, + the caller is responsible for calling `accounts_activate`. """ def wallet_rename(self, wallet_secret: builtins.str, title: typing.Optional[builtins.str] = None, filename: typing.Optional[builtins.str] = None) -> None: r""" @@ -3109,6 +3117,14 @@ class Wallet: wallet_secret: Password for the open wallet. title: New human-readable title, or None to leave unchanged. filename: New on-disk filename, or None to leave unchanged. + + Note: + Upstream rusty-kaspa bug: when `filename` is supplied, the rename + resolves the new path relative to the process cwd instead of the + wallet store folder, and does not append `.wallet`. The renamed + file ends up at `./` and the in-memory store starts + pointing at that bare path, leaving the original wallet behind in + the store folder. Prefer renaming `title` only until fixed upstream. """ def wallet_change_secret(self, old_wallet_secret: builtins.str, new_wallet_secret: builtins.str) -> None: r""" @@ -3120,14 +3136,18 @@ class Wallet: """ def wallet_export(self, wallet_secret: builtins.str, include_transactions: builtins.bool) -> str: r""" - Export the wallet's encrypted data as raw bytes. + Export the wallet's serialized data as a hex string. + + Returns the borsh-serialized wallet payload (private key data remains + encrypted with `wallet_secret`). Suitable for backup or transfer to + another instance via `wallet_import`. Args: wallet_secret: Password for the open wallet. include_transactions: If True, include stored transaction history in the export. Returns: - str: The encrypted wallet payload as a hex-encoded string, suitable for backup or transfer. + str: The wallet payload as a hex-encoded string. """ def wallet_import(self, wallet_secret: builtins.str, wallet_data: str | bytes | list[int]) -> dict: r""" @@ -3163,14 +3183,6 @@ class Wallet: Returns: PrvKeyDataId: The id of the newly created private key data entry. """ - def prv_key_data_remove(self, wallet_secret: builtins.str, prv_key_data_id: PrvKeyDataId | str) -> None: - r""" - Remove a private key data entry from the wallet. - - Args: - wallet_secret: Password for the open wallet. - prv_key_data_id: Id of the entry to remove. - """ def prv_key_data_get(self, wallet_secret: builtins.str, prv_key_data_id: PrvKeyDataId | str) -> PrvKeyDataInfo: r""" Fetch metadata for a single private key data entry. @@ -3240,6 +3252,10 @@ class Wallet: r""" Import a keypair (single-key) account from existing private key data. + Like `accounts_create_keypair`, but routes through the import path + which runs an address-discovery scan before adding the account. The + scan is effectively a no-op for keypair accounts (no HD derivation). + Args: wallet_secret: Password for the open wallet. prv_key_data_id: Id of the private key data entry to use. @@ -3279,10 +3295,15 @@ class Wallet: r""" Ensure a default account of the given kind exists, creating one if needed. + Only `bip32` accounts are supported upstream; any other `account_kind` + raises an exception. If a new account is created, its private key data + is generated from `mnemonic_phrase` if supplied (with `payment_secret` + as the BIP39 passphrase) or from a freshly generated mnemonic otherwise. + Args: wallet_secret: Password for the open wallet. account_kind: The AccountKind of the default account to ensure. - payment_secret: Optional payment secret used when generating new key data. + payment_secret: Optional BIP39 passphrase used when generating new key data. mnemonic_phrase: Optional mnemonic phrase to seed the account. Returns: @@ -3297,10 +3318,13 @@ class Wallet: """ def accounts_get(self, account_id: AccountId | str) -> AccountDescriptor: r""" - Verify that an account exists in the open wallet. + Fetch the AccountDescriptor for an account. Args: account_id: Hex-encoded id of the account to look up. + + Returns: + AccountDescriptor: Descriptor of the requested account. """ def accounts_create_new_address(self, account_id: AccountId | str, address_kind: NewAddressKind | str) -> Address: r""" @@ -3325,7 +3349,9 @@ class Wallet: priority_fee_sompi: Priority fee specification (Fees object or dict). fee_rate: Optional explicit fee rate (sompi per gram of mass). payload: Optional binary payload to embed in the transaction. - destination: Optional list of PaymentOutputs. If None, sends to change. + destination: Optional list of PaymentOutputs. If None, the entire + sweepable balance is routed to the account's change address + (useful for compounding/sweeping UTXOs). Returns: GeneratorSummary: Summary of the estimated transaction(s). @@ -3341,12 +3367,14 @@ class Wallet: payment_secret: Optional payment secret if the source key data is encrypted with one. fee_rate: Optional explicit fee rate (sompi per gram of mass). payload: Optional binary payload to embed in the transaction. - destination: Optional list of PaymentOutputs. If None, sends to change. + destination: Optional list of PaymentOutputs. If None, the entire + sweepable balance is routed to the account's change address + (useful for compounding/sweeping UTXOs). Returns: GeneratorSummary: Summary of the submitted transaction(s). """ - def accounts_get_utxos(self, account_id: AccountId | str, addresses: None | typing.Sequence[Address | str] = None, min_amount_sompi: typing.Optional[builtins.int] = None) -> dict: + def accounts_get_utxos(self, account_id: AccountId | str, addresses: None | typing.Sequence[Address | str] = None, min_amount_sompi: typing.Optional[builtins.int] = None) -> list[dict]: r""" List UTXOs available to an account, optionally filtered. @@ -3356,12 +3384,15 @@ class Wallet: min_amount_sompi: Optional minimum UTXO value to include, in sompi. Returns: - dict: A serialized list of UTXO entries belonging to the account. + list[dict]: Serialized UTXO entries belonging to the account. """ def accounts_transfer(self, wallet_secret: builtins.str, source_account_id: AccountId | str, destination_account_id: AccountId | str, transfer_amount_sompi: builtins.int, payment_secret: typing.Optional[builtins.str] = None, fee_rate: typing.Optional[builtins.float] = None, priority_fee_sompi: None | Fees | dict = None) -> dict: r""" Transfer funds between two accounts in the same wallet. + Unlike funds sent to an external address, transferred funds are + available immediately on transaction acceptance (no maturity wait). + Args: wallet_secret: Password for the open wallet. source_account_id: Hex-encoded id of the sending account. @@ -3369,7 +3400,7 @@ class Wallet: transfer_amount_sompi: Amount to transfer in sompi. payment_secret: Optional payment secret if the source key data is encrypted with one. fee_rate: Optional explicit fee rate (sompi per gram of mass). - priority_fee_sompi: Optional priority fee specification. + priority_fee_sompi: Optional priority fee specification. Defaults to `SenderPays(0)`. Returns: dict: The transfer response, including generator summary and transaction ids. @@ -3431,10 +3462,18 @@ class Wallet: Args: wallet_secret: Password for the open wallet. + + Note: + Calling `flush` outside of an active `batch` panics in the + upstream local store. Always pair `flush` with a prior `batch`. """ def retain_context(self, name: builtins.str, data: None | str | bytes | list[int] = None) -> None: r""" - Persist arbitrary named context data alongside the wallet. + Store arbitrary named context data in the wallet runtime. + + The data lives in memory for the lifetime of the wallet instance and + is not written to disk. Useful for syncing UI state between front-end + and back-end processes that share a single wallet instance. Args: name: A name identifying the context entry. @@ -3450,13 +3489,6 @@ class Wallet: Returns: dict: Status information including connection state, sync state, and selected network. """ - def address_book_enumerate(self) -> None: - r""" - Enumerate entries in the wallet address book. - - Note: this is currently a no-op placeholder that returns nothing; the - underlying API is reserved for a future address book implementation. - """ def transactions_data_get(self, account_id: AccountId | str, network_id: NetworkId | str, start: builtins.int, end: builtins.int, filter: None | typing.Sequence[TransactionKind | str] = None) -> dict: r""" Fetch a window of stored transaction history for an account. diff --git a/src/wallet/core/wallet.rs b/src/wallet/core/wallet.rs index 55ee4f0b..21043e62 100644 --- a/src/wallet/core/wallet.rs +++ b/src/wallet/core/wallet.rs @@ -195,8 +195,9 @@ impl PyWallet { /// Start the wallet runtime and event notification task. /// - /// Spawns the background task that dispatches wallet events to any - /// registered Python listeners. Must be called before opening a wallet. + /// Boots the underlying `UtxoProcessor`, the wRPC client notifier, and + /// the background task that dispatches wallet events to registered + /// Python listeners. Must be called before opening a wallet. #[gen_stub(override_return_type(type_repr = "None"))] pub fn start<'py>(&self, py: Python<'py>) -> PyResult> { self.start_notification_task(py, self.wallet().multiplexer()) @@ -212,7 +213,7 @@ impl PyWallet { /// Stop the wallet runtime and event notification task. /// /// Tears down the background notification task and stops the wallet's - /// internal services. Should be called before disposing of the Wallet. + /// `UtxoProcessor`. #[gen_stub(override_return_type(type_repr = "None"))] pub fn stop<'py>(&self, py: Python<'py>) -> PyResult> { let slf = self.clone(); @@ -346,7 +347,8 @@ impl PyWallet { /// network_id: The NetworkId (or string) to bind the wallet to. /// /// Raises: - /// Exception: If the wallet rejects the network change. + /// Exception: If the wRPC client is currently connected. Disconnect + /// before changing networks. pub fn set_network_id( &self, #[gen_stub(override_type(type_repr = "NetworkId | str"))] network_id: PyNetworkId, @@ -540,10 +542,16 @@ impl PyWallet { }) } - /// Reload the wallet from disk. + /// Reboot the wallet's account runtime without re-reading from disk. + /// + /// Stops every active account, cleans up the `UtxoProcessor`, and emits + /// a `WalletReload` event. The cached wallet data is reused as-is; the + /// store is not re-opened. /// /// Args: - /// reactivate: If True, re-activate previously active accounts after reload. + /// reactivate: If True, re-`start()` previously active accounts so + /// they resume UTXO discovery and balance tracking. If False, + /// the caller is responsible for calling `accounts_activate`. #[gen_stub(override_return_type(type_repr = "None"))] pub fn wallet_reload<'py>( &self, @@ -567,6 +575,14 @@ impl PyWallet { /// wallet_secret: Password for the open wallet. /// title: New human-readable title, or None to leave unchanged. /// filename: New on-disk filename, or None to leave unchanged. + /// + /// Note: + /// Upstream rusty-kaspa bug: when `filename` is supplied, the rename + /// resolves the new path relative to the process cwd instead of the + /// wallet store folder, and does not append `.wallet`. The renamed + /// file ends up at `./` and the in-memory store starts + /// pointing at that bare path, leaving the original wallet behind in + /// the store folder. Prefer renaming `title` only until fixed upstream. #[gen_stub(override_return_type(type_repr = "None"))] #[pyo3(signature = (wallet_secret, title=None, filename=None))] pub fn wallet_rename<'py>( @@ -616,14 +632,18 @@ impl PyWallet { }) } - /// Export the wallet's encrypted data as raw bytes. + /// Export the wallet's serialized data as a hex string. + /// + /// Returns the borsh-serialized wallet payload (private key data remains + /// encrypted with `wallet_secret`). Suitable for backup or transfer to + /// another instance via `wallet_import`. /// /// Args: /// wallet_secret: Password for the open wallet. /// include_transactions: If True, include stored transaction history in the export. /// /// Returns: - /// str: The encrypted wallet payload as a hex-encoded string, suitable for backup or transfer. + /// str: The wallet payload as a hex-encoded string. #[gen_stub(override_return_type(type_repr = "str"))] pub fn wallet_export<'py>( &self, @@ -751,34 +771,37 @@ impl PyWallet { }) } - /// Remove a private key data entry from the wallet. - /// - /// Args: - /// wallet_secret: Password for the open wallet. - /// prv_key_data_id: Id of the entry to remove. - #[gen_stub(override_return_type(type_repr = "None"))] - pub fn prv_key_data_remove<'py>( - &self, - py: Python<'py>, - wallet_secret: String, - #[gen_stub(override_type(type_repr = "PrvKeyDataId | str"))] - prv_key_data_id: PyPrvKeyDataId, - ) -> PyResult> { - let request = PrvKeyDataRemoveRequest { - wallet_secret: wallet_secret.into(), - prv_key_data_id: prv_key_data_id.into(), - }; + // Remove a private key data entry from the wallet. + // + // Args: + // wallet_secret: Password for the open wallet. + // prv_key_data_id: Id of the entry to remove. + // + // Note: + // Not implemented upstream — currently always raises an exception. + // #[gen_stub(override_return_type(type_repr = "None"))] + // pub fn prv_key_data_remove<'py>( + // &self, + // py: Python<'py>, + // wallet_secret: String, + // #[gen_stub(override_type(type_repr = "PrvKeyDataId | str"))] + // prv_key_data_id: PyPrvKeyDataId, + // ) -> PyResult> { + // let request = PrvKeyDataRemoveRequest { + // wallet_secret: wallet_secret.into(), + // prv_key_data_id: prv_key_data_id.into(), + // }; - let wallet = self.wallet().clone(); - pyo3_async_runtimes::tokio::future_into_py(py, async move { - wallet - .prv_key_data_remove_call(request) - .await - .into_py_result()?; + // let wallet = self.wallet().clone(); + // pyo3_async_runtimes::tokio::future_into_py(py, async move { + // wallet + // .prv_key_data_remove_call(request) + // .await + // .into_py_result()?; - Ok(()) - }) - } + // Ok(()) + // }) + // } /// Fetch metadata for a single private key data entry. /// @@ -984,6 +1007,10 @@ impl PyWallet { /// Import a keypair (single-key) account from existing private key data. /// + /// Like `accounts_create_keypair`, but routes through the import path + /// which runs an address-discovery scan before adding the account. The + /// scan is effectively a no-op for keypair accounts (no HD derivation). + /// /// Args: /// wallet_secret: Password for the open wallet. /// prv_key_data_id: Id of the private key data entry to use. @@ -1103,10 +1130,15 @@ impl PyWallet { /// Ensure a default account of the given kind exists, creating one if needed. /// + /// Only `bip32` accounts are supported upstream; any other `account_kind` + /// raises an exception. If a new account is created, its private key data + /// is generated from `mnemonic_phrase` if supplied (with `payment_secret` + /// as the BIP39 passphrase) or from a freshly generated mnemonic otherwise. + /// /// Args: /// wallet_secret: Password for the open wallet. /// account_kind: The AccountKind of the default account to ensure. - /// payment_secret: Optional payment secret used when generating new key data. + /// payment_secret: Optional BIP39 passphrase used when generating new key data. /// mnemonic_phrase: Optional mnemonic phrase to seed the account. /// /// Returns: @@ -1203,10 +1235,13 @@ impl PyWallet { // }) // } - /// Verify that an account exists in the open wallet. + /// Fetch the AccountDescriptor for an account. /// /// Args: /// account_id: Hex-encoded id of the account to look up. + /// + /// Returns: + /// AccountDescriptor: Descriptor of the requested account. #[gen_stub(override_return_type(type_repr = "AccountDescriptor"))] pub fn accounts_get<'py>( &self, @@ -1267,7 +1302,9 @@ impl PyWallet { /// priority_fee_sompi: Priority fee specification (Fees object or dict). /// fee_rate: Optional explicit fee rate (sompi per gram of mass). /// payload: Optional binary payload to embed in the transaction. - /// destination: Optional list of PaymentOutputs. If None, sends to change. + /// destination: Optional list of PaymentOutputs. If None, the entire + /// sweepable balance is routed to the account's change address + /// (useful for compounding/sweeping UTXOs). /// /// Returns: /// GeneratorSummary: Summary of the estimated transaction(s). @@ -1322,7 +1359,9 @@ impl PyWallet { /// payment_secret: Optional payment secret if the source key data is encrypted with one. /// fee_rate: Optional explicit fee rate (sompi per gram of mass). /// payload: Optional binary payload to embed in the transaction. - /// destination: Optional list of PaymentOutputs. If None, sends to change. + /// destination: Optional list of PaymentOutputs. If None, the entire + /// sweepable balance is routed to the account's change address + /// (useful for compounding/sweeping UTXOs). /// /// Returns: /// GeneratorSummary: Summary of the submitted transaction(s). @@ -1377,8 +1416,8 @@ impl PyWallet { /// min_amount_sompi: Optional minimum UTXO value to include, in sompi. /// /// Returns: - /// dict: A serialized list of UTXO entries belonging to the account. - #[gen_stub(override_return_type(type_repr = "dict"))] + /// list[dict]: Serialized UTXO entries belonging to the account. + #[gen_stub(override_return_type(type_repr = "list[dict]"))] #[pyo3(signature = (account_id, addresses=None, min_amount_sompi=None))] pub fn accounts_get_utxos<'py>( &self, @@ -1407,6 +1446,9 @@ impl PyWallet { /// Transfer funds between two accounts in the same wallet. /// + /// Unlike funds sent to an external address, transferred funds are + /// available immediately on transaction acceptance (no maturity wait). + /// /// Args: /// wallet_secret: Password for the open wallet. /// source_account_id: Hex-encoded id of the sending account. @@ -1414,7 +1456,7 @@ impl PyWallet { /// transfer_amount_sompi: Amount to transfer in sompi. /// payment_secret: Optional payment secret if the source key data is encrypted with one. /// fee_rate: Optional explicit fee rate (sompi per gram of mass). - /// priority_fee_sompi: Optional priority fee specification. + /// priority_fee_sompi: Optional priority fee specification. Defaults to `SenderPays(0)`. /// /// Returns: /// dict: The transfer response, including generator summary and transaction ids. @@ -1637,6 +1679,10 @@ impl PyWallet { /// /// Args: /// wallet_secret: Password for the open wallet. + /// + /// Note: + /// Calling `flush` outside of an active `batch` panics in the + /// upstream local store. Always pair `flush` with a prior `batch`. #[gen_stub(override_return_type(type_repr = "None"))] pub fn flush<'py>( &self, @@ -1654,7 +1700,11 @@ impl PyWallet { }) } - /// Persist arbitrary named context data alongside the wallet. + /// Store arbitrary named context data in the wallet runtime. + /// + /// The data lives in memory for the lifetime of the wallet instance and + /// is not written to disk. Useful for syncing UI state between front-end + /// and back-end processes that share a single wallet instance. /// /// Args: /// name: A name identifying the context entry. @@ -1705,21 +1755,23 @@ impl PyWallet { }) } - /// Enumerate entries in the wallet address book. - /// - /// Note: this is currently a no-op placeholder that returns nothing; the - /// underlying API is reserved for a future address book implementation. - #[gen_stub(override_return_type(type_repr = "None"))] - pub fn address_book_enumerate<'py>(&self, py: Python<'py>) -> PyResult> { - let wallet = self.wallet().clone(); - pyo3_async_runtimes::tokio::future_into_py(py, async move { - wallet - .address_book_enumerate_call(AddressBookEnumerateRequest {}) - .await - .into_py_result()?; - Ok(()) - }) - } + // Temporarily removed until upstream is implemented + // Enumerate entries in the wallet address book. + // + // Note: + // Not implemented upstream — currently always raises an exception. + // The underlying API is reserved for a future address book implementation. + // #[gen_stub(override_return_type(type_repr = "None"))] + // pub fn address_book_enumerate<'py>(&self, py: Python<'py>) -> PyResult> { + // let wallet = self.wallet().clone(); + // pyo3_async_runtimes::tokio::future_into_py(py, async move { + // wallet + // .address_book_enumerate_call(AddressBookEnumerateRequest {}) + // .await + // .into_py_result()?; + // Ok(()) + // }) + // } } // Wallet API transactions_ functions