From 65d596a6ea13afd4e33b191849fe56581e217a79 Mon Sep 17 00:00:00 2001 From: Matt Hess Date: Thu, 26 Feb 2026 02:23:12 +0000 Subject: [PATCH] Add sync cancellation via AtomicBool flag for FFI consumers Allows Android/FFI callers to interrupt an in-progress wallet sync cleanly between batches. Adds salvium_wallet_stop_sync FFI export, WalletError::Cancelled, SyncEvent::Cancelled, and WalletHandle wrapper that pairs each wallet with its cancellation flag. --- crates/salvium-cli/src/commands/daemon.rs | 6 +- crates/salvium-crypto/src/lib.rs | 2 + crates/salvium-crypto/src/wasm_ffi.rs | 76 +++ crates/salvium-ffi/src/wallet.rs | 121 +++-- crates/salvium-ffi/tests/wallet_sync.rs | 8 +- crates/salvium-sync-bench/src/main.rs | 6 +- crates/salvium-wallet/src/error.rs | 3 + crates/salvium-wallet/src/sync.rs | 10 + crates/salvium-wallet/src/wallet.rs | 2 + crates/salvium-wallet/tests/full_testnet.rs | 3 +- crates/salvium-wallet/tests/testnet_burn.rs | 10 +- .../salvium-wallet/tests/testnet_convert.rs | 10 +- crates/salvium-wallet/tests/testnet_stake.rs | 15 +- .../tests/testnet_subaddress.rs | 5 +- crates/salvium-wallet/tests/testnet_sync.rs | 23 +- .../salvium-wallet/tests/testnet_transfer.rs | 3 +- docs/explorer-spec.md | 477 ++++++++++++++++++ docs/wallet-sync-spec.md | 336 ++++++++++++ scripts/build-wasm.sh | 10 + 19 files changed, 1075 insertions(+), 51 deletions(-) create mode 100644 crates/salvium-crypto/src/wasm_ffi.rs create mode 100644 docs/explorer-spec.md create mode 100644 docs/wallet-sync-spec.md diff --git a/crates/salvium-cli/src/commands/daemon.rs b/crates/salvium-cli/src/commands/daemon.rs index fcb743a..25a8b47 100644 --- a/crates/salvium-cli/src/commands/daemon.rs +++ b/crates/salvium-cli/src/commands/daemon.rs @@ -203,11 +203,15 @@ pub async fn sync_wallet(ctx: &AppContext) -> Result { error ); } + SyncEvent::Cancelled { height } => { + println!("Sync cancelled at height {}", height); + } } } }); - let _final_height = wallet.sync(&daemon, Some(&tx)).await?; + let no_cancel = std::sync::atomic::AtomicBool::new(false); + let _final_height = wallet.sync(&daemon, Some(&tx), &no_cancel).await?; drop(tx); let _ = progress_task.await; diff --git a/crates/salvium-crypto/src/lib.rs b/crates/salvium-crypto/src/lib.rs index eb91e48..34cebed 100644 --- a/crates/salvium-crypto/src/lib.rs +++ b/crates/salvium-crypto/src/lib.rs @@ -29,6 +29,8 @@ pub mod storage; #[cfg(not(target_arch = "wasm32"))] mod ffi; +mod wasm_ffi; + /// Keccak-256 hash (CryptoNote variant with 0x01 padding, NOT SHA3) /// Matches Salvium C++ cn_fast_hash / keccak() #[cfg_attr(feature = "wasm-exports", wasm_bindgen)] diff --git a/crates/salvium-crypto/src/wasm_ffi.rs b/crates/salvium-crypto/src/wasm_ffi.rs new file mode 100644 index 0000000..c6382a7 --- /dev/null +++ b/crates/salvium-crypto/src/wasm_ffi.rs @@ -0,0 +1,76 @@ +//! C ABI wrappers for WASM static library consumption. +//! +//! These functions use the caller-provided buffer pattern: the caller passes +//! `out_buf` / `out_len`, and the function writes a UTF-8 result string into +//! the buffer and returns the number of bytes written (0 on error or null +//! pointers). +//! +//! Intended consumer: Cloudflare Worker linking `libsalvium_crypto.a` directly +//! (no wasm-bindgen / JS glue). + +use core::slice; + +/// Helper: write `src` into the caller's buffer, truncating if necessary. +/// Returns bytes actually written, or 0 if pointers are null. +unsafe fn write_to_buf(src: &[u8], out_buf: *mut u8, out_len: usize) -> usize { + if out_buf.is_null() || out_len == 0 { + return 0; + } + let n = src.len().min(out_len); + unsafe { + core::ptr::copy_nonoverlapping(src.as_ptr(), out_buf, n); + } + n +} + +/// Parse a complete transaction from raw bytes to JSON. +/// +/// Writes the JSON string into `out_buf` (up to `out_len` bytes) and returns +/// the number of bytes written. Returns 0 on error or null pointers. +#[no_mangle] +pub unsafe extern "C" fn salvium_parse_transaction_bytes( + data: *const u8, + data_len: usize, + out_buf: *mut u8, + out_len: usize, +) -> usize { + if data.is_null() || data_len == 0 { + return 0; + } + let input = unsafe { slice::from_raw_parts(data, data_len) }; + let json = crate::parse_transaction_bytes(input); + unsafe { write_to_buf(json.as_bytes(), out_buf, out_len) } +} + +/// Parse a complete block from raw bytes to JSON. +/// +/// Writes the JSON string into `out_buf` (up to `out_len` bytes) and returns +/// the number of bytes written. Returns 0 on error or null pointers. +#[no_mangle] +pub unsafe extern "C" fn salvium_parse_block_bytes( + data: *const u8, + data_len: usize, + out_buf: *mut u8, + out_len: usize, +) -> usize { + if data.is_null() || data_len == 0 { + return 0; + } + let input = unsafe { slice::from_raw_parts(data, data_len) }; + let json = crate::parse_block_bytes(input); + unsafe { write_to_buf(json.as_bytes(), out_buf, out_len) } +} + +/// Get the human-readable name for a transaction type code. +/// +/// Writes the name string into `out_buf` (up to `out_len` bytes) and returns +/// the number of bytes written. Returns 0 on null pointer. +#[no_mangle] +pub unsafe extern "C" fn salvium_tx_type_name( + tx_type: u32, + out_buf: *mut u8, + out_len: usize, +) -> usize { + let name = crate::wasm_tx_type_name(tx_type as u8); + unsafe { write_to_buf(name.as_bytes(), out_buf, out_len) } +} diff --git a/crates/salvium-ffi/src/wallet.rs b/crates/salvium-ffi/src/wallet.rs index 4b9d7b5..01e50e7 100644 --- a/crates/salvium-ffi/src/wallet.rs +++ b/crates/salvium-ffi/src/wallet.rs @@ -1,6 +1,8 @@ //! Wallet lifecycle, key/address queries, balance, sync, and data operations. use std::ffi::{c_char, c_void}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use crate::error::{ffi_try, ffi_try_ptr, ffi_try_string}; use crate::handles::{borrow_handle, borrow_handle_mut, drop_handle}; @@ -8,6 +10,21 @@ use crate::strings::c_str_to_str; use salvium_wallet::Wallet; +/// Wrapper that pairs a Wallet with its cancellation flag. +pub(crate) struct WalletHandle { + pub wallet: Wallet, + pub sync_cancel: Arc, +} + +impl WalletHandle { + fn new(wallet: Wallet) -> Self { + Self { + wallet, + sync_cancel: Arc::new(AtomicBool::new(false)), + } + } +} + // ============================================================================= // Wallet Lifecycle // ============================================================================= @@ -36,7 +53,9 @@ pub unsafe extern "C" fn salvium_wallet_create( let network = int_to_network(network)?; let path = unsafe { c_str_to_str(db_path) }?; let key = unsafe { crate::strings::c_buf_to_slice(db_key, db_key_len) }?; - Wallet::create(seed_arr, network, path, key).map_err(|e| e.to_string()) + Wallet::create(seed_arr, network, path, key) + .map(WalletHandle::new) + .map_err(|e| e.to_string()) }) } @@ -56,7 +75,9 @@ pub unsafe extern "C" fn salvium_wallet_from_mnemonic( let network = int_to_network(network)?; let path = unsafe { c_str_to_str(db_path) }?; let key = unsafe { crate::strings::c_buf_to_slice(db_key, db_key_len) }?; - Wallet::from_mnemonic(words, network, path, key).map_err(|e| e.to_string()) + Wallet::from_mnemonic(words, network, path, key) + .map(WalletHandle::new) + .map_err(|e| e.to_string()) }) } @@ -79,14 +100,16 @@ pub unsafe extern "C" fn salvium_wallet_open( let key = unsafe { crate::strings::c_buf_to_slice(db_key, db_key_len) }?; let keys = wallet_keys_from_json(json_str)?; - Wallet::open(keys, path, key).map_err(|e| e.to_string()) + Wallet::open(keys, path, key) + .map(WalletHandle::new) + .map_err(|e| e.to_string()) }) } /// Close a wallet handle, releasing all resources. #[no_mangle] pub unsafe extern "C" fn salvium_wallet_close(handle: *mut c_void) { - drop_handle::(handle); + drop_handle::(handle); } // ============================================================================= @@ -104,7 +127,7 @@ pub unsafe extern "C" fn salvium_wallet_get_address( addr_type: i32, ) -> *mut c_char { ffi_try_string(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; match addr_type { 0 => wallet.cn_address().map_err(|e| e.to_string()), 1 => wallet.carrot_address().map_err(|e| e.to_string()), @@ -122,7 +145,7 @@ pub unsafe extern "C" fn salvium_wallet_get_address( #[no_mangle] pub unsafe extern "C" fn salvium_wallet_get_mnemonic(handle: *mut c_void) -> *mut c_char { ffi_try_string(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; match wallet.mnemonic() { Some(Ok(words)) => Ok(words), Some(Err(e)) => Err(e.to_string()), @@ -138,7 +161,7 @@ pub unsafe extern "C" fn salvium_wallet_get_mnemonic(handle: *mut c_void) -> *mu #[no_mangle] pub unsafe extern "C" fn salvium_wallet_get_keys_json(handle: *mut c_void) -> *mut c_char { ffi_try_string(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let keys = wallet.keys(); let json = serde_json::json!({ "wallet_type": format!("{:?}", keys.wallet_type), @@ -159,7 +182,9 @@ pub unsafe extern "C" fn salvium_wallet_get_keys_json(handle: *mut c_void) -> *m #[no_mangle] pub unsafe extern "C" fn salvium_wallet_can_spend(handle: *mut c_void) -> i32 { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let wallet = unsafe { borrow_handle::(handle) }.ok()?; + let wallet = &unsafe { borrow_handle::(handle) } + .ok()? + .wallet; Some(wallet.can_spend()) })); match result { @@ -175,7 +200,9 @@ pub unsafe extern "C" fn salvium_wallet_can_spend(handle: *mut c_void) -> i32 { #[no_mangle] pub unsafe extern "C" fn salvium_wallet_network(handle: *mut c_void) -> i32 { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let wallet = unsafe { borrow_handle::(handle) }.ok()?; + let wallet = &unsafe { borrow_handle::(handle) } + .ok()? + .wallet; Some(wallet.network()) })); match result { @@ -192,7 +219,9 @@ pub unsafe extern "C" fn salvium_wallet_network(handle: *mut c_void) -> i32 { #[no_mangle] pub unsafe extern "C" fn salvium_wallet_sync_height(handle: *mut c_void) -> u64 { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let wallet = unsafe { borrow_handle::(handle) }.ok()?; + let wallet = &unsafe { borrow_handle::(handle) } + .ok()? + .wallet; wallet.sync_height().ok() })); match result { @@ -216,7 +245,7 @@ pub unsafe extern "C" fn salvium_wallet_get_balance( account_index: i32, ) -> *mut c_char { ffi_try_string(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let asset = unsafe { c_str_to_str(asset_type) }?; let result = wallet .get_balance(asset, account_index) @@ -235,7 +264,7 @@ pub unsafe extern "C" fn salvium_wallet_get_all_balances( account_index: i32, ) -> *mut c_char { ffi_try_string(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let result = wallet .get_all_balances(account_index) .map_err(|e| e.to_string())?; @@ -255,7 +284,7 @@ pub unsafe extern "C" fn salvium_wallet_set_attribute( value: *const c_char, ) -> i32 { ffi_try(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let k = unsafe { c_str_to_str(key) }?; let v = unsafe { c_str_to_str(value) }?; wallet.set_attribute(k, v).map_err(|e| e.to_string()) @@ -272,7 +301,7 @@ pub unsafe extern "C" fn salvium_wallet_get_attribute( key: *const c_char, ) -> *mut c_char { ffi_try_string(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let k = unsafe { c_str_to_str(key) }?; match wallet.get_attribute(k).map_err(|e| e.to_string())? { Some(v) => Ok(v), @@ -285,7 +314,7 @@ pub unsafe extern "C" fn salvium_wallet_get_attribute( #[no_mangle] pub unsafe extern "C" fn salvium_wallet_reset_sync_height(handle: *mut c_void, height: u64) -> i32 { ffi_try(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; wallet.reset_sync_height(height).map_err(|e| e.to_string()) }) } @@ -296,7 +325,7 @@ pub unsafe extern "C" fn salvium_wallet_reset_sync_height(handle: *mut c_void, h /// Sync callback function pointer type. /// -/// - `event_type`: 0=started, 1=progress, 2=complete, 3=reorg, 4=error +/// - `event_type`: 0=started, 1=progress, 2=complete, 3=reorg, 4=error, 5=parse_error, 6=cancelled /// - `current_height`: current scan height /// - `target_height`: target chain height /// - `outputs_found`: number of outputs found so far @@ -324,10 +353,13 @@ pub unsafe extern "C" fn salvium_wallet_sync( callback: Option, ) -> i32 { ffi_try(|| { - let wallet_ref = unsafe { borrow_handle_mut::(wallet) }?; + let handle = unsafe { borrow_handle_mut::(wallet) }?; let daemon_ref = unsafe { borrow_handle::(daemon) }?; let rt = crate::runtime(); + // Reset cancel flag before starting. + handle.sync_cancel.store(false, Ordering::Relaxed); + rt.block_on(async { if let Some(cb) = callback { let (tx, mut rx) = tokio::sync::mpsc::channel::(256); @@ -339,14 +371,18 @@ pub unsafe extern "C" fn salvium_wallet_sync( } }); - let result = wallet_ref.sync(daemon_ref, Some(&tx)).await; + let result = handle + .wallet + .sync(daemon_ref, Some(&tx), &handle.sync_cancel) + .await; drop(tx); // Close channel so forwarder exits. let _ = forwarder.await; result.map(|_| ()).map_err(|e| e.to_string()) } else { - wallet_ref - .sync(daemon_ref, None) + handle + .wallet + .sync(daemon_ref, None, &handle.sync_cancel) .await .map(|_| ()) .map_err(|e| e.to_string()) @@ -355,6 +391,22 @@ pub unsafe extern "C" fn salvium_wallet_sync( }) } +/// Cancel an in-progress sync. +/// +/// Sets the cancellation flag. The sync loop will stop before the next +/// batch and return `WalletError::Cancelled`. Safe to call from any thread. +/// No-op if no sync is running. +/// +/// Returns 0 on success, -1 on error. +#[no_mangle] +pub unsafe extern "C" fn salvium_wallet_stop_sync(wallet: *mut c_void) -> i32 { + ffi_try(|| { + let handle = unsafe { borrow_handle::(wallet) }?; + handle.sync_cancel.store(true, Ordering::Relaxed); + Ok(()) + }) +} + /// Dispatch a SyncEvent to the C callback. fn dispatch_sync_event(event: &salvium_wallet::SyncEvent, cb: SyncCallbackFn) { use salvium_wallet::SyncEvent; @@ -412,6 +464,9 @@ fn dispatch_sync_event(event: &salvium_wallet::SyncEvent, cb: SyncCallbackFn) { } } } + SyncEvent::Cancelled { height } => unsafe { + cb(6, *height, 0, 0, std::ptr::null()); + }, } } @@ -433,7 +488,7 @@ pub unsafe extern "C" fn salvium_wallet_get_transfers( filters_json: *const c_char, ) -> *mut c_char { ffi_try_string(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let json_str = unsafe { c_str_to_str(filters_json) }?; let query: salvium_wallet::TxQuery = serde_json::from_str(json_str).map_err(|e| format!("invalid query JSON: {e}"))?; @@ -456,7 +511,7 @@ pub unsafe extern "C" fn salvium_wallet_get_outputs( query_json: *const c_char, ) -> *mut c_char { ffi_try_string(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let json_str = unsafe { c_str_to_str(query_json) }?; let query: salvium_wallet::OutputQuery = serde_json::from_str(json_str).map_err(|e| format!("invalid query JSON: {e}"))?; @@ -476,7 +531,7 @@ pub unsafe extern "C" fn salvium_wallet_get_stakes( status: *const c_char, ) -> *mut c_char { ffi_try_string(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let status_opt = if status.is_null() { None } else { @@ -497,7 +552,7 @@ pub unsafe extern "C" fn salvium_wallet_get_stakes( #[no_mangle] pub unsafe extern "C" fn salvium_wallet_address_book_list(handle: *mut c_void) -> *mut c_char { ffi_try_string(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let entries = wallet.get_address_book().map_err(|e| e.to_string())?; serde_json::to_string(&entries).map_err(|e| e.to_string()) }) @@ -514,7 +569,7 @@ pub unsafe extern "C" fn salvium_wallet_address_book_add( description: *const c_char, ) -> i64 { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let addr = unsafe { c_str_to_str(address) }?; let lbl = unsafe { c_str_to_str(label) }?; let desc = unsafe { c_str_to_str(description) }?; @@ -544,7 +599,7 @@ pub unsafe extern "C" fn salvium_wallet_address_book_delete( row_id: i64, ) -> i32 { ffi_try(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; wallet .delete_address_book_entry(row_id) .map(|_| ()) @@ -564,7 +619,7 @@ pub unsafe extern "C" fn salvium_wallet_set_tx_note( note: *const c_char, ) -> i32 { ffi_try(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let hash = unsafe { c_str_to_str(tx_hash) }?; let n = unsafe { c_str_to_str(note) }?; wallet.set_tx_note(hash, n).map_err(|e| e.to_string()) @@ -582,7 +637,7 @@ pub unsafe extern "C" fn salvium_wallet_get_tx_notes( tx_hashes_json: *const c_char, ) -> *mut c_char { ffi_try_string(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let json_str = unsafe { c_str_to_str(tx_hashes_json) }?; let hashes: Vec = serde_json::from_str(json_str).map_err(|e| format!("invalid JSON array: {e}"))?; @@ -603,7 +658,7 @@ pub unsafe extern "C" fn salvium_wallet_freeze_output( key_image: *const c_char, ) -> i32 { ffi_try(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let ki = unsafe { c_str_to_str(key_image) }?; wallet.freeze_output(ki).map_err(|e| e.to_string()) }) @@ -616,7 +671,7 @@ pub unsafe extern "C" fn salvium_wallet_thaw_output( key_image: *const c_char, ) -> i32 { ffi_try(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let ki = unsafe { c_str_to_str(key_image) }?; wallet.thaw_output(ki).map_err(|e| e.to_string()) }) @@ -641,7 +696,7 @@ pub unsafe extern "C" fn salvium_wallet_export_blob( pin: *const c_char, ) -> *mut c_char { ffi_try_string(|| { - let wallet = unsafe { borrow_handle::(handle) }?; + let wallet = &unsafe { borrow_handle::(handle) }?.wallet; let pin_str = unsafe { c_str_to_str(pin) }?; let keys = wallet.keys(); @@ -714,7 +769,9 @@ pub unsafe extern "C" fn salvium_wallet_import_blob( return Err("blob contains neither seed nor keys".into()); }; - Wallet::open(keys, path, &data_key).map_err(|e| e.to_string()) + Wallet::open(keys, path, &data_key) + .map(WalletHandle::new) + .map_err(|e| e.to_string()) }) } diff --git a/crates/salvium-ffi/tests/wallet_sync.rs b/crates/salvium-ffi/tests/wallet_sync.rs index 697eb71..fdb9837 100644 --- a/crates/salvium-ffi/tests/wallet_sync.rs +++ b/crates/salvium-ffi/tests/wallet_sync.rs @@ -274,12 +274,16 @@ async fn wallet_sync_and_balance_check() { height, blob_len, error ); } + SyncEvent::Cancelled { height } => { + println!(" ** Sync cancelled at height {} **", height); + } } } }); + let no_cancel = std::sync::atomic::AtomicBool::new(false); let sync_height = wallet - .sync(&daemon, Some(&event_tx)) + .sync(&daemon, Some(&event_tx), &no_cancel) .await .expect("wallet sync failed"); @@ -442,7 +446,7 @@ async fn wallet_sync_and_balance_check() { // ── Sync idempotency check ────────────────────────────────────────── println!("\n Verifying sync idempotency..."); let height2 = wallet - .sync(&daemon, None) + .sync(&daemon, None, &no_cancel) .await .expect("second sync failed"); let all_balances_2 = wallet.get_all_balances(0).unwrap(); diff --git a/crates/salvium-sync-bench/src/main.rs b/crates/salvium-sync-bench/src/main.rs index c41e9d5..ce1906a 100644 --- a/crates/salvium-sync-bench/src/main.rs +++ b/crates/salvium-sync-bench/src/main.rs @@ -317,13 +317,17 @@ async fn run() -> Result<(), Box> { ); } SyncEvent::Started { .. } => {} + SyncEvent::Cancelled { height } => { + println!(" ** Sync cancelled at height {} **", height); + } } } (final_parse_errors, final_empty_blobs) }); - let sync_result = wallet.sync(&daemon, Some(&tx)).await; + let no_cancel = std::sync::atomic::AtomicBool::new(false); + let sync_result = wallet.sync(&daemon, Some(&tx), &no_cancel).await; drop(tx); // close channel so progress task finishes let (final_parse_errors, final_empty_blobs) = progress_handle.await.unwrap_or((0, 0)); diff --git a/crates/salvium-wallet/src/error.rs b/crates/salvium-wallet/src/error.rs index 5be4c4b..9f0aed5 100644 --- a/crates/salvium-wallet/src/error.rs +++ b/crates/salvium-wallet/src/error.rs @@ -52,6 +52,9 @@ pub enum WalletError { #[error("hardware device error: {0}")] Device(String), + #[error("sync cancelled by caller")] + Cancelled, + #[error("{0}")] Other(String), } diff --git a/crates/salvium-wallet/src/sync.rs b/crates/salvium-wallet/src/sync.rs index d7f9e50..1283f40 100644 --- a/crates/salvium-wallet/src/sync.rs +++ b/crates/salvium-wallet/src/sync.rs @@ -37,6 +37,8 @@ pub enum SyncEvent { blob_len: usize, error: String, }, + /// Sync cancelled by caller. + Cancelled { height: u64 }, } /// Blockchain sync engine. @@ -119,6 +121,7 @@ impl SyncEngine { scan_ctx: &mut ScanContext, stake_lock_period: u64, event_tx: Option<&tokio::sync::mpsc::Sender>, + cancel: &std::sync::atomic::AtomicBool, ) -> Result { let daemon_height = daemon .get_height() @@ -167,6 +170,13 @@ impl SyncEngine { let mut controller = BatchController::new(); while current < top_block { + if cancel.load(std::sync::atomic::Ordering::Relaxed) { + if let Some(tx) = event_tx { + let _ = tx.send(SyncEvent::Cancelled { height: current }).await; + } + return Err(WalletError::Cancelled); + } + let remaining = top_block - current; let batch_size = controller.next_batch_size(remaining); let batch_start = current + 1; diff --git a/crates/salvium-wallet/src/wallet.rs b/crates/salvium-wallet/src/wallet.rs index c79e944..c16c934 100644 --- a/crates/salvium-wallet/src/wallet.rs +++ b/crates/salvium-wallet/src/wallet.rs @@ -225,6 +225,7 @@ impl Wallet { &mut self, daemon: &salvium_rpc::DaemonRpc, event_tx: Option<&tokio::sync::mpsc::Sender>, + cancel: &std::sync::atomic::AtomicBool, ) -> Result { let lock_period = salvium_types::constants::network_config(self.network()).stake_lock_period; @@ -234,6 +235,7 @@ impl Wallet { &mut self.scan_context, lock_period, event_tx, + cancel, ) .await } diff --git a/crates/salvium-wallet/tests/full_testnet.rs b/crates/salvium-wallet/tests/full_testnet.rs index c481e42..e4bfda6 100644 --- a/crates/salvium-wallet/tests/full_testnet.rs +++ b/crates/salvium-wallet/tests/full_testnet.rs @@ -1145,8 +1145,9 @@ async fn sync_wallet_checked(wallet: &mut Wallet, daemon: &DaemonRpc, label: &st }); let t0 = Instant::now(); + let no_cancel = std::sync::atomic::AtomicBool::new(false); let sync_height = wallet - .sync(daemon, Some(&event_tx)) + .sync(daemon, Some(&event_tx), &no_cancel) .await .expect("sync failed"); drop(event_tx); diff --git a/crates/salvium-wallet/tests/testnet_burn.rs b/crates/salvium-wallet/tests/testnet_burn.rs index 6d5d4cd..6d87f81 100644 --- a/crates/salvium-wallet/tests/testnet_burn.rs +++ b/crates/salvium-wallet/tests/testnet_burn.rs @@ -74,7 +74,10 @@ async fn test_burn_transaction_build() { .expect("create wallet"); let d = daemon(); - let sync_height = wallet.sync(&d, None).await.expect("sync failed"); + let sync_height = wallet + .sync(&d, None, &std::sync::atomic::AtomicBool::new(false)) + .await + .expect("sync failed"); println!("Synced to height {}", sync_height); let hf_info = d.hard_fork_info().await.unwrap(); @@ -138,7 +141,10 @@ async fn test_burn_submit_testnet() { .expect("create wallet"); let d = daemon(); - let sync_height = wallet.sync(&d, None).await.expect("sync failed"); + let sync_height = wallet + .sync(&d, None, &std::sync::atomic::AtomicBool::new(false)) + .await + .expect("sync failed"); println!("Synced to height {}", sync_height); let hf_info = d.hard_fork_info().await.unwrap(); diff --git a/crates/salvium-wallet/tests/testnet_convert.rs b/crates/salvium-wallet/tests/testnet_convert.rs index 1ac3202..4915e90 100644 --- a/crates/salvium-wallet/tests/testnet_convert.rs +++ b/crates/salvium-wallet/tests/testnet_convert.rs @@ -74,7 +74,10 @@ async fn test_convert_transaction_build() { .expect("create wallet"); let d = daemon(); - let sync_height = wallet.sync(&d, None).await.expect("sync failed"); + let sync_height = wallet + .sync(&d, None, &std::sync::atomic::AtomicBool::new(false)) + .await + .expect("sync failed"); println!("Synced to height {}", sync_height); let hf_info = d.hard_fork_info().await.unwrap(); @@ -136,7 +139,10 @@ async fn test_convert_expected_rejection() { .expect("create wallet"); let d = daemon(); - let sync_height = wallet.sync(&d, None).await.expect("sync failed"); + let sync_height = wallet + .sync(&d, None, &std::sync::atomic::AtomicBool::new(false)) + .await + .expect("sync failed"); println!("Synced to height {}", sync_height); let hf_info = d.hard_fork_info().await.unwrap(); diff --git a/crates/salvium-wallet/tests/testnet_stake.rs b/crates/salvium-wallet/tests/testnet_stake.rs index 428d916..b3db9a5 100644 --- a/crates/salvium-wallet/tests/testnet_stake.rs +++ b/crates/salvium-wallet/tests/testnet_stake.rs @@ -76,7 +76,10 @@ async fn test_stake_transaction_build() { // Sync let d = daemon(); - let sync_height = wallet.sync(&d, None).await.expect("sync failed"); + let sync_height = wallet + .sync(&d, None, &std::sync::atomic::AtomicBool::new(false)) + .await + .expect("sync failed"); println!("Synced to height {}", sync_height); // Get hardfork info for asset type @@ -190,7 +193,10 @@ async fn test_stake_submit_testnet() { .expect("create wallet"); let d = daemon(); - let sync_height = wallet.sync(&d, None).await.expect("sync failed"); + let sync_height = wallet + .sync(&d, None, &std::sync::atomic::AtomicBool::new(false)) + .await + .expect("sync failed"); println!("Synced to height {}", sync_height); let hf_info = d.hard_fork_info().await.unwrap(); @@ -467,7 +473,10 @@ async fn test_stake_return_detection() { .expect("create wallet"); let d = daemon(); - let sync_height = wallet.sync(&d, None).await.expect("sync failed"); + let sync_height = wallet + .sync(&d, None, &std::sync::atomic::AtomicBool::new(false)) + .await + .expect("sync failed"); println!("Synced to height {}", sync_height); // Look for protocol TX outputs (stake returns). diff --git a/crates/salvium-wallet/tests/testnet_subaddress.rs b/crates/salvium-wallet/tests/testnet_subaddress.rs index 98ec269..a46e0cf 100644 --- a/crates/salvium-wallet/tests/testnet_subaddress.rs +++ b/crates/salvium-wallet/tests/testnet_subaddress.rs @@ -336,7 +336,10 @@ async fn test_self_transfer_to_subaddress() { .expect("create wallet"); let d = daemon(); - let sync_height = wallet.sync(&d, None).await.expect("sync failed"); + let sync_height = wallet + .sync(&d, None, &std::sync::atomic::AtomicBool::new(false)) + .await + .expect("sync failed"); println!("Synced to height {}", sync_height); let hf_info = d.hard_fork_info().await.unwrap(); diff --git a/crates/salvium-wallet/tests/testnet_sync.rs b/crates/salvium-wallet/tests/testnet_sync.rs index d763cdc..cadfbfc 100644 --- a/crates/salvium-wallet/tests/testnet_sync.rs +++ b/crates/salvium-wallet/tests/testnet_sync.rs @@ -57,7 +57,10 @@ async fn test_full_sync_balance() { println!("Daemon height: {}", info.height); println!("Network: testnet"); - let sync_height = wallet.sync(&d, None).await.expect("sync failed"); + let sync_height = wallet + .sync(&d, None, &std::sync::atomic::AtomicBool::new(false)) + .await + .expect("sync failed"); println!("Synced to height: {}", sync_height); // Sync height should be close to daemon height @@ -140,7 +143,10 @@ async fn test_view_only_sync() { .expect("open view-only wallet"); let d = daemon(); - let sync_height = wallet.sync(&d, None).await.expect("view-only sync failed"); + let sync_height = wallet + .sync(&d, None, &std::sync::atomic::AtomicBool::new(false)) + .await + .expect("view-only sync failed"); println!("View-only synced to height: {}", sync_height); // View-only wallet should detect CN outputs @@ -204,8 +210,9 @@ async fn test_carrot_view_only_sync() { .expect("open CARROT view-only wallet"); let d = daemon(); + let no_cancel = std::sync::atomic::AtomicBool::new(false); let sync_height = wallet - .sync(&d, None) + .sync(&d, None, &no_cancel) .await .expect("CARROT view-only sync failed"); println!("CARROT view-only synced to height: {}", sync_height); @@ -259,7 +266,10 @@ async fn test_sync_idempotent() { let d = daemon(); // First sync - let height_1 = wallet.sync(&d, None).await.expect("first sync failed"); + let height_1 = wallet + .sync(&d, None, &std::sync::atomic::AtomicBool::new(false)) + .await + .expect("first sync failed"); let bal_1 = wallet.get_balance("SAL", 0).unwrap(); let total_1: u64 = bal_1.balance.parse().unwrap_or(0); let unlocked_1: u64 = bal_1.unlocked_balance.parse().unwrap_or(0); @@ -271,7 +281,10 @@ async fn test_sync_idempotent() { ); // Second sync (should be a no-op or near-instant) - let height_2 = wallet.sync(&d, None).await.expect("second sync failed"); + let height_2 = wallet + .sync(&d, None, &std::sync::atomic::AtomicBool::new(false)) + .await + .expect("second sync failed"); let bal_2 = wallet.get_balance("SAL", 0).unwrap(); let total_2: u64 = bal_2.balance.parse().unwrap_or(0); let unlocked_2: u64 = bal_2.unlocked_balance.parse().unwrap_or(0); diff --git a/crates/salvium-wallet/tests/testnet_transfer.rs b/crates/salvium-wallet/tests/testnet_transfer.rs index dd8fa16..767bf5a 100644 --- a/crates/salvium-wallet/tests/testnet_transfer.rs +++ b/crates/salvium-wallet/tests/testnet_transfer.rs @@ -135,8 +135,9 @@ async fn test_real_testnet_transfer() { } }); + let no_cancel = std::sync::atomic::AtomicBool::new(false); let sync_height = wallet - .sync(&daemon, Some(&event_tx)) + .sync(&daemon, Some(&event_tx), &no_cancel) .await .expect("sync failed"); drop(event_tx); diff --git a/docs/explorer-spec.md b/docs/explorer-spec.md new file mode 100644 index 0000000..f40d217 --- /dev/null +++ b/docs/explorer-spec.md @@ -0,0 +1,477 @@ +# Explorer WASM Integration Spec + +How to use the `salvium-explorer` WASM module in a Cloudflare Worker or browser to parse blocks, analyze transactions, and scan outputs. + +## 1. Build + +```bash +# Build the wasm-bindgen package (JS glue + .wasm binary) +wasm-pack build crates/salvium-explorer --release --target bundler --out-dir pkg + +# Output: +# crates/salvium-explorer/pkg/salvium_explorer.js (JS glue) +# crates/salvium-explorer/pkg/salvium_explorer_bg.wasm (WASM binary) +# crates/salvium-explorer/pkg/salvium_explorer.d.ts (TypeScript types) +# crates/salvium-explorer/pkg/package.json +``` + +Or use the build script which also produces the C ABI static library: + +```bash +scripts/build-wasm.sh +# Output in prebuilt/wasm/ +``` + +## 2. Initialize the Module + +### Cloudflare Worker (bundler target) + +```typescript +import init, { initSync } from 'salvium-explorer'; +import wasmModule from 'salvium-explorer/salvium_explorer_bg.wasm'; + +// Synchronous init (preferred for Workers) +initSync({ module: wasmModule }); + +// Or async init +await init({ module_or_path: wasmModule }); +``` + +### Browser + +```typescript +import init from 'salvium-explorer'; + +await init(); // Fetches .wasm from same directory +``` + +After initialization, all exported functions are available as direct imports. + +## 3. Explorer-Specific APIs + +These are the 3 high-level functions unique to the explorer crate. They combine multiple salvium-crypto primitives into single calls. + +### 3a. `parse_and_analyze_tx` + +Parse a raw transaction binary and return enriched JSON. + +```typescript +import { parse_and_analyze_tx } from 'salvium-explorer'; + +const result: string = parse_and_analyze_tx(txBytes: Uint8Array); +const tx = JSON.parse(result); +``` + +**Returns** all fields from `parse_transaction_bytes()` plus these analysis fields: + +| Field | Type | Description | +|-------|------|-------------| +| `tx_type_name` | string | Human-readable TX type (e.g. `"Transfer"`, `"Miner"`, `"Protocol"`) | +| `rct_type_name` | string | Human-readable RCT type (e.g. `"RctBulletproofPlus"`) | +| `input_count` | number | Number of inputs | +| `output_count` | number | Number of outputs | +| `is_coinbase` | boolean | Whether this is a miner/coinbase TX | +| `is_carrot` | boolean | Whether any output uses CARROT format (type `0x04`) | +| `key_images` | string[] | Key image hex strings from inputs | +| `output_keys` | string[] | Output public key hex strings | +| `fee` | string | Transaction fee in atomic units (decimal string) | + +**Error:** Returns `{"error": "..."}` on parse failure. + +**Example response (trimmed):** +```json +{ + "prefix": { + "version": 3, + "txType": 3, + "unlockTime": "0", + "vin": [...], + "vout": [...], + "extra": { "pubkey": "ab12...", ... } + }, + "rct": { + "type": 6, + "txnFee": "24960000", + ... + }, + "tx_type_name": "Transfer", + "rct_type_name": "RctBulletproofPlus", + "input_count": 2, + "output_count": 2, + "is_coinbase": false, + "is_carrot": true, + "key_images": ["aabb...", "ccdd..."], + "output_keys": ["eeff...", "1122..."], + "fee": "24960000" +} +``` + +### 3b. `parse_and_analyze_block` + +Parse a raw block binary and return enriched JSON. + +```typescript +import { parse_and_analyze_block } from 'salvium-explorer'; + +const result: string = parse_and_analyze_block(blockBytes: Uint8Array); +const block = JSON.parse(result); +``` + +**Returns** all fields from `parse_block_bytes()` plus: + +| Field | Type | Description | +|-------|------|-------------| +| `tx_count` | number | Number of transaction hashes (excluding miner tx) | + +**Base block fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `majorVersion` | number | Block major version | +| `minorVersion` | number | Block minor version | +| `timestamp` | number | Block timestamp (Unix) | +| `prevId` | string | Previous block hash (hex) | +| `nonce` | number | Mining nonce | +| `minerTx` | object | Miner transaction (full parsed TX) | +| `txHashes` | string[] | Transaction hashes in this block (hex) | + +### 3c. `decode_outputs_for_view_key` + +Scan a transaction for owned outputs using a view key pair. + +```typescript +import { decode_outputs_for_view_key } from 'salvium-explorer'; + +const result: string = decode_outputs_for_view_key( + txBytes: Uint8Array, // raw transaction binary + viewSecret: Uint8Array, // 32-byte view secret key + spendPub: Uint8Array, // 32-byte spend public key +); +const outputs = JSON.parse(result); +``` + +**Parameters:** +- `txBytes`: Raw transaction blob (binary, NOT hex) +- `viewSecret`: 32-byte view secret key (the private view key) +- `spendPub`: 32-byte spend public key (the public spend key) + +**Returns** a JSON array of owned outputs: + +```json +[ + { + "output_index": 0, + "amount": "1000000000", + "output_key": "aabbccdd...", + "subaddress_major": 0, + "subaddress_minor": 0 + } +] +``` + +| Field | Type | Description | +|-------|------|-------------| +| `output_index` | number | Index within the transaction's outputs | +| `amount` | string | Amount in atomic units (1 SAL = 100,000,000) | +| `output_key` | string | Output public key (hex) | +| `subaddress_major` | number | Subaddress major index (0 = main) | +| `subaddress_minor` | number | Subaddress minor index (0 = main) | + +Returns `[]` if no outputs match. Returns `{"error": "..."}` on failure. + +**How it works internally:** +1. Parses the transaction binary to extract `vout` and `extra.pubkey` +2. Computes key derivation: `D = 8 * view_secret * tx_pub_key` +3. For each output at index `i`: derives expected key `P' = H(D, i)*G + spend_pub` +4. Compares `P'` against the actual output key — match means the output is owned + +**Note:** This is a CryptoNote (legacy) scan only. CARROT output scanning requires the full CARROT key set — use the low-level CARROT helpers for that (see Section 5). + +## 4. Core Parsing Functions + +These do raw binary parsing without the analysis enrichment. + +### `parse_transaction_bytes` + +```typescript +import { parse_transaction_bytes } from 'salvium-explorer'; +const json: string = parse_transaction_bytes(data: Uint8Array); +``` + +Returns the full parsed transaction as JSON. All binary fields are hex-encoded, amounts are decimal strings. + +### `parse_block_bytes` + +```typescript +import { parse_block_bytes } from 'salvium-explorer'; +const json: string = parse_block_bytes(data: Uint8Array); +``` + +### `parse_extra` + +```typescript +import { parse_extra } from 'salvium-explorer'; +const json: string = parse_extra(extraBytes: Uint8Array); +``` + +Parses just the TX extra field. Returns JSON with: +- `pubkey`: TX public key (hex) +- `nonces`: payment IDs, extra nonces +- `additionalPubkeys`: additional TX public keys (for subaddresses) + +### `compute_tx_prefix_hash` + +```typescript +import { compute_tx_prefix_hash } from 'salvium-explorer'; +const hash: Uint8Array = compute_tx_prefix_hash(data: Uint8Array); +// Returns 32-byte keccak256 of the TX prefix +``` + +## 5. Crypto Primitives Available + +The explorer re-exports the full salvium-crypto function set. These are the ones most relevant for explorer use: + +### Hashing + +```typescript +keccak256(data: Uint8Array): Uint8Array // 32-byte CryptoNote hash +blake2b_hash(data: Uint8Array, outLen: number): Uint8Array +sha256(data: Uint8Array): Uint8Array +``` + +### Key Operations + +```typescript +// CryptoNote key derivation (used by decode_outputs_for_view_key internally) +generate_key_derivation(pubKey: Uint8Array, secKey: Uint8Array): Uint8Array +derive_public_key(derivation: Uint8Array, outputIndex: number, basePub: Uint8Array): Uint8Array + +// Key image +generate_key_image(pubKey: Uint8Array, secKey: Uint8Array): Uint8Array +is_valid_key_image(keyImage: Uint8Array): boolean + +// Point operations +scalar_mult_base(s: Uint8Array): Uint8Array // s*G +hash_to_point(data: Uint8Array): Uint8Array // H_p(data) +``` + +### Address Utilities + +```typescript +wasm_parse_address(address: string): string // Address → JSON +wasm_is_valid_address(address: string): boolean // Validate +wasm_describe_address(address: string): string // Human-readable description +wasm_create_address( // Create from components + network: number, // 0=mainnet, 1=testnet, 2=stagenet + format: number, // 0=CryptoNote, 1=CARROT + addrType: number, // 0=standard, 1=subaddress, 2=integrated + spendKey: Uint8Array, + viewKey: Uint8Array +): string +wasm_to_integrated_address(address: string, paymentId: Uint8Array): string +``` + +**`wasm_parse_address` returns:** +```json +{ + "network": "mainnet", + "format": "CryptoNote", + "address_type": "Standard", + "spend_public_key": "hex64", + "view_public_key": "hex64" +} +``` + +### TX Type / RCT Type Names + +```typescript +wasm_tx_type_name(txType: number): string // e.g. "Transfer", "Miner", "Protocol" +wasm_rct_type_name(rctType: number): string // e.g. "RctBulletproofPlus" +``` + +### CARROT Output Scanning (Low-Level) + +For full CARROT scanning (beyond what `decode_outputs_for_view_key` provides): + +```typescript +// Build input context for a TX +make_input_context_rct(firstKeyImage: Uint8Array): Uint8Array // Regular TX +make_input_context_coinbase(blockHeight: bigint): Uint8Array // Coinbase TX + +// View tag check (fast rejection) +compute_carrot_view_tag(sSrUnctx: Uint8Array, inputContext: Uint8Array, ko: Uint8Array): Uint8Array + +// After view tag matches — full decryption +decrypt_carrot_amount(encAmount: Uint8Array, sSrCtx: Uint8Array, ko: Uint8Array): bigint +recover_carrot_address_spend_pubkey(ko: Uint8Array, sSrCtx: Uint8Array, commitment: Uint8Array): Uint8Array +derive_carrot_commitment_mask(sSrCtx: Uint8Array, amount: bigint, addressSpendPubkey: Uint8Array, enoteType: number): Uint8Array + +// CARROT key derivation (9 keys from master secret) +derive_carrot_keys_batch(masterSecret: Uint8Array): Uint8Array // Returns 288 bytes (9 × 32) +derive_carrot_view_only_keys_batch(viewBalanceSecret: Uint8Array, accountSpendPubkey: Uint8Array): Uint8Array + +// Subaddress map generation +carrot_subaddress_map_batch( + accountSpendPubkey: Uint8Array, + accountViewPubkey: Uint8Array, + generateAddressSecret: Uint8Array, + majorCount: number, + minorCount: number +): Uint8Array // Returns majorCount * minorCount * 40 bytes (32-byte key + 4-byte major + 4-byte minor each) +``` + +### Verification + +```typescript +// Full RCT signature verification (all ring sigs + bulletproofs in one call) +verify_rct_signatures_wasm( + rctType: number, + inputCount: number, + ringSize: number, + txPrefixHash: Uint8Array, + rctBaseBytes: Uint8Array, + bpComponents: Uint8Array, + keyImagesFlat: Uint8Array, + pseudoOutsFlat: Uint8Array, + sigsFlat: Uint8Array, + ringPubkeysFlat: Uint8Array, + ringCommitmentsFlat: Uint8Array +): Uint8Array // Returns JSON with verification result +``` + +## 6. Typical Explorer Workflow + +### Block Page + +```typescript +import { parse_and_analyze_block, parse_and_analyze_tx } from 'salvium-explorer'; + +// 1. Fetch block from daemon RPC (get_block endpoint returns binary blob) +const blockBlob: Uint8Array = await fetchBlockBlob(height); + +// 2. Parse + analyze the block +const block = JSON.parse(parse_and_analyze_block(blockBlob)); +// block.majorVersion, block.timestamp, block.prevId, block.tx_count, ... + +// 3. The miner TX is embedded in the block +const minerTx = block.minerTx; +// minerTx.prefix.vout → miner reward outputs + +// 4. Fetch and parse each transaction +for (const txHash of block.txHashes) { + const txBlob: Uint8Array = await fetchTxBlob(txHash); + const tx = JSON.parse(parse_and_analyze_tx(txBlob)); + // tx.tx_type_name, tx.input_count, tx.output_count, tx.fee, ... +} +``` + +### Transaction Page + +```typescript +import { parse_and_analyze_tx, wasm_tx_type_name } from 'salvium-explorer'; + +const tx = JSON.parse(parse_and_analyze_tx(txBlob)); + +// Display summary +console.log(`Type: ${tx.tx_type_name}`); // "Transfer" +console.log(`Fee: ${tx.fee} atomic`); // "24960000" +console.log(`Inputs: ${tx.input_count}`); +console.log(`Outputs: ${tx.output_count}`); +console.log(`CARROT: ${tx.is_carrot}`); +console.log(`Coinbase: ${tx.is_coinbase}`); + +// Key images (for double-spend checking) +for (const ki of tx.key_images) { + console.log(`Key image: ${ki}`); +} + +// Output keys (for output lookup) +for (const ok of tx.output_keys) { + console.log(`Output key: ${ok}`); +} +``` + +### Output Decoding (View Key Search) + +```typescript +import { decode_outputs_for_view_key } from 'salvium-explorer'; + +// User provides their view key + spend public key +const viewSecret = hexToBytes(viewSecretHex); // 32 bytes +const spendPub = hexToBytes(spendPubHex); // 32 bytes + +const owned = JSON.parse(decode_outputs_for_view_key(txBlob, viewSecret, spendPub)); + +for (const out of owned) { + console.log(`Output #${out.output_index}: ${out.amount} atomic SAL`); + console.log(` Key: ${out.output_key}`); +} +``` + +### Address Validation + +```typescript +import { wasm_is_valid_address, wasm_parse_address } from 'salvium-explorer'; + +if (wasm_is_valid_address(userAddress)) { + const info = JSON.parse(wasm_parse_address(userAddress)); + console.log(`Network: ${info.network}, Type: ${info.address_type}`); +} +``` + +## 7. Data Types — All Inputs are Raw Bytes + +Every function that takes transaction or block data expects **raw binary** (`Uint8Array`), not hex strings. If your daemon returns hex, decode first: + +```typescript +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + +// Daemon RPC returns hex → convert before passing to WASM +const txHex: string = await daemon.getTransaction(txHash); +const txBytes: Uint8Array = hexToBytes(txHex); +const result = parse_and_analyze_tx(txBytes); +``` + +## 8. Error Handling + +All string-returning functions follow the same pattern: + +```typescript +const result = JSON.parse(parse_and_analyze_tx(txBytes)); + +if (result.error) { + // Parse failed + console.error(result.error); +} else { + // Success — use result fields +} +``` + +For functions returning `Uint8Array`: an empty array (`length === 0`) indicates an error (invalid input, failed point decompression, etc). + +For functions returning `boolean`: they return `false` on invalid input. + +## 9. Memory / Performance Notes + +- The WASM module is ~4MB (uncompressed). Cloudflare Workers supports this. +- All functions are synchronous — no async/await needed after `initSync()`. +- The module manages its own WASM linear memory. `Uint8Array` inputs are copied into WASM memory and results are copied out — no manual memory management needed from JS. +- `parse_and_analyze_tx` and `parse_and_analyze_block` do JSON serialization internally (serde_json). For hot paths parsing thousands of transactions, prefer `parse_transaction_bytes` and extract only the fields you need. + +## Source Files + +| File | What | +|------|------| +| `crates/salvium-explorer/src/lib.rs` | Explorer WASM APIs (3 custom + 61 re-exports) | +| `crates/salvium-explorer/Cargo.toml` | Crate config | +| `crates/salvium-explorer/pkg/` | Built wasm-pack output (JS glue, .wasm, .d.ts) | +| `crates/salvium-crypto/src/lib.rs` | Underlying crypto implementations | +| `crates/salvium-crypto/src/wasm_ffi.rs` | C ABI static lib (alternative to wasm-bindgen) | +| `crates/salvium-crypto/src/tx_parse.rs` | Transaction/block binary parser | +| `scripts/build-wasm.sh` | Build script (wasm-pack + staticlib) | diff --git a/docs/wallet-sync-spec.md b/docs/wallet-sync-spec.md new file mode 100644 index 0000000..f0840e8 --- /dev/null +++ b/docs/wallet-sync-spec.md @@ -0,0 +1,336 @@ +# Wallet Sync Integration Spec + +How to use the salvium-rs FFI library to create a wallet, sync it, and read data. + +## 1. Initialization + +```c +// Optional — runtime is created lazily on first FFI call. +salvium_ffi_init(); +``` + +## 2. Connect to Daemon + +```c +void* daemon = salvium_daemon_connect("http://seed01.salvium.io:19081"); +if (!daemon) { + const char* err = salvium_last_error(); // DO NOT free this pointer + // handle error... +} +``` + +**Seed nodes:** + +| Network | URL | +|----------|----------------------------------------| +| Mainnet | `http://seed01.salvium.io:19081` | +| Testnet | `http://seed01.salvium.io:29081` | +| Stagenet | `http://seed01.salvium.io:39081` | + +Verify daemon is ready before syncing: + +```c +int synced = salvium_daemon_is_synchronized(daemon); +// 1 = synced, 0 = still syncing, -1 = error +``` + +## 3. Create / Open Wallet + +Three options — all return an opaque `void*` handle (null on error). + +### 3a. From 25-word mnemonic + +```c +void* wallet = salvium_wallet_from_mnemonic( + "word1 word2 ... word25", // null-terminated C string + 0, // network: 0=Mainnet, 1=Testnet, 2=Stagenet + "/path/to/wallet.db", // database file path + db_key, // uint8_t* encryption key + db_key_len // size_t key length +); +``` + +### 3b. From 32-byte seed + +```c +void* wallet = salvium_wallet_create( + seed_bytes, // const uint8_t[32] + 0, // network + "/path/to/wallet.db", + db_key, + db_key_len +); +``` + +### 3c. From JSON keys (view-only supported) + +```c +// Full wallet: {"seed": "hex64", "network": "mainnet"} +// View-only: {"view_secret_key": "hex64", "spend_public_key": "hex64", "network": "mainnet"} +void* wallet = salvium_wallet_open(keys_json, "/path/to/wallet.db", db_key, db_key_len); +``` + +### 3d. From PIN-encrypted blob + +```c +void* wallet = salvium_wallet_import_blob(blob_json, pin, "/path/to/wallet.db"); +``` + +## 4. Sync + +### The call + +```c +int rc = salvium_wallet_sync(wallet, daemon, my_callback); +// rc: 0 = success, -1 = error +``` + +**This function blocks until sync is complete.** It handles everything internally: +- Fetches blocks from the daemon in adaptive batches (2-1000 blocks per HTTP request) +- Parses each block and scans all transaction outputs for owned funds +- Detects chain reorganizations and rolls back automatically +- Stores matched outputs and transactions in the wallet database +- Updates sync height per-block for crash safety + +### Callback (optional, may be NULL) + +```c +typedef void (*SyncCallbackFn)( + int event_type, // event code (see below) + uint64_t current_height, // current scan position + uint64_t target_height, // chain tip + uint32_t outputs_found, // cumulative owned outputs found + const char* error_msg // null unless event_type=4 or 5 +); + +void my_callback(int type, uint64_t cur, uint64_t target, uint32_t outs, const char* msg) { + switch (type) { + case 0: printf("Started: target=%llu\n", target); break; + case 1: printf("Progress: %llu/%llu (%u outputs)\n", cur, target, outs); break; + case 2: printf("Complete: height=%llu\n", cur); break; + case 3: printf("Reorg: %llu -> %llu\n", cur, target); break; + case 4: printf("Error: %s\n", msg); break; + case 5: printf("Parse error at %llu: %s\n", cur, msg); break; + } +} +``` + +| Event | Code | Frequency | Fields | +|-------|------|-----------|--------| +| Started | 0 | Once at start | `target_height` | +| Progress | 1 | Per batch (~every 2000 blocks) | `current_height`, `target_height`, `outputs_found` | +| Complete | 2 | Once at end | `current_height` = final height | +| Reorg | 3 | When chain fork detected | `current_height` = old tip, `target_height` = fork point | +| Error | 4 | On RPC/network error | `error_msg` = description | +| ParseError | 5 | On block parse failure | `current_height` = block height, `error_msg` = details | + +### Incremental sync + +`salvium_wallet_sync` is always incremental. It starts from the last persisted height and only fetches new blocks. Calling it again after completion returns immediately if no new blocks exist. + +### Rescan + +```c +salvium_wallet_reset_sync_height(wallet, 0); // reset to genesis +salvium_wallet_sync(wallet, daemon, callback); // full rescan +``` + +## 5. Query Balance + +```c +// Single asset +char* json = salvium_wallet_get_balance(wallet, "SAL", 0); +// Returns: {"balance":"123456789","unlocked_balance":"100000000","locked_balance":"23456789"} +// Amounts are atomic units (1 SAL = 100,000,000 atomic) +// CALLER MUST FREE: +salvium_string_free(json); + +// All assets +char* all = salvium_wallet_get_all_balances(wallet, 0); +// Returns: {"SAL":{"balance":"...","unlocked_balance":"...","locked_balance":"..."}, ...} +salvium_string_free(all); +``` + +**You MUST sync before querying balances.** Without sync, the balance will be 0. + +## 6. Query Transactions + +```c +// All confirmed transfers +char* txs = salvium_wallet_get_transfers(wallet, "{\"is_confirmed\":true}"); + +// Only incoming +char* txs = salvium_wallet_get_transfers(wallet, "{\"is_incoming\":true}"); + +// Height range +char* txs = salvium_wallet_get_transfers(wallet, "{\"min_height\":100000,\"max_height\":200000}"); + +// Specific tx hash +char* txs = salvium_wallet_get_transfers(wallet, "{\"tx_hash\":\"abc123...\"}"); + +// Returns JSON array. ALWAYS free: +salvium_string_free(txs); +``` + +**TxQuery fields (all optional):** + +| Field | Type | Description | +|-------|------|-------------| +| `is_incoming` | bool | Filter incoming transfers | +| `is_outgoing` | bool | Filter outgoing transfers | +| `is_confirmed` | bool | Filter confirmed (in-block) | +| `in_pool` | bool | Filter mempool transactions | +| `tx_type` | i64 | 1=miner, 2=protocol, 3=transfer | +| `min_height` | i64 | Minimum block height | +| `max_height` | i64 | Maximum block height | +| `tx_hash` | string | Exact transaction hash | + +**TransactionRow fields in response:** + +| Field | Type | Description | +|-------|------|-------------| +| `tx_hash` | string | Transaction hash (hex) | +| `block_height` | i64/null | Block height (null if in pool) | +| `block_timestamp` | i64 | Unix timestamp | +| `is_incoming` | bool | Received funds | +| `is_outgoing` | bool | Sent funds | +| `is_confirmed` | bool | In a block | +| `incoming_amount` | string | Atomic units received | +| `outgoing_amount` | string | Atomic units sent | +| `fee` | string | Transaction fee (atomic) | +| `asset_type` | string | e.g. "SAL" | +| `tx_type` | i64 | Transaction type code | + +## 7. Query Outputs (UTXOs) + +```c +// All unspent outputs +char* outs = salvium_wallet_get_outputs(wallet, "{\"is_spent\":false}"); + +// Unspent SAL only +char* outs = salvium_wallet_get_outputs(wallet, "{\"is_spent\":false,\"asset_type\":\"SAL\"}"); + +salvium_string_free(outs); +``` + +## 8. Other Wallet Queries + +```c +// Addresses +char* cn_addr = salvium_wallet_get_address(wallet, 0); // CryptoNote +char* carr_addr = salvium_wallet_get_address(wallet, 1); // CARROT +salvium_string_free(cn_addr); +salvium_string_free(carr_addr); + +// Mnemonic (null if view-only) +char* words = salvium_wallet_get_mnemonic(wallet); +if (words) salvium_string_free(words); + +// Key material as JSON +char* keys = salvium_wallet_get_keys_json(wallet); +salvium_string_free(keys); + +// Can this wallet spend? +int can = salvium_wallet_can_spend(wallet); // 1=yes, 0=no + +// Current sync height +uint64_t h = salvium_wallet_sync_height(wallet); + +// Network +int net = salvium_wallet_network(wallet); // 0=main, 1=test, 2=stage +``` + +## 9. Staking + +```c +char* stakes = salvium_wallet_get_stakes(wallet, "locked"); // or "returned" or NULL for all +salvium_string_free(stakes); +``` + +## 10. Cleanup + +**Close in reverse order.** Always close handles when done. + +```c +salvium_wallet_close(wallet); // wallet first +salvium_daemon_close(daemon); // daemon second +``` + +## Error Handling + +Every FFI function follows one of these patterns: + +| Return type | Success | Error | +|-------------|---------|-------| +| `i32` | `0` | `-1` | +| `*mut c_char` | non-null string | `NULL` | +| `*mut c_void` | non-null handle | `NULL` | +| `u64` | value | `u64::MAX` | + +On error, call `salvium_last_error()` to get the error message: + +```c +const char* err = salvium_last_error(); +// This pointer is valid until the NEXT FFI call on the same thread. +// DO NOT free it. Copy it if you need to keep it. +``` + +## Memory Rules + +1. **Strings** returned by FFI functions (`*mut c_char`) MUST be freed with `salvium_string_free()`. +2. **Handles** (`*mut c_void`) MUST be closed with the matching `_close()` function. +3. **Error strings** from `salvium_last_error()` must NOT be freed — they are owned by the library. +4. **Input strings** (parameters you pass in) must be valid null-terminated UTF-8. + +## Complete Example (Pseudocode) + +```c +salvium_ffi_init(); + +// Connect +void* daemon = salvium_daemon_connect("http://seed01.salvium.io:19081"); +assert(daemon != NULL); + +// Wait for daemon to sync +while (salvium_daemon_is_synchronized(daemon) != 1) { + sleep(5); +} + +// Create wallet +uint8_t db_key[32]; +generate_random_bytes(db_key, 32); +void* wallet = salvium_wallet_from_mnemonic(mnemonic, 0, "wallet.db", db_key, 32); +assert(wallet != NULL); + +// Sync blockchain (blocks until complete) +int rc = salvium_wallet_sync(wallet, daemon, progress_callback); +assert(rc == 0); + +// Read balance +char* bal = salvium_wallet_get_balance(wallet, "SAL", 0); +printf("Balance: %s\n", bal); +salvium_string_free(bal); + +// Read transactions +char* txs = salvium_wallet_get_transfers(wallet, "{\"is_confirmed\":true}"); +printf("Transfers: %s\n", txs); +salvium_string_free(txs); + +// Cleanup +salvium_wallet_close(wallet); +salvium_daemon_close(daemon); +``` + +## Source Files + +| File | What | +|------|------| +| `crates/salvium-ffi/src/lib.rs` | FFI entry point, runtime singleton | +| `crates/salvium-ffi/src/wallet.rs` | Wallet lifecycle, sync, queries | +| `crates/salvium-ffi/src/daemon.rs` | Daemon RPC handle | +| `crates/salvium-ffi/src/error.rs` | Error storage, `ffi_try` helpers | +| `crates/salvium-ffi/src/strings.rs` | String marshalling, `salvium_string_free` | +| `crates/salvium-wallet/src/wallet.rs` | Rust wallet implementation | +| `crates/salvium-wallet/src/sync.rs` | Sync engine internals | +| `crates/salvium-rpc/src/daemon.rs` | RPC client | +| `crates/salvium-sync-bench/src/main.rs` | Working Rust sync example | diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 9d5de82..451ce73 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -36,6 +36,16 @@ wasm-pack build \ # Remove wasm-pack's generated .gitignore (we want to commit/ship this) rm -f "$OUT_DIR/.gitignore" +# Build WASM static library (C ABI, no wasm-bindgen glue). +# Produces libsalvium_crypto.a for direct linking (e.g. Cloudflare Worker). +echo "" +echo "==> Building WASM static library (C ABI)..." +cargo build -p salvium-crypto \ + --target wasm32-unknown-unknown \ + --release \ + --no-default-features +cp "$ROOT_DIR/target/wasm32-unknown-unknown/release/libsalvium_crypto.a" "$OUT_DIR/" + echo "" echo "==> Done:" ls -lh "$OUT_DIR/"*