diff --git a/.gitignore b/.gitignore index 0f75e9bda9..11d27c8c8f 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 5f3a1593b4..594fe11751 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,9 +190,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "append-only-vec" @@ -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.117", +] + +[[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" @@ -299,7 +341,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] @@ -311,7 +353,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -340,7 +382,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -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" @@ -559,7 +610,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -577,7 +628,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -722,7 +773,7 @@ checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -776,7 +827,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -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.117", +] + [[package]] name = "clap_lex" version = "1.0.0" @@ -1403,7 +1499,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1486,7 +1582,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1508,7 +1604,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1569,14 +1665,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5f2b7218a51c827a11d22d1439b598121fac94bf9b99452e4afffe512d78c9" dependencies = [ "heck", - "indexmap 2.13.0", + "indexmap 1.9.3", "itertools 0.14.0", "proc-macro-crate", "proc-macro2", "quote", "sha3", "strum", - "syn 2.0.116", + "syn 2.0.117", "void", ] @@ -1588,7 +1684,7 @@ checksum = "74ef43543e701c01ad77d3a5922755c6a1d71b22d942cb8042be4994b380caff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1652,7 +1748,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.116", + "syn 2.0.117", "unicode-xid", ] @@ -1744,7 +1840,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1791,7 +1887,7 @@ checksum = "0b0713d5c1d52e774c5cd7bb8b043d7c0fc4f921abfb678556140bfbe6ab2364" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1905,7 +2001,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1917,7 +2013,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -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" @@ -2262,7 +2383,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2376,7 +2497,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -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" @@ -2916,7 +3048,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3130,7 +3262,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3792,7 +3924,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4009,7 +4141,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4186,7 +4318,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4215,14 +4347,14 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -4257,6 +4389,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" @@ -4357,7 +4495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4419,7 +4557,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4478,7 +4616,7 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.116", + "syn 2.0.117", "tempfile", ] @@ -4492,7 +4630,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4569,7 +4707,7 @@ dependencies = [ "derive-deftly", "libc", "paste", - "thiserror 2.0.18", + "thiserror 1.0.69", ] [[package]] @@ -4658,9 +4796,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -4924,7 +5062,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4952,9 +5090,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rend" @@ -5163,7 +5301,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.116", + "syn 2.0.117", "walkdir", ] @@ -5483,6 +5621,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.117", +] + [[package]] name = "sct" version = "0.7.1" @@ -5589,6 +5747,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" @@ -5636,7 +5798,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5720,7 +5882,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5917,6 +6079,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" @@ -6049,7 +6217,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6071,9 +6239,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.116" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -6097,7 +6265,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6152,7 +6320,16 @@ checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", ] [[package]] @@ -6181,7 +6358,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6192,7 +6369,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6307,7 +6484,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6478,7 +6655,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6503,7 +6680,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn 2.0.116", + "syn 2.0.117", "tempfile", "tonic-build", ] @@ -7547,7 +7724,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7627,7 +7804,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" dependencies = [ "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7638,7 +7815,7 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7750,6 +7927,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.117", +] + +[[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.117", + "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" @@ -7847,7 +8157,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8019,7 +8329,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -8107,6 +8417,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" @@ -8216,7 +8535,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8227,7 +8546,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8573,7 +8892,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn 2.0.116", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -8589,7 +8908,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -8683,7 +9002,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] @@ -8938,9 +9257,9 @@ dependencies = [ [[package]] name = "zcash_script" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bed6cf5b2b4361105d4ea06b2752f0c8af4641756c7fbc9858a80af186c234f" +checksum = "c6ef9d04e0434a80b62ad06c5a610557be358ef60a98afa5dbc8ecaf19ad72e7" dependencies = [ "bip32", "bitflags 2.11.0", @@ -9261,7 +9580,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9281,7 +9600,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] @@ -9302,7 +9621,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9336,7 +9655,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9420,7 +9739,7 @@ dependencies = [ [[package]] name = "zingo-status" -version = "0.2.0" +version = "0.1.0" dependencies = [ "byteorder", "zcash_primitives", diff --git a/Cargo.toml b/Cargo.toml index f18e9a9c52..ccd90e0ec9 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,12 @@ 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" } + +[patch.crates-io] +# Necessary to cross-compile for armv7a. Hopefully this merges upstream soon. +# zcash_script = { git = "https://github.com/dorianvp/zcash_script", rev = "a86c8a81f76e9dc6b3dc27c508f4c34fa77a6da3" } +# libzcash_script = { git = "https://github.com/dorianvp/zcash_script", rev = "a86c8a81f76e9dc6b3dc27c508f4c34fa77a6da3" } [profile.test] opt-level = 3 - diff --git a/ffi/rust/.gitignore b/ffi/rust/.gitignore new file mode 100644 index 0000000000..de2f0d0c2a --- /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 0000000000..b2590cfc4a --- /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 0000000000..7be468e830 --- /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 0000000000..4f0001367c --- /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 0000000000..f6cff6cf1d --- /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 0000000000..379a79cc5d --- /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 0000000000..f21c1e7a88 --- /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 0000000000..50649fafda --- /dev/null +++ b/ffi/rust/src/lib.rs @@ -0,0 +1,1787 @@ +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, + }, + Unloaded, +} + +#[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> { + let mut guard = self.lc.write().await; + + match guard.poll_sync() { + PollReport::NotReady => PollReport::NotReady, + PollReport::NoHandle => PollReport::NoHandle, + PollReport::Ready(Ok(r)) => PollReport::Ready(Ok(r)), + PollReport::Ready(Err(e)) => PollReport::Ready(Err(map_sync_error_from_zingolib(e))), + } + } + + 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(), + } +} + +fn map_sync_error_from_zingolib( + e: SyncError, +) -> SyncError { + use pepper_sync::error::SyncError as SE; + + match e { + SE::MempoolError(x) => SE::MempoolError(x), + SE::ScanError(x) => SE::ScanError(x), + SE::ServerError(x) => SE::ServerError(x), + SE::SyncModeError(x) => SE::SyncModeError(x), + SE::ChainError(a, b, c) => SE::ChainError(a, b, c), + SE::ShardTreeError(x) => SE::ShardTreeError(x), + SE::TruncationError(a, b) => SE::TruncationError(a, b), + SE::TransparentAddressDerivationError(x) => SE::TransparentAddressDerivationError(x), + SE::BirthdayBelowSapling(a, b) => SE::BirthdayBelowSapling(a, b), + + SE::WalletError(w) => SE::WalletError(crate::error::WalletError::Internal(w.to_string())), + } +} + +struct EngineInner { + cmd_tx: mpsc::Sender, + listener: std::sync::Mutex>>, +} + +impl EngineInner { + pub(crate) async fn handle_unload_wallet( + self: Arc, + engine_state: Arc>, + reply: oneshot::Sender>, + ) { + let (backend, was_syncing, sync_task) = { + let mut state = engine_state.lock().await; + + let backend = state.backend.clone(); + let was_syncing = state.syncing; + + let sync_task = state.sync_task.take(); + + state.syncing = false; + + (backend, was_syncing, sync_task) + }; + + if was_syncing { + if let Some(task) = sync_task { + task.abort(); + } + + // TODO: maybe remove, or put first + if let Some(b) = backend.as_ref() { + let _ = b.pause_sync().await; // TODO: this should be replaced with the future "stop" command + } + } + + { + let mut state = engine_state.lock().await; + state.backend = None; + state.last_balance = None; + } + + emit(&self, WalletEvent::Unloaded); + + let _ = reply.send(Ok(())); + } + + 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, + ShutdownSync, + Unload { + reply: oneshot::Sender>, + }, +} + +#[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") +} + +#[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::Unload { reply } => { + inner_for_task + .clone() + .handle_unload_wallet(st.clone(), 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::ShutdownSync => 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::ShutdownSync) + .map_err(|_| WalletError::CommandQueueClosed) + } + + /// Shuts down the engine thread and unloads the wallet from memory. + pub fn unload_wallet(&self) -> Result<(), WalletError> { + let (reply_tx, reply_rx) = oneshot::channel(); + self.inner + .cmd_tx + .blocking_send(Command::Unload { reply: reply_tx }) + .map_err(|_| WalletError::CommandQueueClosed)?; + + reply_rx + .blocking_recv() + .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::ShutdownSync => break, + Command::InitNew { reply, .. } + | Command::InitFromSeed { reply, .. } + | Command::InitViewOnly { reply, .. } => { + let _ = reply.send(Err(WalletError::Internal( + "init disabled in fake backend tests".into(), + ))); + } + Command::Unload { reply } => todo!(), + } + } + }); + }); + + (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 0000000000..2fec9dcb37 --- /dev/null +++ b/ffi/rust/src/state.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use crate::{BalanceSnapshot, WalletBackend}; + +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, + } + } + + /// 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; + } +} diff --git a/ffi/rust/uniffi.toml b/ffi/rust/uniffi.toml new file mode 100644 index 0000000000..b286f52fbe --- /dev/null +++ b/ffi/rust/uniffi.toml @@ -0,0 +1,6 @@ +[bindings.kotlin] +package_name = "ZingolibFfi" + +[bindings.swift] +module_name = "zingolib_ffi" +ffi_module_filename = "zingolib_ffi" diff --git a/justfile b/justfile new file mode 100644 index 0000000000..d85b77af31 --- /dev/null +++ b/justfile @@ -0,0 +1,39 @@ +# justfile + +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +# Runs `build` and `bindings` +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/kotlin + +cargo-ndk: kotlin + cargo ndk \ + -t arm64-v8a \ + build --release -p ffi + +copy-kt-bindings: kotlin + cp -r ffi/rust/uniffi-output/kotlin/ZingolibFfi ffi/android/ZingolibFfi/zingolibffi/src/main/java/ + +# Generate Swift bindings +swift: build + cargo run --bin generate-bindings generate \ + --library target/release/libffi.dylib \ + --language swift \ + --out-dir ffi/rust/uniffi-output/swift + +# Clean build artifacts +clean: + cargo clean