diff --git a/package.json b/package.json index 5203371..a2a2481 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@lendasat/lendaswap-sdk-pure": "^0.2.24", "@tanstack/react-router": "^1.166.3", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-http": "^2", "@tauri-apps/plugin-opener": "^2", "@walletconnect/sign-client": "^2.23.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 150f2f1..5f715a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@tauri-apps/api': specifier: ^2 version: 2.10.1 + '@tauri-apps/plugin-clipboard-manager': + specifier: ^2.3.2 + version: 2.3.2 '@tauri-apps/plugin-http': specifier: ^2 version: 2.5.8 @@ -1227,6 +1230,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-clipboard-manager@2.3.2': + resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} + '@tauri-apps/plugin-http@2.5.8': resolution: {integrity: sha512-oxd7oypzQeu8kAfFCrw534Kq7Cw+NzozcnCY21O4rz3A+veJiIiuSCMIprgGcZOcLAXFP9GmDhKUbhuKWcunRw==} @@ -3559,6 +3565,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1 + '@tauri-apps/plugin-clipboard-manager@2.3.2': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-http@2.5.8': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b583f40..debd3bf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -92,10 +92,31 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "ark-bdk-wallet" -version = "0.9.2" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.2#8c0e8e3d91ab80e8107415072cf6351573eac19f" +version = "0.9.3" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.3#677b1c2d1ef68ac6e19b68048c8810e27001acf7" dependencies = [ "anyhow", "ark-client", @@ -113,8 +134,8 @@ dependencies = [ [[package]] name = "ark-client" -version = "0.9.2" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.2#8c0e8e3d91ab80e8107415072cf6351573eac19f" +version = "0.9.3" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.3#677b1c2d1ef68ac6e19b68048c8810e27001acf7" dependencies = [ "ark-core", "ark-delegator", @@ -152,8 +173,8 @@ dependencies = [ [[package]] name = "ark-core" -version = "0.9.2" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.2#8c0e8e3d91ab80e8107415072cf6351573eac19f" +version = "0.9.3" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.3#677b1c2d1ef68ac6e19b68048c8810e27001acf7" dependencies = [ "bech32", "bitcoin", @@ -173,8 +194,8 @@ dependencies = [ [[package]] name = "ark-delegator" -version = "0.9.2" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.2#8c0e8e3d91ab80e8107415072cf6351573eac19f" +version = "0.9.3" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.3#677b1c2d1ef68ac6e19b68048c8810e27001acf7" dependencies = [ "ark-core", "bitcoin", @@ -186,16 +207,16 @@ dependencies = [ [[package]] name = "ark-fees" -version = "0.9.2" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.2#8c0e8e3d91ab80e8107415072cf6351573eac19f" +version = "0.9.3" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.3#677b1c2d1ef68ac6e19b68048c8810e27001acf7" dependencies = [ "cel", ] [[package]] name = "ark-grpc" -version = "0.9.2" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.2#8c0e8e3d91ab80e8107415072cf6351573eac19f" +version = "0.9.3" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.3#677b1c2d1ef68ac6e19b68048c8810e27001acf7" dependencies = [ "ark-core", "async-stream", @@ -479,6 +500,7 @@ dependencies = [ "subtle", "tauri", "tauri-build", + "tauri-plugin-clipboard-manager", "tauri-plugin-http", "tauri-plugin-opener", "thiserror 2.0.18", @@ -641,8 +663,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ "bitcoin_hashes", - "rand 0.7.3", - "rand_core 0.5.1", + "rand 0.8.5", + "rand_core 0.6.4", "serde", "unicode-normalization", "zeroize", @@ -821,6 +843,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -925,7 +953,7 @@ dependencies = [ "antlr4rust", "chrono", "lazy_static", - "nom", + "nom 7.1.3", "paste", "regex", "serde", @@ -1018,6 +1046,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "combine" version = "4.6.7" @@ -1185,6 +1222,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1365,7 +1408,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1429,6 +1472,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1554,9 +1603,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "esplora-client" version = "0.11.0" @@ -1609,6 +1664,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1964,6 +2025,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -2213,6 +2284,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2562,7 +2644,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2679,6 +2761,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2818,7 +2914,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3219,6 +3315,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.1" @@ -3234,7 +3340,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -3330,6 +3436,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nostr" version = "0.42.2" @@ -3402,7 +3517,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3499,6 +3614,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -3697,6 +3813,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "pango" version = "0.18.3" @@ -4028,7 +4154,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -4046,6 +4172,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -4220,7 +4359,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools", "log", "multimap", @@ -4315,6 +4454,18 @@ dependencies = [ "pulldown-cmark", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -4324,6 +4475,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4376,7 +4536,7 @@ dependencies = [ "once_cell", "socket2 0.6.3", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4774,7 +4934,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5883,7 +6043,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -5930,6 +6090,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-fs" version = "2.5.0" @@ -6110,7 +6285,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6173,6 +6348,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -6673,12 +6862,23 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6730,7 +6930,7 @@ checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7102,6 +7302,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.91" @@ -7220,6 +7490,12 @@ dependencies = [ "windows-core", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -7252,7 +7528,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7854,6 +8130,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.2" @@ -7926,6 +8220,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "yoke" version = "0.8.1" @@ -8110,6 +8421,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 85641c9..437eec2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ dotenvy = "0.15" [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" +tauri-plugin-clipboard-manager = "2" # Proxy SDK fetches through Rust so mobile WebView origins aren't blocked by # api.lendaswap.com's CORS whitelist (desktop localhost:1420 is allowed; the # Android `http://tauri.localhost` and iOS `tauri://localhost` origins are not). @@ -32,10 +33,10 @@ thiserror = { version = "2", default-features = false } tokio = { version = "1", default-features = false, features = ["sync", "fs", "time"] } # Ark protocol -ark-client = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.2", default-features = false, features = ["tls-webpki-roots", "sqlite"] } -ark-core = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.2" } -ark-bdk-wallet = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.2" } -ark-grpc = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.2" } +ark-client = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.3", default-features = false, features = ["tls-webpki-roots", "sqlite"] } +ark-core = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.3" } +ark-bdk-wallet = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.3" } +ark-grpc = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.3" } # Bitcoin / BIP39 bip39 = { version = "2", features = ["rand", "zeroize"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 65f6ae9..349831d 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,8 @@ "permissions": [ "core:default", "opener:default", + "clipboard-manager:allow-read-text", + "clipboard-manager:allow-write-text", { "identifier": "opener:allow-open-url", "allow": [ diff --git a/src-tauri/gen/android/app/tauri.properties b/src-tauri/gen/android/app/tauri.properties index 6c552ad..b0e9bcf 100644 --- a/src-tauri/gen/android/app/tauri.properties +++ b/src-tauri/gen/android/app/tauri.properties @@ -1,3 +1,3 @@ // THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -tauri.android.versionName=0.3.1 -tauri.android.versionCode=3001 \ No newline at end of file +tauri.android.versionName=0.4.0 +tauri.android.versionCode=4000 \ No newline at end of file diff --git a/src-tauri/src/ark.rs b/src-tauri/src/ark.rs index 6282446..a12f9ac 100644 --- a/src-tauri/src/ark.rs +++ b/src-tauri/src/ark.rs @@ -177,8 +177,16 @@ impl Blockchain for EsploraBlockchain { let estimates = esplora_retry(|| self.client.get_fee_estimates()) .await .map_err(Error::consumer)?; - // Target ~6 blocks confirmation, fall back to 1.0 sat/vB - Ok(estimates.get(&6).copied().unwrap_or(1.0)) + + // Target ~6 blocks. If esplora didn't return that exact target, use the + // nearest available target rather than collapsing to 1.0 sat/vB, which + // would under-price a sweep in a busy mempool. Only a truly empty + // estimate set falls back to 1.0. + let nearest = estimates + .iter() + .min_by_key(|(target, _)| (**target as i64 - 6).abs()) + .map(|(_, rate)| *rate); + Ok(estimates.get(&6).copied().or(nearest).unwrap_or(1.0)) } /// Broadcasts transactions sequentially. **Not atomic**: Esplora has no diff --git a/src-tauri/src/commands/recovery.rs b/src-tauri/src/commands/recovery.rs index 002fb77..aa7cfd8 100644 --- a/src-tauri/src/commands/recovery.rs +++ b/src-tauri/src/commands/recovery.rs @@ -456,11 +456,15 @@ pub(crate) async fn refresh_unilateral_exit_cache_for_client( let result = build_exit_trees_best_effort(app, &client).await?; + let server_info = client + .server_info() + .map_err(|e| AppError::Wallet(format!("Failed to read ASP server info: {e}")))?; + if result.branches.is_empty() && !result.failed_outpoints.is_empty() { return Ok(UnilateralExitCacheStatus { exists: false, generated_at: None, - network: Some(client.server_info.network.to_string()), + network: Some(server_info.network.to_string()), branch_count: 0, tx_count: 0, failed_count: result.failed_outpoints.len(), @@ -484,12 +488,12 @@ pub(crate) async fn refresh_unilateral_exit_cache_for_client( .collect::>(); let last_error = result.last_error; - let (server_pk, _parity) = client.server_info.signer_pk.x_only_public_key(); + let (server_pk, _parity) = server_info.signer_pk.x_only_public_key(); let cache = UnilateralExitCache { version: CACHE_VERSION, generated_at: now_unix()?, - asp_digest: client.server_info.digest.clone(), - network: client.server_info.network.to_string(), + asp_digest: server_info.digest.clone(), + network: server_info.network.to_string(), server_pk: Some(server_pk.to_string()), branch_count: cached_branches.len(), tx_count, @@ -497,8 +501,8 @@ pub(crate) async fn refresh_unilateral_exit_cache_for_client( branches: cached_branches, sweep_candidates: merge_sweep_candidates(result.sources.clone(), result.unrolled), branch_sources: result.sources, - exit_delay_seconds: exit_delay_to_seconds(client.server_info.unilateral_exit_delay), - dust_sat: Some(client.server_info.dust.to_sat()), + exit_delay_seconds: exit_delay_to_seconds(server_info.unilateral_exit_delay), + dust_sat: Some(server_info.dust.to_sat()), }; let path = unilateral_exit_cache_path(app)?; @@ -1488,9 +1492,171 @@ pub async fn unilateral_exit_sweep_status( }) } -/// The SDK's `send_on_chain` uses the same fixed fee; mirrored here so the -/// offline path produces an identical transaction shape. -const SWEEP_FEE: bitcoin::Amount = bitcoin::Amount::from_sat(1_000); +/// Upper bound on VTXO owner-key derivation indices pre-derived into the local +/// lookup map. Derivation is local-only (no network), so this is generous. It is +/// only a *fast path*: when connected, a miss falls back to the live key +/// provider's `get_keypair_for_pk`, which searches up to the wallet's real +/// high-water mark — so there is no ceiling online. Offline (no live provider) a +/// key beyond this bound is logged and skipped, since there's no cached +/// high-water mark to search against. +const MAX_VTXO_KEY_SCAN: u32 = 1_000; + +/// Relative (CSV) timelocks are enforced by consensus against a block's +/// median-time-past, which lags wall-clock by up to ~2h. Maturity is judged +/// against `now - MTP_SAFETY_MARGIN` so a sweep is never built that's final by +/// wall-clock but non-final by MTP (which the relay would reject as non-BIP68). +const MTP_SAFETY_MARGIN: Duration = Duration::from_secs(2 * 60 * 60); + +/// A single sweep input, reduced to the raw parts needed to build + sign the +/// taproot exit-script spend (boarding outputs and unrolled VTXOs both reduce +/// to this shape). +struct SweepInput { + outpoint: bitcoin::OutPoint, + sequence: bitcoin::Sequence, + witness_utxo: bitcoin::TxOut, + exit_script: bitcoin::ScriptBuf, + control_block: bitcoin::taproot::ControlBlock, +} + +/// Absolute fee for a sweep of `vsize` vbytes at `fee_rate_sat_vb`, floored at +/// 1 sat/vB so it always clears the relay minimum. The SDK's `send_on_chain` +/// (and `create_unilateral_exit_transaction`) reserve **no** fee — they send +/// every input sat to the destination + change, producing a 0-fee tx the relay +/// rejects ("min relay fee not met"). avark builds the sweep itself and sizes +/// the fee from the live fee rate instead. See upstream-send-on-chain-fee-issue.md. +fn sweep_fee(vsize: u64, fee_rate_sat_vb: f64) -> bitcoin::Amount { + let rate = if fee_rate_sat_vb.is_finite() && fee_rate_sat_vb > 1.0 { + fee_rate_sat_vb + } else { + 1.0 + }; + bitcoin::Amount::from_sat(((vsize as f64) * rate).ceil() as u64) +} + +/// Build and sign the sweep transaction. Mirrors ark-core's +/// `create_unilateral_exit_transaction` (the proven taproot script-path +/// signing sequence) but reserves `fee` from the change instead of producing a +/// 0-fee transaction. Change below `dust` is dropped (rolled into the fee). +fn build_sweep_tx( + to_address: &bitcoin::Address, + to_amount: bitcoin::Amount, + change_address: &bitcoin::Address, + fee: bitcoin::Amount, + dust: bitcoin::Amount, + inputs: &[SweepInput], + sign_fn: S, +) -> Result +where + S: Fn( + &mut bitcoin::psbt::Input, + bitcoin::secp256k1::Message, + ) -> Result< + Vec<( + bitcoin::secp256k1::schnorr::Signature, + bitcoin::XOnlyPublicKey, + )>, + ark_core::Error, + >, +{ + use bitcoin::hashes::Hash as _; + use bitcoin::sighash::{Prevouts, SighashCache, TapSighashType}; + use bitcoin::taproot::TapLeafHash; + + if inputs.is_empty() { + return Err(AppError::Wallet("No inputs to sweep".into())); + } + + let total: bitcoin::Amount = inputs.iter().map(|i| i.witness_utxo.value).sum(); + let spend = to_amount + fee; + let change = total.checked_sub(spend).ok_or_else(|| { + AppError::Wallet(format!( + "Insufficient funds for sweep: need {spend} (amount {to_amount} + fee {fee}), have {total}" + )) + })?; + + let mut output = vec![bitcoin::TxOut { + value: to_amount, + script_pubkey: to_address.script_pubkey(), + }]; + // Change below dust is uneconomical to create — leave it in the fee. + if change >= dust { + output.push(bitcoin::TxOut { + value: change, + script_pubkey: change_address.script_pubkey(), + }); + } + + let input = inputs + .iter() + .map(|i| bitcoin::TxIn { + previous_output: i.outpoint, + sequence: i.sequence, + ..Default::default() + }) + .collect(); + + let mut psbt = bitcoin::psbt::Psbt::from_unsigned_tx(Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input, + output, + }) + .map_err(|e| AppError::Wallet(format!("Failed to build sweep PSBT: {e}")))?; + + // Attach witness_utxo + the taproot exit leaf for every input. + for (i, pin) in psbt.inputs.iter_mut().enumerate() { + let si = &inputs[i]; + pin.witness_utxo = Some(si.witness_utxo.clone()); + let leaf_version = si.control_block.leaf_version; + pin.tap_scripts.insert( + si.control_block.clone(), + (si.exit_script.clone(), leaf_version), + ); + } + + let prevouts = inputs + .iter() + .map(|i| i.witness_utxo.clone()) + .collect::>(); + let secp = bitcoin::key::Secp256k1::new(); + + for (i, pin) in psbt.inputs.iter_mut().enumerate() { + let (control_block, (exit_script, leaf_version)) = pin + .tap_scripts + .pop_first() + .ok_or_else(|| AppError::Wallet(format!("No exit script for sweep input {i}")))?; + + pin.witness_script = Some(exit_script.clone()); + + let leaf_hash = TapLeafHash::from_script(&exit_script, leaf_version); + let sighash = SighashCache::new(&psbt.unsigned_tx) + .taproot_script_spend_signature_hash( + i, + &Prevouts::All(&prevouts), + leaf_hash, + TapSighashType::Default, + ) + .map_err(|e| AppError::Wallet(format!("Failed to compute sweep sighash: {e}")))?; + let msg = bitcoin::secp256k1::Message::from_digest(sighash.to_raw_hash().to_byte_array()); + + let sigs = sign_fn(pin, msg) + .map_err(|e| AppError::Wallet(format!("Failed to sign sweep input {i}: {e}")))?; + + let mut witness: Vec> = Vec::new(); + for (sig, pk) in sigs.iter() { + secp.verify_schnorr(sig, &msg, pk).map_err(|e| { + AppError::Wallet(format!("Sweep signature failed verification: {e}")) + })?; + witness.push(sig[..].to_vec()); + } + witness.push(exit_script.as_bytes().to_vec()); + witness.push(control_block.serialize()); + pin.final_script_witness = Some(bitcoin::Witness::from_slice(&witness)); + } + + psbt.extract_tx() + .map_err(|e| AppError::Wallet(format!("Failed to extract sweep tx: {e}"))) +} /// Offline replica of the SDK's `Client::send_on_chain` (which hard-requires a /// connected client even though — per the SDK's own TODO — nothing about a @@ -1502,15 +1668,19 @@ const SWEEP_FEE: bitcoin::Amount = bitcoin::Amount::from_sat(1_000); /// indices and checking esplora for on-chain history, stopping after a /// gap-limit run of unused keys — the esplora analogue of the SDK's /// ASP-backed `discover_keys`. -async fn sweep_unrolled_offline( +async fn sweep_unrolled( app: &tauri::AppHandle, + client: Option>, + // The live key provider when connected: its `get_keypair_for_pk` searches up + // to the wallet's real high-water mark (and hits the populated cache), so a + // VTXO whose owner key sits beyond MAX_VTXO_KEY_SCAN is still resolvable. + live_key_provider: Option>, to_address: bitcoin::Address, to_amount: bitcoin::Amount, ) -> Result { use ark_client::wallet::{BoardingWallet, OnchainWallet}; use ark_client::{Blockchain as _, KeyProvider as _}; use ark_core::script::extract_checksig_pubkeys; - use ark_core::unilateral_exit::{create_unilateral_exit_transaction, OnChainInput, VtxoInput}; use ark_core::{ExplorerUtxo, Vtxo}; let cache = read_cache(app).await?.ok_or_else(|| { @@ -1563,20 +1733,25 @@ async fn sweep_unrolled_offline( // existing ones via discovery indices. let key_provider = ark_client::Bip32KeyProvider::new_with_index(xpriv, derivation_path, 0); - let target = to_amount + SWEEP_FEE; let now = Duration::from_secs(now_unix()? as u64); + // Conservative "now" for maturity: see MTP_SAFETY_MARGIN. Used for both + // boarding outputs and VTXOs so the two input classes apply one rule. + let mature_now = now.saturating_sub(MTP_SAFETY_MARGIN); let mut selected = bitcoin::Amount::ZERO; - // Boarding outputs first, mirroring the SDK's selection priority. - let mut onchain_inputs: Vec = Vec::new(); + // Select *all* mature outputs rather than stopping at a fixed headroom: the + // network fee is sized dynamically below, and a fixed headroom (e.g. 1000 + // sats) silently strands inputs whenever the real fee is larger, making a + // near-max sweep impossible exactly when fees are high. Each extra input adds + // far more value than its marginal fee, so taking them all maximises the + // headroom available to cover the fee. The requested amount is clamped to + // what's actually left after the fee. + let mut inputs: Vec = Vec::new(); for boarding_output in ctx .wallet .get_boarding_outputs() .map_err(|e| AppError::Wallet(format!("Failed to list boarding outputs: {e}")))? { - if selected >= target { - break; - } let utxos = tokio::time::timeout( ctx.timeout, ctx.blockchain.find_outpoints(boarding_output.address()), @@ -1595,28 +1770,42 @@ async fn sweep_unrolled_offline( } = utxo { if boarding_output.can_be_claimed_unilaterally_by_owner( - now, + mature_now, Duration::from_secs(blocktime), confirmations, ) { - onchain_inputs.push(OnChainInput::new( - boarding_output.clone(), - amount, + let (exit_script, control_block) = boarding_output.exit_spend_info(); + inputs.push(SweepInput { outpoint, - )); + sequence: boarding_output.exit_delay(), + witness_utxo: bitcoin::TxOut { + value: amount, + script_pubkey: boarding_output.address().script_pubkey(), + }, + exit_script, + control_block, + }); selected += amount; } } } } - // Then unrolled VTXOs, discovered by deriving keys and asking esplora. + // Then unrolled VTXOs. Discovery is driven by the *cached sweep candidates* + // — the exact outpoints `unilateral_exit_sweep_status` surfaces to the UI — + // not an on-chain address scan. A virtual VTXO key has no on-chain history + // until it's unrolled, so a gap-limit scan keyed on on-chain presence bails + // (after DEFAULT_GAP_LIMIT empties) long before reaching an unrolled VTXO at + // a higher derivation index, finding nothing. We resolve each candidate's + // scriptPubKey to a VTXO, then its owner key to a derived keypair. let secp = bitcoin::key::Secp256k1::new(); let mut keypairs: HashMap = HashMap::new(); - let mut vtxo_inputs: Vec = Vec::new(); - let mut consecutive_unused = 0u32; - let mut index = 0u32; - while selected < target && consecutive_unused < ark_client::DEFAULT_GAP_LIMIT { + + // owner pubkey -> keypair, for signing the exit-leaf spend. Derivation is + // local (no network), so the bound can be generous. + let mut owner_to_keypair: HashMap = + HashMap::new(); + for index in 0..MAX_VTXO_KEY_SCAN { let keypair = match key_provider .derive_at_discovery_index(index) .map_err(|e| AppError::Wallet(format!("Key derivation failed: {e}")))? @@ -1624,62 +1813,166 @@ async fn sweep_unrolled_offline( Some(kp) => kp, None => break, }; - index += 1; + owner_to_keypair.insert(keypair.x_only_public_key().0, keypair); + } - let owner_pk = keypair.x_only_public_key().0; - let vtxo = Vtxo::new_default(&secp, server_pk, owner_pk, exit_delay, network) - .map_err(|e| AppError::Wallet(format!("Failed to derive VTXO script: {e}")))?; + // scriptPubKey -> VTXO. When connected, use the client's authoritative + // address set (`get_offchain_addresses` — exactly what the SDK's own + // coin-selection uses), which covers every VTXO variant (default, + // delegator, custom scripts). Offline, fall back to reconstructing the + // default (2-leaf) VTXO per derived key — correct for ordinary receives, + // though it can't reproduce non-default scripts without the ASP. + let mut script_to_vtxo: HashMap = HashMap::new(); + match &client { + Some(client) => { + for (_, vtxo) in client + .get_offchain_addresses() + .map_err(|e| AppError::Wallet(format!("Failed to list offchain addresses: {e}")))? + { + script_to_vtxo.insert(vtxo.script_pubkey(), vtxo); + } + } + None => { + for keypair in owner_to_keypair.values() { + let owner_pk = keypair.x_only_public_key().0; + let vtxo = Vtxo::new_default(&secp, server_pk, owner_pk, exit_delay, network) + .map_err(|e| AppError::Wallet(format!("Failed to derive VTXO script: {e}")))?; + script_to_vtxo.insert(vtxo.script_pubkey(), vtxo); + } + } + } - let utxos = - tokio::time::timeout(ctx.timeout, ctx.blockchain.find_outpoints(vtxo.address())) - .await - .map_err(|_| AppError::Wallet("Timed out querying VTXO outpoints".into()))? - .map_err(|e| AppError::Wallet(format!("Failed to query VTXO outpoints: {e}")))?; + let candidates = merge_sweep_candidates(cache.sweep_candidates, cache.branch_sources); + for candidate in &candidates { + let outpoint: bitcoin::OutPoint = match candidate.outpoint.parse() { + Ok(o) => o, + Err(e) => { + warn!(outpoint = %candidate.outpoint, %e, "invalid outpoint in recovery cache; skipping"); + continue; + } + }; + + // The on-chain exit leaf: its scriptPubKey identifies the owner key, and + // its value is needed for the (taproot) sighash. + let tx = match tokio::time::timeout(ctx.timeout, ctx.blockchain.find_tx(&outpoint.txid)) + .await + .map_err(|_| { + AppError::Wallet(format!("Timed out fetching exit leaf {}", outpoint.txid)) + })? + .map_err(|e| { + AppError::Wallet(format!("Failed to fetch exit leaf {}: {e}", outpoint.txid)) + })? { + Some(tx) => tx, + None => continue, // exit leaf not on-chain yet + }; + let txout = match tx.output.get(outpoint.vout as usize) { + Some(o) => o.clone(), + None => { + warn!(outpoint = %candidate.outpoint, "cached outpoint vout out of range; skipping"); + continue; + } + }; - if utxos.is_empty() { - consecutive_unused += 1; + // Resolve the VTXO + its signing key first + let vtxo = match script_to_vtxo.get(&txout.script_pubkey) { + Some(v) => v, + None => { + warn!( + outpoint = %candidate.outpoint, + "no known VTXO matches this exit leaf; skipping (refresh while connected?)" + ); + continue; + } + }; + let owner_pk = vtxo.owner_pk(); + // Fast path: the locally-derived 0..MAX_VTXO_KEY_SCAN map. + let keypair = match owner_to_keypair.get(&owner_pk).copied().or_else(|| { + live_key_provider + .as_ref() + .and_then(|kp| kp.get_keypair_for_pk(&owner_pk).ok()) + }) { + Some(kp) => kp, + None => { + warn!( + outpoint = %candidate.outpoint, %owner_pk, + "no signing key for this VTXO's owner (beyond local scan window and \ + no live key provider); skipping" + ); + continue; + } + }; + + // Already spent (our prior sweep, or a counterparty checkpoint) — not ours. + let spend_txid = tokio::time::timeout( + ctx.timeout, + ctx.blockchain + .get_output_status(&outpoint.txid, outpoint.vout), + ) + .await + .map_err(|_| { + AppError::Wallet(format!( + "Timed out checking spend status for {}", + candidate.outpoint + )) + })? + .map_err(|e| { + AppError::Wallet(format!( + "Failed to check spend status for {}: {e}", + candidate.outpoint + )) + })? + .spend_txid; + if spend_txid.is_some() { continue; } - consecutive_unused = 0; - for utxo in &utxos { - if let ExplorerUtxo { - outpoint, - amount, - confirmation_blocktime: Some(blocktime), - confirmations, - is_spent: false, - } = utxo - { - if vtxo.can_be_claimed_unilaterally_by_owner( - now, - Duration::from_secs(*blocktime), - *confirmations, - ) { - let spend_info = vtxo.exit_spend_info().map_err(|e| { - AppError::Wallet(format!("Failed to derive exit spend info: {e}")) - })?; - vtxo_inputs.push(VtxoInput::new( - *outpoint, - vtxo.exit_delay(), - bitcoin::TxOut { - value: *amount, - script_pubkey: vtxo.script_pubkey(), - }, - spend_info, - )); - keypairs.insert(owner_pk, keypair); - selected += *amount; - } - } + // The VTXO's *own* CSV exit delay must have elapsed — the same per-output + // rule the boarding path uses, against `mature_now` (MTP-margined), rather + // than the cached server-wide delay against raw wall-clock. + let confirmed_at = + tokio::time::timeout(ctx.timeout, ctx.blockchain.get_tx_status(&outpoint.txid)) + .await + .map_err(|_| { + AppError::Wallet(format!( + "Timed out checking maturity for {}", + candidate.outpoint + )) + })? + .map_err(|e| { + AppError::Wallet(format!( + "Failed to check maturity for {}: {e}", + candidate.outpoint + )) + })? + .confirmed_at; + let confirmed = match confirmed_at { + Some(t) if t >= 0 => Duration::from_secs(t as u64), + _ => continue, + }; + if !vtxo.can_be_claimed_unilaterally_by_owner(mature_now, confirmed, 0) { + continue; } + + let (exit_script, control_block) = vtxo + .exit_spend_info() + .map_err(|e| AppError::Wallet(format!("Failed to derive exit spend info: {e}")))?; + let value = txout.value; + inputs.push(SweepInput { + outpoint, + sequence: vtxo.exit_delay(), + witness_utxo: txout, + exit_script, + control_block, + }); + keypairs.insert(owner_pk, keypair); + selected += value; } - if selected < target { - return Err(AppError::Wallet(format!( - "Insufficient mature funds to sweep: found {selected}, need {target} \ - (amount + {SWEEP_FEE} fee). Outputs still inside the exit delay are not selectable." - ))); + if inputs.is_empty() { + return Err(AppError::Wallet( + "No mature funds to sweep. Outputs still inside the exit delay are not selectable yet." + .into(), + )); } let change_address = ctx @@ -1709,15 +2002,61 @@ async fn sweep_unrolled_offline( Ok(res) }; - let tx = create_unilateral_exit_transaction( - to_address, + let dust = bitcoin::Amount::from_sat(dust_sat); + + // Size the fee from the live fee rate. Build once at zero fee to measure the + // signed vsize (taproot script-path inputs dominate it). The probe amount is + // capped to `selected` so the build can't fail on an over-large output, and + // it keeps a change output so the probe is never smaller than the final tx + let probe_amount = to_amount.min(selected); + let probe = build_sweep_tx( + &to_address, + probe_amount, + &change_address, + bitcoin::Amount::ZERO, + dust, + &inputs, + &sign, + )?; + let vsize = probe.vsize() as u64; + + // Surface an estimate failure instead of silently broadcasting at the + // relay-minimum: an under-priced emergency sweep can sit unconfirmed for a + // long time while the exit output stays exposed. If esplora is reachable + // enough to broadcast the sweep, it can answer this too (it retries + // internally), so failing here is a transient-retry signal, not a dead end. + let fee_rate = ctx.blockchain.get_fee_rate().await.map_err(|e| { + AppError::Wallet(format!( + "Couldn't estimate the network fee (block explorer unreachable): {e}. Try again." + )) + })?; + let fee = sweep_fee(vsize, fee_rate); + + // Clamp the sent amount to what's actually left after the fee. A near-max + // request (the UI prefills total minus a rough buffer) then sends + // `selected - fee` with no change instead of failing when the real fee + // exceeds that buffer; a genuine partial request is sent as-is, the rest as + // change. Only a pool too small to cover fee + dust is a hard error. + let max_sendable = selected + .checked_sub(fee) + .filter(|a| *a >= dust) + .ok_or_else(|| { + AppError::Wallet(format!( + "Mature funds ({selected}) can't cover the network fee ({fee}) plus the dust \ + minimum. Wait for more outputs to mature." + )) + })?; + let to_amount = to_amount.min(max_sendable); + + let tx = build_sweep_tx( + &to_address, to_amount, - change_address, - &onchain_inputs, - &vtxo_inputs, - sign, - ) - .map_err(|e| AppError::Wallet(format!("Failed to build sweep transaction: {e}")))?; + &change_address, + fee, + dust, + &inputs, + &sign, + )?; let txid = tx.compute_txid(); tokio::time::timeout(ctx.timeout, ctx.blockchain.broadcast(&tx)) @@ -1738,9 +2077,12 @@ async fn sweep_unrolled_offline( /// will accept the tx but it won't mine until the timelock elapses — so the /// caller should ideally only sweep mature outputs. /// -/// Uses the SDK's `send_on_chain` when a connected client exists, and the -/// offline replica otherwise — the sweep must work while the ASP is down, -/// because that's the scenario the emergency-exit flow exists for. +/// Always builds the sweep itself (the SDK's `send_on_chain` reserves no fee and +/// broadcasts a 0-fee tx the relay rejects — "min relay fee not met"). When a +/// live client exists it supplies the authoritative VTXO set (covering every +/// address variant); otherwise the builder falls back to the recovery cache + +/// esplora + locally-derived keys, so the sweep still works with the ASP down — +/// the emergency-exit scenario the flow exists for. #[tauri::command(rename_all = "camelCase")] pub async fn sweep_unrolled_to_onchain( app: tauri::AppHandle, @@ -1758,21 +2100,21 @@ pub async fn sweep_unrolled_to_onchain( .map_err(|e| AppError::Wallet(format!("Invalid network in wallet.json: {e}")))?; let btc_addr = super::send::parse_onchain_address(&address, network)?; - let client = { + let (client, key_provider) = { let state = app.state::(); let guard = state.0.read().await; - guard.as_ref().map(|ws| Arc::clone(&ws.client)) + match guard.as_ref() { + Some(ws) => ( + Some(Arc::clone(&ws.client)), + Some(Arc::clone(&ws.key_provider)), + ), + None => (None, None), + } }; - info!(address = %address, amount_sat, offline = client.is_none(), "sweeping unrolled VTXOs to onchain"); + info!(address = %address, amount_sat, connected = client.is_some(), "sweeping unrolled VTXOs to onchain"); - let txid = match client { - Some(client) => client - .send_on_chain(btc_addr, amount) - .await - .map_err(|e| AppError::Wallet(format!("Sweep failed: {e}")))?, - None => sweep_unrolled_offline(&app, btc_addr, amount).await?, - }; + let txid = sweep_unrolled(&app, client, key_provider, btc_addr, amount).await?; info!(txid = %txid, "swept unrolled VTXOs"); Ok(txid.to_string()) @@ -1789,6 +2131,24 @@ mod tests { Txid::from_byte_array([n; 32]) } + #[test] + fn sweep_fee_scales_with_rate_and_size() { + // 150 vB at 10 sat/vB → 1500 sats. + assert_eq!(sweep_fee(150, 10.0), bitcoin::Amount::from_sat(1500)); + // Rounds up to clear the relay minimum. + assert_eq!(sweep_fee(150, 1.5), bitcoin::Amount::from_sat(225)); + assert_eq!(sweep_fee(151, 1.5), bitcoin::Amount::from_sat(227)); + } + + #[test] + fn sweep_fee_floors_at_one_sat_per_vbyte() { + // A degenerate/zero/sub-1 rate must never produce a below-relay fee: + // floor at vsize (1 sat/vB) so the tx always clears "min relay fee not met". + assert_eq!(sweep_fee(200, 0.0), bitcoin::Amount::from_sat(200)); + assert_eq!(sweep_fee(200, 0.5), bitcoin::Amount::from_sat(200)); + assert_eq!(sweep_fee(200, f64::NAN), bitcoin::Amount::from_sat(200)); + } + fn node(id: u8, tx_type: ChainedTxType, spends: &[u8]) -> VtxoChain { VtxoChain { txid: txid(id), diff --git a/src-tauri/src/commands/send.rs b/src-tauri/src/commands/send.rs index 1f10e24..1446c7a 100644 --- a/src-tauri/src/commands/send.rs +++ b/src-tauri/src/commands/send.rs @@ -360,7 +360,11 @@ pub async fn send_onchain( Arc::clone(&ws.client) }; - let btc_addr = parse_onchain_address(&address, client.server_info.network)?; + let network = client + .server_info() + .map_err(|e| AppError::Wallet(format!("Failed to read ASP server info: {e}")))? + .network; + let btc_addr = parse_onchain_address(&address, network)?; info!(address = %address, amount_sat = amount_sat, "offboarding to onchain"); @@ -405,7 +409,11 @@ pub async fn estimate_onchain_send_fee( Arc::clone(&ws.client) }; - let btc_addr = parse_onchain_address(&address, client.server_info.network)?; + let network = client + .server_info() + .map_err(|e| AppError::Wallet(format!("Failed to read ASP server info: {e}")))? + .network; + let btc_addr = parse_onchain_address(&address, network)?; let mut last_err = String::new(); for attempt in 1..=FEE_ESTIMATE_MAX_RETRIES { diff --git a/src-tauri/src/commands/wallet.rs b/src-tauri/src/commands/wallet.rs index 86e029e..0fe726b 100644 --- a/src-tauri/src/commands/wallet.rs +++ b/src-tauri/src/commands/wallet.rs @@ -423,36 +423,28 @@ pub async fn is_wallet_loaded(app: tauri::AppHandle) -> bool { loaded } -#[tauri::command] -pub async fn connect_wallet(app: tauri::AppHandle) -> Result<(), AppError> { - info!("connecting wallet to ASP"); - - let creation_lock = app.state::(); - let _guard = creation_lock.0.lock().await; - - let global = app.state::(); - if global.0.read().await.is_some() { - debug!("Ark client already initialized, skipping connect"); - return Ok(()); - } - - let wallet_data = read_wallet_data(&app).await?; - let xpriv = derive_wallet_xpriv(&app, &wallet_data)?; +/// Build the Ark client from the on-disk wallet data and store it in +/// `GlobalWalletState` +async fn reconnect_wallet_client(app: &tauri::AppHandle) -> Result<(), AppError> { + let wallet_data = read_wallet_data(app).await?; + let xpriv = derive_wallet_xpriv(app, &wallet_data)?; let custom_esplora = { let state = app.state::(); let _lock = state.0.read().await; - read_settings(&app).await.ok().and_then(|s| s.esplora_url) + read_settings(app).await.ok().and_then(|s| s.esplora_url) }; let (client, wallet_arc, swap_storage, key_provider) = - build_ark_client(&app, xpriv, &wallet_data.asp_url, custom_esplora.as_deref()).await?; + build_ark_client(app, xpriv, &wallet_data.asp_url, custom_esplora.as_deref()).await?; - let sync_cancel = spawn_onchain_sync(Arc::clone(&wallet_arc), &app).await; + let sync_cancel = spawn_onchain_sync(Arc::clone(&wallet_arc), app).await; let client_arc = Arc::new(client); let swap_storage_arc = Arc::clone(&swap_storage); let client_for_recovery = Arc::clone(&client_arc); let key_provider_for_recovery = Arc::clone(&key_provider); let (wallet_cancel, cancel_rx) = tokio::sync::watch::channel(()); + + let global = app.state::(); *global.0.write().await = Some(AppWalletState { client: Arc::clone(&client_arc), wallet: wallet_arc, @@ -466,15 +458,101 @@ pub async fn connect_wallet(app: tauri::AppHandle) -> Result<(), AppError> { client_for_recovery, swap_storage_arc, key_provider_for_recovery, - &app, + app, cancel_rx, ); spawn_unilateral_exit_cache_refresh(app.clone(), Arc::clone(&client_arc)); + Ok(()) +} + +#[tauri::command] +pub async fn connect_wallet(app: tauri::AppHandle) -> Result<(), AppError> { + info!("connecting wallet to ASP"); + + let creation_lock = app.state::(); + let _guard = creation_lock.0.lock().await; + + let global = app.state::(); + if global.0.read().await.is_some() { + debug!("Ark client already initialized, skipping connect"); + return Ok(()); + } + + reconnect_wallet_client(&app).await?; + info!("wallet connected successfully"); Ok(()) } +/// Switch the wallet to a different ASP. +#[tauri::command(rename_all = "camelCase")] +pub async fn switch_asp(app: tauri::AppHandle, url: String) -> Result { + info!(url = %url, "switching ASP"); + + let creation_lock = app.state::(); + let _guard = creation_lock.0.lock().await; + + // Probe the target ASP (validates URL + connects, persists nothing). + let probed = crate::probe_asp_server(&url).await?; + + // Hard-block a network change — funds live on the current ASP's network + let current_network = match read_wallet_data(&app).await { + Ok(w) if !w.network.is_empty() => Some(w.network), + _ => { + let state = app.state::(); + let _lock = state.0.read().await; + read_settings(&app).await?.network + } + }; + let current = current_network.as_deref().ok_or_else(|| { + AppError::Asp( + "Can't determine the wallet's current network, so the ASP switch is blocked to \ + avoid a network mismatch that could strand funds. Reconnect and try again." + .into(), + ) + })?; + if current != probed.network { + return Err(AppError::Asp(format!( + "Network mismatch: current ASP is on {current}, the new ASP is on {}. \ + Switching networks would strand your funds.", + probed.network + ))); + } + + // Persist the new ASP to settings.json (Settings display) and wallet.json + { + let state = app.state::(); + let _lock = state.0.write().await; + let mut settings = read_settings(&app).await?; + settings.asp_url = Some(url.clone()); + settings.network = Some(probed.network.clone()); + write_settings(&app, &settings).await?; + } + let wallet_data = WalletData { + asp_url: url.clone(), + network: probed.network.clone(), + }; + let path = wallet_path(&app)?; + tokio::fs::write(&path, serde_json::to_string_pretty(&wallet_data)?).await?; + + // Tear down the old client (drops its sync/recovery tasks), then rebuild + // against the new ASP. + { + let global = app.state::(); + *global.0.write().await = None; + } + if let Err(e) = reconnect_wallet_client(&app).await { + warn!(error = %e, "ASP switched in settings but reconnect failed"); + return Err(AppError::Asp(format!( + "ASP updated, but reconnecting failed: {e}. Restart the app to retry." + ))); + } + + info!(network = %probed.network, "ASP switched successfully"); + Ok(probed) +} + #[tauri::command] pub async fn delete_wallet(app: tauri::AppHandle) -> Result<(), AppError> { warn!("deleting wallet data"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index db42474..3313549 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -191,16 +191,14 @@ async fn set_onboarding_seen(app: tauri::AppHandle) -> Result<(), AppError> { } #[derive(Serialize)] -struct AspInfo { - network: String, - version: String, +pub(crate) struct AspInfo { + pub(crate) network: String, + pub(crate) version: String, } -#[tauri::command] -async fn connect_asp(app: tauri::AppHandle, url: String) -> Result { - info!(url = %url, "connecting to ASP"); - - let parsed = url::Url::parse(&url).map_err(|e| AppError::Asp(format!("Invalid URL: {e}")))?; +/// Validate `url` and test-connect to the ASP, returning its network + version. +pub(crate) async fn probe_asp_server(url: &str) -> Result { + let parsed = url::Url::parse(url).map_err(|e| AppError::Asp(format!("Invalid URL: {e}")))?; if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::Asp("URL scheme must be http or https".into())); } @@ -208,7 +206,7 @@ async fn connect_asp(app: tauri::AppHandle, url: String) -> Result Result Result { + info!(url = %url, "connecting to ASP"); + + let info = probe_asp_server(&url).await?; + info!(network = %info.network, version = %info.version, "ASP connected"); let state = app.state::(); let _lock = state.0.write().await; let mut settings = read_settings(&app).await?; settings.asp_url = Some(url); - settings.network = Some(info.network.to_string()); + settings.network = Some(info.network.clone()); write_settings(&app, &settings).await?; - Ok(AspInfo { - network: info.network.to_string(), - version: info.version, - }) + Ok(info) +} + +/// Test-connect to a candidate ASP without persisting anything +#[tauri::command] +async fn probe_asp(url: String) -> Result { + info!(url = %url, "probing ASP"); + probe_asp_server(&url).await } // ── App entry point ───────────────────────────────────────────────────── @@ -254,6 +268,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_clipboard_manager::init()) .plugin(secure_storage::init()) .manage(SettingsLock(RwLock::new(()))) .manage(GlobalWalletState(RwLock::new(None))) @@ -291,6 +306,7 @@ pub fn run() { has_seen_onboarding, set_onboarding_seen, connect_asp, + probe_asp, // Settings commands::settings::settings, commands::settings::set_theme, @@ -313,6 +329,7 @@ pub fn run() { commands::wallet::load_wallet_local, commands::wallet::is_wallet_loaded, commands::wallet::connect_wallet, + commands::wallet::switch_asp, commands::wallet::delete_wallet, // Wallet data commands::wallet::get_balance, diff --git a/src/App.css b/src/App.css index 553cc0d..d7bdfa8 100644 --- a/src/App.css +++ b/src/App.css @@ -401,3 +401,17 @@ .animate-shake { animation: shake 0.4s ease-in-out; } + +/* Kill the Android/iOS WebView media-controls overlay (the big centre play + button + start-playback button) on the QR scanner's