diff --git a/dash-spv-ffi/Cargo.toml b/dash-spv-ffi/Cargo.toml index 3fc359a4e..92465f830 100644 --- a/dash-spv-ffi/Cargo.toml +++ b/dash-spv-ffi/Cargo.toml @@ -30,6 +30,7 @@ key-wallet-ffi = { path = "../key-wallet-ffi" } key-wallet = { path = "../key-wallet" } key-wallet-manager = { path = "../key-wallet-manager" } rand = "0.8" +clap = { version = "4.5", features = ["derive"] } [dev-dependencies] tempfile = "3.8" @@ -39,3 +40,7 @@ dashcore-test-utils = { path = "../test-utils" } [build-dependencies] cbindgen = "0.29" + +[[bin]] +name = "dash-spv-ffi" +path = "src/bin/ffi_cli.rs" diff --git a/dash-spv-ffi/FFI_API.md b/dash-spv-ffi/FFI_API.md index 64b7084ac..b3e2902bb 100644 --- a/dash-spv-ffi/FFI_API.md +++ b/dash-spv-ffi/FFI_API.md @@ -4,7 +4,7 @@ This document provides a comprehensive reference for all FFI (Foreign Function I **Auto-generated**: This documentation is automatically generated from the source code. Do not edit manually. -**Total Functions**: 68 +**Total Functions**: 70 ## Table of Contents @@ -34,7 +34,7 @@ Functions: 4 ### Configuration -Functions: 26 +Functions: 27 | Function | Description | Module | |----------|-------------|--------| @@ -63,6 +63,7 @@ Functions: 26 | `dash_spv_ffi_config_set_user_agent` | Sets the user agent string to advertise in the P2P handshake # Safety - `con... | config | | `dash_spv_ffi_config_set_validation_mode` | Sets the validation mode for the SPV client # Safety - `config` must be a va... | config | | `dash_spv_ffi_config_set_wallet_creation_time` | Sets the wallet creation timestamp for synchronization optimization # Safety... | config | +| `dash_spv_ffi_config_set_worker_threads` | Sets the number of Tokio worker threads for the FFI runtime (0 = auto) # Saf... | config | | `dash_spv_ffi_config_testnet` | No description | config | ### Synchronization @@ -119,10 +120,11 @@ Functions: 4 ### Event Callbacks -Functions: 1 +Functions: 2 | Function | Description | Module | |----------|-------------|--------| +| `dash_spv_ffi_client_drain_events` | Drain pending events and invoke configured callbacks (non-blocking) | client | | `dash_spv_ffi_client_set_event_callbacks` | Set event callbacks for the client | client | ### Error Handling @@ -617,6 +619,22 @@ Sets the wallet creation timestamp for synchronization optimization # Safety - --- +#### `dash_spv_ffi_config_set_worker_threads` + +```c +dash_spv_ffi_config_set_worker_threads(config: *mut FFIClientConfig, threads: u32,) -> i32 +``` + +**Description:** +Sets the number of Tokio worker threads for the FFI runtime (0 = auto) # Safety - `config` must be a valid pointer to an FFIClientConfig + +**Safety:** +- `config` must be a valid pointer to an FFIClientConfig + +**Module:** `config` + +--- + #### `dash_spv_ffi_config_testnet` ```c @@ -905,6 +923,22 @@ This function is unsafe because: - The caller must ensure the handle pointer is ### Event Callbacks - Detailed +#### `dash_spv_ffi_client_drain_events` + +```c +dash_spv_ffi_client_drain_events(client: *mut FFIDashSpvClient) -> i32 +``` + +**Description:** +Drain pending events and invoke configured callbacks (non-blocking). # Safety - `client` must be a valid, non-null pointer. + +**Safety:** +- `client` must be a valid, non-null pointer. + +**Module:** `client` + +--- + #### `dash_spv_ffi_client_set_event_callbacks` ```c diff --git a/dash-spv-ffi/dash_spv_ffi.h b/dash-spv-ffi/dash_spv_ffi.h new file mode 100644 index 000000000..644ce1094 --- /dev/null +++ b/dash-spv-ffi/dash_spv_ffi.h @@ -0,0 +1,994 @@ +/* dash-spv-ffi C bindings - Auto-generated by cbindgen */ + +#ifndef DASH_SPV_FFI_H +#define DASH_SPV_FFI_H + +/* Generated with cbindgen:0.29.0 */ + +/* Warning: This file is auto-generated by cbindgen. Do not modify manually. */ + +#include +#include +#include +#include + +#ifdef __cplusplus +namespace dash_spv_ffi { +#endif // __cplusplus + +typedef enum FFIMempoolStrategy { + FetchAll = 0, + BloomFilter = 1, + Selective = 2, +} FFIMempoolStrategy; + +typedef enum FFISyncStage { + Connecting = 0, + QueryingHeight = 1, + Downloading = 2, + Validating = 3, + Storing = 4, + Complete = 5, + Failed = 6, +} FFISyncStage; + +typedef enum DashSpvValidationMode { + None = 0, + Basic = 1, + Full = 2, +} DashSpvValidationMode; + +typedef struct FFIDashSpvClient FFIDashSpvClient; + +/** + * FFI-safe array that transfers ownership of memory to the C caller. + * + * # Safety + * + * This struct represents memory that has been allocated by Rust but ownership + * has been transferred to the C caller. The caller is responsible for: + * - Not accessing the memory after it has been freed + * - Calling `dash_spv_ffi_array_destroy` to properly deallocate the memory + * - Ensuring the data, len, and capacity fields remain consistent + */ +typedef struct FFIArray { + void *data; + uintptr_t len; + uintptr_t capacity; + uintptr_t elem_size; + uintptr_t elem_align; +} FFIArray; + +typedef struct FFIClientConfig { + void *inner; + uint32_t worker_threads; + +} FFIClientConfig; + +typedef struct FFIString { + char *ptr; + uintptr_t length; +} FFIString; + +typedef struct FFIDetailedSyncProgress { + uint32_t current_height; + uint32_t total_height; + double percentage; + double headers_per_second; + int64_t estimated_seconds_remaining; + enum FFISyncStage stage; + struct FFIString stage_message; + uint32_t connected_peers; + uint64_t total_headers; + int64_t sync_start_timestamp; +} FFIDetailedSyncProgress; + +typedef struct FFISyncProgress { + uint32_t header_height; + uint32_t filter_header_height; + uint32_t masternode_height; + uint32_t peer_count; + bool headers_synced; + bool filter_headers_synced; + bool masternodes_synced; + bool filter_sync_available; + uint32_t filters_downloaded; + uint32_t last_synced_filter_height; +} FFISyncProgress; + +typedef struct FFISpvStats { + uint32_t connected_peers; + uint32_t total_peers; + uint32_t header_height; + uint32_t filter_height; + uint64_t headers_downloaded; + uint64_t filter_headers_downloaded; + uint64_t filters_downloaded; + uint64_t filters_matched; + uint64_t blocks_processed; + uint64_t bytes_received; + uint64_t bytes_sent; + uint64_t uptime; +} FFISpvStats; + +typedef void (*BlockCallback)(uint32_t height, const uint8_t (*hash)[32], void *user_data); + +typedef void (*TransactionCallback)(const uint8_t (*txid)[32], + bool confirmed, + int64_t amount, + const char *addresses, + uint32_t block_height, + void *user_data); + +typedef void (*BalanceCallback)(uint64_t confirmed, uint64_t unconfirmed, void *user_data); + +typedef void (*MempoolTransactionCallback)(const uint8_t (*txid)[32], + int64_t amount, + const char *addresses, + bool is_instant_send, + void *user_data); + +typedef void (*MempoolConfirmedCallback)(const uint8_t (*txid)[32], + uint32_t block_height, + const uint8_t (*block_hash)[32], + void *user_data); + +typedef void (*MempoolRemovedCallback)(const uint8_t (*txid)[32], uint8_t reason, void *user_data); + +typedef void (*CompactFilterMatchedCallback)(const uint8_t (*block_hash)[32], + const char *matched_scripts, + const char *wallet_id, + void *user_data); + +typedef void (*WalletTransactionCallback)(const char *wallet_id, + uint32_t account_index, + const uint8_t (*txid)[32], + bool confirmed, + int64_t amount, + const char *addresses, + uint32_t block_height, + bool is_ours, + void *user_data); + +typedef void (*FilterHeadersProgressCallback)(uint32_t filter_height, + uint32_t header_height, + double percentage, + void *user_data); + +typedef struct FFIEventCallbacks { + BlockCallback on_block; + TransactionCallback on_transaction; + BalanceCallback on_balance_update; + MempoolTransactionCallback on_mempool_transaction_added; + MempoolConfirmedCallback on_mempool_transaction_confirmed; + MempoolRemovedCallback on_mempool_transaction_removed; + CompactFilterMatchedCallback on_compact_filter_matched; + WalletTransactionCallback on_wallet_transaction; + FilterHeadersProgressCallback on_filter_headers_progress; + void *user_data; +} FFIEventCallbacks; + +/** + * Handle for Core SDK that can be passed to Platform SDK + */ +typedef struct CoreSDKHandle { + struct FFIDashSpvClient *client; +} CoreSDKHandle; + +/** + * FFIResult type for error handling + */ +typedef struct FFIResult { + int32_t error_code; + const char *error_message; +} FFIResult; + +/** + * FFI-safe representation of an unconfirmed transaction + * + * # Safety + * + * This struct contains raw pointers that must be properly managed: + * + * - `raw_tx`: A pointer to the raw transaction bytes. The caller is responsible for: + * - Allocating this memory before passing it to Rust + * - Ensuring the pointer remains valid for the lifetime of this struct + * - Freeing the memory after use with `dash_spv_ffi_unconfirmed_transaction_destroy_raw_tx` + * + * - `addresses`: A pointer to an array of FFIString objects. The caller is responsible for: + * - Allocating this array before passing it to Rust + * - Ensuring the pointer remains valid for the lifetime of this struct + * - Freeing each FFIString in the array with `dash_spv_ffi_string_destroy` + * - Freeing the array itself after use with `dash_spv_ffi_unconfirmed_transaction_destroy_addresses` + * + * Use `dash_spv_ffi_unconfirmed_transaction_destroy` to safely clean up all resources + * associated with this struct. + */ +typedef struct FFIUnconfirmedTransaction { + struct FFIString txid; + uint8_t *raw_tx; + uintptr_t raw_tx_len; + int64_t amount; + uint64_t fee; + bool is_instant_send; + bool is_outgoing; + struct FFIString *addresses; + uintptr_t addresses_len; +} FFIUnconfirmedTransaction; + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +/** + * Get the latest checkpoint for the given network. + * + * # Safety + * - `out_height` must be a valid pointer to a `u32`. + * - `out_hash` must point to at least 32 writable bytes. + */ + +int32_t dash_spv_ffi_checkpoint_latest(FFINetwork network, + uint32_t *out_height, + uint8_t *out_hash) +; + +/** + * Get the last checkpoint at or before a given height. + * + * # Safety + * - `out_height` must be a valid pointer to a `u32`. + * - `out_hash` must point to at least 32 writable bytes. + */ + +int32_t dash_spv_ffi_checkpoint_before_height(FFINetwork network, + uint32_t height, + uint32_t *out_height, + uint8_t *out_hash) +; + +/** + * Get the last checkpoint at or before a given UNIX timestamp (seconds). + * + * # Safety + * - `out_height` must be a valid pointer to a `u32`. + * - `out_hash` must point to at least 32 writable bytes. + */ + +int32_t dash_spv_ffi_checkpoint_before_timestamp(FFINetwork network, + uint32_t timestamp, + uint32_t *out_height, + uint8_t *out_hash) +; + +/** + * Get all checkpoints between two heights (inclusive). + * + * Returns an `FFIArray` of `FFICheckpoint` items. The caller owns the memory and + * must free the array buffer using `dash_spv_ffi_array_destroy` when done. + */ + +struct FFIArray dash_spv_ffi_checkpoints_between_heights(FFINetwork network, + uint32_t start_height, + uint32_t end_height) +; + +/** + * Create a new SPV client and return an opaque pointer. + * + * # Safety + * - `config` must be a valid, non-null pointer for the duration of the call. + * - The returned pointer must be freed with `dash_spv_ffi_client_destroy`. + */ + struct FFIDashSpvClient *dash_spv_ffi_client_new(const struct FFIClientConfig *config) ; + +/** + * Drain pending events and invoke configured callbacks (non-blocking). + * + * # Safety + * - `client` must be a valid, non-null pointer. + */ + int32_t dash_spv_ffi_client_drain_events(struct FFIDashSpvClient *client) ; + +/** + * Update the running client's configuration. + * + * # Safety + * - `client` must be a valid pointer to an `FFIDashSpvClient`. + * - `config` must be a valid pointer to an `FFIClientConfig`. + * - The network in `config` must match the client's network; changing networks at runtime is not supported. + */ + +int32_t dash_spv_ffi_client_update_config(struct FFIDashSpvClient *client, + const struct FFIClientConfig *config) +; + +/** + * Start the SPV client. + * + * # Safety + * - `client` must be a valid, non-null pointer to a created client. + */ + int32_t dash_spv_ffi_client_start(struct FFIDashSpvClient *client) ; + +/** + * Stop the SPV client. + * + * # Safety + * - `client` must be a valid, non-null pointer to a created client. + */ + int32_t dash_spv_ffi_client_stop(struct FFIDashSpvClient *client) ; + +/** + * Sync the SPV client to the chain tip. + * + * # Safety + * + * This function is unsafe because: + * - `client` must be a valid pointer to an initialized `FFIDashSpvClient` + * - `user_data` must satisfy thread safety requirements: + * - If non-null, it must point to data that is safe to access from multiple threads + * - The caller must ensure proper synchronization if the data is mutable + * - The data must remain valid for the entire duration of the sync operation + * - `completion_callback` must be thread-safe and can be called from any thread + * + * # Parameters + * + * - `client`: Pointer to the SPV client + * - `completion_callback`: Optional callback invoked on completion + * - `user_data`: Optional user data pointer passed to callbacks + * + * # Returns + * + * 0 on success, error code on failure + */ + +int32_t dash_spv_ffi_client_sync_to_tip(struct FFIDashSpvClient *client, + void (*completion_callback)(bool, const char*, void*), + void *user_data) +; + +/** + * Performs a test synchronization of the SPV client + * + * # Parameters + * - `client`: Pointer to an FFIDashSpvClient instance + * + * # Returns + * - `0` on success + * - Negative error code on failure + * + * # Safety + * This function is unsafe because it dereferences a raw pointer. + * The caller must ensure that the client pointer is valid. + */ + int32_t dash_spv_ffi_client_test_sync(struct FFIDashSpvClient *client) ; + +/** + * Sync the SPV client to the chain tip with detailed progress updates. + * + * # Safety + * + * This function is unsafe because: + * - `client` must be a valid pointer to an initialized `FFIDashSpvClient` + * - `user_data` must satisfy thread safety requirements: + * - If non-null, it must point to data that is safe to access from multiple threads + * - The caller must ensure proper synchronization if the data is mutable + * - The data must remain valid for the entire duration of the sync operation + * - Both `progress_callback` and `completion_callback` must be thread-safe and can be called from any thread + * + * # Parameters + * + * - `client`: Pointer to the SPV client + * - `progress_callback`: Optional callback invoked periodically with sync progress + * - `completion_callback`: Optional callback invoked on completion + * - `user_data`: Optional user data pointer passed to all callbacks + * + * # Returns + * + * 0 on success, error code on failure + */ + +int32_t dash_spv_ffi_client_sync_to_tip_with_progress(struct FFIDashSpvClient *client, + void (*progress_callback)(const struct FFIDetailedSyncProgress*, + void*), + void (*completion_callback)(bool, + const char*, + void*), + void *user_data) +; + +/** + * Cancels the sync operation. + * + * This stops the SPV client, clears callbacks, and joins active threads so the sync + * operation halts immediately. + * + * # Safety + * The client pointer must be valid and non-null. + * + * # Returns + * Returns 0 on success, or an error code on failure. + */ + int32_t dash_spv_ffi_client_cancel_sync(struct FFIDashSpvClient *client) ; + +/** + * Get the current sync progress snapshot. + * + * # Safety + * - `client` must be a valid, non-null pointer. + */ + struct FFISyncProgress *dash_spv_ffi_client_get_sync_progress(struct FFIDashSpvClient *client) ; + +/** + * Get current runtime statistics for the SPV client. + * + * # Safety + * - `client` must be a valid, non-null pointer. + */ + struct FFISpvStats *dash_spv_ffi_client_get_stats(struct FFIDashSpvClient *client) ; + +/** + * Get the current chain tip hash (32 bytes) if available. + * + * # Safety + * - `client` must be a valid, non-null pointer. + * - `out_hash` must be a valid pointer to a 32-byte buffer. + */ + int32_t dash_spv_ffi_client_get_tip_hash(struct FFIDashSpvClient *client, uint8_t *out_hash) ; + +/** + * Get the current chain tip height (absolute). + * + * # Safety + * - `client` must be a valid, non-null pointer. + * - `out_height` must be a valid, non-null pointer. + */ + int32_t dash_spv_ffi_client_get_tip_height(struct FFIDashSpvClient *client, uint32_t *out_height) ; + +/** + * Clear all persisted SPV storage (headers, filters, metadata, sync state). + * + * # Safety + * - `client` must be a valid, non-null pointer. + */ + int32_t dash_spv_ffi_client_clear_storage(struct FFIDashSpvClient *client) ; + +/** + * Clear only the persisted sync-state snapshot. + * + * # Safety + * - `client` must be a valid, non-null pointer. + */ + int32_t dash_spv_ffi_client_clear_sync_state(struct FFIDashSpvClient *client) ; + +/** + * Check if compact filter sync is currently available. + * + * # Safety + * - `client` must be a valid, non-null pointer. + */ + bool dash_spv_ffi_client_is_filter_sync_available(struct FFIDashSpvClient *client) ; + +/** + * Set event callbacks for the client. + * + * # Safety + * - `client` must be a valid, non-null pointer. + */ + +int32_t dash_spv_ffi_client_set_event_callbacks(struct FFIDashSpvClient *client, + struct FFIEventCallbacks callbacks) +; + +/** + * Destroy the client and free associated resources. + * + * # Safety + * - `client` must be either null or a pointer obtained from `dash_spv_ffi_client_new`. + */ + void dash_spv_ffi_client_destroy(struct FFIDashSpvClient *client) ; + +/** + * Destroy a `FFISyncProgress` object returned by this crate. + * + * # Safety + * - `progress` must be a pointer returned from this crate, or null. + */ + void dash_spv_ffi_sync_progress_destroy(struct FFISyncProgress *progress) ; + +/** + * Destroy an `FFISpvStats` object returned by this crate. + * + * # Safety + * - `stats` must be a pointer returned from this crate, or null. + */ + void dash_spv_ffi_spv_stats_destroy(struct FFISpvStats *stats) ; + +/** + * Request a rescan of the blockchain from a given height (not yet implemented). + * + * # Safety + * - `client` must be a valid, non-null pointer. + */ + +int32_t dash_spv_ffi_client_rescan_blockchain(struct FFIDashSpvClient *client, + uint32_t _from_height) +; + +/** + * Enable mempool tracking with a given strategy. + * + * # Safety + * - `client` must be a valid, non-null pointer. + */ + +int32_t dash_spv_ffi_client_enable_mempool_tracking(struct FFIDashSpvClient *client, + enum FFIMempoolStrategy strategy) +; + +/** + * Record that we attempted to send a transaction by its txid. + * + * # Safety + * - `client` and `txid` must be valid, non-null pointers. + */ + int32_t dash_spv_ffi_client_record_send(struct FFIDashSpvClient *client, const char *txid) ; + +/** + * Get the wallet manager from the SPV client + * + * Returns an opaque pointer to FFIWalletManager that contains a cloned Arc reference to the wallet manager. + * This allows direct interaction with the wallet manager without going through the client. + * + * # Safety + * + * The caller must ensure that: + * - The client pointer is valid + * - The returned pointer is freed using `wallet_manager_free` from key-wallet-ffi + * + * # Returns + * + * An opaque pointer (void*) to the wallet manager, or NULL if the client is not initialized. + * Swift should treat this as an OpaquePointer. + * Get a handle to the wallet manager owned by this client. + * + * # Safety + * - `client` must be a valid, non-null pointer. + */ + +void *dash_spv_ffi_client_get_wallet_manager(struct FFIDashSpvClient *client) +; + + struct FFIClientConfig *dash_spv_ffi_config_new(FFINetwork network) ; + + struct FFIClientConfig *dash_spv_ffi_config_mainnet(void) ; + + struct FFIClientConfig *dash_spv_ffi_config_testnet(void) ; + +/** + * Sets the data directory for storing blockchain data + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - `path` must be a valid null-terminated C string + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_data_dir(struct FFIClientConfig *config, + const char *path) +; + +/** + * Sets the validation mode for the SPV client + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_validation_mode(struct FFIClientConfig *config, + enum DashSpvValidationMode mode) +; + +/** + * Sets the maximum number of peers to connect to + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_max_peers(struct FFIClientConfig *config, + uint32_t max_peers) +; + +/** + * Adds a peer address to the configuration + * + * Accepts either a full socket address (e.g., "192.168.1.1:9999" or "[::1]:19999") + * or an IP-only string (e.g., "127.0.0.1" or "2001:db8::1"). When an IP-only + * string is given, the default P2P port for the configured network is used. + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - `addr` must be a valid null-terminated C string containing a socket address or IP-only string + * - The caller must ensure both pointers remain valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_add_peer(struct FFIClientConfig *config, + const char *addr) +; + +/** + * Sets the user agent string to advertise in the P2P handshake + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - `user_agent` must be a valid null-terminated C string + * - The caller must ensure both pointers remain valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_user_agent(struct FFIClientConfig *config, + const char *user_agent) +; + +/** + * Sets whether to relay transactions (currently a no-op) + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_relay_transactions(struct FFIClientConfig *config, + bool _relay) +; + +/** + * Sets whether to load bloom filters + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_filter_load(struct FFIClientConfig *config, + bool load_filters) +; + +/** + * Restrict connections strictly to configured peers (disable DNS discovery and peer store) + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + */ + +int32_t dash_spv_ffi_config_set_restrict_to_configured_peers(struct FFIClientConfig *config, + bool restrict_peers) +; + +/** + * Enables or disables masternode synchronization + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_masternode_sync_enabled(struct FFIClientConfig *config, + bool enable) +; + +/** + * Gets the network type from the configuration + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig or null + * - If null, returns FFINetwork::Dash as default + */ + FFINetwork dash_spv_ffi_config_get_network(const struct FFIClientConfig *config) ; + +/** + * Gets the data directory path from the configuration + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig or null + * - If null or no data directory is set, returns an FFIString with null pointer + * - The returned FFIString must be freed by the caller using `dash_spv_ffi_string_destroy` + */ + struct FFIString dash_spv_ffi_config_get_data_dir(const struct FFIClientConfig *config) ; + +/** + * Destroys an FFIClientConfig and frees its memory + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet, or null + * - After calling this function, the config pointer becomes invalid and must not be used + * - This function should only be called once per config instance + */ + +void dash_spv_ffi_config_destroy(struct FFIClientConfig *config) +; + +/** + * Sets the number of Tokio worker threads for the FFI runtime (0 = auto) + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig + */ + int32_t dash_spv_ffi_config_set_worker_threads(struct FFIClientConfig *config, uint32_t threads) ; + +/** + * Enables or disables mempool tracking + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_mempool_tracking(struct FFIClientConfig *config, + bool enable) +; + +/** + * Sets the mempool synchronization strategy + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_mempool_strategy(struct FFIClientConfig *config, + enum FFIMempoolStrategy strategy) +; + +/** + * Sets the maximum number of mempool transactions to track + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_max_mempool_transactions(struct FFIClientConfig *config, + uint32_t max_transactions) +; + +/** + * Sets the mempool transaction timeout in seconds + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_mempool_timeout(struct FFIClientConfig *config, + uint64_t timeout_secs) +; + +/** + * Sets whether to fetch full mempool transaction data + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_fetch_mempool_transactions(struct FFIClientConfig *config, + bool fetch) +; + +/** + * Sets whether to persist mempool state to disk + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_persist_mempool(struct FFIClientConfig *config, + bool persist) +; + +/** + * Gets whether mempool tracking is enabled + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig or null + * - If null, returns false as default + */ + bool dash_spv_ffi_config_get_mempool_tracking(const struct FFIClientConfig *config) ; + +/** + * Gets the mempool synchronization strategy + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig or null + * - If null, returns FFIMempoolStrategy::Selective as default + */ + +enum FFIMempoolStrategy dash_spv_ffi_config_get_mempool_strategy(const struct FFIClientConfig *config) +; + +/** + * Sets the starting block height for synchronization + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_start_from_height(struct FFIClientConfig *config, + uint32_t height) +; + +/** + * Sets the wallet creation timestamp for synchronization optimization + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet + * - The caller must ensure the config pointer remains valid for the duration of this call + */ + +int32_t dash_spv_ffi_config_set_wallet_creation_time(struct FFIClientConfig *config, + uint32_t timestamp) +; + + const char *dash_spv_ffi_get_last_error(void) ; + + void dash_spv_ffi_clear_error(void) ; + +/** + * Creates a CoreSDKHandle from an FFIDashSpvClient + * + * # Safety + * + * This function is unsafe because: + * - The caller must ensure the client pointer is valid + * - The returned handle must be properly released with ffi_dash_spv_release_core_handle + */ + struct CoreSDKHandle *ffi_dash_spv_get_core_handle(struct FFIDashSpvClient *client) ; + +/** + * Releases a CoreSDKHandle + * + * # Safety + * + * This function is unsafe because: + * - The caller must ensure the handle pointer is valid + * - The handle must not be used after this call + */ + void ffi_dash_spv_release_core_handle(struct CoreSDKHandle *handle) ; + +/** + * Gets a quorum public key from the Core chain + * + * # Safety + * + * This function is unsafe because: + * - The caller must ensure all pointers are valid + * - quorum_hash must point to a 32-byte array + * - out_pubkey must point to a buffer of at least out_pubkey_size bytes + * - out_pubkey_size must be at least 48 bytes + */ + +struct FFIResult ffi_dash_spv_get_quorum_public_key(struct FFIDashSpvClient *client, + uint32_t quorum_type, + const uint8_t *quorum_hash, + uint32_t core_chain_locked_height, + uint8_t *out_pubkey, + uintptr_t out_pubkey_size) +; + +/** + * Gets the platform activation height from the Core chain + * + * # Safety + * + * This function is unsafe because: + * - The caller must ensure all pointers are valid + * - out_height must point to a valid u32 + */ + +struct FFIResult ffi_dash_spv_get_platform_activation_height(struct FFIDashSpvClient *client, + uint32_t *out_height) +; + +/** + * # Safety + * - `s.ptr` must be a pointer previously returned by `FFIString::new` or compatible. + * - It must not be used after this call. + */ + void dash_spv_ffi_string_destroy(struct FFIString s) ; + +/** + * # Safety + * - `arr` must be either null or a valid pointer to an `FFIArray` previously constructed in Rust. + * - The memory referenced by `arr.data` must not be used after this call. + */ + void dash_spv_ffi_array_destroy(struct FFIArray *arr) ; + +/** + * Destroy an array of FFIString pointers (Vec<*mut FFIString>) and their contents. + * + * This function: + * - Iterates the array elements as pointers to FFIString and destroys each via dash_spv_ffi_string_destroy + * - Frees the underlying vector buffer stored in FFIArray + * - Does not free the FFIArray struct itself (safe for both stack- and heap-allocated structs) + * # Safety + * - `arr` must be either null or a valid pointer to an `FFIArray` whose elements are `*mut FFIString`. + * - Each element pointer must be valid or null; non-null entries are freed. + * - The memory referenced by `arr.data` must not be used after this call. + */ + +void dash_spv_ffi_string_array_destroy(struct FFIArray *arr) +; + +/** + * Destroys the raw transaction bytes allocated for an FFIUnconfirmedTransaction + * + * # Safety + * + * - `raw_tx` must be a valid pointer to memory allocated by the caller + * - `raw_tx_len` must be the correct length of the allocated memory + * - The pointer must not be used after this function is called + * - This function should only be called once per allocation + */ + void dash_spv_ffi_unconfirmed_transaction_destroy_raw_tx(uint8_t *raw_tx, uintptr_t raw_tx_len) ; + +/** + * Destroys the addresses array allocated for an FFIUnconfirmedTransaction + * + * # Safety + * + * - `addresses` must be a valid pointer to an array of FFIString objects + * - `addresses_len` must be the correct length of the array + * - Each FFIString in the array must be destroyed separately using `dash_spv_ffi_string_destroy` + * - The pointer must not be used after this function is called + * - This function should only be called once per allocation + */ + +void dash_spv_ffi_unconfirmed_transaction_destroy_addresses(struct FFIString *addresses, + uintptr_t addresses_len) +; + +/** + * Destroys an FFIUnconfirmedTransaction and all its associated resources + * + * # Safety + * + * - `tx` must be a valid pointer to an FFIUnconfirmedTransaction + * - All resources (raw_tx, addresses array, and individual FFIStrings) will be freed + * - The pointer must not be used after this function is called + * - This function should only be called once per FFIUnconfirmedTransaction + */ + void dash_spv_ffi_unconfirmed_transaction_destroy(struct FFIUnconfirmedTransaction *tx) ; + +/** + * Initialize logging for the SPV library. + * + * # Safety + * - `level` may be null or point to a valid, NUL-terminated C string. + * - If non-null, the pointer must remain valid for the duration of this call. + */ + int32_t dash_spv_ffi_init_logging(const char *level) ; + + const char *dash_spv_ffi_version(void) ; + + void dash_spv_ffi_enable_test_mode(void) ; + + +int32_t dash_spv_ffi_client_broadcast_transaction(struct FFIDashSpvClient *client, + const char *tx_hex) +; + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#ifdef __cplusplus +} // namespace dash_spv_ffi +#endif // __cplusplus + +#endif /* DASH_SPV_FFI_H */ diff --git a/dash-spv-ffi/include/dash_spv_ffi.h b/dash-spv-ffi/include/dash_spv_ffi.h index a5d104bf4..644ce1094 100644 --- a/dash-spv-ffi/include/dash_spv_ffi.h +++ b/dash-spv-ffi/include/dash_spv_ffi.h @@ -61,6 +61,7 @@ typedef struct FFIArray { typedef struct FFIClientConfig { void *inner; + uint32_t worker_threads; } FFIClientConfig; @@ -281,6 +282,14 @@ struct FFIArray dash_spv_ffi_checkpoints_between_heights(FFINetwork network, */ struct FFIDashSpvClient *dash_spv_ffi_client_new(const struct FFIClientConfig *config) ; +/** + * Drain pending events and invoke configured callbacks (non-blocking). + * + * # Safety + * - `client` must be a valid, non-null pointer. + */ + int32_t dash_spv_ffi_client_drain_events(struct FFIDashSpvClient *client) ; + /** * Update the running client's configuration. * @@ -702,6 +711,14 @@ int32_t dash_spv_ffi_config_set_masternode_sync_enabled(struct FFIClientConfig * void dash_spv_ffi_config_destroy(struct FFIClientConfig *config) ; +/** + * Sets the number of Tokio worker threads for the FFI runtime (0 = auto) + * + * # Safety + * - `config` must be a valid pointer to an FFIClientConfig + */ + int32_t dash_spv_ffi_config_set_worker_threads(struct FFIClientConfig *config, uint32_t threads) ; + /** * Enables or disables mempool tracking * diff --git a/dash-spv-ffi/src/bin/ffi_cli.rs b/dash-spv-ffi/src/bin/ffi_cli.rs new file mode 100644 index 000000000..21ccdb71d --- /dev/null +++ b/dash-spv-ffi/src/bin/ffi_cli.rs @@ -0,0 +1,234 @@ +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_void}; +use std::ptr; +use std::thread; +use std::time::Duration; + +use clap::{Arg, ArgAction, Command, ValueEnum}; + +use dash_spv_ffi::*; +use key_wallet_ffi::FFINetwork; + +#[derive(Copy, Clone, Debug, ValueEnum)] +enum NetworkOpt { + Mainnet, + Testnet, + Regtest, +} + +fn ffi_string_to_rust(s: *const c_char) -> String { + if s.is_null() { + return String::new(); + } + unsafe { CStr::from_ptr(s) }.to_str().unwrap_or_default().to_owned() +} + +extern "C" fn on_filter_headers_progress(filter: u32, headers: u32, pct: f64, _ud: *mut c_void) { + println!("filters: {} headers: {} progress: {:.2}%", filter, headers, pct * 100.0); +} + +extern "C" fn on_detailed_progress(progress: *const FFIDetailedSyncProgress, _ud: *mut c_void) { + if progress.is_null() { + return; + } + unsafe { + let p = &*progress; + println!( + "height {}/{} {:.2}% peers {} hps {:.1}", + p.current_height, + p.total_height, + p.percentage * 100.0, + p.connected_peers, + p.headers_per_second + ); + } +} + +extern "C" fn on_completion(success: bool, msg: *const c_char, _ud: *mut c_void) { + let m = ffi_string_to_rust(msg); + if success { + println!("Completed: {}", m); + } else { + eprintln!("Failed: {}", m); + } +} + +fn main() { + env_logger::init(); + + let matches = Command::new("dash-spv-ffi") + .about("Run SPV sync via FFI") + .arg( + Arg::new("network") + .long("network") + .short('n') + .value_parser(clap::builder::PossibleValuesParser::new([ + "mainnet", "testnet", "regtest", + ])) + .default_value("mainnet"), + ) + .arg( + Arg::new("peer") + .long("peer") + .short('p') + .action(ArgAction::Append) + .help("Peer address host:port (repeatable)"), + ) + .arg( + Arg::new("workers") + .long("workers") + .value_parser(clap::value_parser!(u32)) + .help("Tokio worker threads (0=auto)"), + ) + .arg( + Arg::new("log-level") + .long("log-level") + .value_parser(["error", "warn", "info", "debug", "trace"]) + .default_value("info") + .help("Tracing log level"), + ) + .arg( + Arg::new("start-height") + .long("start-height") + .value_parser(clap::value_parser!(u32)) + .help("Start syncing from nearest checkpoint at height"), + ) + .arg( + Arg::new("no-masternodes") + .long("no-masternodes") + .action(ArgAction::SetTrue) + .help("Disable masternode list synchronization"), + ) + .arg( + Arg::new("no-filters") + .long("no-filters") + .action(ArgAction::SetTrue) + .help("Disable compact filter synchronization"), + ) + .get_matches(); + + // Map network + let network = match matches.get_one::("network").map(|s| s.as_str()) { + Some("mainnet") => FFINetwork::Dash, + Some("testnet") => FFINetwork::Testnet, + Some("regtest") => FFINetwork::Regtest, + _ => FFINetwork::Dash, + }; + + let disable_filter_sync = matches.get_flag("no-filters"); + + unsafe { + // Initialize tracing/logging via FFI so `tracing::info!` emits output + let level = matches.get_one::("log-level").map(String::as_str).unwrap_or("info"); + let level_c = CString::new(level).unwrap(); + let _ = dash_spv_ffi_init_logging(level_c.as_ptr()); + + // Build config + let cfg = dash_spv_ffi_config_new(network); + if cfg.is_null() { + eprintln!( + "Failed to allocate config: {}", + ffi_string_to_rust(dash_spv_ffi_get_last_error()) + ); + std::process::exit(1); + } + + let _ = dash_spv_ffi_config_set_filter_load(cfg, !disable_filter_sync); + + if let Some(workers) = matches.get_one::("workers") { + let _ = dash_spv_ffi_config_set_worker_threads(cfg, *workers); + } + + if let Some(height) = matches.get_one::("start-height") { + let _ = dash_spv_ffi_config_set_start_from_height(cfg, *height); + } + + if matches.get_flag("no-masternodes") { + let _ = dash_spv_ffi_config_set_masternode_sync_enabled(cfg, false); + } + + if let Some(peers) = matches.get_many::("peer") { + for p in peers { + let c = CString::new(p.as_str()).unwrap(); + let rc = dash_spv_ffi_config_add_peer(cfg, c.as_ptr()); + if rc != FFIErrorCode::Success as i32 { + eprintln!( + "Invalid peer {}: {}", + p, + ffi_string_to_rust(dash_spv_ffi_get_last_error()) + ); + } + } + } + + // Create client + let client = dash_spv_ffi_client_new(cfg); + if client.is_null() { + eprintln!( + "Client create failed: {}", + ffi_string_to_rust(dash_spv_ffi_get_last_error()) + ); + std::process::exit(1); + } + + // Set minimal event callbacks (progress via filter headers) + let callbacks = FFIEventCallbacks { + on_block: None, + on_transaction: None, + on_balance_update: None, + on_mempool_transaction_added: None, + on_mempool_transaction_confirmed: None, + on_mempool_transaction_removed: None, + on_compact_filter_matched: None, + on_wallet_transaction: None, + on_filter_headers_progress: Some(on_filter_headers_progress), + user_data: ptr::null_mut(), + }; + let _ = dash_spv_ffi_client_set_event_callbacks(client, callbacks); + + // Start client + let rc = dash_spv_ffi_client_start(client); + if rc != FFIErrorCode::Success as i32 { + eprintln!("Start failed: {}", ffi_string_to_rust(dash_spv_ffi_get_last_error())); + std::process::exit(1); + } + + // Run sync on this thread; detailed progress will print via callback + let rc = dash_spv_ffi_client_sync_to_tip_with_progress( + client, + Some(on_detailed_progress), + Some(on_completion), + ptr::null_mut(), + ); + if rc != FFIErrorCode::Success as i32 { + eprintln!("Sync failed: {}", ffi_string_to_rust(dash_spv_ffi_get_last_error())); + std::process::exit(1); + } + + // Wait for sync completion by polling basic progress flags; drain events meanwhile + loop { + let _ = dash_spv_ffi_client_drain_events(client); + let prog_ptr = dash_spv_ffi_client_get_sync_progress(client); + if !prog_ptr.is_null() { + let prog = &*prog_ptr; + let filters_complete = prog.filter_headers_synced + || !prog.filter_sync_available + || disable_filter_sync; + if prog.headers_synced && filters_complete { + dash_spv_ffi_sync_progress_destroy(prog_ptr); + break; + } + dash_spv_ffi_sync_progress_destroy(prog_ptr); + } else { + // If progress is unavailable, assume sync finished or errored + break; + } + thread::sleep(Duration::from_millis(300)); + } + + // Cleanup + dash_spv_ffi_client_stop(client); + dash_spv_ffi_client_destroy(client); + dash_spv_ffi_config_destroy(cfg); + } +} diff --git a/dash-spv-ffi/src/client.rs b/dash-spv-ffi/src/client.rs index a564b90f6..581a7f4d8 100644 --- a/dash-spv-ffi/src/client.rs +++ b/dash-spv-ffi/src/client.rs @@ -21,6 +21,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::runtime::Runtime; +use tokio::sync::mpsc::{error::TryRecvError, UnboundedReceiver}; /// Global callback registry for thread-safe callback management static CALLBACK_REGISTRY: Lazy>> = @@ -126,6 +127,8 @@ pub struct FFIDashSpvClient { active_threads: Arc>>>, sync_callbacks: Arc>>, shutdown_signal: Arc, + // Stored event receiver for pull-based draining (no background thread by default) + event_rx: Arc>>>, } /// Create a new SPV client and return an opaque pointer. @@ -140,12 +143,13 @@ pub unsafe extern "C" fn dash_spv_ffi_client_new( null_check!(config, std::ptr::null_mut()); let config = &(*config); - let runtime = match tokio::runtime::Builder::new_multi_thread() - .thread_name("dash-spv-worker") - .worker_threads(4) // Use 4 threads for better performance on iOS - .enable_all() - .build() - { + // Build runtime with configurable worker threads (0 => auto) + let mut builder = tokio::runtime::Builder::new_multi_thread(); + builder.thread_name("dash-spv-worker").enable_all(); + if config.worker_threads > 0 { + builder.worker_threads(config.worker_threads as usize); + } + let runtime = match builder.build() { Ok(rt) => Arc::new(rt), Err(e) => { set_last_error(&format!("Failed to create runtime: {}", e)); @@ -194,6 +198,7 @@ pub unsafe extern "C" fn dash_spv_ffi_client_new( active_threads: Arc::new(Mutex::new(Vec::new())), sync_callbacks: Arc::new(Mutex::new(None)), shutdown_signal: Arc::new(AtomicBool::new(false)), + event_rx: Arc::new(Mutex::new(None)), }; Box::into_raw(Box::new(ffi_client)) } @@ -227,165 +232,149 @@ impl FFIDashSpvClient { } } - /// Start the event listener task to handle events from the SPV client. - fn start_event_listener(&self) { - let inner = self.inner.clone(); - let event_callbacks = self.event_callbacks.clone(); - let runtime = self.runtime.clone(); - let shutdown_signal = self.shutdown_signal.clone(); - - let handle = std::thread::spawn(move || { - runtime.block_on(async { - let event_rx = { - let mut guard = inner.lock().unwrap(); - if let Some(ref mut client) = *guard { - client.take_event_receiver() - } else { - None + /// Drain pending events and invoke configured callbacks (non-blocking). + fn drain_events_internal(&self) { + let mut rx_guard = self.event_rx.lock().unwrap(); + let Some(rx) = rx_guard.as_mut() else { + return; + }; + let callbacks = self.event_callbacks.lock().unwrap(); + loop { + match rx.try_recv() { + Ok(event) => match event { + dash_spv::types::SpvEvent::BalanceUpdate { + confirmed, + unconfirmed, + .. + } => { + callbacks.call_balance_update(confirmed, unconfirmed); } - }; - - if let Some(mut rx) = event_rx { - tracing::info!("🎧 FFI event listener started successfully"); - loop { - // Check shutdown signal - if shutdown_signal.load(Ordering::Relaxed) { - tracing::info!("🛑 FFI event listener received shutdown signal"); - break; + dash_spv::types::SpvEvent::FilterHeadersProgress { + filter_header_height, + header_height, + percentage, + } => { + callbacks.call_filter_headers_progress( + filter_header_height, + header_height, + percentage, + ); + } + dash_spv::types::SpvEvent::TransactionDetected { + ref txid, + confirmed, + ref addresses, + amount, + block_height, + .. + } => { + if let Ok(txid_parsed) = txid.parse::() { + callbacks.call_transaction( + &txid_parsed, + confirmed, + amount, + addresses, + block_height, + ); + let wallet_id_hex = "unknown"; + let account_index = 0; + let block_height = block_height.unwrap_or(0); + let is_ours = amount != 0; + callbacks.call_wallet_transaction( + wallet_id_hex, + account_index, + &txid_parsed, + confirmed, + amount, + addresses, + block_height, + is_ours, + ); } - - // Use recv with timeout to periodically check shutdown signal - match tokio::time::timeout(Duration::from_millis(100), rx.recv()).await { - Ok(Some(event)) => { - tracing::info!("🎧 FFI received event: {:?}", event); - let callbacks = event_callbacks.lock().unwrap(); - match event { - dash_spv::types::SpvEvent::BalanceUpdate { confirmed, unconfirmed, total } => { - tracing::info!("💰 Balance update event: confirmed={}, unconfirmed={}, total={}", - confirmed, unconfirmed, total); - callbacks.call_balance_update(confirmed, unconfirmed); - } - dash_spv::types::SpvEvent::FilterHeadersProgress { filter_header_height, header_height, percentage } => { - tracing::info!("📊 Filter headers progress event: filter={}, header={}, pct={:.2}", - filter_header_height, header_height, percentage); - callbacks - .call_filter_headers_progress( - filter_header_height, - header_height, - percentage, - ); - } - dash_spv::types::SpvEvent::TransactionDetected { ref txid, confirmed, ref addresses, amount, block_height, .. } => { - tracing::info!("ðŸ’ļ Transaction detected: txid={}, confirmed={}, amount={}, addresses={:?}, height={:?}", - txid, confirmed, amount, addresses, block_height); - // Parse the txid string to a Txid type - if let Ok(txid_parsed) = txid.parse::() { - // Call the general transaction callback - callbacks.call_transaction(&txid_parsed, confirmed, amount, addresses, block_height); - - // Also try to provide wallet-specific context - // Note: For now, we provide basic wallet context. - // In a more advanced implementation, we could enhance this - // to look up the actual wallet/account that owns this transaction. - let wallet_id_hex = "unknown"; // Placeholder - would need wallet lookup - let account_index = 0; // Default account index - let block_height = block_height.unwrap_or(0); - let is_ours = amount != 0; // Simple heuristic - - callbacks.call_wallet_transaction( - wallet_id_hex, - account_index, - &txid_parsed, - confirmed, - amount, - addresses, - block_height, - is_ours, - ); - } else { - tracing::error!("Failed to parse transaction ID: {}", txid); - } - } - dash_spv::types::SpvEvent::BlockProcessed { height, ref hash, transactions_count, relevant_transactions } => { - tracing::info!("ðŸ“Ķ Block processed: height={}, hash={}, total_tx={}, relevant_tx={}", - height, hash, transactions_count, relevant_transactions); - // Parse the block hash string to a BlockHash type - if let Ok(hash_parsed) = hash.parse::() { - callbacks.call_block(height, &hash_parsed); - } else { - tracing::error!("Failed to parse block hash: {}", hash); - } - } - dash_spv::types::SpvEvent::SyncProgress { .. } => { - // Sync progress is handled via existing progress callback - tracing::debug!("📊 Sync progress event (handled separately)"); - } - dash_spv::types::SpvEvent::ChainLockReceived { height, hash } => { - // ChainLock events can be handled here - tracing::info!("🔒 ChainLock received for height {} hash {}", height, hash); - } - dash_spv::types::SpvEvent::MempoolTransactionAdded { ref txid, transaction: _, amount, ref addresses, is_instant_send } => { - tracing::info!("➕ Mempool transaction added: txid={}, amount={}, addresses={:?}, instant_send={}", - txid, amount, addresses, is_instant_send); - // Call the mempool-specific callback - callbacks.call_mempool_transaction_added(txid, amount, addresses, is_instant_send); - } - dash_spv::types::SpvEvent::MempoolTransactionConfirmed { ref txid, block_height, ref block_hash } => { - tracing::info!("✅ Mempool transaction confirmed: txid={}, height={}, hash={}", - txid, block_height, block_hash); - // Call the mempool confirmed callback - callbacks.call_mempool_transaction_confirmed(txid, block_height, block_hash); - } - dash_spv::types::SpvEvent::MempoolTransactionRemoved { ref txid, ref reason } => { - tracing::info!("❌ Mempool transaction removed: txid={}, reason={:?}", - txid, reason); - // Convert reason to u8 for FFI using existing conversion - let ffi_reason: crate::types::FFIMempoolRemovalReason = reason.clone().into(); - let reason_code = ffi_reason as u8; - callbacks.call_mempool_transaction_removed(txid, reason_code); - } - dash_spv::types::SpvEvent::CompactFilterMatched { hash } => { - tracing::info!("📄 Compact filter matched: block={}", hash); - - // Try to provide richer information by looking up which wallet matched - // Since we don't have direct access to filter details, we'll provide basic info - if let Ok(block_hash_parsed) = hash.parse::() { - // For now, we'll call with empty matched scripts and unknown wallet - // In a more advanced implementation, we could enhance the SpvEvent to include this info - callbacks.call_compact_filter_matched( - &block_hash_parsed, - &[], // matched_scripts - empty for now - "unknown", // wallet_id - unknown for now - ); - } else { - tracing::error!("Failed to parse compact filter block hash: {}", hash); - } - } + } + dash_spv::types::SpvEvent::BlockProcessed { + height, + ref hash, + .. + } => { + if let Ok(hash_parsed) = hash.parse::() { + callbacks.call_block(height, &hash_parsed); } - } - Ok(None) => { - // Channel closed, exit loop - tracing::info!("🎧 FFI event channel closed"); - break; - } - Err(_) => { - // Timeout, continue to check shutdown signal - continue; - } + } + dash_spv::types::SpvEvent::SyncProgress { + .. + } => {} + dash_spv::types::SpvEvent::ChainLockReceived { + .. + } => {} + dash_spv::types::SpvEvent::MempoolTransactionAdded { + ref txid, + amount, + ref addresses, + is_instant_send, + .. + } => { + callbacks.call_mempool_transaction_added( + txid, + amount, + addresses, + is_instant_send, + ); + } + dash_spv::types::SpvEvent::MempoolTransactionConfirmed { + ref txid, + block_height, + ref block_hash, + } => { + callbacks.call_mempool_transaction_confirmed( + txid, + block_height, + block_hash, + ); + } + dash_spv::types::SpvEvent::MempoolTransactionRemoved { + ref txid, + ref reason, + } => { + let ffi_reason: crate::types::FFIMempoolRemovalReason = + reason.clone().into(); + let reason_code = ffi_reason as u8; + callbacks.call_mempool_transaction_removed(txid, reason_code); + } + dash_spv::types::SpvEvent::CompactFilterMatched { + hash, + } => { + if let Ok(block_hash_parsed) = hash.parse::() { + callbacks.call_compact_filter_matched( + &block_hash_parsed, + &[], + "unknown", + ); } } - tracing::info!("🎧 FFI event listener stopped"); - } else { - tracing::error!("❌ Failed to get event receiver from SPV client"); + }, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + *rx_guard = None; + break; } - }); - }); - - // Store thread handle - self.active_threads.lock().unwrap().push(handle); + } + } } } +/// Drain pending events and invoke configured callbacks (non-blocking). +/// +/// # Safety +/// - `client` must be a valid, non-null pointer. +#[no_mangle] +pub unsafe extern "C" fn dash_spv_ffi_client_drain_events(client: *mut FFIDashSpvClient) -> i32 { + null_check!(client); + let client = &*client; + client.drain_events_internal(); + FFIErrorCode::Success as i32 +} + fn stop_client_internal(client: &FFIDashSpvClient) -> Result<(), dash_spv::SpvError> { client.shutdown_signal.store(true, Ordering::Relaxed); @@ -501,9 +490,21 @@ pub unsafe extern "C" fn dash_spv_ffi_client_start(client: *mut FFIDashSpvClient match result { Ok(()) => { - client.shutdown_signal.store(false, Ordering::Relaxed); - // Start event listener after successful start - client.start_event_listener(); + // After successful start, take event receiver for pull-based draining + let mut guard = client.inner.lock().unwrap(); + if let Some(ref mut spv_client) = *guard { + match spv_client.take_event_receiver() { + Some(rx) => { + *client.event_rx.lock().unwrap() = Some(rx); + tracing::debug!("Replaced FFI event receiver after client start"); + } + None => { + tracing::debug!( + "No new event receiver returned after client start; keeping existing receiver" + ); + } + } + } FFIErrorCode::Success as i32 } Err(e) => { @@ -1273,11 +1274,11 @@ pub unsafe extern "C" fn dash_spv_ffi_client_set_event_callbacks( let client = &(*client); - tracing::info!("🔧 Setting event callbacks on FFI client"); - tracing::info!(" Block callback: {}", callbacks.on_block.is_some()); - tracing::info!(" Transaction callback: {}", callbacks.on_transaction.is_some()); - tracing::info!(" Balance update callback: {}", callbacks.on_balance_update.is_some()); - tracing::info!( + tracing::debug!("Setting event callbacks on FFI client"); + tracing::debug!(" Block callback: {}", callbacks.on_block.is_some()); + tracing::debug!(" Transaction callback: {}", callbacks.on_transaction.is_some()); + tracing::debug!(" Balance update callback: {}", callbacks.on_balance_update.is_some()); + tracing::debug!( " Filter headers progress callback: {}", callbacks.on_filter_headers_progress.is_some() ); @@ -1285,17 +1286,7 @@ pub unsafe extern "C" fn dash_spv_ffi_client_set_event_callbacks( let mut event_callbacks = client.event_callbacks.lock().unwrap(); *event_callbacks = callbacks; - // Check if we need to start the event listener - // This ensures callbacks work even if set after client.start() - let inner = client.inner.lock().unwrap(); - if inner.is_some() { - drop(inner); // Release lock before starting listener - tracing::info!("🚀 Client already started, ensuring event listener is running"); - // The event listener should already be running from start() - // but we log this for debugging - } - - tracing::info!("✅ Event callbacks set successfully"); + tracing::debug!("Event callbacks set successfully"); FFIErrorCode::Success as i32 } diff --git a/dash-spv-ffi/src/config.rs b/dash-spv-ffi/src/config.rs index 4d31a3844..f7393b07f 100644 --- a/dash-spv-ffi/src/config.rs +++ b/dash-spv-ffi/src/config.rs @@ -26,6 +26,8 @@ impl From for ValidationMode { pub struct FFIClientConfig { // Opaque pointer to avoid exposing internal ClientConfig in generated C headers inner: *mut std::ffi::c_void, + // Tokio runtime worker thread count (0 = auto) + pub worker_threads: u32, } #[no_mangle] @@ -34,6 +36,7 @@ pub extern "C" fn dash_spv_ffi_config_new(network: FFINetwork) -> *mut FFIClient let inner = Box::into_raw(Box::new(config)) as *mut std::ffi::c_void; Box::into_raw(Box::new(FFIClientConfig { inner, + worker_threads: 0, })) } @@ -43,6 +46,7 @@ pub extern "C" fn dash_spv_ffi_config_mainnet() -> *mut FFIClientConfig { let inner = Box::into_raw(Box::new(config)) as *mut std::ffi::c_void; Box::into_raw(Box::new(FFIClientConfig { inner, + worker_threads: 0, })) } @@ -52,6 +56,7 @@ pub extern "C" fn dash_spv_ffi_config_testnet() -> *mut FFIClientConfig { let inner = Box::into_raw(Box::new(config)) as *mut std::ffi::c_void; Box::into_raw(Box::new(FFIClientConfig { inner, + worker_threads: 0, })) } @@ -358,6 +363,21 @@ impl FFIClientConfig { } } +/// Sets the number of Tokio worker threads for the FFI runtime (0 = auto) +/// +/// # Safety +/// - `config` must be a valid pointer to an FFIClientConfig +#[no_mangle] +pub unsafe extern "C" fn dash_spv_ffi_config_set_worker_threads( + config: *mut FFIClientConfig, + threads: u32, +) -> i32 { + null_check!(config); + let cfg = &mut *config; + cfg.worker_threads = threads; + FFIErrorCode::Success as i32 +} + // Mempool configuration functions /// Enables or disables mempool tracking diff --git a/dash-spv-ffi/tests/c_tests/Makefile b/dash-spv-ffi/tests/c_tests/Makefile index edf1a0c8c..1afadf309 100644 --- a/dash-spv-ffi/tests/c_tests/Makefile +++ b/dash-spv-ffi/tests/c_tests/Makefile @@ -4,20 +4,20 @@ PROFILE ?= debug CC = gcc -CFLAGS = -Wall -Wextra -Werror -std=c99 -I../.. -g -O0 -LDFLAGS = -L../../target/$(PROFILE) -ldash_spv_ffi -lpthread -ldl -lm +CFLAGS = -Wall -Wextra -Werror -std=c99 -I../.. -I../../../key-wallet-ffi/include -g -O0 +LDFLAGS = -L../../../target/$(PROFILE) -ldash_spv_ffi -lkey_wallet_ffi -lpthread -ldl -lm # Platform-specific settings UNAME_S := $(shell uname -s) ifeq ($(UNAME_S),Linux) - LDFLAGS += -Wl,-rpath,../../target/$(PROFILE) + LDFLAGS += -Wl,-rpath,../../../target/$(PROFILE) endif ifeq ($(UNAME_S),Darwin) - LDFLAGS += -Wl,-rpath,@loader_path/../../target/$(PROFILE) + LDFLAGS += -Wl,-rpath,@loader_path/../../../target/$(PROFILE) endif # Test programs -TESTS = test_basic test_advanced test_integration +TESTS = test_basic test_advanced test_integration test_event_draining test_configuration # Build all tests all: $(TESTS) @@ -32,6 +32,12 @@ test_advanced: test_advanced.c test_integration: test_integration.c $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) +test_event_draining: test_event_draining.c + $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) + +test_configuration: test_configuration.c + $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) + # Run all tests test: all @echo "Running C tests..." diff --git a/dash-spv-ffi/tests/c_tests/test_configuration.c b/dash-spv-ffi/tests/c_tests/test_configuration.c new file mode 100644 index 000000000..54c78a857 --- /dev/null +++ b/dash-spv-ffi/tests/c_tests/test_configuration.c @@ -0,0 +1,273 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "../../../key-wallet-ffi/include/key_wallet_ffi.h" +#include "../../dash_spv_ffi.h" + +// Define constants for better readability +#define FFIErrorCode_Success 0 +#define FFIErrorCode_NullPointer 1 +#define FFIValidationMode_None 0 +#define FFIValidationMode_Basic 1 + +// Test helper macros +#define TEST_ASSERT(condition) do { \ + if (!(condition)) { \ + fprintf(stderr, "Assertion failed: %s at %s:%d\n", #condition, __FILE__, __LINE__); \ + exit(1); \ + } \ +} while(0) + +#define TEST_SUCCESS(name) printf("✓ %s\n", name) +#define TEST_START(name) printf("Running %s...\n", name) + +void test_worker_threads_basic() { + TEST_START("test_worker_threads_basic"); + + FFIClientConfig* config = dash_spv_ffi_config_testnet(); + TEST_ASSERT(config != NULL); + + // Test setting worker threads to 0 (auto mode) + int result = dash_spv_ffi_config_set_worker_threads(config, 0); + TEST_ASSERT(result == FFIErrorCode_Success); + + // Test setting specific worker thread counts + uint32_t thread_counts[] = {1, 2, 4, 8, 16, 32}; + size_t num_counts = sizeof(thread_counts) / sizeof(thread_counts[0]); + + for (size_t i = 0; i < num_counts; i++) { + result = dash_spv_ffi_config_set_worker_threads(config, thread_counts[i]); + TEST_ASSERT(result == FFIErrorCode_Success); + } + + dash_spv_ffi_config_destroy(config); + TEST_SUCCESS("test_worker_threads_basic"); +} + +void test_worker_threads_null_config() { + TEST_START("test_worker_threads_null_config"); + + // Test with null config pointer + int result = dash_spv_ffi_config_set_worker_threads(NULL, 4); + TEST_ASSERT(result == FFIErrorCode_NullPointer); + + // Check error was set + const char* error = dash_spv_ffi_get_last_error(); + TEST_ASSERT(error != NULL); + TEST_ASSERT(strstr(error, "Null") != NULL || strstr(error, "null") != NULL || strstr(error, "invalid") != NULL); + + TEST_SUCCESS("test_worker_threads_null_config"); +} + +void test_worker_threads_extreme_values() { + TEST_START("test_worker_threads_extreme_values"); + + FFIClientConfig* config = dash_spv_ffi_config_mainnet(); + TEST_ASSERT(config != NULL); + + // Test large worker thread count + int result = dash_spv_ffi_config_set_worker_threads(config, 1000); + TEST_ASSERT(result == FFIErrorCode_Success); + + // Test maximum value + result = dash_spv_ffi_config_set_worker_threads(config, UINT32_MAX); + TEST_ASSERT(result == FFIErrorCode_Success); + + // Test back to reasonable value + result = dash_spv_ffi_config_set_worker_threads(config, 4); + TEST_ASSERT(result == FFIErrorCode_Success); + + dash_spv_ffi_config_destroy(config); + TEST_SUCCESS("test_worker_threads_extreme_values"); +} + +void test_worker_threads_with_client_creation() { + TEST_START("test_worker_threads_with_client_creation"); + + // Test that worker thread setting is used when creating client + uint32_t thread_counts[] = {0, 1, 4, 8}; + size_t num_counts = sizeof(thread_counts) / sizeof(thread_counts[0]); + + for (size_t i = 0; i < num_counts; i++) { + FFIClientConfig* config = dash_spv_ffi_config_new(REGTEST); + TEST_ASSERT(config != NULL); + + // Set worker threads + int result = dash_spv_ffi_config_set_worker_threads(config, thread_counts[i]); + TEST_ASSERT(result == FFIErrorCode_Success); + + // Set up config for client creation + char temp_path[256]; + snprintf(temp_path, sizeof(temp_path), "/tmp/dash_spv_worker_test_%d_%zu", getpid(), i); + result = dash_spv_ffi_config_set_data_dir(config, temp_path); + TEST_ASSERT(result == FFIErrorCode_Success); + + result = dash_spv_ffi_config_set_validation_mode(config, FFIValidationMode_None); + TEST_ASSERT(result == FFIErrorCode_Success); + + // Create client - should succeed regardless of worker thread count + FFIDashSpvClient* client = dash_spv_ffi_client_new(config); + TEST_ASSERT(client != NULL); + + printf("Created client successfully with %u worker threads\n", thread_counts[i]); + + dash_spv_ffi_client_destroy(client); + dash_spv_ffi_config_destroy(config); + } + + TEST_SUCCESS("test_worker_threads_with_client_creation"); +} + +void test_worker_threads_multiple_configs() { + TEST_START("test_worker_threads_multiple_configs"); + + // Test that different configs can have different worker thread counts + typedef struct { + FFIClientConfig* config; + uint32_t thread_count; + } ConfigThreadPair; + + ConfigThreadPair pairs[] = { + {dash_spv_ffi_config_testnet(), 1}, + {dash_spv_ffi_config_mainnet(), 4}, + {dash_spv_ffi_config_new(REGTEST), 8} + }; + size_t num_pairs = sizeof(pairs) / sizeof(pairs[0]); + + for (size_t i = 0; i < num_pairs; i++) { + TEST_ASSERT(pairs[i].config != NULL); + int result = dash_spv_ffi_config_set_worker_threads(pairs[i].config, pairs[i].thread_count); + TEST_ASSERT(result == FFIErrorCode_Success); + } + + // Clean up all configs + for (size_t i = 0; i < num_pairs; i++) { + dash_spv_ffi_config_destroy(pairs[i].config); + } + + TEST_SUCCESS("test_worker_threads_multiple_configs"); +} + +void test_worker_threads_repeated_setting() { + TEST_START("test_worker_threads_repeated_setting"); + + FFIClientConfig* config = dash_spv_ffi_config_testnet(); + TEST_ASSERT(config != NULL); + + // Test repeated setting of worker threads + for (int i = 0; i < 10; i++) { + int result = dash_spv_ffi_config_set_worker_threads(config, 4); + TEST_ASSERT(result == FFIErrorCode_Success); + } + + // Test setting different values in sequence + uint32_t sequence[] = {0, 1, 0, 8, 0, 16, 0}; + size_t sequence_len = sizeof(sequence) / sizeof(sequence[0]); + + for (size_t i = 0; i < sequence_len; i++) { + int result = dash_spv_ffi_config_set_worker_threads(config, sequence[i]); + TEST_ASSERT(result == FFIErrorCode_Success); + } + + dash_spv_ffi_config_destroy(config); + TEST_SUCCESS("test_worker_threads_repeated_setting"); +} + +void test_worker_threads_performance() { + TEST_START("test_worker_threads_performance"); + + FFIClientConfig* config = dash_spv_ffi_config_new(REGTEST); + TEST_ASSERT(config != NULL); + + // Test performance of setting worker threads many times + const int num_calls = 1000; + clock_t start = clock(); + + for (int i = 0; i < num_calls; i++) { + uint32_t thread_count = (i % 8) + 1; // 1-8 threads + int result = dash_spv_ffi_config_set_worker_threads(config, thread_count); + TEST_ASSERT(result == FFIErrorCode_Success); + } + + clock_t end = clock(); + double elapsed = ((double)(end - start)) / CLOCKS_PER_SEC; + + printf("Performance: %d worker thread settings took %.3f seconds (%.1f Ξs per call)\n", + num_calls, elapsed, (elapsed * 1000000) / num_calls); + + // Should be very fast + TEST_ASSERT(elapsed < 0.01); + + dash_spv_ffi_config_destroy(config); + TEST_SUCCESS("test_worker_threads_performance"); +} + +void test_worker_threads_edge_cases() { + TEST_START("test_worker_threads_edge_cases"); + + // Test with different network configs + FFINetwork networks[] = {DASH, TESTNET, REGTEST, DEVNET}; + size_t num_networks = sizeof(networks) / sizeof(networks[0]); + + for (size_t i = 0; i < num_networks; i++) { + FFIClientConfig* config = dash_spv_ffi_config_new(networks[i]); + TEST_ASSERT(config != NULL); + + // Set worker threads + int result = dash_spv_ffi_config_set_worker_threads(config, 2); + TEST_ASSERT(result == FFIErrorCode_Success); + + // Test setting to 0 (auto) + result = dash_spv_ffi_config_set_worker_threads(config, 0); + TEST_ASSERT(result == FFIErrorCode_Success); + + dash_spv_ffi_config_destroy(config); + } + + TEST_SUCCESS("test_worker_threads_edge_cases"); +} + +void test_worker_threads_memory_safety() { + TEST_START("test_worker_threads_memory_safety"); + + // Test that repeated config creation/destruction doesn't leak + for (int iteration = 0; iteration < 10; iteration++) { + FFIClientConfig* config = dash_spv_ffi_config_testnet(); + TEST_ASSERT(config != NULL); + + // Set various worker thread counts + uint32_t counts[] = {0, 1, 2, 4, 8, 0}; + size_t num_counts = sizeof(counts) / sizeof(counts[0]); + + for (size_t i = 0; i < num_counts; i++) { + int result = dash_spv_ffi_config_set_worker_threads(config, counts[i]); + TEST_ASSERT(result == FFIErrorCode_Success); + } + + dash_spv_ffi_config_destroy(config); + } + + TEST_SUCCESS("test_worker_threads_memory_safety"); +} + +int main() { + printf("=== C Tests for dash_spv_ffi_config_set_worker_threads ===\n"); + + test_worker_threads_basic(); + test_worker_threads_null_config(); + test_worker_threads_extreme_values(); + test_worker_threads_with_client_creation(); + test_worker_threads_multiple_configs(); + test_worker_threads_repeated_setting(); + test_worker_threads_performance(); + test_worker_threads_edge_cases(); + test_worker_threads_memory_safety(); + + printf("\n=== All worker thread configuration tests passed! ===\n"); + return 0; +} \ No newline at end of file diff --git a/dash-spv-ffi/tests/c_tests/test_event_draining.c b/dash-spv-ffi/tests/c_tests/test_event_draining.c new file mode 100644 index 000000000..398610e8c --- /dev/null +++ b/dash-spv-ffi/tests/c_tests/test_event_draining.c @@ -0,0 +1,153 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "../../../key-wallet-ffi/include/key_wallet_ffi.h" +#include "../../dash_spv_ffi.h" + +// Define constants for better readability +#define FFIErrorCode_Success 0 +#define FFIErrorCode_NullPointer 1 +#define FFIValidationMode_None 0 + +// Test helper macros +#define TEST_ASSERT(condition) do { \ + if (!(condition)) { \ + fprintf(stderr, "Assertion failed: %s at %s:%d\n", #condition, __FILE__, __LINE__); \ + exit(1); \ + } \ +} while(0) + +#define TEST_SUCCESS(name) printf("✓ %s\n", name) +#define TEST_START(name) printf("Running %s...\n", name) + +FFIDashSpvClient* create_simple_test_client() { + // Create config + FFIClientConfig* config = dash_spv_ffi_config_new(REGTEST); + TEST_ASSERT(config != NULL); + + // Set data directory to temporary location + char temp_path[256]; + snprintf(temp_path, sizeof(temp_path), "/tmp/dash_spv_test_%d", getpid()); + int result = dash_spv_ffi_config_set_data_dir(config, temp_path); + TEST_ASSERT(result == FFIErrorCode_Success); + + // Set validation mode to none for faster testing + result = dash_spv_ffi_config_set_validation_mode(config, FFIValidationMode_None); + TEST_ASSERT(result == FFIErrorCode_Success); + + // Create client + FFIDashSpvClient* client = dash_spv_ffi_client_new(config); + TEST_ASSERT(client != NULL); + + // Clean up config + dash_spv_ffi_config_destroy(config); + + return client; +} + +void test_drain_events_null_client() { + TEST_START("test_drain_events_null_client"); + + // Test with null client pointer + int result = dash_spv_ffi_client_drain_events(NULL); + TEST_ASSERT(result == FFIErrorCode_NullPointer); + + // Check error was set + const char* error = dash_spv_ffi_get_last_error(); + TEST_ASSERT(error != NULL); + TEST_ASSERT(strstr(error, "Null") != NULL || strstr(error, "null") != NULL || strstr(error, "invalid") != NULL); + + TEST_SUCCESS("test_drain_events_null_client"); +} + +void test_drain_events_no_events() { + TEST_START("test_drain_events_no_events"); + + FFIDashSpvClient* client = create_simple_test_client(); + + // Call drain events - should succeed with no events + int result = dash_spv_ffi_client_drain_events(client); + TEST_ASSERT(result == FFIErrorCode_Success); + + dash_spv_ffi_client_destroy(client); + TEST_SUCCESS("test_drain_events_no_events"); +} + +void test_drain_events_multiple_calls() { + TEST_START("test_drain_events_multiple_calls"); + + FFIDashSpvClient* client = create_simple_test_client(); + + // Make multiple drain calls - should be idempotent + for (int i = 0; i < 10; i++) { + int result = dash_spv_ffi_client_drain_events(client); + TEST_ASSERT(result == FFIErrorCode_Success); + } + + dash_spv_ffi_client_destroy(client); + TEST_SUCCESS("test_drain_events_multiple_calls"); +} + +void test_drain_events_performance() { + TEST_START("test_drain_events_performance"); + + FFIDashSpvClient* client = create_simple_test_client(); + + // Test performance with many calls + const int num_calls = 1000; + clock_t start = clock(); + + for (int i = 0; i < num_calls; i++) { + int result = dash_spv_ffi_client_drain_events(client); + TEST_ASSERT(result == FFIErrorCode_Success); + } + + clock_t end = clock(); + double elapsed = ((double)(end - start)) / CLOCKS_PER_SEC; + + printf("Performance: %d drain_events calls took %.3f seconds (%.1f Ξs per call)\n", + num_calls, elapsed, (elapsed * 1000000) / num_calls); + + // Should be very fast - less than 100ms for 1000 calls + TEST_ASSERT(elapsed < 0.1); + + dash_spv_ffi_client_destroy(client); + TEST_SUCCESS("test_drain_events_performance"); +} + +void test_drain_events_memory_safety() { + TEST_START("test_drain_events_memory_safety"); + + // Test that repeated client creation/destruction with drain events doesn't leak + for (int iteration = 0; iteration < 5; iteration++) { + FFIDashSpvClient* client = create_simple_test_client(); + + // Multiple rapid drain calls + for (int i = 0; i < 20; i++) { + int result = dash_spv_ffi_client_drain_events(client); + TEST_ASSERT(result == FFIErrorCode_Success); + } + + dash_spv_ffi_client_destroy(client); + } + + TEST_SUCCESS("test_drain_events_memory_safety"); +} + +int main() { + printf("=== C Tests for dash_spv_ffi_client_drain_events ===\n"); + + test_drain_events_null_client(); + test_drain_events_no_events(); + test_drain_events_multiple_calls(); + test_drain_events_performance(); + test_drain_events_memory_safety(); + + printf("\n=== All event draining tests passed! ===\n"); + return 0; +} \ No newline at end of file diff --git a/dash-spv-ffi/tests/test_event_callbacks.rs b/dash-spv-ffi/tests/test_event_callbacks.rs index 50cbded24..0fcdddefd 100644 --- a/dash-spv-ffi/tests/test_event_callbacks.rs +++ b/dash-spv-ffi/tests/test_event_callbacks.rs @@ -1,3 +1,4 @@ +use dash_spv_ffi::callbacks::FFIEventCallbacks; use dash_spv_ffi::*; use key_wallet_ffi::FFINetwork; use serial_test::serial; @@ -290,3 +291,243 @@ fn test_enhanced_event_callbacks() { println!("✅ Enhanced event callbacks test completed successfully"); } } + +#[test] +#[serial] +fn test_drain_events_integration() { + unsafe { + println!("Testing drain_events integration with event callbacks..."); + + let event_data = TestEventData::new(); + + // Create config + let config = dash_spv_ffi_config_new(FFINetwork::Regtest); + assert!(!config.is_null()); + + // Set data directory + let temp_dir = TempDir::new().unwrap(); + let path = CString::new(temp_dir.path().to_str().unwrap()).unwrap(); + dash_spv_ffi_config_set_data_dir(config, path.as_ptr()); + dash_spv_ffi_config_set_validation_mode(config, FFIValidationMode::None); + + // Create client + let client = dash_spv_ffi_client_new(config); + assert!(!client.is_null()); + + // Set up all event callbacks using the unified API + let user_data = Arc::as_ptr(&event_data) as *mut c_void; + let callbacks = FFIEventCallbacks { + on_balance_update: Some(test_balance_callback), + on_transaction: Some(test_transaction_callback), + on_block: Some(test_block_callback), + on_compact_filter_matched: Some(test_compact_filter_matched_callback), + on_mempool_transaction_added: None, + on_mempool_transaction_confirmed: None, + on_mempool_transaction_removed: None, + on_wallet_transaction: None, + on_filter_headers_progress: None, + user_data, + }; + dash_spv_ffi_client_set_event_callbacks(client, callbacks); + + // Test drain_events with no pending events + let result = dash_spv_ffi_client_drain_events(client); + assert_eq!(result, FFIErrorCode::Success as i32); + + // Verify no events were processed (callbacks not called) + assert!(!event_data.block_received.load(Ordering::SeqCst)); + assert!(!event_data.transaction_received.load(Ordering::SeqCst)); + assert!(!event_data.balance_updated.load(Ordering::SeqCst)); + assert!(!event_data.compact_filter_matched.load(Ordering::SeqCst)); + + // Test multiple drain calls + for _ in 0..10 { + let result = dash_spv_ffi_client_drain_events(client); + assert_eq!(result, FFIErrorCode::Success as i32); + } + + // State should remain unchanged + assert!(!event_data.block_received.load(Ordering::SeqCst)); + assert!(!event_data.transaction_received.load(Ordering::SeqCst)); + assert!(!event_data.balance_updated.load(Ordering::SeqCst)); + + // Clean up + dash_spv_ffi_client_destroy(client); + dash_spv_ffi_config_destroy(config); + + println!("✅ drain_events integration test completed successfully"); + } +} + +#[test] +#[serial] +fn test_drain_events_concurrent_with_callbacks() { + unsafe { + println!("Testing drain_events concurrent access with callback setup..."); + + let event_data = TestEventData::new(); + + // Create config and client + let config = dash_spv_ffi_config_new(FFINetwork::Regtest); + assert!(!config.is_null()); + + let temp_dir = TempDir::new().unwrap(); + let path = CString::new(temp_dir.path().to_str().unwrap()).unwrap(); + dash_spv_ffi_config_set_data_dir(config, path.as_ptr()); + dash_spv_ffi_config_set_validation_mode(config, FFIValidationMode::None); + + let client = dash_spv_ffi_client_new(config); + assert!(!client.is_null()); + + // Set up callbacks while draining events concurrently + let user_data = Arc::as_ptr(&event_data) as *mut c_void; + + // Set up callbacks and drain events + let callbacks = FFIEventCallbacks { + on_balance_update: Some(test_balance_callback), + on_transaction: Some(test_transaction_callback), + on_block: Some(test_block_callback), + on_compact_filter_matched: None, + on_mempool_transaction_added: None, + on_mempool_transaction_confirmed: None, + on_mempool_transaction_removed: None, + on_wallet_transaction: None, + on_filter_headers_progress: None, + user_data, + }; + dash_spv_ffi_client_set_event_callbacks(client, callbacks); + + let result = dash_spv_ffi_client_drain_events(client); + assert_eq!(result, FFIErrorCode::Success as i32); + + // Test concurrent draining from multiple threads + let client_ptr = client as usize; + let handles: Vec<_> = (0..3) + .map(|thread_id| { + thread::spawn(move || { + let client = client_ptr as *mut FFIDashSpvClient; + for i in 0..20 { + let result = dash_spv_ffi_client_drain_events(client); + assert_eq!(result, FFIErrorCode::Success as i32); + + // Small delay to allow interleaving + if i % 5 == 0 { + thread::sleep(Duration::from_millis(1)); + } + } + println!("Thread {} completed drain operations", thread_id); + }) + }) + .collect(); + + // Wait for all threads + for handle in handles { + handle.join().unwrap(); + } + + // Final drain to ensure everything is cleaned up + let result = dash_spv_ffi_client_drain_events(client); + assert_eq!(result, FFIErrorCode::Success as i32); + + // Clean up + dash_spv_ffi_client_destroy(client); + dash_spv_ffi_config_destroy(config); + + println!("✅ Concurrent drain_events test completed successfully"); + } +} + +#[test] +#[serial] +fn test_drain_events_callback_lifecycle() { + unsafe { + println!("Testing drain_events through callback lifecycle..."); + + let event_data = TestEventData::new(); + + let config = dash_spv_ffi_config_new(FFINetwork::Regtest); + assert!(!config.is_null()); + + let temp_dir = TempDir::new().unwrap(); + let path = CString::new(temp_dir.path().to_str().unwrap()).unwrap(); + dash_spv_ffi_config_set_data_dir(config, path.as_ptr()); + dash_spv_ffi_config_set_validation_mode(config, FFIValidationMode::None); + + let client = dash_spv_ffi_client_new(config); + assert!(!client.is_null()); + + let user_data = Arc::as_ptr(&event_data) as *mut c_void; + + // Phase 1: No callbacks set - should work fine + let result = dash_spv_ffi_client_drain_events(client); + assert_eq!(result, FFIErrorCode::Success as i32); + + // Phase 2: Set some callbacks + let callbacks = FFIEventCallbacks { + on_balance_update: Some(test_balance_callback), + on_transaction: Some(test_transaction_callback), + on_block: None, + on_compact_filter_matched: None, + on_mempool_transaction_added: None, + on_mempool_transaction_confirmed: None, + on_mempool_transaction_removed: None, + on_wallet_transaction: None, + on_filter_headers_progress: None, + user_data, + }; + dash_spv_ffi_client_set_event_callbacks(client, callbacks); + + // Drain with callbacks set + let result = dash_spv_ffi_client_drain_events(client); + assert_eq!(result, FFIErrorCode::Success as i32); + + // Phase 3: Clear callbacks by setting to None + let callbacks = FFIEventCallbacks { + on_balance_update: None, + on_transaction: None, + on_block: None, + on_compact_filter_matched: None, + on_mempool_transaction_added: None, + on_mempool_transaction_confirmed: None, + on_mempool_transaction_removed: None, + on_wallet_transaction: None, + on_filter_headers_progress: None, + user_data: std::ptr::null_mut(), + }; + dash_spv_ffi_client_set_event_callbacks(client, callbacks); + + // Drain with cleared callbacks + let result = dash_spv_ffi_client_drain_events(client); + assert_eq!(result, FFIErrorCode::Success as i32); + + // Phase 4: Re-set callbacks with different functions + let callbacks = FFIEventCallbacks { + on_balance_update: None, + on_transaction: None, + on_block: Some(test_block_callback), + on_compact_filter_matched: None, + on_mempool_transaction_added: None, + on_mempool_transaction_confirmed: None, + on_mempool_transaction_removed: None, + on_wallet_transaction: None, + on_filter_headers_progress: None, + user_data, + }; + dash_spv_ffi_client_set_event_callbacks(client, callbacks); + + // Final drain + let result = dash_spv_ffi_client_drain_events(client); + assert_eq!(result, FFIErrorCode::Success as i32); + + // Verify no unexpected events were triggered + assert!(!event_data.balance_updated.load(Ordering::SeqCst)); + assert!(!event_data.transaction_received.load(Ordering::SeqCst)); + assert!(!event_data.block_received.load(Ordering::SeqCst)); + + // Clean up + dash_spv_ffi_client_destroy(client); + dash_spv_ffi_config_destroy(config); + + println!("✅ Callback lifecycle drain_events test completed successfully"); + } +} diff --git a/dash-spv-ffi/tests/unit/test_configuration.rs b/dash-spv-ffi/tests/unit/test_configuration.rs index 1ff9c8271..47916532c 100644 --- a/dash-spv-ffi/tests/unit/test_configuration.rs +++ b/dash-spv-ffi/tests/unit/test_configuration.rs @@ -3,7 +3,7 @@ mod tests { use crate::*; use key_wallet_ffi::FFINetwork; use serial_test::serial; - use std::ffi::CString; + use std::ffi::{CStr, CString}; #[test] #[serial] @@ -312,4 +312,131 @@ mod tests { dash_spv_ffi_config_destroy(config); } } + + #[test] + #[serial] + fn test_worker_threads_configuration() { + unsafe { + let config = dash_spv_ffi_config_testnet(); + + // Test setting worker threads to 0 (auto mode) + let result = dash_spv_ffi_config_set_worker_threads(config, 0); + assert_eq!(result, FFIErrorCode::Success as i32); + + // Test setting specific worker thread counts + let thread_counts = [1, 2, 4, 8, 16, 32]; + for &count in &thread_counts { + let result = dash_spv_ffi_config_set_worker_threads(config, count); + assert_eq!(result, FFIErrorCode::Success as i32); + } + + // Test large worker thread count + let result = dash_spv_ffi_config_set_worker_threads(config, 1000); + assert_eq!(result, FFIErrorCode::Success as i32); + + // Test maximum value + let result = dash_spv_ffi_config_set_worker_threads(config, u32::MAX); + assert_eq!(result, FFIErrorCode::Success as i32); + + dash_spv_ffi_config_destroy(config); + } + } + + #[test] + #[serial] + fn test_worker_threads_with_null_config() { + unsafe { + // Test with null config pointer + let result = dash_spv_ffi_config_set_worker_threads(std::ptr::null_mut(), 4); + assert_eq!(result, FFIErrorCode::NullPointer as i32); + + // Check error was set + let error_ptr = dash_spv_ffi_get_last_error(); + assert!(!error_ptr.is_null()); + let error_str = CStr::from_ptr(error_ptr).to_str().unwrap(); + assert!( + error_str.contains("Null") + || error_str.contains("null") + || error_str.contains("invalid") + ); + } + } + + #[test] + #[serial] + fn test_worker_threads_persistence() { + unsafe { + // Test that worker thread setting is preserved + for &thread_count in &[0, 1, 4, 8] { + let config = dash_spv_ffi_config_new(FFINetwork::Testnet); + + // Set worker threads + let result = dash_spv_ffi_config_set_worker_threads(config, thread_count); + assert_eq!(result, FFIErrorCode::Success as i32); + + // Create client with this config (this tests that the setting is used) + let temp_dir = tempfile::TempDir::new().unwrap(); + let path = CString::new(temp_dir.path().to_str().unwrap()).unwrap(); + dash_spv_ffi_config_set_data_dir(config, path.as_ptr()); + dash_spv_ffi_config_set_validation_mode(config, FFIValidationMode::None); + + let client = dash_spv_ffi_client_new(config); + // Client creation should succeed regardless of worker thread count + assert!( + !client.is_null(), + "Failed to create client with {} worker threads", + thread_count + ); + + dash_spv_ffi_client_destroy(client); + dash_spv_ffi_config_destroy(config); + } + } + } + + #[test] + #[serial] + fn test_worker_threads_multiple_configs() { + unsafe { + // Test that different configs can have different worker thread counts + let configs = [ + (dash_spv_ffi_config_testnet(), 1), + (dash_spv_ffi_config_mainnet(), 4), + (dash_spv_ffi_config_new(FFINetwork::Regtest), 8), + ]; + + for (config, thread_count) in configs { + let result = dash_spv_ffi_config_set_worker_threads(config, thread_count); + assert_eq!(result, FFIErrorCode::Success as i32); + } + + // Clean up all configs + for (config, _) in configs { + dash_spv_ffi_config_destroy(config); + } + } + } + + #[test] + #[serial] + fn test_worker_threads_edge_cases() { + unsafe { + let config = dash_spv_ffi_config_testnet(); + + // Test repeated setting of worker threads + for _ in 0..10 { + let result = dash_spv_ffi_config_set_worker_threads(config, 4); + assert_eq!(result, FFIErrorCode::Success as i32); + } + + // Test setting different values in sequence + let sequence = [0, 1, 0, 8, 0, 16, 0]; + for &count in &sequence { + let result = dash_spv_ffi_config_set_worker_threads(config, count); + assert_eq!(result, FFIErrorCode::Success as i32); + } + + dash_spv_ffi_config_destroy(config); + } + } } diff --git a/dash-spv/src/chain/checkpoint_test.rs b/dash-spv/src/chain/checkpoint_test.rs index db93963e8..f11a178e9 100644 --- a/dash-spv/src/chain/checkpoint_test.rs +++ b/dash-spv/src/chain/checkpoint_test.rs @@ -22,7 +22,7 @@ mod tests { target: Target::from_compact(CompactTarget::from_consensus(0x1d00ffff)), merkle_root: Some(BlockHash::from_raw_hash(hash_bytes)), chain_work: format!("0x{:064x}", height * 1000), - masternode_list_name: if height % 100000 == 0 && height > 0 { + masternode_list_name: if height.is_multiple_of(100000) && height > 0 { Some(format!("ML{}__70230", height)) } else { None diff --git a/dash-spv/src/client/filter_sync.rs b/dash-spv/src/client/filter_sync.rs index 63204d561..f2a90725e 100644 --- a/dash-spv/src/client/filter_sync.rs +++ b/dash-spv/src/client/filter_sync.rs @@ -69,14 +69,28 @@ impl< let tip_height = self.storage.get_filter_tip_height().await.map_err(SpvError::Storage)?.unwrap_or(0); - // TODO: Get earliest height from wallet's birth height or earliest address usage - // For now, default to last 100 blocks - let earliest_height = tip_height.saturating_sub(99); - - let num_blocks = num_blocks.unwrap_or(100); + // Determine how many blocks to request + let num_blocks = num_blocks.unwrap_or(100).max(1); let default_start = tip_height.saturating_sub(num_blocks - 1); - let start_height = earliest_height.min(default_start); // Go back to the earliest required height - let actual_count = tip_height - start_height + 1; // Actual number of blocks available + + // Ask the wallet for an earliest rescan height, falling back to the default window. + let wallet_hint = self.sync_manager.wallet_birth_height_hint().await; + let mut start_height = wallet_hint.unwrap_or(default_start).min(default_start); + + // Respect any user-provided start height hint from the configuration. + if let Some(config_start) = self.sync_manager.config_start_height() { + let capped = config_start.min(tip_height); + start_height = start_height.max(capped); + } + + // Make sure we never request past the current tip + start_height = start_height.min(tip_height); + + let actual_count = if start_height <= tip_height { + tip_height - start_height + 1 + } else { + 0 + }; tracing::info!( "Requesting filters from height {} to {} ({} blocks based on filter tip height)", @@ -84,10 +98,17 @@ impl< tip_height, actual_count ); + if let Some(hint) = wallet_hint { + tracing::debug!("Wallet hint for earliest required height: {}", hint); + } tracing::info!("Filter processing and matching will happen automatically in background thread as CFilter messages arrive"); // Send filter requests - processing will happen automatically in the background - self.sync_filters_coordinated(start_height, actual_count).await?; + if actual_count > 0 { + self.sync_filters_coordinated(start_height, actual_count).await?; + } else { + tracing::debug!("No filters requested because calculated range is empty"); + } // Return empty vector since matching happens asynchronously in the filter processor thread // Actual matches will be processed and blocks requested automatically when CFilter messages arrive diff --git a/dash-spv/src/client/mod.rs b/dash-spv/src/client/mod.rs index 9e6e479e5..0791b96a8 100644 --- a/dash-spv/src/client/mod.rs +++ b/dash-spv/src/client/mod.rs @@ -1816,7 +1816,7 @@ impl< } // Progress logging for large header counts - if loaded_count % 50_000 == 0 || loaded_count == target_height { + if loaded_count.is_multiple_of(50_000) || loaded_count == target_height { let elapsed = start_time.elapsed(); let headers_per_sec = loaded_count as f64 / elapsed.as_secs_f64(); tracing::info!( diff --git a/dash-spv/src/main.rs b/dash-spv/src/main.rs index 37a875e73..afe40d4cb 100644 --- a/dash-spv/src/main.rs +++ b/dash-spv/src/main.rs @@ -411,7 +411,7 @@ async fn run_client { // Log snapshot if interval has elapsed if last_snapshot.elapsed() >= snapshot_interval { - let (tx_count, confirmed, unconfirmed, locked, total, derived_incoming) = { + let (tx_count, wallet_affecting_tx_count, confirmed, unconfirmed, locked, total, derived_incoming) = { let mgr = wallet_for_logger.read().await; // Count transactions via network state for the selected network let txs = mgr @@ -419,6 +419,12 @@ async fn run_client= 1000 || blockchain_height % 1000 == 0 { + if headers.len() >= 1000 || blockchain_height.is_multiple_of(1000) { self.save_dirty_segments().await?; } diff --git a/dash-spv/src/storage/sync_state.rs b/dash-spv/src/storage/sync_state.rs index 40b6081c8..379a0b55d 100644 --- a/dash-spv/src/storage/sync_state.rs +++ b/dash-spv/src/storage/sync_state.rs @@ -344,7 +344,7 @@ impl PersistentSyncState { 50000 }; - height % interval == 0 + height.is_multiple_of(interval) } } diff --git a/dash-spv/src/sync/headers.rs b/dash-spv/src/sync/headers.rs index 048317088..a3eb60ff9 100644 --- a/dash-spv/src/sync/headers.rs +++ b/dash-spv/src/sync/headers.rs @@ -94,7 +94,7 @@ impl HeaderSyncManager { None => true, Some(last_time) => { last_time.elapsed() >= std::time::Duration::from_secs(30) - || self.total_headers_synced % 10000 == 0 + || self.total_headers_synced.is_multiple_of(10000) } }; @@ -407,7 +407,7 @@ impl HeaderSyncManager { // Headers request sent successfully - if self.total_headers_synced % 10000 == 0 { + if self.total_headers_synced.is_multiple_of(10000) { tracing::debug!("Requested headers starting from {:?}", base_hash); } diff --git a/dash-spv/src/sync/headers_with_reorg.rs b/dash-spv/src/sync/headers_with_reorg.rs index 400be5e7b..4e9f1ffc2 100644 --- a/dash-spv/src/sync/headers_with_reorg.rs +++ b/dash-spv/src/sync/headers_with_reorg.rs @@ -205,7 +205,7 @@ impl Option { + // Map the dashcore network to wallet network, returning None for unknown variants + let wallet_network = match self.config.network { + dashcore::Network::Dash => WalletNetwork::Dash, + dashcore::Network::Testnet => WalletNetwork::Testnet, + dashcore::Network::Devnet => WalletNetwork::Devnet, + dashcore::Network::Regtest => WalletNetwork::Regtest, + _ => return None, // Unknown network variant - return None instead of defaulting + }; + + // Only acquire the wallet lock if we have a valid network mapping + let wallet_guard = self.wallet.read().await; + let result = wallet_guard.earliest_required_height(wallet_network).await; + drop(wallet_guard); + result + } + + /// Get the configured start height hint, if any. + pub fn config_start_height(&self) -> Option { + self.config.start_from_height + } + /// Start the sequential sync process pub async fn start_sync(&mut self, network: &mut N, storage: &mut S) -> SyncResult { if self.current_phase.is_syncing() { diff --git a/dash/src/bip158.rs b/dash/src/bip158.rs index 684f632a7..213923140 100644 --- a/dash/src/bip158.rs +++ b/dash/src/bip158.rs @@ -297,11 +297,12 @@ impl GcsFilterReader { let nm = n_elements.0 * self.m; let mut mapped = query.map(|e| map_to_range(self.filter.hash(e.borrow()), nm)).collect::>(); - // sort - mapped.sort_unstable(); + // For an empty query set, "any" should be false (no items to match). if mapped.is_empty() { - return Ok(true); + return Ok(false); } + // sort + mapped.sort_unstable(); if n_elements.0 == 0 { return Ok(false); } @@ -343,12 +344,12 @@ impl GcsFilterReader { let nm = n_elements.0 * self.m; let mut mapped = query.map(|e| map_to_range(self.filter.hash(e.borrow()), nm)).collect::>(); - // sort - mapped.sort_unstable(); - mapped.dedup(); if mapped.is_empty() { return Ok(true); } + // sort + mapped.sort_unstable(); + mapped.dedup(); if n_elements.0 == 0 { return Ok(false); } diff --git a/dash/src/blockdata/transaction/special_transaction/coinbase.rs b/dash/src/blockdata/transaction/special_transaction/coinbase.rs index 09973eb04..b103aaf24 100644 --- a/dash/src/blockdata/transaction/special_transaction/coinbase.rs +++ b/dash/src/blockdata/transaction/special_transaction/coinbase.rs @@ -249,7 +249,7 @@ mod tests { } fn hex_decode(s: &str) -> Result, &'static str> { - if s.len() % 2 != 0 { + if !s.len().is_multiple_of(2) { return Err("Hex string has odd length"); } diff --git a/dash/src/blockdata/transaction/special_transaction/mnhf_signal.rs b/dash/src/blockdata/transaction/special_transaction/mnhf_signal.rs index 7c49fa63b..eccd4d1a8 100644 --- a/dash/src/blockdata/transaction/special_transaction/mnhf_signal.rs +++ b/dash/src/blockdata/transaction/special_transaction/mnhf_signal.rs @@ -154,7 +154,7 @@ mod tests { } fn hex_decode(s: &str) -> Result, &'static str> { - if s.len() % 2 != 0 { + if !s.len().is_multiple_of(2) { return Err("Hex string has odd length"); } diff --git a/dash/src/taproot.rs b/dash/src/taproot.rs index 1b0c094a0..932fe25be 100644 --- a/dash/src/taproot.rs +++ b/dash/src/taproot.rs @@ -1180,7 +1180,7 @@ impl TaprootMerkleBranch { /// The function returns an error if the the number of bytes is not an integer multiple of 32 or /// if the number of hashes exceeds 128. pub fn decode(sl: &[u8]) -> Result { - if sl.len() % TAPROOT_CONTROL_NODE_SIZE != 0 { + if !sl.len().is_multiple_of(TAPROOT_CONTROL_NODE_SIZE) { Err(TaprootError::InvalidMerkleBranchSize(sl.len())) } else if sl.len() > TAPROOT_CONTROL_NODE_SIZE * TAPROOT_CONTROL_MAX_NODE_COUNT { Err(TaprootError::InvalidMerkleTreeDepth(sl.len() / TAPROOT_CONTROL_NODE_SIZE)) @@ -1329,7 +1329,7 @@ impl ControlBlock { /// - [`TaprootError::InvalidMerkleTreeDepth`] if merkle tree is too deep (more than 128 levels). pub fn decode(sl: &[u8]) -> Result { if sl.len() < TAPROOT_CONTROL_BASE_SIZE - || (sl.len() - TAPROOT_CONTROL_BASE_SIZE) % TAPROOT_CONTROL_NODE_SIZE != 0 + || !(sl.len() - TAPROOT_CONTROL_BASE_SIZE).is_multiple_of(TAPROOT_CONTROL_NODE_SIZE) { return Err(TaprootError::InvalidControlBlockSize(sl.len())); } diff --git a/hashes/src/hex.rs b/hashes/src/hex.rs index e16a6167b..f5b648b44 100644 --- a/hashes/src/hex.rs +++ b/hashes/src/hex.rs @@ -86,7 +86,7 @@ impl<'a> HexIterator<'a> { /// /// If the input string is of odd length. pub fn new(s: &'a str) -> Result, Error> { - if s.len() % 2 != 0 { + if !s.len().is_multiple_of(2) { Err(Error::OddLengthString(s.len())) } else { Ok(HexIterator { diff --git a/hashes/src/sha256.rs b/hashes/src/sha256.rs index f6c2ba606..40680022c 100644 --- a/hashes/src/sha256.rs +++ b/hashes/src/sha256.rs @@ -404,7 +404,7 @@ impl HashEngine { /// /// If `length` is not a multiple of the block size. pub fn from_midstate(midstate: Midstate, length: usize) -> HashEngine { - assert!(length % BLOCK_SIZE == 0, "length is no multiple of the block size"); + assert!(length.is_multiple_of(BLOCK_SIZE), "length is no multiple of the block size"); let mut ret = [0; 8]; for (ret_val, midstate_bytes) in ret.iter_mut().zip(midstate[..].chunks_exact(4)) { diff --git a/key-wallet-manager/src/wallet_interface.rs b/key-wallet-manager/src/wallet_interface.rs index c7d6f9303..43094eaa3 100644 --- a/key-wallet-manager/src/wallet_interface.rs +++ b/key-wallet-manager/src/wallet_interface.rs @@ -39,4 +39,14 @@ pub trait WalletInterface: Send + Sync { block_hash: &dashcore::BlockHash, network: Network, ) -> bool; + + /// Return the earliest block height that should be scanned for this wallet on the + /// specified network. Implementations can use the wallet's birth height or other + /// metadata to provide a more precise rescan starting point. + /// + /// The default implementation returns `None`, which signals that the caller should + /// fall back to its existing behaviour. + async fn earliest_required_height(&self, _network: Network) -> Option { + None + } } diff --git a/key-wallet-manager/src/wallet_manager/process_block.rs b/key-wallet-manager/src/wallet_manager/process_block.rs index 8fa82dd1b..0f18892d4 100644 --- a/key-wallet-manager/src/wallet_manager/process_block.rs +++ b/key-wallet-manager/src/wallet_manager/process_block.rs @@ -101,14 +101,39 @@ impl WalletInterface for WalletM } } - // Check if any of our scripts match the filter - let hit = filter - .match_any(block_hash, &mut script_bytes.iter().map(|s| s.as_slice())) - .unwrap_or(false); + // If we don't watch any scripts for this network, there can be no match. + // Note: BlockFilterReader::match_any returns true for an empty query set, + // so we must guard this case explicitly to avoid false positives. + let hit = if script_bytes.is_empty() { + false + } else { + filter + .match_any(block_hash, &mut script_bytes.iter().map(|s| s.as_slice())) + .unwrap_or(false) + }; // Cache the result self.filter_matches.entry(network).or_default().insert(*block_hash, hit); hit } + + async fn earliest_required_height(&self, network: Network) -> Option { + let mut earliest: Option = None; + + for info in self.wallet_infos.values() { + // Only consider wallets that actually track this network AND have a known birth height + if info.accounts(network).is_some() { + if let Some(birth_height) = info.birth_height() { + earliest = Some(match earliest { + Some(current) => current.min(birth_height), + None => birth_height, + }); + } + } + } + + // Return None if no wallets with known birth heights were found for this network + earliest + } } diff --git a/key-wallet/src/psbt/mod.rs b/key-wallet/src/psbt/mod.rs index 48519cfdd..5529f8383 100644 --- a/key-wallet/src/psbt/mod.rs +++ b/key-wallet/src/psbt/mod.rs @@ -1132,7 +1132,7 @@ mod tests { use super::*; use crate::psbt::map::{Input, Map, Output}; - use crate::psbt::{raw, Error, PartiallySignedTransaction}; + use crate::psbt::{raw, PartiallySignedTransaction}; use dashcore::blockdata::script::ScriptBuf; use dashcore::blockdata::transaction::outpoint::OutPoint; use dashcore::blockdata::transaction::txin::TxIn; @@ -1158,8 +1158,6 @@ mod tests { #[should_panic(expected = "ConsensusEncoding")] fn invalid_vector_2() { hex_psbt!("70736274ff0100750200000001268171371edff285e937adeea4b37b78000c0566cbb3ad64641713ca42171bf60000000000feffffff02d3dff505000000001976a914d0c59903c5bac2868760e90fd521a4665aa7652088ac00e1f5050000000017a9143545e6e33b832c47050f24d3eeb93c9c03948bc787b32e1300000100fda5010100000000010289a3c71eab4d20e0371bbba4cc698fa295c9463afa2e397f8533ccb62f9567e50100000017160014be18d152a9b012039daf3da7de4f53349eecb985ffffffff86f8aa43a71dff1448893a530a7237ef6b4608bbb2dd2d0171e63aec6a4890b40100000017160014fe3e9ef1a745e974d902c4355943abcb34bd5353ffffffff0200c2eb0b000000001976a91485cff1097fd9e008bb34af709c62197b38978a4888ac72fef84e2c00000017a914339725ba21efd62ac753a9bcd067d6c7a6a39d05870247304402202712be22e0270f394f568311dc7ca9a68970b8025fdd3b240229f07f8a5f3a240220018b38d7dcd314e734c9276bd6fb40f673325bc4baa144c800d2f2f02db2765c012103d2e15674941bad4a996372cb87e1856d3652606d98562fe39c5e9e7e413f210502483045022100d12b852d85dcd961d2f5f4ab660654df6eedcc794c0c33ce5cc309ffb5fce58d022067338a8e0e1725c197fb1a88af59f51e44e4255b20167c8684031c05d1f2592a01210223b72beef0965d10be0778efecd61fcac6f79a4ea169393380734464f84f2ab30000000000") - // This weird thing is necessary since rustc 0.29 prints out I/O error in a different format than later versions - .map_err(Error::from) .unwrap(); } diff --git a/key-wallet/src/transaction_checking/account_checker.rs b/key-wallet/src/transaction_checking/account_checker.rs index 688abc29b..6b4b967ee 100644 --- a/key-wallet/src/transaction_checking/account_checker.rs +++ b/key-wallet/src/transaction_checking/account_checker.rs @@ -391,7 +391,7 @@ impl ManagedAccount { let mut involved_change_addresses = Vec::new(); let mut involved_other_addresses = Vec::new(); // For non-standard accounts let mut received = 0u64; - let sent = 0u64; + let mut sent = 0u64; let mut provider_payout_involved = false; // Check provider payouts in special transactions @@ -455,15 +455,35 @@ impl ManagedAccount { } } - // Check inputs (sent) - would need UTXO information to properly calculate - // For now, we just mark that addresses are involved - // In a real implementation, we'd look up the previous outputs being spent + // Check inputs (sent) - rely on tracked UTXOs to determine spends + if !tx.is_coin_base() { + for input in &tx.input { + if let Some(utxo) = self.utxos.get(&input.previous_output) { + sent = sent.saturating_add(utxo.txout.value); + + if let Some(address_info) = self.get_address_info(&utxo.address) { + match self.classify_address(&utxo.address) { + AddressClassification::External => { + involved_receive_addresses.push(address_info); + } + AddressClassification::Internal => { + involved_change_addresses.push(address_info); + } + AddressClassification::Other => { + involved_other_addresses.push(address_info); + } + } + } + } + } + } // Create the appropriate AccountTypeMatch based on account type let has_addresses = !involved_receive_addresses.is_empty() || !involved_change_addresses.is_empty() || !involved_other_addresses.is_empty() - || provider_payout_involved; + || provider_payout_involved + || sent > 0; if has_addresses { let account_type_match = match &self.account_type { diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 7c5b6fd8f..09e9cb40d 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -568,6 +568,96 @@ mod tests { assert!(managed_account.transactions.contains_key(&coinbase_tx.txid())); } + /// Test that spending a wallet-owned UTXO without creating change is detected + #[test] + fn test_wallet_checker_detects_spend_only_transaction() { + let network = Network::Testnet; + let wallet = Wallet::new_random(&[network], WalletAccountCreationOptions::Default) + .expect("Should create wallet"); + + let mut managed_wallet = + ManagedWalletInfo::from_wallet_with_name(&wallet, "Test".to_string()); + + // Prepare a managed BIP44 account and derive a receive address + let account_collection = wallet.accounts.get(&network).expect("Should have accounts"); + let wallet_account = + account_collection.standard_bip44_accounts.get(&0).expect("Should have BIP44 account"); + + let receive_address = managed_wallet + .first_bip44_managed_account_mut(network) + .expect("Should have managed account") + .next_receive_address(Some(&wallet_account.account_xpub), true) + .expect("Should derive receive address"); + + // Fund the wallet with a transaction paying to the receive address + let funding_value = 50_000_000u64; + let funding_tx = create_transaction_to_address(&receive_address, funding_value); + let funding_context = TransactionContext::InBlock { + height: 1, + block_hash: Some(BlockHash::from_slice(&[2u8; 32]).expect("Should create block hash")), + timestamp: Some(1_650_000_000), + }; + + let funding_result = + managed_wallet.check_transaction(&funding_tx, network, funding_context, Some(&wallet)); + assert!(funding_result.is_relevant, "Funding transaction must be relevant"); + assert_eq!(funding_result.total_received, funding_value); + + // Build a spend transaction that sends funds to an external address only + let external_address = Address::p2pkh( + &dashcore::PublicKey::from_slice(&[0x02; 33]).expect("Should create pubkey"), + network, + ); + let spend_tx = Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint { + txid: funding_tx.txid(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: 0xffffffff, + witness: dashcore::Witness::new(), + }], + output: vec![TxOut { + value: funding_value - 1_000, // leave a small fee + script_pubkey: external_address.script_pubkey(), + }], + special_transaction_payload: None, + }; + + let spend_context = TransactionContext::InBlock { + height: 2, + block_hash: Some(BlockHash::from_slice(&[3u8; 32]).expect("Should create block hash")), + timestamp: Some(1_650_000_100), + }; + + let spend_result = + managed_wallet.check_transaction(&spend_tx, network, spend_context, Some(&wallet)); + + assert!(spend_result.is_relevant, "Spend transaction should be detected"); + assert_eq!(spend_result.total_received, 0); + assert_eq!(spend_result.total_sent, funding_value); + + // Ensure the UTXO was removed and the transaction record reflects the spend + let account = managed_wallet + .accounts + .get(&network) + .expect("Should have managed accounts") + .standard_bip44_accounts + .get(&0) + .expect("Should have managed BIP44 account"); + + assert!(account.utxos.is_empty(), "Spent UTXO should be removed"); + + let record = account + .transactions + .get(&spend_tx.txid()) + .expect("Spend transaction should be recorded"); + assert_eq!(record.net_amount, -(funding_value as i64)); + } + /// Test mempool context for timestamp/height handling #[test] fn test_wallet_checker_mempool_context() {