diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c4252532..b4cd4f58 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,12 +2,12 @@ ### 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. - 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). @@ -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 @@ -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. 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..d2c75ea5 --- /dev/null +++ b/examples/wallet/accounts.py @@ -0,0 +1,265 @@ +"""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 ( + NewAddressKind, + PrivateKey, + PrvKeyDataVariantKind, + Resolver, + Wallet, +) +from kaspa.exceptions import WalletAlreadyExistsError + +from shared import ( + FIXED_MNEMONIC_PHRASE, + NETWORK_ID, + WALLET_SECRET, +) + +TITLE = "example wallet accounts" +FILENAME = "-".join(TITLE.split(" ")) + +PRIVATE_KEY = PrivateKey("389840d7696e89c38856a066175e8e92697f0cf182b854c883237a50acaf1f69") + + +def bip32_account_path(account_index: int) -> str: + return f"m/44'/111111'/{account_index}'" + + +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}" + + +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: + wallet = Wallet(network_id=NETWORK_ID, resolver=Resolver()) + + await wallet.start() + print("Wallet started\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() + 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() + + # 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_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 {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=PRIVATE_KEY.to_string(), + kind=PrvKeyDataVariantKind.SecretKey, + payment_secret=None, + name="demo-secret-key", + ) + keypair_account = await wallet.accounts_create_keypair( + wallet_secret=WALLET_SECRET, + prv_key_data_id=secret_key_prv_key_id, + ecdsa=False, + account_name="keypair-acct", + ) + print(f"Created keypair account: {keypair_account}\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) + + 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(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") + + 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/creation.py b/examples/wallet/creation.py new file mode 100644 index 00000000..38910fc7 --- /dev/null +++ b/examples/wallet/creation.py @@ -0,0 +1,137 @@ +"""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 + +TITLE = "example wallet creation" +FILENAME = "-".join(TITLE.split(" ")) + + +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: + wallet = Wallet(network_id=NETWORK_ID, resolver=Resolver()) + + 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() + + # ------------------------------------------------------------------ + # Open existing wallet or create new + # ------------------------------------------------------------------ + 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() + + 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") + + # 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, + kind=PrvKeyDataVariantKind.Mnemonic, + payment_secret=None, + name="demo-key", + ) + 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, + payment_secret=None, + account_name="demo-acct", + account_index=0, + ) + print(f"Created BIP32 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") + + 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 (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}\n") + + # ------------------------------------------------------------------ + # Wind down + # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tWind down") + print("-" * 100) + + 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/export_import.py b/examples/wallet/export_import.py new file mode 100644 index 00000000..cc716bab --- /dev/null +++ b/examples/wallet/export_import.py @@ -0,0 +1,133 @@ +"""Example showing wallet export, import, and reload.""" + +import argparse +import asyncio +from pathlib import Path + +from kaspa import PrvKeyDataVariantKind, Resolver, Wallet +from kaspa.exceptions import WalletAlreadyExistsError + +from shared import ( + FIXED_MNEMONIC_PHRASE, + NETWORK_ID, + WALLET_SECRET, +) + +TITLE = "example wallet export import" +FILENAME = "-".join(TITLE.split(" ")) + + +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: + wallet = Wallet(network_id=NETWORK_ID, resolver=Resolver()) + + await wallet.start() + print("Wallet started\n") + + # ------------------------------------------------------------------ + # 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", + ) + 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", + ) + + # 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, + ) + 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") + + # ------------------------------------------------------------------ + # 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") + + await wallet.wallet_close() + 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 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` creates new wallet file + imported = await wallet.wallet_import(WALLET_SECRET, exported) + 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}\n") + + # ------------------------------------------------------------------ + # Wind down + # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tWind down") + print("-" * 100) + + 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 new file mode 100644 index 00000000..a56e0855 --- /dev/null +++ b/examples/wallet/shared.py @@ -0,0 +1,14 @@ +"""Shared variables for the wallet examples. +""" + +from kaspa import AccountKind + +BIP32_KIND = AccountKind("bip32") + +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" diff --git a/examples/wallet/transactions.py b/examples/wallet/transactions.py new file mode 100644 index 00000000..559ffeb6 --- /dev/null +++ b/examples/wallet/transactions.py @@ -0,0 +1,374 @@ +"""Example showing transactions via wallet. + +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, +) +from kaspa.exceptions import WalletAlreadyExistsError + +from shared import ( + FIXED_MNEMONIC_PHRASE, + NETWORK_ID, + WALLET_SECRET, +) + +TITLE = "example wallet transactions" +FILENAME = "-".join(TITLE.split(" ")) + +# Per-destination amount for the multi-output send +PER_OUTPUT_SOMPI = 100_000_000 # 1 KAS +NUM_DESTINATIONS = 5 + + +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): + # ------------------------------------------------------------------ + # 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\n") + + await wallet.connect(url=rpc_url, strategy="fallback", timeout_duration=5000) + print("RPC connected\n") + + while not wallet.is_synced: + await asyncio.sleep(0.5) + print("Wallet synced\n") + + # ------------------------------------------------------------------ + # Open existing wallet or create new (with two BIP32 accounts) + # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tOpen existing wallet or create new") + print("-" * 100) + + 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") + + # ------------------------------------------------------------------ + # Derive 5 receive addresses on account_1 (multi-output destinations) + # ------------------------------------------------------------------ + print() + 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() + + # ------------------------------------------------------------------ + # Wait for testnet-10 funds at account_0 + # ------------------------------------------------------------------ + 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") + + 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") + + # ------------------------------------------------------------------ + # 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") + + # ------------------------------------------------------------------ + # Multi-output send: account_0 -> 5 receive addresses on account_1 + # (one transaction, 6 outputs: 5 destinations + 1 change) + # ------------------------------------------------------------------ + 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") + + 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=outputs, + ) + print(f"Multi-output estimate: {multi_estimate}\n") + + multi_send = await wallet.accounts_send( + wallet_secret=WALLET_SECRET, + account_id=account_0.account_id, + priority_fee_sompi=Fees(0, FeeSource.SenderPays), + payment_secret=None, + fee_rate=None, + payload=None, + destination=outputs, + ) + 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) + + 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, + account_id=account_1.account_id, + priority_fee_sompi=Fees(0, FeeSource.ReceiverPays), + payment_secret=None, + fee_rate=None, + payload=None, + destination=[PaymentOutput(sweep_address, sweep_total)], + ) + 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") + + # ------------------------------------------------------------------ + # Transaction history (both accounts) + # ------------------------------------------------------------------ + 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=20, + filter=[TransactionKind.Outgoing, TransactionKind.Incoming], + ) + 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") + + 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") + + # ------------------------------------------------------------------ + # 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) + + 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() + + # ------------------------------------------------------------------ + # Wind down + # ------------------------------------------------------------------ + print() + print("-" * 100) + print("\tWind down") + print("-" * 100) + + await wallet.wallet_close() + print("Wallet closed\n") + + await wallet.disconnect() + print("RPC disconnected\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/python/kaspa/__init__.pyi b/python/kaspa/__init__.pyi index 2b854c37..203bd04d 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. @@ -262,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: @@ -868,8 +915,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. @@ -2947,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""" @@ -3000,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""" @@ -3041,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""" @@ -3054,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""" @@ -3065,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""" @@ -3098,7 +3173,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 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. @@ -3106,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. @@ -3183,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. @@ -3222,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: @@ -3238,12 +3316,15 @@ 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. + 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""" @@ -3268,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). @@ -3284,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. @@ -3299,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. @@ -3312,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. @@ -3374,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. @@ -3393,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/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/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`", + )) + } + } +} 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 { diff --git a/src/wallet/core/wallet.rs b/src/wallet/core/wallet.rs index b18899fe..21043e62 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, @@ -193,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()) @@ -210,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(); @@ -344,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, @@ -538,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, @@ -565,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>( @@ -614,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, @@ -700,7 +722,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 +743,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(), ), }; @@ -739,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. /// @@ -972,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. @@ -1091,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: @@ -1191,11 +1235,14 @@ 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. - #[gen_stub(override_return_type(type_repr = "None"))] + /// + /// Returns: + /// AccountDescriptor: Descriptor of the requested account. + #[gen_stub(override_return_type(type_repr = "AccountDescriptor"))] pub fn accounts_get<'py>( &self, py: Python<'py>, @@ -1207,9 +1254,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)) }) } @@ -1255,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). @@ -1310,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). @@ -1365,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, @@ -1395,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. @@ -1402,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. @@ -1625,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, @@ -1642,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. @@ -1693,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 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(