diff --git a/.gitignore b/.gitignore index 0f75e9bda..11d27c8c8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ zingolib/proptest-regressions test_binaries/bins/* libtonode-tests/tests/chain_generics.proptest-regressions libtonode-tests/store_all_checkpoints_test + +.DS_Store +*.dat diff --git a/Cargo.lock b/Cargo.lock index 2a54a1a5b..060cae2da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,6 +276,48 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash 2.1.1", + "serde", + "serde_derive", + "syn 2.0.116", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + [[package]] name = "asn1-rs" version = "0.7.1" @@ -496,6 +538,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bech32" version = "0.11.1" @@ -878,12 +929,44 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + [[package]] name = "caret" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4d27042e727de6261ee6391b834c6e1adec7031a03228cc1a67f95a3d8f2202" +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "cast" version = "0.3.0" @@ -1026,6 +1109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -1040,6 +1124,18 @@ dependencies = [ "strsim 0.11.1", ] +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "clap_lex" version = "1.0.0" @@ -2064,6 +2160,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "ffi" +version = "0.0.0" +dependencies = [ + "async-trait", + "bip0039 0.13.4", + "http", + "pepper-sync", + "thiserror 2.0.18", + "tokio", + "uniffi", + "zcash_primitives", + "zingo_common_components 0.2.0 (git+https://github.com/zingolabs/zingo-common.git)", + "zingolib", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2169,6 +2281,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "fs-mistrust" version = "0.12.0" @@ -2397,6 +2518,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "group" version = "0.13.0" @@ -4256,6 +4388,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plotters" version = "0.3.7" @@ -5482,6 +5620,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "sct" version = "0.7.1" @@ -5588,6 +5746,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -5916,6 +6078,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.6.2" @@ -6154,6 +6322,15 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -7749,6 +7926,139 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "uniffi" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c6dec3fc6645f71a16a3fa9ff57991028153bd194ca97f4b55e610c73ce66a" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_build", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ed0150801958d4825da56a41c71f000a457ac3a4613fa9647df78ac4b6b6881" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap 2.13.0", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml 0.8.23", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_build" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b78fd9271a4c2e85bd2c266c5a9ede1fac676eb39fd77f636c27eaf67426fd5f" +dependencies = [ + "anyhow", + "camino", + "uniffi_bindgen", +] + +[[package]] +name = "uniffi_core" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0ef62e69762fbb9386dcb6c87cd3dd05d525fa8a3a579a290892e60ddbda47e" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98f51ebca0d9a4b2aa6c644d5ede45c56f73906b96403c08a1985e75ccb64a01" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "uniffi_macros" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db9d12529f1223d014fd501e5f29ca0884d15d6ed5ddddd9f506e55350327dc3" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.116", + "toml 0.8.23", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df6d413db2827c68588f8149d30d49b71d540d46539e435b23a7f7dbd4d4f86" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a806dddc8208f22efd7e95a5cdf88ed43d0f3271e8f63b47e757a8bbdb43b63a" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d1a7339539bf6f6fa3e9b534dece13f778bda2d54b1a6d4e40b4d6090ac26e7" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -8106,6 +8416,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + [[package]] name = "which" version = "8.0.0" diff --git a/Cargo.toml b/Cargo.toml index 9db5de1d2..aa1bcd576 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "zingo-price", "zingo-status", "zingolib_testutils", + "ffi/rust", ] resolver = "2" @@ -122,7 +123,7 @@ pepper-sync = { path = "pepper-sync" } zingolib = { path = "zingolib" } zcash_local_net = { git = "https://github.com/zingolabs/infrastructure.git", rev = "e4714fd" } zingo_test_vectors = { git = "https://github.com/zingolabs/infrastructure.git", rev = "e4714fd" } +ffi = { path = "ffi/rust" } [profile.test] opt-level = 3 - diff --git a/ffi/rust/.gitignore b/ffi/rust/.gitignore new file mode 100644 index 000000000..de2f0d0c2 --- /dev/null +++ b/ffi/rust/.gitignore @@ -0,0 +1 @@ +uniffi-output diff --git a/ffi/rust/Cargo.toml b/ffi/rust/Cargo.toml new file mode 100644 index 000000000..b2590cfc4 --- /dev/null +++ b/ffi/rust/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "ffi" +version = "0.0.0" +edition = "2024" + +[dependencies] +bip0039.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread", "time", "sync"] } +uniffi = { version = "0.31", features = ["cli"] } +zcash_primitives.workspace = true + +zingolib.workspace = true +pepper-sync.workspace = true +zingo_common_components = { workspace = true, features = ["for_test"] } +http.workspace = true +async-trait = "0.1.89" + +[build-dependencies] +uniffi = { version = "0.31", features = ["build"] } + + +[lib] +# rlib is necessary to run examples +crate-type = ["rlib", "cdylib"] +name = "ffi" + +[[bin]] +name = "generate-bindings" +path = "generate-bindings.rs" diff --git a/ffi/rust/examples/basic.rs b/ffi/rust/examples/basic.rs new file mode 100644 index 000000000..7be468e83 --- /dev/null +++ b/ffi/rust/examples/basic.rs @@ -0,0 +1,80 @@ +use std::{env, time::Duration}; + +use bip0039::{English, Mnemonic}; +use ffi::{ + Chain, Performance, RestoreParams, SeedPhrase, WalletEngine, WalletEvent, WalletListener, +}; + +struct PrintListener; +impl WalletListener for PrintListener { + fn on_event(&self, event: WalletEvent) { + println!("[event] {event:?}"); + } +} + +fn require_env(name: &str) -> String { + env::var(name).unwrap_or_else(|_| { + eprintln!("Missing required env var: {name}"); + eprintln!("Example:"); + eprintln!(" {name}=..."); + std::process::exit(2); + }) +} + +fn preflight(seed_words: &str, indexer_uri: &str) { + if let Err(e) = Mnemonic::::from_phrase(seed_words.to_string()) { + eprintln!("Invalid ZINGO_SEED mnemonic: {e}"); + std::process::exit(2); + } + + match indexer_uri.parse::() { + Ok(uri) => { + let scheme_ok = uri.scheme_str() == Some("http") || uri.scheme_str() == Some("https"); + let has_authority = uri.authority().is_some(); + if !scheme_ok || !has_authority { + eprintln!( + "Invalid ZINGO_INDEXER_URI='{indexer_uri}'. Expected http(s)://host:port" + ); + std::process::exit(2); + } + } + Err(e) => { + eprintln!("Invalid ZINGO_INDEXER_URI='{indexer_uri}': {e}"); + std::process::exit(2); + } + } +} + +pub fn main() { + let seed_words = require_env("ZINGO_SEED"); + let indexer_uri = require_env("ZINGO_INDEXER_URI"); + + let birthday = 1; + + let chain = Chain::Regtest; + + let perf = Performance::High; + + let minconf = 1; + + preflight(&seed_words, &indexer_uri); + + let engine = WalletEngine::new().expect("engine new"); + engine + .set_listener(Box::new(PrintListener)) + .expect("set listener"); + + engine + .init_from_seed(RestoreParams { + seed_phrase: SeedPhrase { words: seed_words }, + birthday, + indexer_uri, + chain, + perf, + minconf, + }) + .unwrap(); + + engine.start_sync().unwrap(); + std::thread::sleep(Duration::from_secs(20)); +} diff --git a/ffi/rust/examples/ufvk.rs b/ffi/rust/examples/ufvk.rs new file mode 100644 index 000000000..4f0001367 --- /dev/null +++ b/ffi/rust/examples/ufvk.rs @@ -0,0 +1,51 @@ +use std::{env, time::Duration}; + +use ffi::{Chain, Performance, UFVKImportParams, WalletEngine, WalletEvent, WalletListener}; + +struct PrintListener; +impl WalletListener for PrintListener { + fn on_event(&self, event: WalletEvent) { + println!("[event] {event:?}"); + } +} + +fn require_env(name: &str) -> String { + env::var(name).unwrap_or_else(|_| { + eprintln!("Missing required env var: {name}"); + eprintln!("Example:"); + eprintln!(" {name}=..."); + std::process::exit(2); + }) +} + +pub fn main() { + let ufvk = require_env("ZINGO_UFVK"); + let indexer_uri = require_env("ZINGO_INDEXER_URI"); + + let birthday = 1; + + let chain = Chain::Regtest; + + let perf = Performance::High; + + let minconf = 1; + + let engine = WalletEngine::new().expect("engine new"); + engine + .set_listener(Box::new(PrintListener)) + .expect("set listener"); + + engine + .init_from_ufvk(UFVKImportParams { + ufvk: ufvk.to_string(), + birthday, + indexer_uri, + chain, + perf, + minconf, + }) + .unwrap(); + + engine.start_sync().unwrap(); + std::thread::sleep(Duration::from_secs(20)); +} diff --git a/ffi/rust/generate-bindings.rs b/ffi/rust/generate-bindings.rs new file mode 100644 index 000000000..f6cff6cf1 --- /dev/null +++ b/ffi/rust/generate-bindings.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/ffi/rust/src/config.rs b/ffi/rust/src/config.rs new file mode 100644 index 000000000..379a79cc5 --- /dev/null +++ b/ffi/rust/src/config.rs @@ -0,0 +1,59 @@ +use std::num::NonZeroU32; + +use pepper_sync::config::PerformanceLevel; +use zingo_common_components::protocol::activation_heights::for_test; +use zingolib::{ + config::{ + ChainType, SyncConfig, TransparentAddressDiscovery, ZingoConfig, construct_lightwalletd_uri, + }, + wallet::WalletSettings, +}; + +use crate::{Chain, Performance, error::WalletError}; + +pub fn chain_to_chaintype(chain: Chain) -> ChainType { + match chain { + Chain::Mainnet => ChainType::Mainnet, + Chain::Testnet => ChainType::Testnet, + Chain::Regtest => ChainType::Regtest(for_test::all_height_one_nus()), + } +} + +pub fn perf_to_level(p: Performance) -> PerformanceLevel { + match p { + Performance::Maximum => PerformanceLevel::Maximum, + Performance::High => PerformanceLevel::High, + Performance::Medium => PerformanceLevel::Medium, + Performance::Low => PerformanceLevel::Low, + } +} + +pub fn construct_config( + indexer_uri: String, + chain: Chain, + perf: Performance, + min_confirmations: u32, +) -> Result<(ZingoConfig, http::Uri), WalletError> { + let lightwalletd_uri = construct_lightwalletd_uri(Some(indexer_uri)); + + let min_conf = NonZeroU32::try_from(min_confirmations) + .map_err(|_| WalletError::Internal("min_confirmations must be >= 1".into()))?; + + let config = zingolib::config::load_clientconfig( + lightwalletd_uri.clone(), + None, + chain_to_chaintype(chain), + WalletSettings { + sync_config: SyncConfig { + transparent_address_discovery: TransparentAddressDiscovery::minimal(), + performance_level: perf_to_level(perf), + }, + min_confirmations: min_conf, + }, + NonZeroU32::try_from(1).expect("hard-coded integer"), + "".to_string(), + ) + .map_err(|e| WalletError::Internal(format!("Config load error: {e}")))?; + + Ok((config, lightwalletd_uri)) +} diff --git a/ffi/rust/src/error.rs b/ffi/rust/src/error.rs new file mode 100644 index 000000000..f21c1e7a8 --- /dev/null +++ b/ffi/rust/src/error.rs @@ -0,0 +1,11 @@ +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum WalletError { + #[error("Command queue closed")] + CommandQueueClosed, + #[error("Listener lock poisoned")] + ListenerLockPoisoned, + #[error("Wallet not initialized")] + NotInitialized, + #[error("Internal error: {0}")] + Internal(String), +} diff --git a/ffi/rust/src/lib.rs b/ffi/rust/src/lib.rs new file mode 100644 index 000000000..5c91ef4c7 --- /dev/null +++ b/ffi/rust/src/lib.rs @@ -0,0 +1,1742 @@ +pub mod config; +pub mod error; +pub mod state; + +use std::{ + panic::{self, AssertUnwindSafe}, + sync::Arc, + thread, + time::Duration, +}; + +use async_trait::async_trait; + +use bip0039::Mnemonic; +use pepper_sync::{error::SyncError, sync::SyncResult, wallet::SyncMode}; +use tokio::sync::{Mutex, RwLock, mpsc, oneshot}; +use zcash_primitives::consensus::BlockHeight; +use zcash_primitives::zip32::AccountId; +use zingolib::{ + data::PollReport, + wallet::{LightWallet, WalletBase, balance::AccountBalance}, +}; + +use crate::{config::construct_config, error::WalletError, state::EngineState}; + +uniffi::setup_scaffolding!(); + +#[derive(Clone, Copy, Debug, uniffi::Enum)] +pub enum Chain { + Mainnet, + Testnet, + Regtest, +} + +#[derive(Clone, Copy, Debug, uniffi::Enum)] +pub enum Performance { + Maximum, + High, + Medium, + Low, +} + +#[derive(Clone, Debug, uniffi::Record, PartialEq, Eq)] +pub struct BalanceSnapshot { + pub confirmed: String, + pub total: String, +} + +#[derive(Clone, Debug, uniffi::Record, PartialEq, Eq)] +pub struct SeedPhrase { + pub words: String, +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct RestoreParams { + pub seed_phrase: SeedPhrase, + pub birthday: u32, + pub indexer_uri: String, + pub chain: Chain, + pub perf: Performance, + pub minconf: u32, +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct UFVKImportParams { + pub ufvk: String, + pub birthday: u32, + pub indexer_uri: String, + pub chain: Chain, + pub perf: Performance, + pub minconf: u32, +} + +#[derive(Clone, Debug, uniffi::Enum)] +pub enum WalletEvent { + EngineReady, + SyncStarted, + SyncProgress { + wallet_height: u32, + network_height: u32, + percent: f32, + }, + SyncPaused, + SyncFinished, + BalanceChanged(BalanceSnapshot), + Error { + code: String, + message: String, + }, +} + +#[uniffi::export(callback_interface)] +pub trait WalletListener: Send + Sync { + fn on_event(&self, event: WalletEvent); +} + +#[async_trait] +pub trait WalletBackend: Send + Sync { + /// Starts a sync run. + /// + /// This is expected to *kick off* syncing and return reasonably quickly. + /// It should **not** block until the sync is fully complete. + /// + /// Typical behavior: + /// - If the backend is currently paused, this should resume. + /// - If no sync is running, this should start a new one. + /// - If a sync is already running, this may return `Ok(())` (idempotent) or a + /// descriptive error string, depending on your policy. + /// + /// Errors: + /// - Returns `Err(String)` for backend-specific failures (e.g. cannot start sync, + /// bad internal state). + async fn start_sync(&self) -> Result<(), String>; + + /// Polls the status of the currently running sync task. + /// + /// This should be a *non-blocking* operation that reports progress/completion + /// for the sync started via [`WalletBackend::start_sync`]. + /// + /// The engine will typically call this on a timer (e.g. every 250ms) to drive + /// event emission: + /// - `PollReport::NotReady` → still syncing + /// - `PollReport::Ready(Ok(_))` → finished successfully + /// - `PollReport::Ready(Err(_))` → finished with error + /// - `PollReport::NoHandle` → no sync is currently running / nothing to poll + /// + /// Note: + /// - Even though this is `async`, implementations should keep it fast. Use a + /// short lock section if you must acquire interior mutability. + async fn poll_sync(&self) -> PollReport>; + + /// Requests that an in-progress sync pause. + /// + /// This is best-effort: depending on the backend, the sync may pause at the + /// next safe point, or it may complete before pausing. + /// + /// Typical behavior: + /// - If syncing is active, request a pause and return `Ok(())` if the request + /// was accepted. + /// - If no sync is running, this may return `Ok(())` or an error, depending on policy. + /// + /// Errors: + /// - Returns `Err(String)` for backend-specific failures (e.g. cannot pause). + async fn pause_sync(&self) -> Result<(), String>; + + /// Returns the current sync mode/state. + /// + /// This is used by the engine to determine whether a sync is paused vs running, + /// and to gate transitions (e.g. “start sync” may mean “resume”). + async fn sync_mode(&self) -> SyncMode; + + // data + async fn wallet_height(&self) -> u32; + async fn balance_snapshot(&self) -> Option; + async fn network_height(&self) -> u32; +} + +/// Zingolib-backed implementation. +/// Keeps all zingolib state behind async locks so engine can remain responsive. +pub struct ZingolibBackend { + lc: Arc>, + indexer_uri: http::Uri, +} + +impl ZingolibBackend { + pub fn new(lc: zingolib::lightclient::LightClient, indexer_uri: http::Uri) -> Self { + Self { + lc: Arc::new(RwLock::new(lc)), + indexer_uri, + } + } +} + +#[async_trait] +impl WalletBackend for ZingolibBackend { + async fn start_sync(&self) -> Result<(), String> { + let mut guard = self.lc.write().await; + + if guard.sync_mode() == SyncMode::Paused { + // TODO: replace with proper resume when available + guard.resume_sync().map_err(|e| e.to_string()) + } else { + guard.sync().await.map_err(|e| e.to_string()) + } + } + + async fn poll_sync(&self) -> PollReport> { + // poll_sync requires &mut self on LightClient + let mut guard = self.lc.write().await; + let return_value = guard.poll_sync(); + match return_value { + PollReport::NotReady => PollReport::NotReady, + PollReport::Ready(r) => match r { + Ok(r) => PollReport::Ready(Ok(r)), + Err(e) => { + let matched_error = match e { + SyncError::MempoolError(mempool_error) => { + WalletError::Internal(mempool_error.to_string()) + } + SyncError::ScanError(scan_error) => { + WalletError::Internal(scan_error.to_string()) + } + SyncError::ServerError(server_error) => { + WalletError::Internal(server_error.to_string()) + } + SyncError::SyncModeError(sync_mode_error) => { + WalletError::Internal(sync_mode_error.to_string()) + } + SyncError::ChainError(_, _, _) => { + WalletError::Internal("ChainError".to_string()) + } + SyncError::ShardTreeError(shard_tree_error) => { + WalletError::Internal(shard_tree_error.to_string()) + } + SyncError::TruncationError(_, _) => { + WalletError::Internal("TruncationError".to_string()) + } + SyncError::TransparentAddressDerivationError(error) => { + WalletError::Internal(error.to_string()) + } + SyncError::WalletError(e) => WalletError::Internal(e.to_string()), + SyncError::BirthdayBelowSapling(_, _) => { + WalletError::Internal("BirthdayBelowSapling".to_string()) + } + }; + return PollReport::Ready(Err(pepper_sync::error::SyncError::WalletError( + matched_error, + ))); + } + }, + PollReport::NoHandle => PollReport::NoHandle, + } + } + + async fn pause_sync(&self) -> Result<(), String> { + let guard = self.lc.write().await; + guard.pause_sync().map_err(|e| e.to_string())?; + Ok(()) + } + + async fn sync_mode(&self) -> SyncMode { + let guard = self.lc.read().await; + guard.sync_mode() + } + + async fn wallet_height(&self) -> u32 { + let guard = self.lc.read().await; + let w = guard.wallet.read().await; + w.sync_state + .highest_scanned_height() + .map(u32::from) + .unwrap_or(0) + } + + async fn balance_snapshot(&self) -> Option { + let guard = self.lc.read().await; + guard + .account_balance(AccountId::ZERO) + .await + .ok() + .map(|b| balance_snapshot_from_balance(&b)) + } + + async fn network_height(&self) -> u32 { + zingolib::grpc_connector::get_latest_block(self.indexer_uri.clone()) + .await + .map(|b| b.height as u32) + .unwrap_or(0) + } +} + +fn balance_snapshot_from_balance(b: &AccountBalance) -> BalanceSnapshot { + let confirmed = b + .confirmed_orchard_balance + .map(|v| v.into_u64()) + .unwrap_or(0) + + b.confirmed_sapling_balance + .map(|v| v.into_u64()) + .unwrap_or(0) + + b.confirmed_transparent_balance + .map(|v| v.into_u64()) + .unwrap_or(0); + + let total = b.total_orchard_balance.map(|v| v.into_u64()).unwrap_or(0) + + b.total_sapling_balance.map(|v| v.into_u64()).unwrap_or(0) + + b.total_transparent_balance + .map(|v| v.into_u64()) + .unwrap_or(0); + + BalanceSnapshot { + confirmed: confirmed.to_string(), + total: total.to_string(), + } +} + +struct EngineInner { + cmd_tx: mpsc::Sender, + listener: std::sync::Mutex>>, +} + +impl EngineInner { + pub(crate) async fn handle_start_sync_spawn( + self: Arc, + engine_state: Arc>, + ) { + let backend = { + let mut state = engine_state.lock().await; + + if state.syncing { + return; + } + + let Some(backend) = state.backend.as_ref().cloned() else { + emit( + &self, + WalletEvent::Error { + code: "start_sync_failed".into(), + message: WalletError::NotInitialized.to_string(), + }, + ); + return; + }; + + state.syncing = true; + backend + }; + + emit(&self, WalletEvent::SyncStarted); + + let inner = self.clone(); + let st_for_task = engine_state.clone(); + + let task = tokio::spawn(async move { + if let Err(e) = backend.start_sync().await { + emit( + &inner, + WalletEvent::Error { + code: "sync_failed".into(), + message: e, + }, + ); + let mut s = st_for_task.lock().await; + s.syncing = false; + return; + } + + let mut last_balance_emitted: Option = None; + + loop { + if backend.sync_mode().await == SyncMode::Paused { + emit(&inner, WalletEvent::SyncPaused); + let mut s = st_for_task.lock().await; + s.syncing = false; + break; + } + + let wallet_height = backend.wallet_height().await; + let network_height = backend.network_height().await; + + let percent = if network_height > 0 { + (wallet_height as f32 / network_height as f32).clamp(0.0, 1.0) + } else { + 0.0 + }; + + emit( + &inner, + WalletEvent::SyncProgress { + wallet_height, + network_height, + percent, + }, + ); + + if let Some(snap) = backend.balance_snapshot().await { + if last_balance_emitted.as_ref() != Some(&snap) { + last_balance_emitted = Some(snap.clone()); + emit(&inner, WalletEvent::BalanceChanged(snap)); + } + } + + match backend.poll_sync().await { + PollReport::Ready(Ok(_)) => { + emit(&inner, WalletEvent::SyncFinished); + let mut s = st_for_task.lock().await; + s.syncing = false; + break; + } + PollReport::Ready(Err(e)) => { + emit( + &inner, + WalletEvent::Error { + code: "sync_failed".into(), + message: e.to_string(), + }, + ); + let mut s = st_for_task.lock().await; + s.syncing = false; + break; + } + PollReport::NotReady | PollReport::NoHandle => {} + } + + tokio::time::sleep(Duration::from_millis(250)).await; + } + }); + + let mut s = engine_state.lock().await; + s.sync_task = Some(task); + } + + pub(crate) async fn handle_init_new( + &self, + st: &mut EngineState, + indexer_uri: String, + chain: Chain, + perf: Performance, + minconf: u32, + reply: oneshot::Sender>, + ) { + let res: Result<(), WalletError> = (async { + let (config, lw_uri) = construct_config(indexer_uri, chain, perf, minconf)?; + + let chain_height = zingolib::grpc_connector::get_latest_block(lw_uri.clone()) + .await + .map(|b| BlockHeight::from_u32(b.height as u32)) + .map_err(|e| WalletError::Internal(format!("get_latest_block: {e}")))?; + + let birthday = chain_height.saturating_sub(100); + + let lc = zingolib::lightclient::LightClient::new(config, birthday, false) + .map_err(|e| WalletError::Internal(format!("LightClient::new: {e}")))?; + + st.set_backend(Arc::new(ZingolibBackend::new(lc, lw_uri))); + Ok(()) + }) + .await; + + let _ = reply.send(res); + } + + pub(crate) async fn handle_init_from_seed( + &self, + st: &mut EngineState, + seed_phrase: String, + birthday: u32, + indexer_uri: String, + chain: Chain, + perf: Performance, + minconf: u32, + reply: oneshot::Sender>, + ) { + let res: Result<(), WalletError> = (async { + let (config, lw_uri) = construct_config(indexer_uri, chain, perf, minconf)?; + + let mnemonic = Mnemonic::from_phrase(seed_phrase) + .map_err(|e| WalletError::Internal(format!("Mnemonic: {e}")))?; + + let wallet = LightWallet::new( + config.chain, + WalletBase::Mnemonic { + mnemonic, + no_of_accounts: config.no_of_accounts, + }, + BlockHeight::from_u32(birthday), + config.wallet_settings.clone(), + ) + .map_err(|e| WalletError::Internal(format!("LightWallet::new: {e}")))?; + + let lc = zingolib::lightclient::LightClient::create_from_wallet(wallet, config, false) + .map_err(|e| WalletError::Internal(format!("create_from_wallet: {e}")))?; + + st.set_backend(Arc::new(ZingolibBackend::new(lc, lw_uri))); + Ok(()) + }) + .await; + + let _ = reply.send(res); + } + + pub(crate) async fn handle_init_view_only( + &self, + st: &mut EngineState, + viewing_key: String, + birthday: u32, + indexer_uri: String, + chain: Chain, + perf: Performance, + minconf: u32, + reply: oneshot::Sender>, + ) { + let res: Result<(), WalletError> = (async { + let (config, lw_uri) = construct_config(indexer_uri, chain, perf, minconf)?; + + let wallet = LightWallet::new( + config.chain, + WalletBase::Ufvk(viewing_key), + BlockHeight::from_u32(birthday), + config.wallet_settings.clone(), + ) + .map_err(|e| WalletError::Internal(format!("LightWallet::new: {e}")))?; + + let lc = zingolib::lightclient::LightClient::create_from_wallet(wallet, config, false) + .map_err(|e| WalletError::Internal(format!("create_from_wallet: {e}")))?; + + st.set_backend(Arc::new(ZingolibBackend::new(lc, lw_uri))); + Ok(()) + }) + .await; + + let _ = reply.send(res); + } + + pub(crate) async fn handle_get_balance( + &self, + backend: Option>, + reply: oneshot::Sender>, + ) { + let res: Result = async { + let backend = backend.ok_or(WalletError::NotInitialized)?; + + backend + .balance_snapshot() + .await + .ok_or_else(|| WalletError::Internal("balance unavailable".into())) + } + .await; + + let _ = reply.send(res); + } + + pub(crate) async fn handle_get_network_height( + &self, + backend: Option>, + reply: oneshot::Sender>, + ) { + let res: Result = async { + let backend = backend.ok_or(WalletError::NotInitialized)?; + Ok(backend.network_height().await) + } + .await; + + let _ = reply.send(res); + } + + pub(crate) async fn handle_pause_sync(&self, backend: Option>) { + let Some(backend) = backend else { + emit( + self, + WalletEvent::Error { + code: "pause_sync_failed".into(), + message: WalletError::NotInitialized.to_string(), + }, + ); + return; + }; + + match backend.pause_sync().await { + Ok(_) => emit(self, WalletEvent::SyncPaused), + Err(e) => emit( + self, + WalletEvent::Error { + code: "pause_sync_failed".into(), + message: e, + }, + ), + } + } +} + +fn emit(inner: &EngineInner, event: WalletEvent) { + let listener_opt = inner.listener.lock().ok().and_then(|g| g.clone()); + if let Some(listener) = listener_opt { + let _ = panic::catch_unwind(AssertUnwindSafe(|| { + listener.on_event(event); + })); + } +} + +// TODO; Remove repetition!! +enum Command { + InitNew { + indexer_uri: String, + chain: Chain, + perf: Performance, + minconf: u32, + reply: oneshot::Sender>, + }, + InitFromSeed { + seed_phrase: String, + birthday: u32, + indexer_uri: String, + chain: Chain, + perf: Performance, + minconf: u32, + reply: oneshot::Sender>, + }, + InitViewOnly { + viewing_key: String, + birthday: u32, + indexer_uri: String, + chain: Chain, + perf: Performance, + minconf: u32, + reply: oneshot::Sender>, + }, + GetBalance { + reply: oneshot::Sender>, + }, + GetNetworkHeight { + reply: oneshot::Sender>, + }, + StartSync, + PauseSync, + Shutdown, +} + +#[derive(uniffi::Object, Clone)] +pub struct WalletEngine { + inner: Arc, +} + +/// Engine thread runtime only. +fn create_engine_runtime() -> tokio::runtime::Runtime { + tokio::runtime::Runtime::new().expect("tokio runtime") +} + +// TODO: THIS NEEDS TO BE BEHIND AN ASYNC LOCK. With the current setup, sync will block the thread. +#[uniffi::export] +impl WalletEngine { + /// Creates a new [`WalletEngine`] and starts the internal engine thread. + /// + /// This constructor: + /// - Allocates a command queue used to communicate with the engine thread. + /// - Spawns a dedicated OS thread that owns a Tokio runtime and all async wallet state, through the [`LightClient`]. + /// - Emits [`WalletEvent::EngineReady`] once the engine thread is running. + /// + /// ## Threading / FFI design + /// All UniFFI-exposed methods on [`WalletEngine`] are *synchronous* and safe to call from + /// Swift/Kotlin. Any async work is executed on the engine thread. + /// + /// ## Errors + /// Returns [`WalletError`] if the engine cannot be created. + #[uniffi::constructor] + pub fn new() -> Result { + let (cmd_tx, mut cmd_rx) = mpsc::channel::(64); + + let inner = Arc::new(EngineInner { + cmd_tx, + listener: std::sync::Mutex::new(None), + }); + + let inner_for_task = inner.clone(); + + thread::spawn(move || { + let rt = create_engine_runtime(); + rt.block_on(async move { + emit(&inner_for_task, WalletEvent::EngineReady); + + let st = Arc::new(Mutex::new(EngineState::new())); + + while let Some(cmd) = cmd_rx.recv().await { + match cmd { + Command::InitNew { + indexer_uri, + chain, + perf, + minconf, + reply, + } => { + let mut guard = st.lock().await; + inner_for_task + .handle_init_new( + &mut guard, + indexer_uri, + chain, + perf, + minconf, + reply, + ) + .await; + } + + Command::InitFromSeed { + seed_phrase, + birthday, + indexer_uri, + chain, + perf, + minconf, + reply, + } => { + let mut guard = st.lock().await; + inner_for_task + .handle_init_from_seed( + &mut guard, + seed_phrase, + birthday, + indexer_uri, + chain, + perf, + minconf, + reply, + ) + .await; + } + + Command::InitViewOnly { + viewing_key, + birthday, + indexer_uri, + chain, + perf, + minconf, + reply, + } => { + let mut guard = st.lock().await; + inner_for_task + .handle_init_view_only( + &mut guard, + viewing_key, + birthday, + indexer_uri, + chain, + perf, + minconf, + reply, + ) + .await; + } + + Command::GetBalance { reply } => { + // TODO: Make this all less repetitive/convoluted + let backend = { + let guard = st.lock().await; + guard.backend.clone() + }; + inner_for_task.handle_get_balance(backend, reply).await; + } + + Command::GetNetworkHeight { reply } => { + let backend = { + let guard = st.lock().await; + guard.backend.clone() + }; + inner_for_task + .handle_get_network_height(backend, reply) + .await; + } + + Command::StartSync => { + inner_for_task + .clone() + .handle_start_sync_spawn(st.clone()) + .await; + } + + Command::PauseSync => { + let backend = { + let guard = st.lock().await; + guard.backend.clone() + }; + inner_for_task.handle_pause_sync(backend).await; + } + + Command::Shutdown => break, + } + } + }); + }); + + Ok(Self { inner }) + } + + /// Installs a listener that receives asynchronous [`WalletEvent`] callbacks. + /// + /// The listener is invoked from the engine thread. Implementations must be: + /// - thread-safe (`Send + Sync`) + /// - fast / non-blocking (heavy work should be offloaded by the caller) + /// + /// If the listener panics, the engine catches the panic to avoid crashing the engine thread. + /// + /// Replaces any previously installed listener. + /// + /// ## Errors + /// Returns [`WalletError::ListenerLockPoisoned`] if the listener mutex is poisoned. + pub fn set_listener(&self, listener: Box) -> Result<(), WalletError> { + let mut guard = self + .inner + .listener + .lock() + .map_err(|_| WalletError::ListenerLockPoisoned)?; + *guard = Some(Arc::from(listener)); + Ok(()) + } + + /// Clears the currently installed listener, if any. + /// + /// After calling this, no further [`WalletEvent`] callbacks will be delivered until a new + /// listener is set via [`WalletEngine::set_listener`]. + /// + /// ## Errors + /// Returns [`WalletError::ListenerLockPoisoned`] if the listener mutex is poisoned. + pub fn clear_listener(&self) -> Result<(), WalletError> { + let mut guard = self + .inner + .listener + .lock() + .map_err(|_| WalletError::ListenerLockPoisoned)?; + *guard = None; + Ok(()) + } + + /// Initializes a brand-new wallet on the engine thread. + /// + /// This is the entrypoint for new wallets. It: + /// - Builds a [`ZingoConfig`] from the provided parameters. + /// - Queries the indexer for the latest block height to derive a conservative birthday. + /// - Constructs a new [`LightClient`], replacing any previously loaded wallet. + /// + /// This method is **blocking** by design. The async work is performed on the + /// engine thread and the result is returned via a oneshot reply channel. + /// + /// ## Parameters + /// - `indexer_uri`: zainod/lightwalletd URI, e.g. `http://localhost:9067` + /// - `chain`: chain selection (mainnet/testnet/regtest) + /// - `perf`: sync performance preset + /// - `minconf`: minimum confirmations for spendable funds. Must be >= 1. + /// + /// ## Events + /// Does not automatically start syncing. Call [`WalletEngine::start_sync`] to begin a sync round. + /// + /// ## Errors + /// - [`WalletError::CommandQueueClosed`] if the engine thread has exited. + /// - [`WalletError::Internal`] on config/build errors or indexer gRPC failures. + pub fn init_new( + &self, + indexer_uri: String, + chain: Chain, + perf: Performance, + minconf: u32, + ) -> Result<(), WalletError> { + let (reply_tx, reply_rx) = oneshot::channel(); + self.inner + .cmd_tx + .blocking_send(Command::InitNew { + indexer_uri, + chain, + perf, + minconf, + reply: reply_tx, + }) + .map_err(|_| WalletError::CommandQueueClosed)?; + + reply_rx + .blocking_recv() + .map_err(|_| WalletError::CommandQueueClosed)? + } + + /// Initializes a wallet from a seed phrase and explicit birthday height. + /// + /// This is the entrypoint for restoring from seed. It: + /// - Builds a [`ZingoConfig`] from the provided parameters. + /// - Parses the BIP39 mnemonic from `seed_phrase`. + /// - Constructs a [`LightWallet`] using the provided `birthday`. + /// - Creates a [`LightClient`] from that wallet, replacing any previously loaded wallet. + /// + /// This method is **blocking** by design (FFI-friendly). The async work is performed on the + /// engine thread and the result is returned via a oneshot reply channel. + /// + /// ## Parameters + /// - `seed_phrase`: BIP39 mnemonic words separated by spaces + /// - `birthday`: wallet birthday (starting scan height) + /// - `indexer_uri`: lightwalletd URI + /// - `chain`: chain selection (mainnet/testnet/regtest) + /// - `perf`: sync performance preset + /// - `minconf`: minimum confirmations for spendable funds. Must be >= 1. + /// + /// ## Events + /// Does not automatically start syncing. Call [`WalletEngine::start_sync`] to begin a sync round. + /// + /// ## Errors + /// - [`WalletError::CommandQueueClosed`] if the engine thread has exited. + /// - [`WalletError::Internal`] on config/mnemonic/wallet construction errors. + pub fn init_from_seed(&self, params: RestoreParams) -> Result<(), WalletError> { + let (reply_tx, reply_rx) = oneshot::channel(); + self.inner + .cmd_tx + .blocking_send(Command::InitFromSeed { + seed_phrase: params.seed_phrase.words, + birthday: params.birthday, + indexer_uri: params.indexer_uri, + chain: params.chain, + perf: params.perf, + minconf: params.minconf, + reply: reply_tx, + }) + .map_err(|_| WalletError::CommandQueueClosed)?; + + reply_rx + .blocking_recv() + .map_err(|_| WalletError::CommandQueueClosed)? + } + + pub fn init_from_ufvk(&self, params: UFVKImportParams) -> Result<(), WalletError> { + let (reply_tx, reply_rx) = oneshot::channel(); + self.inner + .cmd_tx + .blocking_send(Command::InitViewOnly { + viewing_key: params.ufvk, + birthday: params.birthday, + indexer_uri: params.indexer_uri, + chain: params.chain, + perf: params.perf, + minconf: params.minconf, + reply: reply_tx, + }) + .map_err(|_| WalletError::CommandQueueClosed)?; + + reply_rx + .blocking_recv() + .map_err(|_| WalletError::CommandQueueClosed)? + } + + /// Returns a snapshot of the wallet balance for Account 0. + /// + /// The returned [`BalanceSnapshot`] is a simplified, FFI-stable view derived from the + /// underlying zingolib [`AccountBalance`] type. + /// + /// This method is **blocking**. The balance query runs on the engine thread and the result + /// is returned via a oneshot reply channel. + /// + /// ## Errors + /// - [`WalletError::NotInitialized`] if no wallet has been initialized. + /// - [`WalletError::CommandQueueClosed`] if the engine thread has exited. + /// - [`WalletError::Internal`] if the underlying balance query fails. + pub fn get_balance_snapshot(&self) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.inner + .cmd_tx + .blocking_send(Command::GetBalance { reply: reply_tx }) + .map_err(|_| WalletError::CommandQueueClosed)?; + + reply_rx + .blocking_recv() + .map_err(|_| WalletError::CommandQueueClosed)? + } + + /// Returns the latest known network height from the configured indexer. + /// + /// This is a gRPC call to the indexer (`get_latest_block`) and is useful for: + /// - UI display (“current tip”) + /// - tests that need to observe tip movement independently of sync progress + /// + /// This method is **blocking**. The gRPC call runs on the engine thread and the result is returned + /// via a oneshot reply channel. + /// + /// ## Errors + /// - [`WalletError::NotInitialized`] if no indexer has been configured yet. + /// - [`WalletError::CommandQueueClosed`] if the engine thread has exited. + /// - [`WalletError::Internal`] if the indexer gRPC fails. + pub fn get_network_height(&self) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.inner + .cmd_tx + .blocking_send(Command::GetNetworkHeight { reply: reply_tx }) + .map_err(|_| WalletError::CommandQueueClosed)?; + + reply_rx + .blocking_recv() + .map_err(|_| WalletError::CommandQueueClosed)? + } + + /// Starts a **single manual sync round**. + /// + /// The sync round runs on the engine thread and emits events: + /// - [`WalletEvent::SyncStarted`] immediately when accepted + /// - repeated [`WalletEvent::SyncProgress`] updates while syncing + /// - optional [`WalletEvent::BalanceChanged`] updates when balance changes + /// - [`WalletEvent::SyncFinished`] once the sync completes successfully + /// - [`WalletEvent::Error`] if the sync fails + /// + /// ## Manual model + /// This method performs **one** round per invocation. It does not “follow” the chain forever. + /// If the network tip advances later and you want to catch up again, call `start_sync()` again. + /// + /// ## Reentrancy + /// If a sync is already running, additional `start_sync()` calls are ignored. + /// + /// ## Errors + /// Returns [`WalletError::CommandQueueClosed`] if the engine thread has exited. + /// If the wallet is not initialized, an async [`WalletEvent::Error`] is emitted with + /// `code="start_sync_failed"`. + pub fn start_sync(&self) -> Result<(), WalletError> { + self.inner + .cmd_tx + .try_send(Command::StartSync) + .map_err(|_| WalletError::CommandQueueClosed) + } + + /// Requests that an in-progress sync pause. + /// + /// This calls into zingolib's pause mechanism. If successful, the engine emits + /// [`WalletEvent::SyncPaused`]. + /// + /// Note: pausing is best-effort. If no wallet exists, an async [`WalletEvent::Error`] is emitted + /// with `code="pause_sync_failed"`. + /// + /// ## Errors + /// Returns [`WalletError::CommandQueueClosed`] if the engine thread has exited. + pub fn pause_sync(&self) -> Result<(), WalletError> { + self.inner + .cmd_tx + .try_send(Command::PauseSync) + .map_err(|_| WalletError::CommandQueueClosed) + } + + /// Shuts down the engine thread. + /// + /// This sends a shutdown command to the engine loop. After shutdown: + /// - all subsequent method calls that require the engine thread will typically fail with + /// [`WalletError::CommandQueueClosed`] + /// - no further [`WalletEvent`] callbacks will be delivered + /// + /// Shutdown is best-effort; the command is queued if possible. + /// + /// ## Errors + /// Returns [`WalletError::CommandQueueClosed`] if the command queue is already closed. + pub fn shutdown(&self) -> Result<(), WalletError> { + self.inner + .cmd_tx + .try_send(Command::Shutdown) + .map_err(|_| WalletError::CommandQueueClosed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc as std_mpsc; + + /// Test-only listener that forwards every [`WalletEvent`] it receives into a + /// standard-library `mpsc` channel. + /// + /// This is used in unit tests to: + /// - observe asynchronous events emitted by the engine thread + /// - make assertions about ordering (e.g. `EngineReady` then `SyncStarted`) + /// - avoid blocking the engine thread (sending into `std::sync::mpsc::Sender` is fast) + #[derive(Clone)] + struct CapturingListener { + tx: std_mpsc::Sender, + } + + impl WalletListener for CapturingListener { + fn on_event(&self, event: WalletEvent) { + let _ = self.tx.send(event); + } + } + + /// A listener that panics on every callback, to verify panic containment. + struct PanickingListener; + + impl WalletListener for PanickingListener { + fn on_event(&self, _event: WalletEvent) { + panic!("listener panicked"); + } + } + + mod fake_backend_tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + thread, + time::{Duration, Instant}, + }; + + use async_trait::async_trait; + use pepper_sync::{error::SyncError, sync::SyncResult, wallet::SyncMode}; + use tokio::sync::{Mutex, mpsc}; + use zingolib::data::PollReport; + + use crate::{ + BalanceSnapshot, Command, EngineInner, WalletBackend, WalletEngine, WalletEvent, + create_engine_runtime, emit, + error::WalletError, + state::EngineState, + tests::{CapturingListener, recv_timeout}, + }; + + struct FakeBackend { + start_sync_calls: AtomicUsize, + poll_calls: AtomicUsize, + balance_calls: AtomicUsize, + wallet_height: AtomicUsize, + network_height: AtomicUsize, + } + + impl FakeBackend { + fn new() -> Self { + Self { + start_sync_calls: AtomicUsize::new(0), + poll_calls: AtomicUsize::new(0), + balance_calls: AtomicUsize::new(0), + wallet_height: AtomicUsize::new(100), + network_height: AtomicUsize::new(200), + } + } + + fn start_sync_call_count(&self) -> usize { + self.start_sync_calls.load(Ordering::SeqCst) + } + } + + #[async_trait] + impl WalletBackend for FakeBackend { + async fn start_sync(&self) -> Result<(), String> { + self.start_sync_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn poll_sync(&self) -> PollReport> { + self.poll_calls.fetch_add(1, Ordering::SeqCst); + + // Keep the sync task "alive" but always yielding. + tokio::time::sleep(Duration::from_millis(25)).await; + + // Never finish; this is enough to prove responsiveness while "syncing". + PollReport::NotReady + } + + async fn pause_sync(&self) -> Result<(), String> { + Ok(()) + } + + async fn sync_mode(&self) -> SyncMode { + // We only need "not paused" for the engine loop to keep going. + // In production backends, this is a real mode. + // + // For tests, we avoid relying on non-Paused enum variant names (which may change) + // by using an unsafe transmute to "some other variant". + // + // SAFETY: this is test-only code; any mismatch just fails the test build. + #[allow(unsafe_code)] + unsafe { + // Choose a discriminant different than the one for `Paused`. + // This assumes `Paused` is not discriminant 0; if it is, swap 0/1. + // If this ever breaks, just update the number to match pepper_sync. + std::mem::transmute::(0) + } + } + + async fn wallet_height(&self) -> u32 { + self.wallet_height.load(Ordering::SeqCst) as u32 + } + + async fn balance_snapshot(&self) -> Option { + self.balance_calls.fetch_add(1, Ordering::SeqCst); + + // Fast path: returns immediately (this is what we want to verify is reachable + // while sync is running). + Some(BalanceSnapshot { + confirmed: "1".to_string(), + total: "2".to_string(), + }) + } + + async fn network_height(&self) -> u32 { + self.network_height.load(Ordering::SeqCst) as u32 + } + } + + fn spawn_test_engine_with_backend( + backend: Arc, + ) -> (WalletEngine, std::sync::mpsc::Receiver) { + let (cmd_tx, mut cmd_rx) = mpsc::channel::(64); + + let inner = Arc::new(EngineInner { + cmd_tx, + listener: std::sync::Mutex::new(None), + }); + + let engine = WalletEngine { + inner: inner.clone(), + }; + + let (ev_tx, ev_rx) = std::sync::mpsc::channel(); + engine + .set_listener(Box::new(CapturingListener { tx: ev_tx })) + .expect("set_listener"); + + thread::spawn(move || { + let rt = create_engine_runtime(); + rt.block_on(async move { + emit(&inner, WalletEvent::EngineReady); + + let st = Arc::new(Mutex::new(EngineState::new())); + + // IMPORTANT: do NOT use blocking_lock() inside runtime + { + let mut guard = st.lock().await; + guard.backend = Some(backend); + } + + while let Some(cmd) = cmd_rx.recv().await { + let backend = { + let guard = st.lock().await; + guard.backend.clone() + }; + match cmd { + Command::GetBalance { reply } => { + inner.handle_get_balance(backend, reply).await; + } + Command::GetNetworkHeight { reply } => { + inner.handle_get_network_height(backend, reply).await; + } + Command::StartSync => { + inner.clone().handle_start_sync_spawn(st.clone()).await; + } + Command::PauseSync => { + inner.handle_pause_sync(backend).await; + } + Command::Shutdown => break, + + // In this helper, init commands are disabled. + Command::InitNew { reply, .. } + | Command::InitFromSeed { reply, .. } + | Command::InitViewOnly { reply, .. } => { + let _ = reply.send(Err(WalletError::Internal( + "init disabled in fake backend tests".into(), + ))); + } + } + } + }); + }); + + (engine, ev_rx) + } + + /// Proves: starting sync does NOT make the engine loop unusable. + /// + /// Specifically: while the spawned sync task is running (and continuously polling), + /// we can still call get_balance_snapshot() and get an answer quickly. + #[test] + fn sync_does_not_block_get_balance_snapshot() { + let fake = Arc::new(FakeBackend::new()); + let (engine, rx) = spawn_test_engine_with_backend(fake); + + // EngineReady + let ev = recv_timeout(&rx, Duration::from_secs(2)); + assert!(matches!(ev, WalletEvent::EngineReady), "got: {ev:?}"); + + engine.start_sync().expect("start_sync"); + + // SyncStarted + let ev = recv_timeout(&rx, Duration::from_secs(2)); + assert!(matches!(ev, WalletEvent::SyncStarted), "got: {ev:?}"); + + // Hammer balance while sync loop is active. + for i in 0..30 { + let t0 = Instant::now(); + let bal = engine.get_balance_snapshot().expect("balance snapshot"); + let dt = t0.elapsed(); + + assert_eq!(bal.confirmed, "1"); + assert_eq!(bal.total, "2"); + + // Thread hop and oneshot should stay well under this. + assert!( + dt < Duration::from_millis(150), + "get_balance_snapshot call {i} too slow: {dt:?} (sync may be blocking)" + ); + + // small sleep so we interleave with poll ticks (is this truly necessary though?) + std::thread::sleep(Duration::from_millis(10)); + } + + engine.shutdown().ok(); + } + + /// Proves: StartSync is re-entrancy protected (second call is ignored while syncing=true). + /// + /// This also indirectly proves the command loop remains responsive enough to *process* + /// multiple StartSync commands while a sync task is running. + #[test] + fn start_sync_is_idempotent_while_running() { + let fake = Arc::new(FakeBackend::new()); + let (engine, rx) = spawn_test_engine_with_backend(fake.clone()); + + let _ = recv_timeout(&rx, Duration::from_secs(2)); // EngineReady + + engine.start_sync().expect("start_sync #1"); + let ev = recv_timeout(&rx, Duration::from_secs(2)); + assert!(matches!(ev, WalletEvent::SyncStarted), "got: {ev:?}"); + + // Should be ignored. Should not emit SyncStarted. TODO: how can we assert that? + engine.start_sync().expect("start_sync #2"); + + // Give a little time for command loop + potential bogus second start + std::thread::sleep(Duration::from_millis(200)); + + assert_eq!( + fake.start_sync_call_count(), + 1, + "backend.start_sync() was called more than once; StartSync was not guarded by syncing flag" + ); + + engine.shutdown().ok(); + } + + /// Proves that while sync task is running we still see progress events, + /// indicating the runtime is scheduling both the sync task and the command loop. + #[test] + fn sync_task_runs_concurrently_with_command_loop() { + let fake_backend = Arc::new(FakeBackend::new()); + let (engine, rx) = spawn_test_engine_with_backend(fake_backend); + + let _ = recv_timeout(&rx, Duration::from_secs(2)); // EngineReady + + engine.start_sync().expect("start_sync"); + let event = recv_timeout(&rx, Duration::from_secs(2)); + assert!(matches!(event, WalletEvent::SyncStarted), "got: {event:?}"); + + // We should see at least one SyncProgress fairly soon. + // (FakeBackend.poll_sync sleeps 25ms and returns NotReady, so engine loop should emit progress regularly.) + let deadline = Instant::now() + Duration::from_secs(2); + let mut saw_progress = false; + + while Instant::now() < deadline { + let ev = recv_timeout(&rx, Duration::from_millis(250)); + if matches!(ev, WalletEvent::SyncProgress { .. }) { + saw_progress = true; + break; + } + } + + assert!( + saw_progress, + "never saw SyncProgress while sync task running" + ); + + // While progress events are flowing, also do a balance call to ensure the engine loop services commands. + let bal = engine.get_balance_snapshot().expect("balance snapshot"); + assert_eq!(bal.total, "2"); + + engine.shutdown().ok(); + } + } + + fn recv_timeout(rx: &std_mpsc::Receiver, dur: Duration) -> WalletEvent { + rx.recv_timeout(dur).expect("timeout waiting for event") + } + + #[test] + fn emits_engine_ready() { + let engine = WalletEngine::new().expect("engine new"); + + let (tx, rx) = std_mpsc::channel(); + engine + .set_listener(Box::new(CapturingListener { tx })) + .expect("set_listener"); + + let ev = recv_timeout(&rx, Duration::from_secs(2)); + assert!(matches!(ev, WalletEvent::EngineReady), "got: {ev:?}"); + } + + #[test] + fn get_balance_snapshot_errors_when_not_initialized() { + let engine = WalletEngine::new().expect("engine new"); + let res = engine.get_balance_snapshot(); + assert!( + matches!(res, Err(WalletError::NotInitialized)), + "got: {res:?}" + ); + } + + #[test] + fn start_sync_emits_error_when_not_initialized() { + let engine = WalletEngine::new().expect("engine new"); + + let (tx, rx) = std_mpsc::channel(); + engine + .set_listener(Box::new(CapturingListener { tx })) + .expect("set_listener"); + + let _ = recv_timeout(&rx, Duration::from_secs(2)); + + engine.start_sync().expect("start_sync send command"); + + loop { + let ev = recv_timeout(&rx, Duration::from_secs(2)); + match ev { + WalletEvent::Error { code, message } => { + assert_eq!(code, "start_sync_failed"); + assert!(!message.is_empty()); + break; + } + _ => {} + } + } + } + + #[test] + fn listener_panics_do_not_crash_engine_thread() { + let engine = WalletEngine::new().expect("engine new"); + + engine + .set_listener(Box::new(PanickingListener)) + .expect("set_listener panicking"); + + // Trigger something that will cause a callback (and panic). + engine.start_sync().expect("start_sync send command"); + + // Give engine time to process callback panic. + std::thread::sleep(Duration::from_millis(200)); + + // Swap in capturing listener; if engine thread died, no more events ever. + let (tx, rx) = std_mpsc::channel(); + engine + .set_listener(Box::new(CapturingListener { tx })) + .expect("set_listener capturing"); + + // We should get EngineReady? Not necessarily (already emitted). + // Trigger pause (will error because not initialized), and we should receive it. + engine.pause_sync().expect("pause_sync send command"); + + loop { + let ev = recv_timeout(&rx, Duration::from_secs(2)); + if let WalletEvent::Error { code, .. } = ev { + assert_eq!(code, "pause_sync_failed"); + break; + } + } + } + + /// Requires network up + #[test] + #[ignore = "requires non-existing running regtest networkd"] + fn real_sync_smoke() { + let indexer_uri = "http://localhost:20956".to_string(); + let chain = Chain::Regtest; + let perf = Performance::High; + let minconf: u32 = 1; + + let engine = WalletEngine::new().expect("engine new"); + + let (tx, rx) = std_mpsc::channel(); + engine + .set_listener(Box::new(CapturingListener { tx })) + .expect("set_listener"); + + // Expect EngineReady + let _ = recv_timeout(&rx, Duration::from_secs(2)); + + engine + .init_new(indexer_uri, chain, perf, minconf) + .expect("init_new"); + + engine.start_sync().expect("start_sync"); + + let deadline = std::time::Instant::now() + Duration::from_secs(90); + loop { + if std::time::Instant::now() > deadline { + panic!("timeout waiting for SyncFinished"); + } + + let ev = recv_timeout(&rx, Duration::from_secs(5)); + match ev { + WalletEvent::SyncFinished => break, + WalletEvent::Error { code, message } => { + panic!("sync error: {code} {message}"); + } + _ => {} + } + } + + let bal = engine.get_balance_snapshot().expect("balance snapshot"); + eprintln!("balance after sync: {bal:?}"); + } + + /// Real sync smoke test (requires a running regtest lightwalletd at the URI). + /// Run manually: + /// cargo test -p ffi real_sync_progress_smoke -- --ignored --nocapture + #[test] + #[ignore = "requires non-existing running regtest networkd"] + fn real_sync_progress_smoke() { + let indexer_uri = "http://localhost:20956".to_string(); + let chain = Chain::Regtest; + let perf = Performance::High; + let minconf: u32 = 1; + + let engine = WalletEngine::new().expect("engine new"); + + let (tx, rx) = std_mpsc::channel(); + engine + .set_listener(Box::new(CapturingListener { tx })) + .expect("set_listener"); + + // Expect EngineReady + let _ = recv_timeout(&rx, Duration::from_secs(2)); + + engine + .init_new(indexer_uri, chain, perf, minconf) + .expect("init_new"); + + engine.start_sync().expect("start_sync"); + + let deadline = std::time::Instant::now() + Duration::from_secs(120); + + let mut saw_started = false; + let mut saw_progress = false; + let mut last_percent: f32 = 0.0; + + loop { + if std::time::Instant::now() > deadline { + panic!( + "timeout waiting for SyncFinished (started={saw_started}, progress={saw_progress})" + ); + } + + let ev = recv_timeout(&rx, Duration::from_secs(5)); + match ev { + WalletEvent::SyncStarted => { + saw_started = true; + eprintln!("[sync] started"); + } + + WalletEvent::SyncProgress { + wallet_height, + network_height, + percent, + } => { + // Require at least one progress tick. + saw_progress = true; + + if percent + 0.05 < last_percent { + eprintln!( + "[sync] WARNING: percent regressed: {last_percent:.3} -> {percent:.3}" + ); + } + last_percent = percent; + + eprintln!( + "[sync] progress: wallet_height={wallet_height} network_height={network_height} percent={percent:.3}" + ); + } + + WalletEvent::BalanceChanged(bal) => { + eprintln!("[sync] balance changed: {bal:?}"); + } + + WalletEvent::SyncPaused => { + panic!("sync paused unexpectedly"); + } + + WalletEvent::SyncFinished => { + eprintln!("[sync] finished"); + break; + } + + WalletEvent::Error { code, message } => { + panic!("sync error: {code} {message}"); + } + + other => { + eprintln!("[sync] other event: {other:?}"); + } + } + } + + assert!(saw_started, "never saw SyncStarted"); + assert!(saw_progress, "never saw SyncProgress"); + + let bal = engine.get_balance_snapshot().expect("balance snapshot"); + eprintln!("balance after sync: {bal:?}"); + } + + /// Smoke test: sync to tip, then restart sync every 10 seconds until we + /// observe >= 5 distinct *new* network heights (via SyncProgress) beyond + /// the initial tip. + /// + /// This does NOT query latest block height externally. + /// It relies purely on SyncProgress events emitted during each sync run. + #[test] + #[ignore = "requires non-existing running regtest networkd"] + fn real_sync_observe_5_new_block_heights_smoke() { + use std::collections::BTreeSet; + use std::sync::mpsc as std_mpsc; + use std::time::{Duration, Instant}; + + let indexer_uri = "http://localhost:18892".to_string(); + let chain = Chain::Regtest; + let perf = Performance::High; + let minconf: u32 = 1; + + let engine = WalletEngine::new().expect("engine new"); + + let (tx, rx) = std_mpsc::channel(); + engine + .set_listener(Box::new(CapturingListener { tx })) + .expect("set_listener"); + + // Best-effort wait for EngineReady. + let _ = recv_timeout(&rx, Duration::from_secs(2)); + + engine + .init_new(indexer_uri, chain, perf, minconf) + .expect("init_new"); + + // TODO: refactor. run a sync to SyncFinished and return the last seen (wh, nh) from progress. + fn sync_to_finished_and_get_last_progress( + engine: &WalletEngine, + rx: &std_mpsc::Receiver, + timeout: Duration, + label: &str, + ) -> (u32, u32) { + engine.start_sync().expect("start_sync"); + eprintln!("[{label}] started sync"); + + let deadline = Instant::now() + timeout; + let mut last_progress: Option<(u32, u32)> = None; + + loop { + if Instant::now() > deadline { + panic!("[{label}] timeout waiting for SyncFinished"); + } + + let ev = recv_timeout(rx, Duration::from_secs(5)); + match ev { + WalletEvent::SyncProgress { + wallet_height, + network_height, + percent, + } => { + last_progress = Some((wallet_height, network_height)); + eprintln!( + "[{label}] progress: wh={wallet_height} nh={network_height} pct={percent:.3}" + ); + } + WalletEvent::SyncFinished => { + let (wh, nh) = last_progress.unwrap_or((0, 0)); + eprintln!("[{label}] SyncFinished (last wh={wh} nh={nh})"); + return (wh, nh); + } + WalletEvent::Error { code, message } => { + panic!("[{label}] sync error: {code} {message}"); + } + _ => {} + } + } + } + + let per_sync_timeout = Duration::from_secs(90); + let overall_deadline = Instant::now() + Duration::from_secs(10 * 60); // 10 minutes + + let (_wh0, nh0) = + sync_to_finished_and_get_last_progress(&engine, &rx, per_sync_timeout, "initial"); + if nh0 == 0 { + eprintln!("[follow-test] warning: initial nh0=0 (did you connect to lightwalletd?)"); + } + eprintln!("[follow-test] baseline nh0={nh0}"); + + let mut observed_new_heights: BTreeSet = BTreeSet::new(); + + while observed_new_heights.len() < 5 { + if Instant::now() > overall_deadline { + panic!( + "timeout waiting for >= 5 distinct new network heights > nh0={nh0}. saw {}: {:?}", + observed_new_heights.len(), + observed_new_heights + ); + } + + // Restart sync every 10 seconds (mining frequency unknown). TODO: make this better somehow + std::thread::sleep(Duration::from_secs(10)); + engine.start_sync().expect("start_sync (restart)"); + eprintln!( + "[follow-test] restart sync attempt; currently have {}/5 heights", + observed_new_heights.len() + ); + + // For this run, we listen for progress (and/or finish). Any progress nh > nh0 counts. + let run_deadline = Instant::now() + per_sync_timeout; + loop { + if Instant::now() > run_deadline { + eprintln!("[follow-test] restart run timed out; will try again"); + break; + } + + let ev = recv_timeout(&rx, Duration::from_secs(5)); + match ev { + WalletEvent::SyncProgress { + wallet_height, + network_height, + percent, + } => { + if network_height > nh0 { + let inserted = observed_new_heights.insert(network_height); + if inserted { + eprintln!( + "[follow-test] NEW network_height observed: nh={network_height} (wh={wallet_height} pct={percent:.3}) distinct={}/5", + observed_new_heights.len() + ); + } else { + eprintln!( + "[follow-test] progress: wh={wallet_height} nh={network_height} pct={percent:.3} distinct={}/5", + observed_new_heights.len() + ); + } + } else { + eprintln!( + "[follow-test] progress (no new blocks): wh={wallet_height} nh={network_height} pct={percent:.3}" + ); + } + + if observed_new_heights.len() >= 5 { + break; + } + } + WalletEvent::SyncFinished => { + eprintln!("[follow-test] SyncFinished (restart run)"); + break; + } + WalletEvent::Error { code, message } => { + panic!("[follow-test] sync error while observing: {code} {message}"); + } + _ => {} + } + } + } + + eprintln!( + "[follow-test] PASS: observed >=5 distinct new network heights beyond nh0={nh0}: {:?}", + observed_new_heights + ); + + let bal = engine.get_balance_snapshot().expect("balance snapshot"); + eprintln!("[follow-test] balance after follow: {bal:?}"); + } +} diff --git a/ffi/rust/src/state.rs b/ffi/rust/src/state.rs new file mode 100644 index 000000000..0a3762028 --- /dev/null +++ b/ffi/rust/src/state.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use tokio::{sync::RwLock, task::JoinHandle}; +use zingolib::lightclient::LightClient; + +use crate::{BalanceSnapshot, WalletBackend, ZingolibBackend}; + +pub(crate) struct EngineState { + pub backend: Option>, + pub syncing: bool, + pub sync_task: Option>, + pub last_balance: Option, +} + +impl EngineState { + /// Fresh engine state with no wallet loaded yet. + /// + /// Call `init_new/init_from_seed/init_from_ufvk` to install a real backend later. + pub(crate) fn new() -> Self { + Self { + backend: None, + syncing: false, + sync_task: None, + last_balance: None, + } + } + + /// Convenience constructor that injects a backend directly. + pub(crate) fn with_backend(backend: Arc) -> Self { + Self { + backend: Some(backend), + syncing: false, + sync_task: None, + last_balance: None, + } + } + + /// Replace any existing backend. + pub(crate) fn set_backend(&mut self, backend: Arc) { + self.backend = Some(backend); + self.last_balance = None; + self.syncing = false; + self.sync_task = None; + } + + /// Clear the backend. + pub(crate) fn clear_backend(&mut self) { + self.backend = None; + self.last_balance = None; + self.syncing = false; + self.sync_task = None; + } +} diff --git a/justfile b/justfile new file mode 100644 index 000000000..1633d73a8 --- /dev/null +++ b/justfile @@ -0,0 +1,31 @@ +# justfile + +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +# Default task +default: build bindings + +# Build the ffi crate in release mode +build: + cargo build -p ffi --release + +# Generate all bindings (Kotlin & Swift) +bindings: build kotlin swift + +# Generate Kotlin bindings +kotlin: build + cargo run --bin generate-bindings generate \ + --library target/release/libffi.dylib \ + --language kotlin \ + --out-dir ffi/rust/uniffi-output + +# Generate Swift bindings +swift: build + cargo run --bin generate-bindings generate \ + --library target/release/libffi.dylib \ + --language swift \ + --out-dir ffi/rust/uniffi-output + +# Clean build artifacts +clean: + cargo clean