From cbf0553afeb331104eb8527dbe7d4a8bdaac5c47 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 27 Jul 2026 16:00:26 +0800 Subject: [PATCH 01/13] feat: cold wallet signing over QR (Keystone / Quantus cold wallet app) Adds watch-only cold wallets (`wallet import-cold`) and QR-based signing for `send`: the CLI displays the raw V4 signing payload as a ur:quantus-sign-request QR, scans the device's animated signature UR with the laptop camera (or file/stdin for headless use), verifies the response against the stored address, and submits. Speaks the exact wire protocol of the mobile app / cold wallet app / Keystone firmware (quantus_ur tag 1.4.0). Includes a hidden `developer cold-sign-sim` command that plays the cold-wallet side with a local hot wallet for dev-node e2e testing. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1061 ++++++++++++++++++++++++++++++++++++--- Cargo.toml | 15 + README.md | 50 ++ src/cli/cold_signing.rs | 641 +++++++++++++++++++++++ src/cli/common.rs | 44 ++ src/cli/mod.rs | 84 +++- src/cli/send.rs | 103 +++- src/cli/wallet.rs | 74 ++- src/error.rs | 3 + src/lib.rs | 1 + src/main.rs | 1 + src/qr/display.rs | 203 ++++++++ src/qr/mod.rs | 12 + src/qr/scanner.rs | 387 ++++++++++++++ src/wallet/keystore.rs | 41 ++ src/wallet/mod.rs | 184 ++++++- 16 files changed, 2827 insertions(+), 77 deletions(-) create mode 100644 src/cli/cold_signing.rs create mode 100644 src/qr/display.rs create mode 100644 src/qr/mod.rs create mode 100644 src/qr/scanner.rs diff --git a/Cargo.lock b/Cargo.lock index 0c19560..eb974ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "aead" version = "0.5.2" @@ -43,7 +49,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cipher", "cpufeatures 0.2.17", ] @@ -68,7 +74,7 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.3.4", "once_cell", "version_check", @@ -546,13 +552,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ "autocfg", - "cfg-if", + "cfg-if 1.0.4", "concurrent-queue", "futures-io", "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -591,10 +597,10 @@ dependencies = [ "async-signal", "async-task", "blocking", - "cfg-if", + "cfg-if 1.0.4", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -606,10 +612,10 @@ dependencies = [ "async-io", "async-lock", "atomic-waker", - "cfg-if", + "cfg-if 1.0.4", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -657,7 +663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", - "cfg-if", + "cfg-if 1.0.4", "libc", "miniz_oxide", "object", @@ -700,19 +706,57 @@ dependencies = [ "parity-scale-codec", ] +[[package]] +name = "bindgen" +version = "0.65.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease 0.2.37", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", + "which", +] + [[package]] name = "bip39" version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "rand 0.8.6", "rand_core 0.6.4", "serde", "unicode-normalization", ] +[[package]] +name = "bitcoin-private" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73290177011694f38ec25e165d0387ab7ea749a4b81cd4c80dae5988229f7a57" + +[[package]] +name = "bitcoin_hashes" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d7066118b13d4b20b23645932dfb3a81ce7e29f95726c2036fa33cd7b092501" +dependencies = [ + "bitcoin-private", +] + [[package]] name = "bitcoin_hashes" version = "0.14.101" @@ -785,11 +829,17 @@ dependencies = [ "arrayref", "arrayvec 0.7.8", "cc", - "cfg-if", + "cfg-if 1.0.4", "constant_time_eq 0.4.2", "cpufeatures 0.3.0", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.9.0" @@ -840,6 +890,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ + "sha2 0.10.9", "tinyvec", ] @@ -855,12 +906,24 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" 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.12.1" @@ -874,7 +937,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", - "shlex", + "jobserver", + "libc", + "shlex 2.0.1", ] [[package]] @@ -883,6 +948,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.4" @@ -901,7 +981,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cipher", "cpufeatures 0.2.17", ] @@ -912,7 +992,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -941,6 +1021,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.1" @@ -969,7 +1060,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -981,6 +1072,34 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cocoa" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c49e86fc36d5704151f5996b7b3795385f50ce09e3be0f47a0cfde869681cf8" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.7.0", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-foundation 0.10.1", + "core-graphics-types", + "objc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1110,22 +1229,86 @@ dependencies = [ "url", ] +[[package]] +name = "core-foundation" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" +dependencies = [ + "core-foundation-sys 0.7.0", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] +[[package]] +name = "core-foundation-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.7.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-media-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273bf3fc5bf51fd06a7766a84788c1540b6527130a0bce39e00567d6ab9f31f1" +dependencies = [ + "cfg-if 0.1.10", + "core-foundation-sys 0.7.0", + "libc", +] + +[[package]] +name = "core-video-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ecad23610ad9757664d644e369246edde1803fcb43ed72876565098a5d3828" +dependencies = [ + "cfg-if 0.1.10", + "core-foundation-sys 0.7.0", + "core-graphics", + "libc", + "metal", + "objc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1144,13 +1327,28 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -1247,7 +1445,7 @@ version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", @@ -1303,6 +1501,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + [[package]] name = "der" version = "0.7.10" @@ -1502,6 +1706,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1614,7 +1824,7 @@ version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -1689,7 +1899,7 @@ dependencies = [ "blake2", "file-guard", "fs-err", - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "quote", "syn 2.0.119", @@ -1745,7 +1955,7 @@ version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", ] @@ -1776,6 +1986,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -1787,6 +2003,18 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1805,6 +2033,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1836,7 +2079,7 @@ version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ba5be0edbdb824843a0f9c6f0906ecfc66c5316218d74457003218b24909ed0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "parity-scale-codec", "scale-info", "serde", @@ -1968,6 +2211,34 @@ dependencies = [ "slab", ] +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "g2p" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" +dependencies = [ + "g2gen", + "g2poly", +] + +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + [[package]] name = "generic-array" version = "0.14.9" @@ -1985,7 +2256,7 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", "wasi", @@ -1998,7 +2269,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "r-efi 5.3.0", "wasip2", @@ -2010,7 +2281,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", "r-efi 6.0.0", @@ -2044,6 +2315,12 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "gloo-net" version = "0.6.0" @@ -2185,6 +2462,12 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -2248,6 +2531,15 @@ dependencies = [ "hmac 0.8.1", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.2" @@ -2362,7 +2654,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "iana-time-zone-haiku", "js-sys", "log", @@ -2488,6 +2780,18 @@ 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", +] + [[package]] name = "impl-codec" version = "0.7.1" @@ -2658,7 +2962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ "cesu8", - "cfg-if", + "cfg-if 1.0.4", "combine", "jni-sys 0.3.1", "log", @@ -2695,13 +2999,23 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "futures-util", "wasm-bindgen", ] @@ -2760,7 +3074,7 @@ dependencies = [ "http-body-util", "jsonrpsee-types", "pin-project", - "rustc-hash", + "rustc-hash 2.1.3", "serde", "serde_json", "thiserror 1.0.69", @@ -2837,7 +3151,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "ecdsa", "elliptic-curve", "once_cell", @@ -2874,6 +3188,19 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "keystone-ur" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a08a7e0ccd113dac19485c5c0fe87c70b663f896c48e4493814e446175078d4" +dependencies = [ + "bitcoin_hashes 0.12.0", + "crc", + "minicbor", + "phf", + "rand_xoshiro", +] + [[package]] name = "konst" version = "0.2.20" @@ -2895,12 +3222,52 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libflate" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f7ef5c7e3c2ed51f0fbc40e016c66558b699f16593521f30b98713bbb99cb8" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if 1.0.4", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -2964,6 +3331,12 @@ dependencies = [ "libsecp256k1-core", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3012,6 +3385,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3050,6 +3432,47 @@ dependencies = [ "zeroize", ] +[[package]] +name = "metal" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e198a0ee42bdbe9ef2c09d0b9426f3b2b47d90d93a4a9b0395c4cea605e92dc0" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa", + "core-graphics", + "foreign-types", + "log", + "objc", +] + +[[package]] +name = "minicbor" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7005aaf257a59ff4de471a9d5538ec868a21586534fff7f85dd97d4043a6139" +dependencies = [ + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1154809406efdb7982841adb6311b3d095b46f78342dd646736122fe6b19e267" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3071,6 +3494,41 @@ 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 = "mozjpeg" +version = "0.10.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7891b80aaa86097d38d276eb98b3805d6280708c4e0a1e6f6aed9380c51fec9" +dependencies = [ + "arrayvec 0.7.8", + "bytemuck", + "libc", + "mozjpeg-sys", + "rgb", +] + +[[package]] +name = "mozjpeg-sys" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0dc668bf9bf888c88e2fb1ab16a406d2c380f1d082b20d51dd540ab2aa70c1" +dependencies = [ + "cc", + "dunce", + "libc", + "nasm-rs", +] + [[package]] name = "multi-stash" version = "0.2.0" @@ -3078,16 +3536,127 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" [[package]] -name = "nodrop" -version = "0.1.14" +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "nasm-rs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" +dependencies = [ + "jobserver", + "log", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nokhwa" +version = "0.10.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d63f10b450319a0ace7aa8e0e25477d1fdb345313a97e220e886175539a1dbb" +dependencies = [ + "flume", + "image", + "nokhwa-bindings-linux", + "nokhwa-bindings-macos", + "nokhwa-bindings-windows", + "nokhwa-core", + "paste", + "thiserror 2.0.18", +] + +[[package]] +name = "nokhwa-bindings-linux" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb67e22201a53322291740ca064b20eaaade7222ef0349f312d9b37b004e1984" +dependencies = [ + "libc", + "nokhwa-core", + "v4l", +] + +[[package]] +name = "nokhwa-bindings-macos" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70d3908ea68324e44a6b3a0f885aa59e433fb1f6678839d09e0df7d226fb42d" +dependencies = [ + "block", + "cocoa-foundation", + "core-foundation 0.10.1", + "core-media-sys", + "core-video-sys", + "flume", + "nokhwa-core", + "objc", + "once_cell", +] + +[[package]] +name = "nokhwa-bindings-windows" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be28886bad8abcec3655c1f24b965b4cb596a72b23164c910c54439ce55d2a4" +dependencies = [ + "nokhwa-core", + "once_cell", + "windows", +] + +[[package]] +name = "nokhwa-core" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +checksum = "b1cba20bebd3bd9ae22f9273ade5bbe49da3e047c8512b53fbaf8b4b9c80d496" +dependencies = [ + "bytes", + "image", + "mozjpeg", + "thiserror 2.0.18", +] [[package]] -name = "nohash-hasher" -version = "0.2.0" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] [[package]] name = "nom" @@ -3195,6 +3764,25 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + [[package]] name = "object" version = "0.37.3" @@ -3405,7 +3993,7 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "redox_syscall", "smallvec", @@ -3439,6 +4027,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3454,6 +4048,58 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -3559,11 +4205,11 @@ version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3584,7 +4230,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "opaque-debug", "universal-hash", @@ -3620,6 +4266,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8646e95016a7a6c4adea95bafa8a16baab64b583356217f2c85db4a39d9a86" +dependencies = [ + "proc-macro2", + "syn 1.0.109", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -3700,7 +4356,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "fnv", "lazy_static", "memchr", @@ -3708,6 +4364,66 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "prost" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" +dependencies = [ + "bytes", + "heck 0.4.1", + "itertools 0.10.5", + "lazy_static", + "log", + "multimap", + "petgraph", + "prettyplease 0.1.25", + "prost", + "prost-types", + "regex", + "syn 1.0.109", + "tempfile", + "which", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" +dependencies = [ + "prost", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "qp-dilithium-crypto" version = "0.5.0" @@ -3958,6 +4674,12 @@ dependencies = [ "serde", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quantus-cli" version = "1.6.0" @@ -3974,6 +4696,7 @@ dependencies = [ "hex", "indicatif", "jsonrpsee", + "nokhwa", "parity-scale-codec", "qp-dilithium-crypto", "qp-plonky2", @@ -3988,10 +4711,13 @@ dependencies = [ "qp-wormhole-prover", "qp-wormhole-verifier", "qp-zk-circuits-common", + "qrcode", + "quantus_ur", "quinn-proto", "rand 0.9.5", "reqwest", "rpassword", + "rqrr", "rustls-webpki", "self_update", "serde", @@ -4008,6 +4734,17 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "quantus_ur" +version = "0.1.0" +source = "git+https://github.com/Quantus-Network/quantus_ur.git?tag=1.4.0#f3929da023e9c8081b8e9944b5b665871980fa79" +dependencies = [ + "hex", + "keystone-ur", + "minicbor", + "ur-parse-lib", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4028,7 +4765,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2", "thiserror 2.0.18", @@ -4050,7 +4787,7 @@ dependencies = [ "rand 0.10.1", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", @@ -4186,6 +4923,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rayon" version = "1.12.0" @@ -4326,6 +5072,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -4333,13 +5088,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + [[package]] name = "rpassword" version = "7.5.4" @@ -4351,6 +5112,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rqrr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48eaf9c75f2a8f231b09036c115a45a9845313f7faa6a39fa45a2a2bd06a27c7" +dependencies = [ + "g2p", + "image", + "lru", +] + [[package]] name = "rtoolbox" version = "0.0.5" @@ -4367,6 +5139,12 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -4388,6 +5166,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4397,7 +5188,7 @@ dependencies = [ "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -4444,8 +5235,8 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ - "core-foundation", - "core-foundation-sys", + "core-foundation 0.10.1", + "core-foundation-sys 0.8.7", "jni", "log", "once_cell", @@ -4577,7 +5368,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" dependencies = [ "bitvec", - "cfg-if", + "cfg-if 1.0.4", "derive_more 1.0.0", "parity-scale-codec", "scale-info-derive", @@ -4654,7 +5445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "356285bbf17bea63d9e52e96bd18f039672ac92b55b8cb997d6162a2a37d1649" dependencies = [ "ahash", - "cfg-if", + "cfg-if 1.0.4", "hashbrown 0.13.2", ] @@ -4732,8 +5523,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.1", - "core-foundation", - "core-foundation-sys", + "core-foundation 0.10.1", + "core-foundation-sys 0.8.7", "libc", "security-framework-sys", ] @@ -4744,7 +5535,7 @@ version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] @@ -4921,7 +5712,7 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -4933,7 +5724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", @@ -4945,7 +5736,7 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -4969,6 +5760,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -5071,7 +5868,7 @@ dependencies = [ "libm", "libsecp256k1", "merlin", - "nom", + "nom 8.0.0", "num-bigint", "num-rational", "num-traits", @@ -5505,6 +6302,9 @@ name = "spin" version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -5570,7 +6370,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", @@ -5654,7 +6454,7 @@ version = "0.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "461338acd557773106546b474fbb48d47617735fd50941ddc516818006daf8a0" dependencies = [ - "heck", + "heck 0.5.0", "parity-scale-codec", "proc-macro2", "quote", @@ -5858,7 +6658,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5889,6 +6689,26 @@ dependencies = [ "thiserror-impl 2.0.18", ] +[[package]] +name = "thiserror-core" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c001ee18b7e5e3f62cbf58c7fe220119e68d902bb7443179c0c8aef30090e999" +dependencies = [ + "thiserror-core-impl", +] + +[[package]] +name = "thiserror-core-impl" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c60d69f36615a077cc7663b9cb8e42275722d23e58a7fa3d2c7f2915d09d04" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "thiserror-impl" version = "1.0.69" @@ -5917,7 +6737,7 @@ version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -6321,7 +7141,7 @@ version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "digest 0.10.7", "rand 0.8.6", "static_assertions", @@ -6428,6 +7248,38 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ur-parse-lib" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecf06a6ea8b869cbbb4c9cba0f7271c94d53185b5bbbfc5847dbb77b68ea9a92" +dependencies = [ + "hex", + "keystone-ur", + "ur-registry", +] + +[[package]] +name = "ur-registry" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3e5fdee1510fe809ceaecdf7e3ab75b58b4c578e3f436217cd03132f0596698" +dependencies = [ + "bs58", + "hex", + "keystone-ur", + "libflate", + "minicbor", + "no_std_io2", + "paste", + "prost", + "prost-build", + "prost-types", + "serde", + "thiserror 1.0.69", + "thiserror-core", +] + [[package]] name = "ureq" version = "3.3.0" @@ -6498,6 +7350,26 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "v4l" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8fbfea44a46799d62c55323f3c55d06df722fbe577851d848d328a1041c3403" +dependencies = [ + "bitflags 1.3.2", + "libc", + "v4l2-sys-mit", +] + +[[package]] +name = "v4l2-sys-mit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6779878362b9bacadc7893eac76abe69612e8837ef746573c4a5239daf11990b" +dependencies = [ + "bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -6618,7 +7490,7 @@ version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -6764,6 +7636,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6795,6 +7679,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -6808,6 +7713,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6836,6 +7752,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -6921,6 +7847,15 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -7069,7 +8004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9404314..ac53d97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,11 +19,26 @@ path = "src/lib.rs" name = "quantus" path = "src/main.rs" +[features] +default = ["camera"] +# Camera capture + QR decoding for cold-wallet signing. Disable for headless +# builds; UR input via --cold-response-in keeps working without it. +camera = ["dep:nokhwa", "dep:rqrr"] + [dependencies] # CLI and async runtime clap = { version = "4.5", features = ["derive"] } tokio = { version = "1.46", features = ["full"] } +# Cold-wallet QR signing: UR encoding shared with the mobile/cold wallet apps +# and the Keystone firmware (same tag pinned by all consumers). +quantus_ur = { git = "https://github.com/Quantus-Network/quantus_ur.git", tag = "1.4.0" } +# Terminal QR rendering (string renderers only; image/svg not needed) +qrcode = { version = "0.14", default-features = false } +# Camera capture (AVFoundation / MSMF / V4L2) + pure-Rust QR decoding +nokhwa = { version = "0.10", features = ["input-native"], optional = true } +rqrr = { version = "0.8", optional = true } + # Serialization and configuration serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/README.md b/README.md index c6a65f9..b96d8ba 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A modern command line interface for interacting with the Quantus Network, featur ## 🌟 Features - **Quantum-Safe Wallets**: Built with Dilithium post-quantum cryptography +- **Cold Wallet Signing**: Air-gapped signing over QR codes with Keystone or the Quantus cold wallet app - **SubXT Integration**: Modern Substrate client with type-safe API - **Generic Pallet Calls**: Call ANY blockchain function using metadata-driven parsing - **Real Chain Operations**: Send tokens, query balances, explore metadata @@ -392,6 +393,55 @@ quantus wallet export --name my_wallet --format mnemonic --- +### Cold Wallets (Keystone / Quantus Cold Wallet App) + +Pair the CLI with an air-gapped signer — a Keystone 3 hardware wallet or the +Quantus cold wallet app. The CLI stores only the address (watch-only); every +transaction is signed on the device by exchanging QR codes. + +```bash +# Import by scanning the device's address QR with the laptop camera +quantus wallet import-cold --name my_cold + +# Or paste the address directly (no camera needed) +quantus wallet import-cold --name my_cold --address qz... + +# Send: shows the transaction as a QR, then scans the device's animated +# signature QR with the camera +quantus send --from my_cold --to
--amount 10.5 --wait-for-transaction +``` + +The signing flow: + +1. The CLI displays the transaction as a `ur:quantus-sign-request` QR + (animated if the payload is large). Scan it with the cold wallet, review + the details on the device, and press Enter in the CLI. +2. Sign on the device — it shows an animated QR containing the signature. +3. Confirm in the CLI, then point the laptop camera at the device's screen. + The CLI verifies the signature against the stored address before + submitting; a response signed by any other key is rejected. + +Notes: + +- The transaction stays valid for 256 blocks after the QR is generated; if it + expires or the account's nonce changes before submission, re-run the + command to sign a fresh QR. +- **macOS camera permission**: the permission prompt is attributed to your + terminal app (Terminal, iTerm, VS Code, …) — grant it under System + Settings > Privacy & Security > Camera. +- **Headless / no camera**: `--cold-response-in ` (or `-` for stdin) + reads the response UR parts (one per line) instead of scanning, and the + hidden `--cold-request-out ` writes the request UR parts for + scripted flows. Builds without the default `camera` feature + (`cargo build --no-default-features`) support only this path. +- Real devices only sign for known networks (Planck / Heisenberg genesis + hashes) and whitelisted calls — balance transfers, reversible transfers, + and (cold wallet app only) multisig. Test against a dev node with the + hidden `quantus developer cold-sign-sim` command, which plays the cold + wallet side using a local hot wallet. + +--- + ### Sending Tokens ```bash diff --git a/src/cli/cold_signing.rs b/src/cli/cold_signing.rs new file mode 100644 index 0000000..6eedced --- /dev/null +++ b/src/cli/cold_signing.rs @@ -0,0 +1,641 @@ +//! Cold-wallet (air-gapped) transaction signing over QR codes. +//! +//! Wire protocol (shared with the Quantus mobile app, the Quantus cold wallet +//! app and the Keystone firmware): +//! +//! 1. The CLI displays the **raw, unhashed** Substrate V4 signing payload as a +//! `ur:quantus-sign-request` QR (animated when multi-part): `call ‖ era ‖ nonce ‖ tip ‖ mode ‖ +//! specVersion ‖ txVersion ‖ genesis ‖ blockHash ‖ Option`. Raw (not hashed) so +//! the device can parse and show the user what they are signing. +//! 2. The cold wallet signs `payload.len() > 256 ? blake2b_256(payload) : payload` with ML-DSA-87 +//! and answers with an animated UR containing `signature[4627] ‖ public_key[2592]`. +//! 3. The CLI validates the response against the wallet's stored address, assembles the V4 +//! extrinsic with the *identical* parameters, and submits. +//! +//! Invariant: nonce and block context are captured **once** into [`TxContext`]; +//! the QR payload and the final extrinsic are both built from it. Refetching +//! anything in between would silently invalidate the signature. + +use crate::{ + chain::client::{ChainConfig, QuantusClient}, + error::{QuantusError, Result}, + log_print, log_verbose, + qr::{display_ur_until_enter, render_ur_frames, scan_ur, UrSource}, +}; +use colored::Colorize; +use qp_dilithium_crypto::types::{DilithiumSignatureScheme, DilithiumSignatureWithPublic}; +use sp_core::crypto::AccountId32; +use sp_runtime::traits::IdentifyAccount; +use std::{io::IsTerminal, path::PathBuf, time::Duration}; +use subxt::{ + client::OfflineClientT, + config::{DefaultExtrinsicParamsBuilder, ExtrinsicParams, ExtrinsicParamsEncoder}, +}; + +/// Transactions stay valid this many blocks after the anchor block. Matches the +/// hot path's `.mortal(256)` in `common::submit_transaction`. +pub const MORTALITY_BLOCKS: u64 = 256; + +/// Cold-wallet parsers reject payloads above this size. +pub const MAX_COLD_PAYLOAD: usize = 8 * 1024; + +/// `ML-DSA-87 signature (4627) ‖ public key (2592)` — the only valid response size. +pub const SIGNATURE_RESPONSE_LEN: usize = DilithiumSignatureWithPublic::TOTAL_LEN; + +/// How long to wait for the signed response before giving up. +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(300); + +/// Chain context captured once per signing session. Both the QR payload and +/// the submitted extrinsic derive from this exact snapshot. +pub struct TxContext { + pub nonce: u64, + pub block_number: u64, + pub block_hash: subxt::utils::H256, + pub tip: u128, +} + +/// Where the sign-request goes out and the signature response comes in. +pub struct ColdIo { + /// Also write the request UR parts to this file (one per line). Hidden + /// flag, used for scripting/testing. + pub request_out: Option, + /// Read the response from a file or stdin instead of the camera. + pub response_in: Option, + /// Camera device index when scanning with the camera. + pub camera_index: u32, +} + +/// Why a signature response was rejected. +enum ResponseError { + /// Wrong size — almost always an incomplete or corrupted scan. Rescanning + /// is safe because the payload hasn't changed. + BadLength(usize), + /// Structurally invalid signature/public key bytes; rescan-safe. + Malformed(String), + /// Signed by a different key than the cold wallet's address. Not retryable. + WrongSigner { got: String }, + /// Correct key, invalid signature over this payload. Not retryable. + BadSignature, +} + +impl ResponseError { + fn rescan_safe(&self) -> bool { + matches!(self, ResponseError::BadLength(_) | ResponseError::Malformed(_)) + } + + fn message(&self, wallet_name: &str) -> String { + match self { + ResponseError::BadLength(got) => format!( + "Response has {got} bytes, expected {SIGNATURE_RESPONSE_LEN} (signature ‖ public key). The scan was likely incomplete or picked up the wrong QR — rescan the response on the cold wallet." + ), + ResponseError::Malformed(e) => format!( + "Response bytes do not parse as an ML-DSA-87 signature + public key ({e}) — rescan the response." + ), + ResponseError::WrongSigner { got } => format!( + "Response was signed by a DIFFERENT key ({got}) than cold wallet '{wallet_name}'. Aborting — check that the right device/account signed." + ), + ResponseError::BadSignature => + "Signature does not verify over this transaction. Aborting — the cold wallet may have signed a stale QR; run the command again to generate a fresh one." + .to_string(), + } + } +} + +/// Capture nonce + latest (best) block once. `nonce_override` honors `--nonce`. +pub async fn capture_tx_context( + client: &QuantusClient, + signer: &AccountId32, + tip: u128, + nonce_override: Option, +) -> Result { + let nonce = match nonce_override { + Some(n) => n as u64, + None => client.get_account_nonce_from_best_block(signer).await?, + }; + let block_hash = client.get_latest_block().await?; + let block = client.client().blocks().at(block_hash).await?; + let block_number = block.header().number as u64; + Ok(TxContext { nonce, block_number, block_hash, tip }) +} + +/// Extrinsic params for this context. Deterministic — called once for the raw +/// payload and once for the submitted extrinsic, and both must encode +/// identically. +fn build_params( + ctx: &TxContext, +) -> <::ExtrinsicParams as ExtrinsicParams>::Params { + DefaultExtrinsicParamsBuilder::::new() + .nonce(ctx.nonce) + .tip(ctx.tip) + .mortal_from_unchecked(MORTALITY_BLOCKS, ctx.block_number, ctx.block_hash) + .build() +} + +/// Build the raw, unhashed V4 signer payload that goes into the QR. +/// +/// Built by hand because subxt's `PartialTransaction::signer_payload()` +/// blake2-hashes payloads over 256 bytes (and its raw-bytes path is private), +/// while the cold wallet needs the raw bytes to parse and display the call. +pub fn build_raw_signer_payload( + state: &subxt::client::ClientState, + call: &Call, + ctx: &TxContext, +) -> Result> { + let mut raw = call + .encode_call_data(&state.metadata) + .map_err(|e| QuantusError::Generic(format!("Failed to encode call data: {e:?}")))?; + + let encoder = <::ExtrinsicParams as ExtrinsicParams< + ChainConfig, + >>::new(state, build_params(ctx)) + .map_err(|e| QuantusError::Generic(format!("Failed to build extrinsic params: {e:?}")))?; + + encoder.encode_signer_payload_value_to(&mut raw); // era ‖ nonce ‖ tip ‖ mode + encoder.encode_implicit_to(&mut raw); // specV ‖ txV ‖ genesis ‖ blockHash ‖ metadataHash + + if raw.len() > MAX_COLD_PAYLOAD { + return Err(QuantusError::Generic(format!( + "Signing payload is {} bytes; cold wallets reject payloads over {} bytes", + raw.len(), + MAX_COLD_PAYLOAD + ))); + } + Ok(raw) +} + +/// The bytes the cold wallet actually signs (and the chain verifies): +/// payloads over 256 bytes are Blake2b-256 hashed first. +pub fn signable_payload(raw: &[u8]) -> Vec { + if raw.len() > 256 { + ::hash(raw) + .as_ref() + .to_vec() + } else { + raw.to_vec() + } +} + +/// Split and verify a signature response. Checks length, structure, that the +/// embedded public key matches the cold wallet's address (poseidon binding), +/// and that the signature verifies over this exact payload. +fn validate_signature_response( + raw_payload: &[u8], + response: &[u8], + expected_account: &AccountId32, +) -> std::result::Result { + if response.len() != SIGNATURE_RESPONSE_LEN { + return Err(ResponseError::BadLength(response.len())); + } + + let sig_with_public = DilithiumSignatureWithPublic::from_bytes(response) + .map_err(|e| ResponseError::Malformed(format!("{e:?}")))?; + + let derived_account = sig_with_public.public().into_account(); + if derived_account != *expected_account { + use crate::cli::address_format::QuantusSS58; + return Err(ResponseError::WrongSigner { got: derived_account.to_quantus_ss58() }); + } + + use sp_runtime::traits::Verify; + let msg = signable_payload(raw_payload); + let scheme = DilithiumSignatureScheme::Dilithium(sig_with_public.clone()); + if !scheme.verify(&msg[..], expected_account) { + return Err(ResponseError::BadSignature); + } + + Ok(sig_with_public) +} + +fn confirm_or_abort(prompt: &str) -> Result<()> { + use std::io::Write; + print!("{prompt}"); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if input.trim().eq_ignore_ascii_case("q") { + return Err(QuantusError::Generic("Aborted by user".to_string())); + } + Ok(()) +} + +/// Run the full cold signing flow for `call` and submit the result. +/// +/// Generic over the call payload so transfer, multisig and reversible commands +/// can all route through it. +#[allow(clippy::too_many_arguments)] +pub async fn sign_and_submit_cold( + client: &QuantusClient, + wallet_name: &str, + cold_address_ss58: &str, + call: &Call, + tip: Option, + nonce_override: Option, + execution_mode: crate::cli::common::ExecutionMode, + io: &ColdIo, +) -> Result { + use sp_core::crypto::Ss58Codec; + + let account = AccountId32::from_ss58check_with_version(cold_address_ss58) + .map_err(|e| { + QuantusError::Generic(format!( + "Cold wallet '{wallet_name}' has an invalid stored address: {e:?}" + )) + })? + .0; + + // 1. Capture chain context ONCE; everything below derives from it. + let ctx = capture_tx_context(client, &account, tip.unwrap_or(0), nonce_override).await?; + let state = client.client().client_state(); + let raw_payload = build_raw_signer_payload(&state, call, &ctx)?; + + log_print!("🧊 Cold wallet signing with '{}'", wallet_name.bright_blue().bold()); + log_print!(" Signer: {}", cold_address_ss58.bright_cyan()); + log_print!(" Nonce: {}", ctx.nonce); + if ctx.tip > 0 { + log_print!(" Tip: {}", ctx.tip); + } + log_print!(" Valid for {} blocks from block #{}", MORTALITY_BLOCKS, ctx.block_number); + log_print!(" Payload: {} bytes", raw_payload.len()); + log_verbose!(" Payload hex: 0x{}", hex::encode(&raw_payload)); + + // Fee preview: the fee depends on tx length and weight, not signature + // content, and Dilithium signatures have a fixed length — so a zeroed + // signature estimates accurately. Best-effort only. + if let Some(fee) = estimate_fee_with_dummy_signature(client, call, &ctx, &account).await { + match crate::cli::send::format_balance_with_symbol(client, fee).await { + Ok(formatted) => log_print!(" Estimated fee: {}", formatted.bright_cyan()), + Err(_) => log_print!(" Estimated fee: {} (raw units)", fee), + } + } + + // 2. Encode as UR and hand it to the cold wallet. + let parts = quantus_ur::encode_bytes(&raw_payload) + .map_err(|e| QuantusError::Generic(format!("Failed to UR-encode payload: {e:?}")))?; + + if let Some(path) = &io.request_out { + let content = parts.join("\n") + "\n"; + std::fs::write(path, content)?; + log_print!("📤 Sign request written to {}", path.display()); + } + + // Skip the QR display and prompts when scripted (stdin is not a terminal); + // the request file above is the handoff instead. + let interactive = std::io::stdin().is_terminal(); + if interactive { + let frames = render_ur_frames(&parts)?; + log_print!(""); + display_ur_until_enter( + &frames, + "📱 Scan this QR with your cold wallet, then press Enter here…", + ) + .await?; + confirm_or_abort( + "✍️ Sign the transaction on the cold wallet. Ready to scan its response? [Enter to scan / q to abort]: ", + )?; + } else { + log_print!( + "🤖 Non-interactive session: skipping QR display, waiting for the signature response…" + ); + } + + // 3. Collect and validate the signature response. Fall back to the camera + // only when no explicit source was given. + let source = match &io.response_in { + Some(source) => source.clone(), + None => default_response_source(io)?, + }; + let sig_with_public = loop { + let response = scan_ur(&source, RESPONSE_TIMEOUT).await?; + log_verbose!("📥 Received {} response bytes", response.len()); + + match validate_signature_response(&raw_payload, &response, &account) { + Ok(swp) => break swp, + Err(err) => { + let msg = err.message(wallet_name); + if err.rescan_safe() && interactive { + log_print!("⚠️ {}", msg); + confirm_or_abort("Rescan? [Enter to rescan / q to abort]: ")?; + continue; + } + return Err(QuantusError::Generic(msg)); + }, + } + }; + log_print!("✅ Signature verified against cold wallet '{}'", wallet_name.bright_blue()); + + // 4. Assemble the extrinsic from the SAME context and submit. + let mut partial = client + .client() + .tx() + .create_v4_partial_offline(call, build_params(&ctx)) + .map_err(|e| { + QuantusError::Generic(format!("Failed to build partial transaction: {e:?}")) + })?; + + // Cross-check: subxt's signer payload for the partial tx must equal the + // (hashed-if-long) form of the QR payload, or the two constructions drifted. + if partial.signer_payload() != signable_payload(&raw_payload) { + return Err(QuantusError::Generic( + "Internal error: submitted transaction params differ from the QR payload; nothing was submitted".to_string(), + )); + } + + let signature = DilithiumSignatureScheme::Dilithium(sig_with_public); + let submittable = partial.sign_with_account_and_signature(&account, &signature); + + crate::cli::common::submit_prepared_transaction(client, submittable, execution_mode).await +} + +/// Estimate the fee by assembling a throwaway extrinsic with an all-zero +/// (correct-length) signature. Returns `None` on any failure — the preview is +/// informational and must never block signing. +async fn estimate_fee_with_dummy_signature( + client: &QuantusClient, + call: &Call, + ctx: &TxContext, + account: &AccountId32, +) -> Option { + let dummy = DilithiumSignatureWithPublic::from_bytes(&[0u8; SIGNATURE_RESPONSE_LEN]).ok()?; + let mut partial = + client.client().tx().create_v4_partial_offline(call, build_params(ctx)).ok()?; + let tx = partial + .sign_with_account_and_signature(account, &DilithiumSignatureScheme::Dilithium(dummy)); + tx.partial_fee_estimate().await.ok() +} + +fn default_response_source(io: &ColdIo) -> Result { + #[cfg(feature = "camera")] + { + Ok(UrSource::Camera { index: io.camera_index }) + } + #[cfg(not(feature = "camera"))] + { + let _ = io; + Err(QuantusError::Generic( + "This build has no camera support (built without the `camera` feature). Pass --cold-response-in to read the response from a file or stdin".to_string(), + )) + } +} + +/// `quantus developer cold-sign-sim` — act as the cold wallet side using a +/// local hot wallet. Reads a sign-request UR (file or stdin), signs it per the +/// protocol, and writes the response UR (file or stdout). +/// +/// Unlike real devices, the simulator does not enforce a genesis-hash +/// allowlist, which is what makes end-to-end testing on a dev node possible. +pub async fn handle_cold_sign_sim( + wallet: String, + request_file: Option, + response_file: Option, + password: Option, + password_file: Option, +) -> Result<()> { + // 1. Read the request UR (polling the file allows scripted pipelines). + let request_source = match &request_file { + Some(path) => UrSource::File(PathBuf::from(path)), + None => UrSource::StdinLines, + }; + let payload = scan_ur(&request_source, Duration::from_secs(60)).await?; + + if payload.len() < 2 { + return Err(QuantusError::Generic(format!( + "Request payload is only {} bytes — not a signing payload", + payload.len() + ))); + } + log_print!("🧾 Sign request: {} bytes", payload.len()); + log_print!(" Pallet index: {}, call index: {}", payload[0], payload[1]); + log_verbose!(" Payload hex: 0x{}", hex::encode(&payload)); + + // 2. Sign exactly like the cold wallet app / Keystone firmware. + let keypair = crate::wallet::load_keypair_from_wallet(&wallet, password, password_file)?; + let pair = keypair.to_resonance_pair()?; + let msg = signable_payload(&payload); + let sig_with_public = ::sign(&pair, &msg); + + // 3. Emit the response UR. + let parts = quantus_ur::encode_bytes(&sig_with_public.to_bytes()) + .map_err(|e| QuantusError::Generic(format!("Failed to UR-encode response: {e:?}")))?; + + match &response_file { + Some(path) => { + // Atomic write: pollers must never observe a half-written response. + let tmp = format!("{path}.tmp"); + std::fs::write(&tmp, parts.join("\n") + "\n")?; + std::fs::rename(&tmp, path)?; + log_print!("📤 Signature response ({} UR parts) written to {}", parts.len(), path); + }, + None => + for part in &parts { + println!("{part}"); + }, + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chain::quantus_subxt::api; + use subxt::{client::ClientState, config::substrate::H256, utils::MultiAddress, OfflineClient}; + + fn test_client_state() -> ClientState { + let metadata_bytes: &[u8] = include_bytes!("../quantus_metadata.scale"); + let metadata = ::decode(&mut &metadata_bytes[..]) + .expect("valid metadata"); + ClientState { + genesis_hash: H256::from([0x49u8; 32]), + runtime_version: subxt::client::RuntimeVersion { + spec_version: 136, + transaction_version: 3, + }, + metadata, + } + } + + fn test_ctx() -> TxContext { + TxContext { + nonce: 7, + block_number: 1000, + block_hash: subxt::utils::H256::from([0x11u8; 32]), + tip: 0, + } + } + + fn alice_account() -> AccountId32 { + let pair = qp_dilithium_crypto::crystal_alice(); + crate::wallet::QuantumKeyPair::from_resonance_pair(&pair).to_account_id_32() + } + + fn transfer_call() -> impl subxt::tx::Payload { + let dest: subxt::utils::AccountId32 = + subxt::utils::AccountId32(*b"01234567890123456789012345678901"); + api::tx() + .balances() + .transfer_allow_death(MultiAddress::Id(dest), 1_000_000_000_000u128) + } + + /// The raw payload must follow the exact field layout the cold-wallet + /// parsers expect (see quantus_payload_parser.dart / Keystone parser.rs). + #[test] + fn test_raw_payload_golden_layout() { + let state = test_client_state(); + let ctx = test_ctx(); + let raw = build_raw_signer_payload(&state, &transfer_call(), &ctx).unwrap(); + + // Call: pallet 2 (Balances), call 0 (transfer_allow_death), + // MultiAddress::Id tag 0, 32-byte dest, compact amount + assert_eq!(raw[0], 2, "Balances pallet index"); + assert_eq!(raw[1], 0, "transfer_allow_death call index"); + assert_eq!(raw[2], 0, "MultiAddress::Id tag"); + assert_eq!(&raw[3..35], b"01234567890123456789012345678901"); + // Compact(1_000_000_000_000) = 0x07 0x0010a5d4e8 + let mut expected_tail = vec![0x07u8, 0x00, 0x10, 0xa5, 0xd4, 0xe8]; + // Era: mortal, period 256 → two bytes (low nibble 0b0101 = 5 for 2^(5+4)=... ) + // Era::mortal(256, 1000): period=256 → encoded via Substrate era encoding. + let era = sp_runtime::generic::Era::mortal(256, 1000); + expected_tail.extend(codec::Encode::encode(&era)); + // Compact nonce 7, compact tip 0, metadata mode 0 + expected_tail.extend(codec::Encode::encode(&codec::Compact(7u64))); + expected_tail.extend(codec::Encode::encode(&codec::Compact(0u128))); + expected_tail.push(0u8); // CheckMetadataHash mode = disabled + // Implicit: specVersion, txVersion (LE u32), genesis, era anchor block hash, + // metadataHash Option = None + expected_tail.extend(136u32.to_le_bytes()); + expected_tail.extend(3u32.to_le_bytes()); + expected_tail.extend([0x49u8; 32]); + expected_tail.extend([0x11u8; 32]); + expected_tail.push(0u8); // metadataHash: None + + assert_eq!( + &raw[35..], + &expected_tail[..], + "payload tail (extensions) must match the cold-wallet parser layout" + ); + } + + /// Pin our manual raw-payload builder to subxt's canonical signer payload: + /// identical below 256 bytes, blake2b-256 of ours above. + #[test] + fn test_raw_payload_matches_subxt_signer_payload() { + let state = test_client_state(); + let ctx = test_ctx(); + let client = OfflineClient::::new( + state.genesis_hash, + state.runtime_version, + state.metadata.clone(), + ); + + // Small payload: subxt's signer payload IS the raw payload + let call = transfer_call(); + let raw = build_raw_signer_payload(&state, &call, &ctx).unwrap(); + assert!(raw.len() <= 256); + let partial = client.tx().create_v4_partial_offline(&call, build_params(&ctx)).unwrap(); + assert_eq!(partial.signer_payload(), raw); + assert_eq!(signable_payload(&raw), raw); + + // Large payload (remark > 256 bytes): subxt hashes, and so does signable_payload + let big_call = api::tx().system().remark(vec![0xAB; 400]); + let big_raw = build_raw_signer_payload(&state, &big_call, &ctx).unwrap(); + assert!(big_raw.len() > 256); + let big_partial = + client.tx().create_v4_partial_offline(&big_call, build_params(&ctx)).unwrap(); + assert_eq!(big_partial.signer_payload(), signable_payload(&big_raw)); + assert_ne!(signable_payload(&big_raw), big_raw); + assert_eq!(signable_payload(&big_raw).len(), 32); + } + + #[test] + fn test_validate_signature_response_roundtrip() { + let state = test_client_state(); + let ctx = test_ctx(); + let call = transfer_call(); + let raw = build_raw_signer_payload(&state, &call, &ctx).unwrap(); + + // Simulate the cold wallet: sign the signable form with alice's key + let alice = qp_dilithium_crypto::crystal_alice(); + let msg = signable_payload(&raw); + let swp = ::sign(&alice, &msg); + let response = swp.to_bytes(); + assert_eq!(response.len(), SIGNATURE_RESPONSE_LEN); + + // Valid response verifies + let validated = + validate_signature_response(&raw, &response, &alice_account()).ok().unwrap(); + assert_eq!(validated.to_bytes(), response); + + // Truncated response → BadLength (rescan-safe) + let err = validate_signature_response(&raw, &response[..1000], &alice_account()) + .err() + .unwrap(); + assert!(err.rescan_safe()); + assert!(matches!(err, ResponseError::BadLength(1000))); + + // Signed by a different key → WrongSigner (abort) + let bob = qp_dilithium_crypto::dilithium_bob(); + let bob_swp = ::sign(&bob, &msg); + let err = validate_signature_response(&raw, &bob_swp.to_bytes(), &alice_account()) + .err() + .unwrap(); + assert!(!err.rescan_safe()); + assert!(matches!(err, ResponseError::WrongSigner { .. })); + + // Right key, tampered payload → BadSignature (abort) + let mut other_ctx = test_ctx(); + other_ctx.nonce = 8; + let other_raw = build_raw_signer_payload(&state, &call, &other_ctx).unwrap(); + let err = validate_signature_response(&other_raw, &response, &alice_account()) + .err() + .unwrap(); + assert!(!err.rescan_safe()); + assert!(matches!(err, ResponseError::BadSignature)); + } + + /// The full simulator loop: request UR → sign → response UR → validate → + /// assemble a submittable extrinsic offline. + #[test] + fn test_end_to_end_offline_sign_and_assemble() { + let state = test_client_state(); + let ctx = test_ctx(); + let call = transfer_call(); + let raw = build_raw_signer_payload(&state, &call, &ctx).unwrap(); + + // CLI → UR → cold wallet + let request_parts = quantus_ur::encode_bytes(&raw).unwrap(); + let received = quantus_ur::decode_bytes(&request_parts).unwrap(); + assert_eq!(received, raw); + + // Cold wallet signs and answers over UR + let alice = qp_dilithium_crypto::crystal_alice(); + let swp = ::sign( + &alice, + &signable_payload(&received), + ); + let response_parts = quantus_ur::encode_bytes(&swp.to_bytes()).unwrap(); + assert!(response_parts.len() > 1, "7219-byte response must be multi-part"); + + // CLI decodes, validates, assembles + let response = quantus_ur::decode_bytes(&response_parts).unwrap(); + let validated = + validate_signature_response(&raw, &response, &alice_account()).ok().unwrap(); + + let client = OfflineClient::::new( + state.genesis_hash, + state.runtime_version, + state.metadata.clone(), + ); + let mut partial = client.tx().create_v4_partial_offline(&call, build_params(&ctx)).unwrap(); + assert_eq!(partial.signer_payload(), signable_payload(&raw)); + + let signature = DilithiumSignatureScheme::Dilithium(validated); + let submittable = partial.sign_with_account_and_signature(&alice_account(), &signature); + + // V4 signed extrinsic: version byte 0x84 after the compact length prefix + let encoded = submittable.encoded().to_vec(); + assert!(!encoded.is_empty()); + use codec::Decode; + let mut cursor = &encoded[..]; + let _len = codec::Compact::::decode(&mut cursor).unwrap(); + assert_eq!(cursor[0], 0b1000_0000 | 4, "signed V4 extrinsic marker"); + } +} diff --git a/src/cli/common.rs b/src/cli/common.rs index d8f6fcd..325c6e4 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -531,6 +531,50 @@ where } } +/// Submit an already-signed transaction, honoring the execution mode. +/// +/// Used by the cold-wallet flow: unlike `submit_transaction` there is NO retry +/// loop, because a QR-signed extrinsic can never be rebuilt or resigned behind +/// the user's back — a stale nonce or expired mortality means asking the user +/// to run the command (and sign) again. +pub async fn submit_prepared_transaction( + quantus_client: &crate::chain::client::QuantusClient, + tx: subxt::tx::SubmittableTransaction>, + execution_mode: ExecutionMode, +) -> crate::error::Result { + let map_err = |e: subxt::Error| { + let msg = format!("{e:?}"); + let stale = msg.contains("Transaction is outdated") || + msg.contains("Invalid Transaction") || + msg.contains("Transaction has a bad signature") || + msg.contains("Priority is too low"); + if stale { + crate::error::QuantusError::NetworkError(format!( + "Transaction rejected: {msg}. The signed transaction may have expired or the account nonce changed while signing — run the command again to generate a fresh QR" + )) + } else { + crate::error::QuantusError::NetworkError(format!("Failed to submit transaction: {msg}")) + } + }; + + if execution_mode.should_watch_transaction() { + let mut tx_progress = tx.submit_and_watch().await.map_err(map_err)?; + let tx_hash = tx_progress.extrinsic_hash(); + wait_tx_inclusion( + &mut tx_progress, + quantus_client.client(), + &tx_hash, + execution_mode.transaction_stage(), + ) + .await?; + Ok(tx_hash) + } else { + let tx_hash = tx.submit().await.map_err(map_err)?; + crate::log_print!("✅ Transaction submitted: {:?}", tx_hash); + Ok(tx_hash) + } +} + /// Submit transaction with manual nonce (no retry logic - use exact nonce provided) pub async fn submit_transaction_with_nonce( quantus_client: &crate::chain::client::QuantusClient, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b989fb8..33d92ae 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -5,6 +5,7 @@ use colored::Colorize; pub mod address_format; pub mod batch; pub mod block; +pub mod cold_signing; pub mod common; pub mod events; pub mod exercise; @@ -65,6 +66,19 @@ pub enum Commands { /// Manual nonce override (use with caution - must be exact next nonce for account) #[arg(long)] nonce: Option, + + /// (cold wallets) Also write the sign-request UR parts to this file + #[arg(long, hide = true)] + cold_request_out: Option, + + /// (cold wallets) Read the signature response UR parts from this file, or "-" for stdin, + /// instead of scanning with the camera + #[arg(long, hide = true)] + cold_response_in: Option, + + /// Camera device index for cold-wallet QR scanning + #[arg(long, default_value_t = 0)] + camera_index: u32, }, /// Batch transfer commands and configuration @@ -312,6 +326,33 @@ pub enum Commands { pub enum DeveloperCommands { /// Create standard test wallets (crystal_alice, crystal_bob, crystal_charlie) CreateTestWallets, + + /// Simulate the cold-wallet side of QR signing using a local hot wallet. + /// Reads a sign-request UR, signs it per the cold-wallet protocol, and + /// emits the response UR. Unlike real devices, no genesis-hash allowlist + /// is enforced, so it works against dev nodes. + #[command(hide = true)] + ColdSignSim { + /// Hot wallet that plays the cold wallet's role + #[arg(long)] + wallet: String, + + /// File with the request UR parts, one per line (polls until complete; stdin if omitted) + #[arg(long)] + request_file: Option, + + /// File to write the response UR parts to (stdout if omitted) + #[arg(long)] + response_file: Option, + + /// Password for the wallet + #[arg(short, long)] + password: Option, + + /// Read password from file + #[arg(long)] + password_file: Option, + }, } /// Execute a CLI command @@ -323,7 +364,29 @@ pub async fn execute_command( ) -> crate::error::Result<()> { match command { Commands::Wallet(wallet_cmd) => wallet::handle_wallet_command(wallet_cmd, node_url).await, - Commands::Send { from, to, amount, password, password_file, tip, nonce } => + Commands::Send { + from, + to, + amount, + password, + password_file, + tip, + nonce, + cold_request_out, + cold_response_in, + camera_index, + } => { + let cold_io = cold_signing::ColdIo { + request_out: cold_request_out.map(std::path::PathBuf::from), + response_in: cold_response_in.map(|s| { + if s == "-" { + crate::qr::UrSource::StdinLines + } else { + crate::qr::UrSource::File(std::path::PathBuf::from(s)) + } + }), + camera_index, + }; send::handle_send_command( from, to, @@ -334,8 +397,10 @@ pub async fn execute_command( tip, nonce, execution_mode, + cold_io, ) - .await, + .await + }, Commands::Batch(batch_cmd) => batch::handle_batch_command(batch_cmd, node_url, execution_mode).await, Commands::Reversible(reversible_cmd) => @@ -582,6 +647,21 @@ pub async fn handle_developer_command(command: DeveloperCommands) -> crate::erro Ok(()) }, + DeveloperCommands::ColdSignSim { + wallet, + request_file, + response_file, + password, + password_file, + } => + cold_signing::handle_cold_sign_sim( + wallet, + request_file, + response_file, + password, + password_file, + ) + .await, } } diff --git a/src/cli/send.rs b/src/cli/send.rs index 7e7c783..bdf4aa1 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -560,6 +560,7 @@ pub async fn batch_transfer( // (Removed custom `AccountData` struct – we now use the runtime-generated type) /// Handle the send command +#[allow(clippy::too_many_arguments)] pub async fn handle_send_command( from_wallet: String, to_address: String, @@ -570,6 +571,7 @@ pub async fn handle_send_command( tip: Option, nonce: Option, execution_mode: crate::cli::common::ExecutionMode, + cold_io: crate::cli::cold_signing::ColdIo, ) -> Result<()> { // Create quantus chain client let quantus_client = QuantusClient::new(node_url).await?; @@ -589,8 +591,28 @@ pub async fn handle_send_command( resolved_address.bright_green() ); - // Get password securely for decryption log_verbose!("📦 Using wallet: {}", from_wallet.bright_blue().bold()); + + // Cold (watch-only) wallets sign over QR codes instead of a local key. + let wallet_manager = crate::wallet::WalletManager::new()?; + if wallet_manager.wallet_type(&from_wallet)? == Some(crate::wallet::WalletType::Cold) { + if password.is_some() || password_file.is_some() { + log_print!("⚠️ Cold wallets have no password; ignoring --password/--password-file"); + } + return handle_cold_send( + &quantus_client, + &from_wallet, + to_account_id, + amount, + &tip, + nonce, + execution_mode, + &cold_io, + ) + .await; + } + + // Get password securely for decryption let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?; // Get account information @@ -636,6 +658,77 @@ pub async fn handle_send_command( ) .await?; + print_send_result(&quantus_client, &from_account_id, balance, amount, tx_hash, execution_mode) + .await +} + +/// Send from a cold (watch-only) wallet: builds the transfer call, runs the QR +/// signing flow, submits, and prints the same result summary as the hot path. +#[allow(clippy::too_many_arguments)] +async fn handle_cold_send( + quantus_client: &QuantusClient, + from_wallet: &str, + to_account_id: SubxtAccountId32, + amount: u128, + tip: &Option, + nonce: Option, + execution_mode: crate::cli::common::ExecutionMode, + cold_io: &crate::cli::cold_signing::ColdIo, +) -> Result<()> { + let wallet_manager = crate::wallet::WalletManager::new()?; + let from_address = wallet_manager + .find_wallet_address(from_wallet)? + .ok_or(crate::error::WalletError::NotFound)?; + + let balance = get_balance(quantus_client, &from_address).await?; + let formatted_balance = format_balance_with_symbol(quantus_client, balance).await?; + log_verbose!("💰 Current balance: {}", formatted_balance.bright_yellow()); + + let tip_amount = if let Some(tip_str) = tip { + Some(parse_amount(quantus_client, tip_str).await?) + } else { + None + }; + let effective_tip = effective_tip_amount(tip_amount); + let submit_tip = positive_tip_amount(tip_amount); + let exact_required = checked_add(amount, effective_tip, "required send balance")?; + + // No fee preflight here (fee estimation needs a signer); check amount + tip + // and let the chain reject if fees can't be covered. + if balance < exact_required { + return Err(crate::error::QuantusError::InsufficientBalance { + available: balance, + required: exact_required, + }); + } + + let transfer_call = build_transfer_call_for_account_id(to_account_id, amount); + + let tx_hash = crate::cli::cold_signing::sign_and_submit_cold( + quantus_client, + from_wallet, + &from_address, + &transfer_call, + submit_tip, + nonce, + execution_mode, + cold_io, + ) + .await?; + + print_send_result(quantus_client, &from_address, balance, amount, tx_hash, execution_mode).await +} + +/// Print the post-submission summary (status, new balance, fee) shared by hot +/// and cold send paths. +async fn print_send_result( + quantus_client: &QuantusClient, + from_account_id: &str, + balance_before: u128, + amount: u128, + tx_hash: subxt::utils::H256, + execution_mode: crate::cli::common::ExecutionMode, +) -> Result<()> { let transaction_stage = execution_mode.transaction_stage(); log_print!( "✅ {} Transaction {}. Hash: {:?}", @@ -659,13 +752,13 @@ pub async fn handle_send_command( ); // Show updated balance with proper formatting - let new_balance = get_balance(&quantus_client, &from_account_id).await?; - let formatted_new_balance = format_balance_with_symbol(&quantus_client, new_balance).await?; + let new_balance = get_balance(quantus_client, from_account_id).await?; + let formatted_new_balance = format_balance_with_symbol(quantus_client, new_balance).await?; // Calculate and display transaction fee in verbose mode - let fee_paid = balance.saturating_sub(new_balance).saturating_sub(amount); + let fee_paid = balance_before.saturating_sub(new_balance).saturating_sub(amount); if fee_paid > 0 { - let formatted_fee = format_balance_with_symbol(&quantus_client, fee_paid).await?; + let formatted_fee = format_balance_with_symbol(quantus_client, fee_paid).await?; log_verbose!("💸 Transaction fee: {}", formatted_fee.bright_cyan()); } diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 38f0227..4470354 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -82,6 +82,24 @@ pub enum WalletCommands { no_derivation: bool, }, + /// Import a cold (hardware / air-gapped) wallet as watch-only + /// + /// Pairs the CLI with a Keystone device or the Quantus cold wallet app. + /// Only the address is stored; transactions are signed by scanning QR codes. + ImportCold { + /// Wallet name + #[arg(short, long)] + name: String, + + /// SS58 address (qz…). Omit to scan the device's address QR with the camera + #[arg(short, long)] + address: Option, + + /// Camera device index used when scanning the address QR + #[arg(long, default_value_t = 0)] + camera_index: u32, + }, + /// Create wallet from 32-byte seed FromSeed { /// Wallet name @@ -336,6 +354,44 @@ pub async fn handle_wallet_command( Ok(()) }, + WalletCommands::ImportCold { name, address, camera_index } => { + let wallet_manager = WalletManager::new()?; + + let address = match address { + Some(address) => address, + None => { + log_print!("📷 Point the camera at the address QR shown on your cold wallet"); + let address = crate::qr::scan_quantus_address(camera_index).await?; + log_print!("📇 Scanned address: {}", address.bright_cyan()); + print!("Import this address as '{name}'? [Enter to confirm / q to abort]: "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + if input.trim().eq_ignore_ascii_case("q") { + return Err(QuantusError::Generic("Import aborted".to_string())); + } + address + }, + }; + + match wallet_manager.create_cold_wallet(&name, &address) { + Ok(wallet_info) => { + log_success!("Wallet name: {}", name.bright_green()); + log_success!("Address: {}", wallet_info.address.bright_cyan()); + log_success!("Key type: {}", wallet_info.key_type.bright_yellow()); + log_success!("✅ Cold wallet imported (watch-only)"); + log_print!( + "ℹ️ Sign transfers with `quantus send --from {name} …`: the CLI shows a QR for the cold wallet and scans its signed response." + ); + Ok(()) + }, + Err(e) => { + log_error!("{}", format!("❌ Failed to import cold wallet: {e}").red()); + Err(e) + }, + } + }, + WalletCommands::View { name, all } => { log_print!("👁️ Viewing wallet information..."); @@ -781,10 +837,20 @@ pub async fn handle_wallet_command( addr }, (None, Some(wallet_name)) => { - // Load wallet and get its address - let keypair = - crate::wallet::load_keypair_from_wallet(&wallet_name, password, None)?; - keypair.to_account_id_ss58check() + let wallet_manager = WalletManager::new()?; + if wallet_manager.wallet_type(&wallet_name)? == + Some(crate::wallet::WalletType::Cold) + { + // Watch-only: the address is stored unencrypted, no keys needed + wallet_manager + .find_wallet_address(&wallet_name)? + .ok_or(crate::error::WalletError::NotFound)? + } else { + // Load wallet and get its address + let keypair = + crate::wallet::load_keypair_from_wallet(&wallet_name, password, None)?; + keypair.to_account_id_ss58check() + } }, (None, None) => { // This case should be prevented by clap's `required_unless_present` diff --git a/src/error.rs b/src/error.rs index d8532c9..b73e6b2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -64,6 +64,9 @@ pub enum WalletError { #[error("Decryption failed. Check your password.")] Decryption, + + #[error("'{0}' is a cold (watch-only) wallet with no local key material. Transfers sign via QR (`quantus send --from {0}`); other signing commands need a hot wallet")] + ColdWalletNoKeys(String), } /// Type alias for Results using QuantusError diff --git a/src/lib.rs b/src/lib.rs index e71fd2d..b29987a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod collect_rewards_lib; pub mod config; pub mod error; pub mod log; +pub mod qr; pub mod subsquid; pub mod version_check; pub mod wallet; diff --git a/src/main.rs b/src/main.rs index 9224fea..93a1acc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod collect_rewards_lib; mod config; mod error; mod log; +mod qr; mod subsquid; mod version_check; mod wallet; diff --git a/src/qr/display.rs b/src/qr/display.rs new file mode 100644 index 0000000..8ad1fa8 --- /dev/null +++ b/src/qr/display.rs @@ -0,0 +1,203 @@ +//! Rendering URs as (possibly animated) QR codes in the terminal. +//! +//! Multi-part URs cycle their fragments on a timer, exactly like the mobile +//! app's animated QR widget; the fountain decoder on the cold wallet side +//! tolerates missed frames, so looping forever until the user continues is +//! correct. + +use crate::error::{QuantusError, Result}; +use qrcode::{types::Color, EcLevel, QrCode}; +use std::{io::Write, time::Duration}; + +/// How long each fragment of an animated QR stays on screen. Matches the +/// 200 ms cadence of the mobile and cold wallet apps, slightly relaxed for +/// terminal redraw. +const FRAME_INTERVAL: Duration = Duration::from_millis(250); + +/// Rendered QR frames, all padded to the same height so in-place redraws fully +/// overwrite the previous frame. +pub struct QrFrames { + pub frames: Vec, + /// Number of terminal lines every frame occupies (including the counter line). + pub height: usize, +} + +/// Render one QR code as half-block characters with explicit black/white ANSI +/// colors. Explicit colors keep the QR dark-on-light regardless of the +/// terminal theme — phone scanners often reject inverted codes. +fn render_qr(data: &str) -> Result { + // EC level L: same as the mobile apps; UR fragments carry their own CRC and + // the fountain code recovers lost frames, so denser codes beat redundancy. + let code = QrCode::with_error_correction_level(data.as_bytes(), EcLevel::L) + .map_err(|e| QuantusError::Generic(format!("Failed to build QR code: {e}")))?; + + let width = code.width(); + let colors = code.to_colors(); + const QUIET_ZONE: usize = 3; // modules of white border on every side + + let module = |x: isize, y: isize| -> Color { + if x < 0 || y < 0 || x as usize >= width || y as usize >= width { + Color::Light // quiet zone + } else { + colors[y as usize * width + x as usize] + } + }; + + // Two vertical modules per character cell: '▀' with fg = upper, bg = lower. + const RESET: &str = "\x1b[0m"; + + let mut out = String::new(); + let mut y = -(QUIET_ZONE as isize); + while y < (width + QUIET_ZONE) as isize { + for x in -(QUIET_ZONE as isize)..(width + QUIET_ZONE) as isize { + let upper = module(x, y); + let lower = module(x, y + 1); + let cell = match (upper, lower) { + (Color::Dark, Color::Dark) => concat!("\x1b[30;40m", "▀"), + (Color::Dark, Color::Light) => concat!("\x1b[30;107m", "▀"), + (Color::Light, Color::Dark) => concat!("\x1b[97;40m", "▀"), + (Color::Light, Color::Light) => concat!("\x1b[97;107m", "▀"), + }; + out.push_str(cell); + } + out.push_str(RESET); + out.push('\n'); + y += 2; + } + Ok(out) +} + +/// Render every UR part to a QR frame, padded to uniform height. +pub fn render_ur_frames(parts: &[String]) -> Result { + if parts.is_empty() { + return Err(QuantusError::Generic("No UR parts to render".to_string())); + } + + let mut rendered: Vec = Vec::with_capacity(parts.len()); + for part in parts { + rendered.push(render_qr(part)?); + } + + let max_lines = rendered.iter().map(|f| f.lines().count()).max().unwrap_or(0); + let total = rendered.len(); + + let frames: Vec = rendered + .into_iter() + .enumerate() + .map(|(i, mut frame)| { + let lines = frame.lines().count(); + for _ in lines..max_lines { + // Erase-to-end keeps stale content from a taller earlier frame away + frame.push_str("\x1b[K\n"); + } + if total > 1 { + frame.push_str(&format!(" Frame {}/{}\x1b[K\n", i + 1, total)); + } + frame + }) + .collect(); + + // +1 for the frame-counter line on animated QRs + let height = max_lines + usize::from(total > 1); + Ok(QrFrames { frames, height }) +} + +/// RAII guard: hides the terminal cursor and guarantees it comes back on every +/// exit path (Enter, Ctrl-C error return, panic unwind). +struct CursorGuard; + +impl CursorGuard { + fn new() -> Self { + print!("\x1b[?25l"); + let _ = std::io::stdout().flush(); + CursorGuard + } +} + +impl Drop for CursorGuard { + fn drop(&mut self) { + print!("\x1b[?25h"); + let _ = std::io::stdout().flush(); + } +} + +/// Display the QR (cycling frames if multi-part) until the user presses Enter. +/// Returns Err if the user aborts with Ctrl-C. +pub async fn display_ur_until_enter(frames: &QrFrames, caption: &str) -> Result<()> { + let _cursor = CursorGuard::new(); + + // First frame: printed normally; subsequent redraws move the cursor back up. + print!("{}", frames.frames[0]); + println!("{caption}"); + let _ = std::io::stdout().flush(); + + let (enter_tx, mut enter_rx) = tokio::sync::oneshot::channel::<()>(); + std::thread::spawn(move || { + let mut buf = String::new(); + let _ = std::io::stdin().read_line(&mut buf); + let _ = enter_tx.send(()); + }); + + if frames.frames.len() == 1 { + tokio::select! { + _ = &mut enter_rx => return Ok(()), + _ = tokio::signal::ctrl_c() => { + println!(); + return Err(QuantusError::Generic("Aborted by user".to_string())); + }, + } + } + + let mut interval = tokio::time::interval(FRAME_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut current = 0usize; + loop { + tokio::select! { + _ = interval.tick() => { + current = (current + 1) % frames.frames.len(); + // Move up over the QR block plus the caption line, redraw in place + print!("\x1b[{}F", frames.height + 1); + print!("{}", frames.frames[current]); + println!("{caption}"); + let _ = std::io::stdout().flush(); + }, + _ = &mut enter_rx => return Ok(()), + _ = tokio::signal::ctrl_c() => { + println!(); + return Err(QuantusError::Generic("Aborted by user".to_string())); + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_render_single_frame() { + let parts = vec!["UR:QUANTUS-SIGN-REQUEST/HDCXLKADAEAEAEAEAE".to_string()]; + let frames = render_ur_frames(&parts).unwrap(); + assert_eq!(frames.frames.len(), 1); + assert!(frames.height > 10); + // No counter line on single-frame QRs + assert!(!frames.frames[0].contains("Frame")); + } + + #[test] + fn test_render_multi_frame_uniform_height() { + let payload: Vec = vec![0xAB; 1000]; + let parts = quantus_ur::encode_bytes(&payload).unwrap(); + assert!(parts.len() > 1); + let frames = render_ur_frames(&parts).unwrap(); + let heights: std::collections::HashSet = + frames.frames.iter().map(|f| f.lines().count()).collect(); + assert_eq!(heights.len(), 1, "all frames must have identical line counts"); + assert!(frames.frames[0].contains("Frame 1/")); + } + + #[test] + fn test_render_empty_fails() { + assert!(render_ur_frames(&[]).is_err()); + } +} diff --git a/src/qr/mod.rs b/src/qr/mod.rs new file mode 100644 index 0000000..9063fdd --- /dev/null +++ b/src/qr/mod.rs @@ -0,0 +1,12 @@ +//! Terminal QR display and scanning for cold-wallet signing. +//! +//! Speaks the same wire protocol as the Quantus mobile app, the Quantus cold +//! wallet app and the Keystone firmware: payloads travel as `ur:quantus-sign-request` +//! URs (fountain-coded multi-part QR codes, encoded with the shared `quantus_ur` +//! crate), while cold-wallet addresses are plain SS58 strings in a single QR. + +pub mod display; +pub mod scanner; + +pub use display::{display_ur_until_enter, render_ur_frames}; +pub use scanner::{scan_quantus_address, scan_ur, UrSource}; diff --git a/src/qr/scanner.rs b/src/qr/scanner.rs new file mode 100644 index 0000000..b046517 --- /dev/null +++ b/src/qr/scanner.rs @@ -0,0 +1,387 @@ +//! Collecting UR parts from a camera, a file, or stdin. +//! +//! The signature response from a cold wallet is a ~7.2 KB payload split into +//! dozens of fountain-coded UR fragments cycling on the device screen. Frames +//! may be observed in any order and with gaps; `quantus_ur::is_complete` is the +//! authoritative completion check. + +use crate::error::{QuantusError, Result}; +use std::{path::PathBuf, time::Duration}; + +/// Where to read UR parts from. +#[derive(Debug, Clone)] +pub enum UrSource { + /// Scan the animated QR with a local camera. + #[cfg(feature = "camera")] + Camera { index: u32 }, + /// Read UR parts (one per line) from a file, polling until it exists and + /// holds a complete set. Enables scripted/headless flows. + File(PathBuf), + /// Read UR parts from stdin, one per line, until complete or EOF. + StdinLines, +} + +/// Parse the `seq-total` marker out of a multi-part UR string +/// (`ur:quantus-sign-request/12-37/…` → `(12, 37)`). Single-part URs have no +/// marker. Used only for progress display; completion is decided by the +/// fountain decoder. +pub(crate) fn ur_progress(part: &str) -> Option<(u32, u32)> { + let mut segments = part.split('/'); + let seq = segments.nth(1)?; + let (a, b) = seq.split_once('-')?; + Some((a.parse().ok()?, b.parse().ok()?)) +} + +fn is_ur_line(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.len() > 3 && trimmed[..3].eq_ignore_ascii_case("ur:") +} + +fn decode_if_complete(parts: &[String]) -> Result>> { + if parts.is_empty() || !quantus_ur::is_complete(parts) { + return Ok(None); + } + let bytes = quantus_ur::decode_bytes(parts) + .map_err(|e| QuantusError::Generic(format!("Failed to decode UR parts: {e:?}")))?; + Ok(Some(bytes)) +} + +/// Collect UR parts from `source` until the fountain decoder reports a complete +/// payload, then decode it. +pub async fn scan_ur(source: &UrSource, timeout: Duration) -> Result> { + match source { + #[cfg(feature = "camera")] + UrSource::Camera { index } => camera::scan_ur_with_camera(*index, timeout).await, + UrSource::File(path) => scan_ur_from_file(path, timeout).await, + UrSource::StdinLines => scan_ur_from_stdin().await, + } +} + +/// Poll `path` until it contains a complete set of UR parts (one per line). +async fn scan_ur_from_file(path: &std::path::Path, timeout: Duration) -> Result> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + if let Ok(content) = std::fs::read_to_string(path) { + let parts: Vec = content + .lines() + .filter(|l| is_ur_line(l)) + .map(|l| l.trim().to_string()) + .collect(); + if let Some(bytes) = decode_if_complete(&parts)? { + return Ok(bytes); + } + } + + if tokio::time::Instant::now() >= deadline { + return Err(QuantusError::Generic(format!( + "Timed out waiting for a complete UR response in {}", + path.display() + ))); + } + + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(500)) => {}, + _ = tokio::signal::ctrl_c() => { + return Err(QuantusError::Generic("Aborted by user".to_string())); + }, + } + } +} + +/// Read UR parts from stdin, one per line, until the payload is complete or EOF. +async fn scan_ur_from_stdin() -> Result> { + crate::log_print!("Paste the UR parts (one per line); scanning stops once complete:"); + tokio::task::spawn_blocking(|| { + use std::io::BufRead; + let stdin = std::io::stdin(); + let mut parts: Vec = Vec::new(); + for line in stdin.lock().lines() { + let line = line?; + if is_ur_line(&line) { + parts.push(line.trim().to_string()); + if let Some(bytes) = decode_if_complete(&parts)? { + return Ok(bytes); + } + } + } + Err(QuantusError::Generic( + "Input ended before a complete UR payload was received".to_string(), + )) + }) + .await + .map_err(|e| QuantusError::Generic(format!("stdin reader task failed: {e}")))? +} + +/// Scan a plain (non-UR) QR containing a Quantus SS58 address, as shown on the +/// receive/address screen of a Keystone device or the Quantus cold wallet app. +pub async fn scan_quantus_address(camera_index: u32) -> Result { + #[cfg(feature = "camera")] + { + camera::scan_address_with_camera(camera_index).await + } + #[cfg(not(feature = "camera"))] + { + let _ = camera_index; + Err(QuantusError::Generic( + "This build has no camera support (built without the `camera` feature). Pass the address explicitly with --address".to_string(), + )) + } +} + +#[cfg(feature = "camera")] +mod camera { + use super::*; + use std::{ + collections::HashSet, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, + }, + time::Instant, + }; + + /// Ask for camera permission. On macOS this pops the TCC dialog (attributed + /// to the terminal app, not `quantus`); on other platforms it's a no-op. + fn ensure_permission() -> Result<()> { + let (tx, rx) = mpsc::channel(); + nokhwa::nokhwa_initialize(move |granted| { + let _ = tx.send(granted); + }); + match rx.recv_timeout(Duration::from_secs(30)) { + Ok(true) => Ok(()), + Ok(false) => Err(QuantusError::Generic( + "Camera permission denied. Grant camera access to your terminal app in System Settings > Privacy & Security > Camera, or avoid the camera with --cold-response-in / --address".to_string(), + )), + Err(_) => Err(QuantusError::Generic( + "Timed out waiting for camera permission. Approve the dialog in System Settings > Privacy & Security > Camera and retry, or avoid the camera with --cold-response-in / --address".to_string(), + )), + } + } + + /// Blocking camera loop: grab frames, decode QR codes with rqrr, feed every + /// decoded string to `sink` until it returns `Some(result)`. + fn scan_with_camera( + index: u32, + timeout: Duration, + stop: Arc, + mut sink: impl FnMut(&str) -> Option>, + ) -> Result { + use nokhwa::{ + pixel_format::RgbFormat, + utils::{CameraIndex, RequestedFormat, RequestedFormatType}, + Camera, + }; + + ensure_permission()?; + + let mut camera = Camera::new( + CameraIndex::Index(index), + RequestedFormat::new::(RequestedFormatType::AbsoluteHighestResolution), + ) + .map_err(|e| { + QuantusError::Generic(format!( + "Failed to open camera {index}: {e}. Try a different --camera-index, or avoid the camera with --cold-response-in / --address" + )) + })?; + camera + .open_stream() + .map_err(|e| QuantusError::Generic(format!("Failed to start camera stream: {e}")))?; + + let deadline = Instant::now() + timeout; + let result = 'scan: loop { + if stop.load(Ordering::Relaxed) { + break Err(QuantusError::Generic("Aborted by user".to_string())); + } + if Instant::now() >= deadline { + break Err(QuantusError::Generic( + "Timed out scanning. Keep the QR steady in front of the camera, raise the device's screen brightness (15-25 cm distance works best), or use --cold-response-in ".to_string(), + )); + } + + // frame() blocks until the sensor delivers — natural pacing. + let frame = match camera.frame() { + Ok(frame) => frame, + Err(e) => { + crate::log_verbose!("📷 Dropped camera frame: {e}"); + continue; + }, + }; + let img = match frame.decode_image::() { + Ok(img) => img, + Err(e) => { + crate::log_verbose!("📷 Failed to decode camera frame: {e}"); + continue; + }, + }; + + // Point-sample down to ~1280px so rqrr keeps up with the ~5 fps QR + // animation even on high-resolution cameras. + let (w, h) = (img.width() as usize, img.height() as usize); + let scale = (w.max(h) / 1280).max(1); + let (sw, sh) = (w / scale, h / scale); + let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(sw, sh, |x, y| { + let p = img.get_pixel((x * scale) as u32, (y * scale) as u32).0; + ((p[0] as u16 * 299 + p[1] as u16 * 587 + p[2] as u16 * 114) / 1000) as u8 + }); + + for grid in prepared.detect_grids() { + if let Ok((_meta, content)) = grid.decode() { + if let Some(result) = sink(&content) { + break 'scan result; + } + } + } + }; + + let _ = camera.stop_stream(); + result + } + + /// Drive the blocking camera loop from async code, aborting on Ctrl-C. + async fn run_scan( + index: u32, + timeout: Duration, + sink: impl FnMut(&str) -> Option> + Send + 'static, + ) -> Result { + let stop = Arc::new(AtomicBool::new(false)); + let stop_flag = stop.clone(); + let handle = + tokio::task::spawn_blocking(move || scan_with_camera(index, timeout, stop_flag, sink)); + + tokio::select! { + result = handle => result + .map_err(|e| QuantusError::Generic(format!("Camera task failed: {e}")))?, + _ = tokio::signal::ctrl_c() => { + stop.store(true, Ordering::Relaxed); + println!(); + Err(QuantusError::Generic("Aborted by user".to_string())) + }, + } + } + + fn scan_spinner(message: &str) -> indicatif::ProgressBar { + use indicatif::{ProgressBar, ProgressStyle}; + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") + .template("{spinner:.cyan} {msg}") + .unwrap(), + ); + pb.set_message(message.to_string()); + pb.enable_steady_tick(Duration::from_millis(120)); + pb + } + + /// Scan an animated (or static) UR from the camera until the fountain + /// decoder has a complete payload. + pub(super) async fn scan_ur_with_camera(index: u32, timeout: Duration) -> Result> { + let spinner = scan_spinner("📷 Point the camera at the QR on the cold wallet…"); + let progress = spinner.clone(); + + let mut seen: HashSet = HashSet::new(); + let mut parts: Vec = Vec::new(); + let mut total: Option = None; + + let result = run_scan(index, timeout, move |content| { + if !is_ur_line(content) { + return None; + } + let part = content.trim().to_string(); + if !seen.insert(part.clone()) { + return None; + } + if let Some((_, t)) = ur_progress(&part) { + total = Some(t); + } + parts.push(part); + let of_total = total.map(|t| format!(" (sequence of {t})")).unwrap_or_default(); + progress.set_message(format!( + "📷 Captured {} UR part{}{}…", + parts.len(), + if parts.len() == 1 { "" } else { "s" }, + of_total + )); + decode_if_complete(&parts).transpose() + }) + .await; + + spinner.finish_and_clear(); + result + } + + /// Scan a plain QR until it contains a valid Quantus (prefix 189) address. + pub(super) async fn scan_address_with_camera(index: u32) -> Result { + use sp_core::crypto::{AccountId32, Ss58Codec}; + + let spinner = scan_spinner("📷 Point the camera at the address QR…"); + let progress = spinner.clone(); + + let result = run_scan(index, Duration::from_secs(120), move |content| { + let trimmed = content.trim(); + match AccountId32::from_ss58check_with_version(trimmed) { + Ok((_, format)) if format == crate::cli::address_format::quantus_ss58_format() => + Some(Ok(trimmed.to_string())), + _ => { + progress.set_message(format!( + "📷 Saw a QR that is not a Quantus address ({}…) — keep looking…", + trimmed.chars().take(16).collect::() + )); + None + }, + } + }) + .await; + + spinner.finish_and_clear(); + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ur_progress_multipart() { + assert_eq!(ur_progress("ur:quantus-sign-request/12-37/lpamchcfatttcy…"), Some((12, 37))); + assert_eq!(ur_progress("UR:QUANTUS-SIGN-REQUEST/412-37/LPAM…"), Some((412, 37))); + } + + #[test] + fn test_ur_progress_single_part() { + // Single-part URs carry the bytewords directly in segment 1 + assert_eq!(ur_progress("ur:quantus-sign-request/hdcxlkadaeae"), None); + assert_eq!(ur_progress("not-a-ur"), None); + } + + #[test] + fn test_is_ur_line() { + assert!(is_ur_line("ur:quantus-sign-request/1-3/abc")); + assert!(is_ur_line(" UR:QUANTUS-SIGN-REQUEST/HDCX ")); + assert!(!is_ur_line("ur:")); + assert!(!is_ur_line("# comment")); + assert!(!is_ur_line("")); + } + + #[tokio::test] + async fn test_scan_ur_from_file_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("parts.ur"); + let payload: Vec = (0..=255u8).cycle().take(7219).collect(); + let parts = quantus_ur::encode_bytes(&payload).unwrap(); + assert!(parts.len() > 1, "7219-byte payload must be multi-part"); + std::fs::write(&path, parts.join("\n")).unwrap(); + + let decoded = scan_ur(&UrSource::File(path), Duration::from_secs(5)).await.expect("decode"); + assert_eq!(decoded, payload); + } + + #[tokio::test] + async fn test_scan_ur_from_file_times_out() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("never-written.ur"); + let result = scan_ur(&UrSource::File(path), Duration::from_millis(100)).await; + assert!(result.is_err()); + } +} diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index a3af4c1..ea1155e 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -101,11 +101,27 @@ impl QuantumKeyPair { } } +/// Kind of wallet stored in a wallet file. +/// +/// `Cold` wallets are watch-only: the private key lives on an air-gapped +/// device (Keystone or the Quantus cold wallet app) and signing happens over +/// QR codes. Their files carry an address but no key material. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WalletType { + #[default] + Hot, + Cold, +} + /// Quantum-safe encrypted wallet data structure #[derive(Debug, Serialize, Deserialize)] pub struct EncryptedWallet { pub name: String, pub address: String, // SS58-encoded address (public, not encrypted) + /// Wallet kind; absent in files written by older versions, which are all hot. + #[serde(default)] + pub wallet_type: WalletType, pub encrypted_data: Vec, pub kyber_ciphertext: Vec, // Reserved for future ML-KEM implementation pub kyber_public_key: Vec, // Reserved for future ML-KEM implementation @@ -118,6 +134,25 @@ pub struct EncryptedWallet { pub created_at: chrono::DateTime, } +impl EncryptedWallet { + /// Create a watch-only cold wallet entry: address only, no key material. + pub fn new_cold(name: &str, address: &str) -> Self { + Self { + name: name.to_string(), + address: address.to_string(), + wallet_type: WalletType::Cold, + encrypted_data: vec![], + kyber_ciphertext: vec![], + kyber_public_key: vec![], + argon2_salt: vec![], + argon2_params: String::new(), + aes_nonce: vec![], + encryption_version: 0, + created_at: chrono::Utc::now(), + } + } +} + /// Wallet data structure (before encryption) #[derive(Debug, Serialize, Deserialize)] pub struct WalletData { @@ -240,6 +275,7 @@ impl Keystore { Ok(EncryptedWallet { name: data.name.clone(), address: data.keypair.to_account_id_ss58check(), // Store public address + wallet_type: WalletType::Hot, encrypted_data, kyber_ciphertext: vec![], // Reserved for future ML-KEM implementation kyber_public_key: vec![], // Reserved for future ML-KEM implementation @@ -257,6 +293,10 @@ impl Keystore { encrypted: &EncryptedWallet, password: &str, ) -> Result { + if encrypted.wallet_type == WalletType::Cold { + return Err(WalletError::ColdWalletNoKeys(encrypted.name.clone()).into()); + } + // 1. Re-derive the AES key from the password and the stored salt + params. // The key itself is never stored in the wallet file. let aes_key = Self::derive_aes_key(encrypted, password)?; @@ -788,6 +828,7 @@ mod tests { EncryptedWallet { name: data.name.clone(), address: data.keypair.to_account_id_ss58check(), + wallet_type: WalletType::Hot, encrypted_data, kyber_ciphertext: vec![], kyber_public_key: vec![], diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 12c6b8d..0808b1f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -9,7 +9,7 @@ pub mod keystore; pub mod password; use crate::error::{Result, WalletError}; -pub use keystore::{Keystore, QuantumKeyPair, WalletData}; +pub use keystore::{Keystore, QuantumKeyPair, WalletData, WalletType}; use qp_dilithium_crypto::DilithiumPair; use qp_rusty_crystals_hdwallet::{ derive_key_from_mnemonic, generate_mnemonic, mnemonic_to_seed, SensitiveBytes32, @@ -20,6 +20,9 @@ use serde::{Deserialize, Serialize}; /// Default derivation path for Quantus wallets: m/44'/189189'/0'/0'/0' pub const DEFAULT_DERIVATION_PATH: &str = "m/44'/189189'/0'/0'/0'"; +/// Display label for watch-only cold wallets +pub const COLD_KEY_TYPE: &str = "Cold (watch-only)"; + /// Wallet information structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WalletInfo { @@ -155,6 +158,11 @@ impl WalletManager { /// Export a wallet's mnemonic phrase pub fn export_mnemonic(&self, name: &str, password: Option<&str>) -> Result { + // Cold wallets have nothing to export; check before prompting for a password. + if self.wallet_type(name)? == Some(keystore::WalletType::Cold) { + return Err(WalletError::ColdWalletNoKeys(name.to_string()).into()); + } + let final_password = password::get_wallet_password(name, password.map(String::from), None)?; let wallet_data = self.load_wallet(name, &final_password)?; @@ -162,6 +170,49 @@ impl WalletManager { wallet_data.mnemonic.ok_or_else(|| WalletError::MnemonicNotAvailable.into()) } + /// Import a watch-only cold wallet from an SS58 address. The private key + /// stays on the air-gapped device; signing happens over QR codes. + pub fn create_cold_wallet(&self, name: &str, address: &str) -> Result { + use sp_core::crypto::{AccountId32, Ss58Codec}; + + let keystore = Keystore::new(&self.wallets_dir); + if keystore.load_wallet(name)?.is_some() { + return Err(WalletError::AlreadyExists.into()); + } + + let (_, format) = + AccountId32::from_ss58check_with_version(address.trim()).map_err(|_| { + crate::error::QuantusError::Generic(format!( + "'{}' is not a valid SS58 address", + address.trim() + )) + })?; + if format != crate::cli::address_format::quantus_ss58_format() { + return Err(crate::error::QuantusError::Generic(format!( + "'{}' is not a Quantus address (expected SS58 prefix 189, addresses starting with 'qz')", + address.trim() + ))); + } + + let encrypted_wallet = keystore::EncryptedWallet::new_cold(name, address.trim()); + keystore.save_wallet(&encrypted_wallet)?; + + Ok(WalletInfo { + name: name.to_string(), + address: encrypted_wallet.address, + created_at: encrypted_wallet.created_at, + key_type: COLD_KEY_TYPE.to_string(), + derivation_path: "-".to_string(), + }) + } + + /// Cheap wallet-type probe from the unencrypted wallet file. + /// Returns `None` if no wallet with that name exists. + pub fn wallet_type(&self, name: &str) -> Result> { + let keystore = Keystore::new(&self.wallets_dir); + Ok(keystore.load_wallet(name)?.map(|w| w.wallet_type)) + } + /// List all wallets pub fn list_wallets(&self) -> Result> { let keystore = Keystore::new(&self.wallets_dir); @@ -170,13 +221,22 @@ impl WalletManager { let mut wallets = Vec::new(); for name in wallet_names { if let Some(encrypted_wallet) = keystore.load_wallet(&name)? { + let is_cold = encrypted_wallet.wallet_type == keystore::WalletType::Cold; // Create wallet info using stored public address let wallet_info = WalletInfo { name: encrypted_wallet.name, address: encrypted_wallet.address, // Address is stored unencrypted created_at: encrypted_wallet.created_at, - key_type: "Dilithium ML-DSA-87".to_string(), - derivation_path: "[Encrypted]".to_string(), // Derivation path is encrypted + key_type: if is_cold { + COLD_KEY_TYPE.to_string() + } else { + "Dilithium ML-DSA-87".to_string() + }, + derivation_path: if is_cold { + "-".to_string() + } else { + "[Encrypted]".to_string() // Derivation path is encrypted + }, }; wallets.push(wallet_info); } @@ -423,6 +483,16 @@ impl WalletManager { let keystore = Keystore::new(&self.wallets_dir); if let Some(encrypted_wallet) = keystore.load_wallet(name)? { + if encrypted_wallet.wallet_type == keystore::WalletType::Cold { + // Watch-only: everything known about it is public + return Ok(Some(WalletInfo { + name: encrypted_wallet.name, + address: encrypted_wallet.address, + created_at: encrypted_wallet.created_at, + key_type: COLD_KEY_TYPE.to_string(), + derivation_path: "-".to_string(), + })); + } if let Some(pwd) = password { // Decrypt and show full details match keystore.decrypt_wallet_data(&encrypted_wallet, pwd) { @@ -517,6 +587,10 @@ pub fn load_keypair_from_wallet( password_file: Option, ) -> Result { let wallet_manager = WalletManager::new()?; + // Cold wallets have no key material; fail before any password prompt. + if wallet_manager.wallet_type(wallet_name)? == Some(keystore::WalletType::Cold) { + return Err(WalletError::ColdWalletNoKeys(wallet_name.to_string()).into()); + } let wallet_password = password::get_wallet_password(wallet_name, password, password_file)?; let wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?; let keypair = wallet_data.keypair; @@ -972,4 +1046,108 @@ mod tests { assert!(result.is_none()); } + + // A valid prefix-189 address for cold wallet tests (crystal_alice's account) + fn cold_test_address() -> String { + let pair = qp_dilithium_crypto::crystal_alice(); + QuantumKeyPair::from_resonance_pair(&pair).to_account_id_ss58check() + } + + #[tokio::test] + async fn test_cold_wallet_create_list_find() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let address = cold_test_address(); + + let info = wallet_manager + .create_cold_wallet("frosty", &address) + .expect("Failed to create cold wallet"); + assert_eq!(info.name, "frosty"); + assert_eq!(info.address, address); + assert_eq!(info.key_type, COLD_KEY_TYPE); + + // Listed alongside hot wallets, marked cold + wallet_manager.create_wallet("hot-one", Some("pw")).await.unwrap(); + let wallets = wallet_manager.list_wallets().unwrap(); + assert_eq!(wallets.len(), 2); + let cold = wallets.iter().find(|w| w.name == "frosty").unwrap(); + assert_eq!(cold.key_type, COLD_KEY_TYPE); + let hot = wallets.iter().find(|w| w.name == "hot-one").unwrap(); + assert_eq!(hot.key_type, "Dilithium ML-DSA-87"); + + // Address resolution and type probe work + assert_eq!(wallet_manager.find_wallet_address("frosty").unwrap(), Some(address)); + assert_eq!(wallet_manager.wallet_type("frosty").unwrap(), Some(WalletType::Cold)); + assert_eq!(wallet_manager.wallet_type("hot-one").unwrap(), Some(WalletType::Hot)); + assert_eq!(wallet_manager.wallet_type("nope").unwrap(), None); + + // get_wallet without password shows full public info + let viewed = wallet_manager.get_wallet("frosty", None).unwrap().unwrap(); + assert_eq!(viewed.key_type, COLD_KEY_TYPE); + } + + #[tokio::test] + async fn test_cold_wallet_file_format_and_no_keys() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let address = cold_test_address(); + wallet_manager.create_cold_wallet("frosty", &address).unwrap(); + + // On-disk JSON carries the type tag and no key material + let file = wallet_manager.wallets_dir.join("frosty.json"); + let json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&file).unwrap()).unwrap(); + assert_eq!(json["wallet_type"], "cold"); + assert!(json["encrypted_data"].as_array().unwrap().is_empty()); + + // Decryption paths refuse with ColdWalletNoKeys (no password prompt) + let result = wallet_manager.load_wallet("frosty", ""); + assert!(matches!( + result, + Err(crate::error::QuantusError::Wallet(WalletError::ColdWalletNoKeys(_))) + )); + let result = wallet_manager.export_mnemonic("frosty", Some("pw")); + assert!(matches!( + result, + Err(crate::error::QuantusError::Wallet(WalletError::ColdWalletNoKeys(_))) + )); + } + + #[tokio::test] + async fn test_cold_wallet_rejects_bad_addresses_and_duplicates() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let address = cold_test_address(); + + // Garbage address + assert!(wallet_manager.create_cold_wallet("bad", "not-an-address").is_err()); + // Valid SS58 but wrong prefix (42 = generic Substrate) + use sp_core::crypto::{AccountId32, Ss58AddressFormat, Ss58Codec}; + let generic = + AccountId32::from([7u8; 32]).to_ss58check_with_version(Ss58AddressFormat::custom(42)); + assert!(wallet_manager.create_cold_wallet("bad", &generic).is_err()); + + // Duplicate names rejected across wallet kinds + wallet_manager.create_cold_wallet("frosty", &address).unwrap(); + assert!(matches!( + wallet_manager.create_cold_wallet("frosty", &address), + Err(crate::error::QuantusError::Wallet(WalletError::AlreadyExists)) + )); + } + + #[test] + fn test_legacy_wallet_json_defaults_to_hot() { + // Files written before wallet_type existed must deserialize as Hot + let legacy = r#"{ + "name": "old", + "address": "qzabc", + "encrypted_data": [1, 2, 3], + "kyber_ciphertext": [], + "kyber_public_key": [], + "argon2_salt": [4, 5, 6], + "argon2_params": "$argon2id$v=19$m=19456,t=2,p=1", + "aes_nonce": [7, 8, 9], + "encryption_version": 2, + "created_at": "2025-01-01T00:00:00Z" + }"#; + let wallet: keystore::EncryptedWallet = serde_json::from_str(legacy).unwrap(); + assert_eq!(wallet.wallet_type, WalletType::Hot); + } } From 1b5bb531be2a671c9d0c6542747d9c3f4d539f0b Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 30 Jul 2026 11:51:00 +0800 Subject: [PATCH 02/13] feat: cold wallet signing for all extrinsic commands Introduce WalletSigner (Hot/Cold) and route every command through it: the shared submit stage in cli::common branches to the QR signing flow when the wallet is watch-only, so commands no longer special-case cold wallets. Promote --cold-request-out/--cold-response-in/--camera-index to global flags installed once from main, add cold fee estimation via dummy signature, and drop the send-only cold path. --- README.md | 18 ++- examples/multisig_usage.rs | 2 +- src/cli/batch.rs | 10 +- src/cli/cold_signing.rs | 54 +++++++- src/cli/common.rs | 42 ++++++- src/cli/exercise/runner.rs | 19 ++- src/cli/exercise/scenarios/fuzz.rs | 44 +++++-- src/cli/exercise/scenarios/governance.rs | 10 +- src/cli/exercise/scenarios/negative.rs | 2 +- src/cli/exercise/scenarios/preimage.rs | 8 +- src/cli/exercise/scenarios/reversible.rs | 6 +- src/cli/exercise/scenarios/upgrade.rs | 10 +- src/cli/generic_call.rs | 25 ++-- src/cli/high_security.rs | 4 +- src/cli/mod.rs | 45 +------ src/cli/multisend.rs | 23 +++- src/cli/multisig.rs | 69 ++++++----- src/cli/preimage.rs | 18 +-- src/cli/recovery.rs | 46 +++---- src/cli/reversible.rs | 34 +++--- src/cli/runtime.rs | 14 +-- src/cli/send.rs | 149 +++++++---------------- src/cli/tech_collective.rs | 24 ++-- src/cli/tech_referenda.rs | 38 +++--- src/cli/wallet.rs | 21 +--- src/cli/wormhole.rs | 4 +- src/error.rs | 2 +- src/main.rs | 22 ++++ src/wallet/mod.rs | 63 +++++++++- 29 files changed, 473 insertions(+), 353 deletions(-) diff --git a/README.md b/README.md index b96d8ba..f444e7c 100644 --- a/README.md +++ b/README.md @@ -406,12 +406,15 @@ quantus wallet import-cold --name my_cold # Or paste the address directly (no camera needed) quantus wallet import-cold --name my_cold --address qz... -# Send: shows the transaction as a QR, then scans the device's animated -# signature QR with the camera +# Any extrinsic command works with a cold wallet: the CLI shows the +# transaction as a QR, then scans the device's animated signature QR quantus send --from my_cold --to
--amount 10.5 --wait-for-transaction +quantus multisig approve --from my_cold --address qz... --proposal-id 0 ``` -The signing flow: +The signing flow (identical for every command — commands don't know whether +the wallet is hot or cold; the shared submit stage branches on the wallet +type): 1. The CLI displays the transaction as a `ur:quantus-sign-request` QR (animated if the payload is large). Scan it with the cold wallet, review @@ -426,14 +429,19 @@ Notes: - The transaction stays valid for 256 blocks after the QR is generated; if it expires or the account's nonce changes before submission, re-run the command to sign a fresh QR. +- Commands that submit several extrinsics (e.g. `runtime update`, + `tech-referenda submit-with-preimage`) do one QR roundtrip per extrinsic. +- Wormhole operations are not cold-compatible: they derive secrets from the + wallet's mnemonic and submit unsigned extrinsics. - **macOS camera permission**: the permission prompt is attributed to your terminal app (Terminal, iTerm, VS Code, …) — grant it under System Settings > Privacy & Security > Camera. - **Headless / no camera**: `--cold-response-in ` (or `-` for stdin) reads the response UR parts (one per line) instead of scanning, and the hidden `--cold-request-out ` writes the request UR parts for - scripted flows. Builds without the default `camera` feature - (`cargo build --no-default-features`) support only this path. + scripted flows. Both are global flags that work with any command. Builds + without the default `camera` feature (`cargo build --no-default-features`) + support only this path. - Real devices only sign for known networks (Planck / Heisenberg genesis hashes) and whitelisted calls — balance transfers, reversible transfers, and (cold wallet app only) multisig. Test against a dev node with the diff --git a/examples/multisig_usage.rs b/examples/multisig_usage.rs index 06626b6..b615573 100644 --- a/examples/multisig_usage.rs +++ b/examples/multisig_usage.rs @@ -69,7 +69,7 @@ async fn main() -> Result<()> { let tx_hash = quantus_cli::cli::common::submit_transaction( &quantus_client, - &alice_keypair, + &quantus_cli::wallet::WalletSigner::Hot(alice_keypair), create_tx, None, execution_mode, diff --git a/src/cli/batch.rs b/src/cli/batch.rs index 523c37a..a6be25f 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -146,9 +146,9 @@ async fn handle_batch_send_command( }; // Load wallet - let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?; - let from_account_id = keypair.to_account_id_ss58check(); - validate_batch_transfer_request(&quantus_client, &keypair, &transfers).await?; + let signer = crate::wallet::load_signer_from_wallet(&from_wallet, password, password_file)?; + let from_account_id = signer.account_id_ss58check(); + validate_batch_transfer_request(&quantus_client, &signer, &transfers).await?; let effective_tip = crate::cli::send::effective_tip_amount(tip_amount); let submit_tip = crate::cli::send::positive_tip_amount(tip_amount); @@ -164,7 +164,7 @@ async fn handle_batch_send_command( let batch_call = build_batch_transfer_call(&transfers)?; ensure_balance_covers_call( &quantus_client, - &keypair, + &signer, &batch_call, balance, exact_required, @@ -176,7 +176,7 @@ async fn handle_batch_send_command( // Submit batch transaction let tx_hash = submit_prebuilt_batch_transfer_call( &quantus_client, - &keypair, + &signer, &transfers, batch_call, tip_amount, diff --git a/src/cli/cold_signing.rs b/src/cli/cold_signing.rs index 6eedced..289ebb9 100644 --- a/src/cli/cold_signing.rs +++ b/src/cli/cold_signing.rs @@ -55,6 +55,7 @@ pub struct TxContext { } /// Where the sign-request goes out and the signature response comes in. +#[derive(Default)] pub struct ColdIo { /// Also write the request UR parts to this file (one per line). Hidden /// flag, used for scripting/testing. @@ -65,6 +66,42 @@ pub struct ColdIo { pub camera_index: u32, } +static COLD_IO: std::sync::OnceLock = std::sync::OnceLock::new(); + +impl ColdIo { + /// Build from the global CLI flags. `"-"` for `response_in` means stdin. + pub fn from_flags( + request_out: Option, + response_in: Option, + camera_index: u32, + ) -> Self { + Self { + request_out: request_out.map(PathBuf::from), + response_in: response_in.map(|s| { + if s == "-" { + UrSource::StdinLines + } else { + UrSource::File(PathBuf::from(s)) + } + }), + camera_index, + } + } + + /// Install the process-wide I/O config. Called once from `main` with the + /// global CLI flags so the submit stage can reach it without threading it + /// through every command. + pub fn install(self) { + let _ = COLD_IO.set(self); + } + + /// The installed config, or the default (camera 0, no file overrides) for + /// library and test users that never call `install`. + pub fn global() -> &'static ColdIo { + COLD_IO.get_or_init(ColdIo::default) + } +} + /// Why a signature response was rejected. enum ResponseError { /// Wrong size — almost always an incomplete or corrupted scan. Rescanning @@ -220,8 +257,8 @@ fn confirm_or_abort(prompt: &str) -> Result<()> { /// Run the full cold signing flow for `call` and submit the result. /// -/// Generic over the call payload so transfer, multisig and reversible commands -/// can all route through it. +/// Generic over the call payload; the shared submit stage in `cli::common` +/// routes every extrinsic here when the signer is a cold (watch-only) wallet. #[allow(clippy::too_many_arguments)] pub async fn sign_and_submit_cold( client: &QuantusClient, @@ -363,6 +400,19 @@ async fn estimate_fee_with_dummy_signature( tx.partial_fee_estimate().await.ok() } +/// Fee estimate for a cold wallet when no [`TxContext`] exists yet (balance +/// preflight before signing). Same dummy-signature trick; the fee depends on +/// length and weight, not on nonce or signature content. +pub(crate) async fn estimate_cold_fee( + client: &QuantusClient, + call: &Call, + account: &AccountId32, + tip: u128, +) -> Option { + let ctx = capture_tx_context(client, account, tip, None).await.ok()?; + estimate_fee_with_dummy_signature(client, call, &ctx, account).await +} + fn default_response_source(io: &ColdIo) -> Result { #[cfg(feature = "camera")] { diff --git a/src/cli/common.rs b/src/cli/common.rs index 325c6e4..ab67ed0 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -368,9 +368,13 @@ pub async fn get_incremented_nonce_with_client( /// By default, returns immediately after the node accepts the transaction submission. /// With `wait_for_transaction=true`, waits until the transaction is in a best block. /// With `finalized=true`, waits until the transaction is in a finalized block. +/// +/// Cold (watch-only) signers are routed to the QR signing flow instead of local +/// signing; there is no retry loop there, since a QR-signed extrinsic can never +/// be silently rebuilt. pub async fn submit_transaction( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, call: Call, tip: Option, execution_mode: ExecutionMode, @@ -378,6 +382,21 @@ pub async fn submit_transaction( where Call: subxt::tx::Payload, { + let from_keypair = match signer { + crate::wallet::WalletSigner::Hot(keypair) => keypair, + crate::wallet::WalletSigner::Cold { name, address } => + return crate::cli::cold_signing::sign_and_submit_cold( + quantus_client, + name, + address, + &call, + tip, + None, + execution_mode, + crate::cli::cold_signing::ColdIo::global(), + ) + .await, + }; let signer = from_keypair.to_subxt_signer().map_err(|e| { crate::error::QuantusError::NetworkError(format!("Failed to convert keypair: {e:?}")) })?; @@ -578,7 +597,7 @@ pub async fn submit_prepared_transaction( /// Submit transaction with manual nonce (no retry logic - use exact nonce provided) pub async fn submit_transaction_with_nonce( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, call: Call, tip: Option, nonce: u32, @@ -587,6 +606,21 @@ pub async fn submit_transaction_with_nonce( where Call: subxt::tx::Payload, { + let from_keypair = match signer { + crate::wallet::WalletSigner::Hot(keypair) => keypair, + crate::wallet::WalletSigner::Cold { name, address } => + return crate::cli::cold_signing::sign_and_submit_cold( + quantus_client, + name, + address, + &call, + tip, + Some(nonce), + execution_mode, + crate::cli::cold_signing::ColdIo::global(), + ) + .await, + }; let signer = from_keypair.to_subxt_signer().map_err(|e| { crate::error::QuantusError::NetworkError(format!("Failed to convert keypair: {e:?}")) })?; @@ -814,7 +848,7 @@ fn format_dispatch_error( pub async fn submit_preimage( quantus_client: &crate::chain::client::QuantusClient, - keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, encoded_call: Vec, execution_mode: ExecutionMode, ) -> Result<()> { @@ -827,7 +861,7 @@ pub async fn submit_preimage( crate::chain::quantus_subxt::api::tx().preimage().note_preimage(bounded_bytes); let wait_mode = ExecutionMode { wait_for_transaction: true, ..execution_mode }; - match submit_transaction(quantus_client, keypair, note_preimage_tx, None, wait_mode).await { + match submit_transaction(quantus_client, signer, note_preimage_tx, None, wait_mode).await { Ok(_) => { crate::log_success!("Preimage submitted"); }, diff --git a/src/cli/exercise/runner.rs b/src/cli/exercise/runner.rs index b321aed..8ccf8de 100644 --- a/src/cli/exercise/runner.rs +++ b/src/cli/exercise/runner.rs @@ -53,7 +53,14 @@ pub async fn submit_ok( where Call: subxt::tx::Payload, { - crate::cli::common::submit_transaction(&ctx.client, from, call, None, ctx.wait_mode()).await + crate::cli::common::submit_transaction( + &ctx.client, + &crate::wallet::WalletSigner::Hot(from.clone()), + call, + None, + ctx.wait_mode(), + ) + .await } pub async fn submit_expect_failure( @@ -65,8 +72,14 @@ pub async fn submit_expect_failure( where Call: subxt::tx::Payload, { - match crate::cli::common::submit_transaction(&ctx.client, from, call, None, ctx.wait_mode()) - .await + match crate::cli::common::submit_transaction( + &ctx.client, + &crate::wallet::WalletSigner::Hot(from.clone()), + call, + None, + ctx.wait_mode(), + ) + .await { Ok(hash) => Err(QuantusError::Generic(format!( "expected rejection but transaction succeeded ({hash:?})" diff --git a/src/cli/exercise/scenarios/fuzz.rs b/src/cli/exercise/scenarios/fuzz.rs index d533821..bd1d248 100644 --- a/src/cli/exercise/scenarios/fuzz.rs +++ b/src/cli/exercise/scenarios/fuzz.rs @@ -87,9 +87,14 @@ async fn fuzz_transfer(ctx: &mut ExerciseCtx) -> Result { let call = quantus_subxt::api::tx() .balances() .transfer_allow_death(subxt::ext::subxt_core::utils::MultiAddress::Id(to), amount); - let result = - crate::cli::common::submit_transaction(&ctx.client, &sender, call, None, ctx.wait_mode()) - .await; + let result = crate::cli::common::submit_transaction( + &ctx.client, + &crate::wallet::WalletSigner::Hot(sender.clone()), + call, + None, + ctx.wait_mode(), + ) + .await; classify(result, &format!("transfer of {amount}")) } @@ -99,9 +104,14 @@ async fn fuzz_remark(ctx: &mut ExerciseCtx) -> Result { let mut payload = vec![0u8; len]; ctx.rng.fill(payload.as_mut_slice()); let call = quantus_subxt::api::tx().system().remark(payload); - let result = - crate::cli::common::submit_transaction(&ctx.client, &sender, call, None, ctx.wait_mode()) - .await; + let result = crate::cli::common::submit_transaction( + &ctx.client, + &crate::wallet::WalletSigner::Hot(sender.clone()), + call, + None, + ctx.wait_mode(), + ) + .await; classify(result, &format!("remark of {len} bytes")) } @@ -121,9 +131,14 @@ async fn fuzz_batch(ctx: &mut ExerciseCtx) -> Result { })); } let call = quantus_subxt::api::tx().utility().batch(calls); - let result = - crate::cli::common::submit_transaction(&ctx.client, &sender, call, None, ctx.wait_mode()) - .await; + let result = crate::cli::common::submit_transaction( + &ctx.client, + &crate::wallet::WalletSigner::Hot(sender.clone()), + call, + None, + ctx.wait_mode(), + ) + .await; // Batch succeeds even when inner calls fail. classify(result, &format!("batch of {n} transfers")) } @@ -145,8 +160,13 @@ async fn fuzz_reversible(ctx: &mut ExerciseCtx) -> Result { amount, delay, ); - let result = - crate::cli::common::submit_transaction(&ctx.client, &sender, call, None, ctx.wait_mode()) - .await; + let result = crate::cli::common::submit_transaction( + &ctx.client, + &crate::wallet::WalletSigner::Hot(sender.clone()), + call, + None, + ctx.wait_mode(), + ) + .await; classify(result, &format!("reversible transfer of {amount}")) } diff --git a/src/cli/exercise/scenarios/governance.rs b/src/cli/exercise/scenarios/governance.rs index a80a24a..f559251 100644 --- a/src/cli/exercise/scenarios/governance.rs +++ b/src/cli/exercise/scenarios/governance.rs @@ -54,7 +54,13 @@ async fn referendum_flow(ctx: &mut ExerciseCtx) -> Result { let preimage_hash: sp_core::H256 = BlakeTwo256::hash(&encoded); let call_len = encoded.len() as u32; - crate::cli::common::submit_preimage(&ctx.client, &ctx.alice, encoded, ctx.wait_mode()).await?; + crate::cli::common::submit_preimage( + &ctx.client, + &crate::wallet::WalletSigner::Hot(ctx.alice.clone()), + encoded, + ctx.wait_mode(), + ) + .await?; let count_addr = quantus_subxt::api::storage().tech_referenda().referendum_count(); let index = storage_at.fetch(&count_addr).await?.unwrap_or(0); @@ -69,7 +75,7 @@ async fn referendum_flow(ctx: &mut ExerciseCtx) -> Result { for voter in [ctx.alice.clone(), ctx.bob.clone(), ctx.charlie.clone()] { crate::cli::tech_collective::vote_on_referendum( &ctx.client, - &voter, + &crate::wallet::WalletSigner::Hot(voter.clone()), index, true, ctx.wait_mode(), diff --git a/src/cli/exercise/scenarios/negative.rs b/src/cli/exercise/scenarios/negative.rs index 97e8fd9..a578e2b 100644 --- a/src/cli/exercise/scenarios/negative.rs +++ b/src/cli/exercise/scenarios/negative.rs @@ -91,7 +91,7 @@ async fn stale_nonce(ctx: &mut ExerciseCtx) -> Result { let call = transfer_call(account_id_of(&recipient), ctx.unit); match crate::cli::common::submit_transaction_with_nonce( &ctx.client, - &sender, + &crate::wallet::WalletSigner::Hot(sender.clone()), call, None, 0, diff --git a/src/cli/exercise/scenarios/preimage.rs b/src/cli/exercise/scenarios/preimage.rs index a2d3b69..79f4cb1 100644 --- a/src/cli/exercise/scenarios/preimage.rs +++ b/src/cli/exercise/scenarios/preimage.rs @@ -31,7 +31,13 @@ async fn note_and_verify(ctx: &mut ExerciseCtx) -> Result { let expected_hash: sp_core::H256 = BlakeTwo256::hash(&encoded); let keypair = ctx.eph[3].clone(); - crate::cli::common::submit_preimage(&ctx.client, &keypair, encoded, ctx.wait_mode()).await?; + crate::cli::common::submit_preimage( + &ctx.client, + &crate::wallet::WalletSigner::Hot(keypair.clone()), + encoded, + ctx.wait_mode(), + ) + .await?; let status_addr = quantus_subxt::api::storage().preimage().request_status_for(expected_hash); let latest = ctx.client.get_latest_block().await?; diff --git a/src/cli/exercise/scenarios/reversible.rs b/src/cli/exercise/scenarios/reversible.rs index 96c9a40..8a854d5 100644 --- a/src/cli/exercise/scenarios/reversible.rs +++ b/src/cli/exercise/scenarios/reversible.rs @@ -37,7 +37,7 @@ async fn schedule_and_cancel(ctx: &mut ExerciseCtx) -> Result { crate::cli::reversible::schedule_transfer_with_delay( &ctx.client, - &sender, + &crate::wallet::WalletSigner::Hot(sender.clone()), &recipient, amount, 50, @@ -73,7 +73,7 @@ async fn schedule_with_delay(ctx: &mut ExerciseCtx) -> Result { crate::cli::reversible::schedule_transfer_with_delay( &ctx.client, - &sender, + &crate::wallet::WalletSigner::Hot(sender.clone()), &recipient, ctx.unit, delay_blocks, @@ -138,7 +138,7 @@ async fn set_high_security(ctx: &mut ExerciseCtx) -> Result { let recipient = ctx.fresh_keypair()?.to_account_id_ss58check(); crate::cli::reversible::schedule_transfer( &ctx.client, - &account, + &crate::wallet::WalletSigner::Hot(account.clone()), &recipient, ctx.unit, ctx.wait_mode(), diff --git a/src/cli/exercise/scenarios/upgrade.rs b/src/cli/exercise/scenarios/upgrade.rs index fd3d189..52c5f40 100644 --- a/src/cli/exercise/scenarios/upgrade.rs +++ b/src/cli/exercise/scenarios/upgrade.rs @@ -52,7 +52,13 @@ async fn governance_set_code( .map_err(|e| QuantusError::Generic(format!("failed to encode set_code: {e:?}")))?; let preimage_hash: sp_core::H256 = BlakeTwo256::hash(&encoded); let call_len = encoded.len() as u32; - crate::cli::common::submit_preimage(&ctx.client, &ctx.alice, encoded, ctx.wait_mode()).await?; + crate::cli::common::submit_preimage( + &ctx.client, + &crate::wallet::WalletSigner::Hot(ctx.alice.clone()), + encoded, + ctx.wait_mode(), + ) + .await?; let latest = ctx.client.get_latest_block().await?; let count_addr = quantus_subxt::api::storage().tech_referenda().referendum_count(); @@ -70,7 +76,7 @@ async fn governance_set_code( for voter in [ctx.alice.clone(), ctx.bob.clone(), ctx.charlie.clone()] { crate::cli::tech_collective::vote_on_referendum( &ctx.client, - &voter, + &crate::wallet::WalletSigner::Hot(voter.clone()), index, true, ctx.wait_mode(), diff --git a/src/cli/generic_call.rs b/src/cli/generic_call.rs index 9d718a6..57e9960 100644 --- a/src/cli/generic_call.rs +++ b/src/cli/generic_call.rs @@ -1,7 +1,7 @@ //! `quantus call` subcommand - generic pallet calls use crate::{ chain::quantus_subxt, error::QuantusError, log_error, log_print, log_success, log_verbose, - wallet::QuantumKeyPair, + wallet::WalletSigner, }; use colored::Colorize; use serde_json::Value; @@ -13,23 +13,18 @@ pub async fn execute_generic_call( pallet: &str, call: &str, args: Vec, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, tip: Option, execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { log_print!("🚀 Executing generic call"); log_print!("Pallet: {}", pallet.bright_green()); log_print!("Call: {}", call.bright_cyan()); - log_print!("From: {}", from_keypair.to_account_id_ss58check().bright_yellow()); + log_print!("From: {}", from_keypair.account_id_ss58check().bright_yellow()); if let Some(tip) = &tip { log_print!("Tip: {}", tip.bright_magenta()); } - // Convert our QuantumKeyPair to subxt Signer - let _signer = from_keypair - .to_subxt_signer() - .map_err(|e| QuantusError::NetworkError(format!("Failed to convert keypair: {e:?}")))?; - // Validate pallet/call exists in metadata let metadata = quantus_client.client().metadata(); let pallet_metadata = metadata @@ -126,7 +121,7 @@ pub async fn execute_generic_call( /// Submit balance transfer async fn submit_balance_transfer( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, args: &[Value], keep_alive: bool, tip: Option, @@ -187,7 +182,7 @@ async fn submit_balance_transfer( /// Submit system remark async fn submit_system_remark( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, args: &[Value], tip: Option, execution_mode: crate::cli::common::ExecutionMode, @@ -217,7 +212,7 @@ async fn submit_system_remark( /// Submit tech collective add member async fn submit_tech_collective_add_member( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, args: &[Value], execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -250,7 +245,7 @@ async fn submit_tech_collective_add_member( /// Submit tech collective remove member async fn submit_tech_collective_remove_member( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, args: &[Value], execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -284,7 +279,7 @@ async fn submit_tech_collective_remove_member( /// Submit tech collective vote async fn submit_tech_collective_vote( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, args: &[Value], execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -312,7 +307,7 @@ async fn submit_tech_collective_vote( /// Submit reversible transfer async fn submit_reversible_transfer( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, args: &[Value], execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -358,7 +353,7 @@ pub async fn handle_generic_call( pallet: &str, call: &str, args: Vec, - keypair: &QuantumKeyPair, + keypair: &WalletSigner, tip: Option, node_url: &str, execution_mode: crate::cli::common::ExecutionMode, diff --git a/src/cli/high_security.rs b/src/cli/high_security.rs index dadded4..f8ee4b7 100644 --- a/src/cli/high_security.rs +++ b/src/cli/high_security.rs @@ -129,7 +129,7 @@ pub async fn handle_high_security_command( } => { log_print!("🛡️ Set High Security"); log_verbose!("📦 Using wallet: {}", from.bright_blue().bold()); - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Resolve interceptor: allow wallet name or SS58 address let interceptor_resolved = crate::cli::common::resolve_address(&interceptor)?; @@ -169,7 +169,7 @@ pub async fn handle_high_security_command( let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, tx_call, None, execution_mode, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 33d92ae..9ae29ad 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -66,19 +66,6 @@ pub enum Commands { /// Manual nonce override (use with caution - must be exact next nonce for account) #[arg(long)] nonce: Option, - - /// (cold wallets) Also write the sign-request UR parts to this file - #[arg(long, hide = true)] - cold_request_out: Option, - - /// (cold wallets) Read the signature response UR parts from this file, or "-" for stdin, - /// instead of scanning with the camera - #[arg(long, hide = true)] - cold_response_in: Option, - - /// Camera device index for cold-wallet QR scanning - #[arg(long, default_value_t = 0)] - camera_index: u32, }, /// Batch transfer commands and configuration @@ -364,29 +351,7 @@ pub async fn execute_command( ) -> crate::error::Result<()> { match command { Commands::Wallet(wallet_cmd) => wallet::handle_wallet_command(wallet_cmd, node_url).await, - Commands::Send { - from, - to, - amount, - password, - password_file, - tip, - nonce, - cold_request_out, - cold_response_in, - camera_index, - } => { - let cold_io = cold_signing::ColdIo { - request_out: cold_request_out.map(std::path::PathBuf::from), - response_in: cold_response_in.map(|s| { - if s == "-" { - crate::qr::UrSource::StdinLines - } else { - crate::qr::UrSource::File(std::path::PathBuf::from(s)) - } - }), - camera_index, - }; + Commands::Send { from, to, amount, password, password_file, tip, nonce } => send::handle_send_command( from, to, @@ -397,10 +362,8 @@ pub async fn execute_command( tip, nonce, execution_mode, - cold_io, ) - .await - }, + .await, Commands::Batch(batch_cmd) => batch::handle_batch_command(batch_cmd, node_url, execution_mode).await, Commands::Reversible(reversible_cmd) => @@ -573,7 +536,7 @@ async fn handle_generic_call_command( return Ok(()); } - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; let args_vec = if let Some(args_str) = args { serde_json::from_str(&args_str).map_err(|e| { @@ -587,7 +550,7 @@ async fn handle_generic_call_command( &pallet, &call, args_vec, - &keypair, + &signer, tip, node_url, execution_mode, diff --git a/src/cli/multisend.rs b/src/cli/multisend.rs index 4b5a321..53661c2 100644 --- a/src/cli/multisend.rs +++ b/src/cli/multisend.rs @@ -8,8 +8,9 @@ use crate::{ cli::{ common::{resolve_address, ExecutionMode}, send::{ - batch_transfer, format_balance, format_balance_with_symbol, get_balance, - get_chain_properties, parse_amount, + build_batch_transfer_call, format_balance, format_balance_with_symbol, get_balance, + get_chain_properties, parse_amount, submit_prebuilt_batch_transfer_call, + validate_batch_transfer_request, }, }, error::{QuantusError, Result}, @@ -263,8 +264,8 @@ pub async fn handle_multisend_command( log_info!("Preparing multisend transaction..."); // Load wallet - let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?; - let from_account_id = keypair.to_account_id_ss58check(); + let signer = crate::wallet::load_signer_from_wallet(&from_wallet, password, password_file)?; + let from_account_id = signer.account_id_ss58check(); // Check balance let balance = get_balance(&quantus_client, &from_account_id).await?; @@ -288,8 +289,18 @@ pub async fn handle_multisend_command( }; // Submit batch transaction - let tx_hash = - batch_transfer(&quantus_client, &keypair, transfers, tip_amount, execution_mode).await?; + validate_batch_transfer_request(&quantus_client, &signer, &transfers).await?; + log_verbose!("✍️ Creating batch extrinsic with {} calls...", transfers.len()); + let batch_call = build_batch_transfer_call(&transfers)?; + let tx_hash = submit_prebuilt_batch_transfer_call( + &quantus_client, + &signer, + &transfers, + batch_call, + tip_amount, + execution_mode, + ) + .await?; log_print!( "{} Multisend transaction submitted! Hash: {:?}", diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index d7d224e..7164ea2 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -499,9 +499,10 @@ pub async fn create_multisig( // Submit transaction let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: wait_for_inclusion }; + let signer = crate::wallet::WalletSigner::Hot(creator_keypair.clone()); let tx_hash = crate::cli::common::submit_transaction( quantus_client, - creator_keypair, + &signer, create_tx, None, execution_mode, @@ -570,9 +571,10 @@ pub async fn propose_transfer( // Submit transaction let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; + let signer = crate::wallet::WalletSigner::Hot(proposer_keypair.clone()); let tx_hash = crate::cli::common::submit_transaction( quantus_client, - proposer_keypair, + &signer, propose_tx, None, execution_mode, @@ -603,9 +605,10 @@ pub async fn propose_custom( // Submit transaction let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; + let signer = crate::wallet::WalletSigner::Hot(proposer_keypair.clone()); let tx_hash = crate::cli::common::submit_transaction( quantus_client, - proposer_keypair, + &signer, propose_tx, None, execution_mode, @@ -643,9 +646,10 @@ pub async fn approve_proposal( .approve(multisig_address, proposal_id, proposal.call); let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; + let signer = crate::wallet::WalletSigner::Hot(approver_keypair.clone()); let tx_hash = crate::cli::common::submit_transaction( quantus_client, - approver_keypair, + &signer, approve_tx, None, execution_mode, @@ -669,9 +673,10 @@ pub async fn cancel_proposal( let cancel_tx = quantus_subxt::api::tx().multisig().cancel(multisig_address, proposal_id); let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; + let signer = crate::wallet::WalletSigner::Hot(proposer_keypair.clone()); let tx_hash = crate::cli::common::submit_transaction( quantus_client, - proposer_keypair, + &signer, cancel_tx, None, execution_mode, @@ -1085,8 +1090,8 @@ async fn handle_create_multisig( log_print!(" {}", predicted_address.bright_cyan().bold()); log_print!(""); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -1106,7 +1111,7 @@ async fn handle_create_multisig( let _tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, create_tx, None, create_execution_mode, @@ -1533,8 +1538,8 @@ async fn handle_propose( log_verbose!("Call data size: {} bytes", call_data.len()); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Build transaction let propose_tx = quantus_subxt::api::tx().multisig().propose( @@ -1549,7 +1554,7 @@ async fn handle_propose( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, propose_tx, None, propose_execution_mode, @@ -1602,8 +1607,8 @@ async fn handle_propose_with_call_data( log_verbose!("Current block: {}, expiry valid", current_block_number); log_verbose!("Call data size: {} bytes", call_data.len()); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Build transaction let propose_tx = quantus_subxt::api::tx().multisig().propose( @@ -1618,7 +1623,7 @@ async fn handle_propose_with_call_data( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( quantus_client, - &keypair, + &signer, propose_tx, None, propose_execution_mode, @@ -1655,8 +1660,8 @@ async fn handle_approve( log_verbose!("Multisig: {}", multisig_ss58); log_verbose!("Proposal ID: {}", proposal_id); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -1726,7 +1731,7 @@ async fn handle_approve( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, approve_tx, None, approve_execution_mode, @@ -1767,8 +1772,8 @@ async fn handle_execute( log_verbose!("Multisig: {}", multisig_ss58); log_verbose!("Proposal ID: {}", proposal_id); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -1808,7 +1813,7 @@ async fn handle_execute( crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, execute_tx, None, exec_execution_mode, @@ -1843,8 +1848,8 @@ async fn handle_cancel( log_verbose!("Proposal ID: {}", proposal_id); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -1891,7 +1896,7 @@ async fn handle_cancel( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, cancel_tx, None, cancel_execution_mode, @@ -1927,8 +1932,8 @@ async fn handle_remove_expired( log_verbose!("Proposal ID: {}", proposal_id); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -1944,7 +1949,7 @@ async fn handle_remove_expired( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, remove_tx, None, remove_execution_mode, @@ -1976,8 +1981,8 @@ async fn handle_claim_deposits( let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); let multisig_address = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -1991,7 +1996,7 @@ async fn handle_claim_deposits( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, claim_tx, None, claim_execution_mode, @@ -3036,8 +3041,8 @@ async fn handle_high_security_set( log_print!(""); } - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Build propose transaction let propose_tx = quantus_subxt::api::tx().multisig().propose( @@ -3052,7 +3057,7 @@ async fn handle_high_security_set( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, propose_tx, None, propose_execution_mode, diff --git a/src/cli/preimage.rs b/src/cli/preimage.rs index 3a32a4d..7d0bbd8 100644 --- a/src/cli/preimage.rs +++ b/src/cli/preimage.rs @@ -309,8 +309,8 @@ async fn request_preimage( log_print!("🚀 Requesting preimage for hash: {}", hash_str.bright_cyan()); log_print!(" 👤 From: {}", from_str.bright_yellow()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from_str, None, None)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from_str, None, None)?; // Create request_preimage call let request_call = quantus_subxt::api::tx().preimage().request_preimage(preimage_hash); @@ -318,7 +318,7 @@ async fn request_preimage( // Submit transaction let tx_hash = crate::cli::common::submit_transaction( quantus_client, - &keypair, + &signer, request_call, None, execution_mode, @@ -346,8 +346,8 @@ async fn note_preimage( log_print!("📝 Noting preimage for content length: {} bytes", content.len()); log_print!(" 👤 From: {}", from_str.bright_yellow()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from_str, None, None)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from_str, None, None)?; // Create note_preimage call let note_call = quantus_subxt::api::tx().preimage().note_preimage(content); @@ -355,7 +355,7 @@ async fn note_preimage( // Submit transaction let tx_hash = crate::cli::common::submit_transaction( quantus_client, - &keypair, + &signer, note_call, None, execution_mode, @@ -392,8 +392,8 @@ async fn create_preimage( log_print!("📊 WASM file size: {} bytes", wasm_code.len()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from_str, password, password_file)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from_str, password, password_file)?; // Build a static payload for System::set_code and encode full call data (pallet + call + args) let set_code_payload = quantus_subxt::api::tx().system().set_code(wasm_code.clone()); @@ -416,7 +416,7 @@ async fn create_preimage( let note_preimage_tx = quantus_subxt::api::tx().preimage().note_preimage(bounded_bytes); let preimage_tx_hash = crate::cli::common::submit_transaction( quantus_client, - &keypair, + &signer, note_preimage_tx, None, execution_mode, diff --git a/src/cli/recovery.rs b/src/cli/recovery.rs index b883403..b57b6ee 100644 --- a/src/cli/recovery.rs +++ b/src/cli/recovery.rs @@ -177,9 +177,9 @@ pub async fn handle_recovery_command( match command { RecoveryCommands::Initiate { rescuer, lost, password, password_file } => { - let rescuer_key = - crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; - let rescuer_addr = rescuer_key.to_account_id_ss58check(); + let signer = + crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; + let rescuer_addr = signer.account_id_ss58check(); log_print!("🔑 Rescuer: {}", rescuer); log_print!("🔑 Rescuer address: {}", rescuer_addr); let lost_id = resolve_to_subxt_account_id(&lost)?; @@ -189,7 +189,7 @@ pub async fn handle_recovery_command( let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &rescuer_key, + &signer, call, None, execution_mode, @@ -204,8 +204,8 @@ pub async fn handle_recovery_command( }, RecoveryCommands::Vouch { friend, lost, rescuer, password, password_file } => { - let friend_key = - crate::wallet::load_keypair_from_wallet(&friend, password, password_file)?; + let signer = + crate::wallet::load_signer_from_wallet(&friend, password, password_file)?; let lost_id = resolve_to_subxt_account_id(&lost)?; let rescuer_id = resolve_to_subxt_account_id(&rescuer)?; let call = quantus_subxt::api::tx().recovery().vouch_recovery( @@ -214,7 +214,7 @@ pub async fn handle_recovery_command( ); let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &friend_key, + &signer, call, None, execution_mode, @@ -229,15 +229,15 @@ pub async fn handle_recovery_command( }, RecoveryCommands::Claim { rescuer, lost, password, password_file } => { - let rescuer_key = - crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; + let signer = + crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; let lost_id = resolve_to_subxt_account_id(&lost)?; let call = quantus_subxt::api::tx() .recovery() .claim_recovery(subxt::ext::subxt_core::utils::MultiAddress::Id(lost_id)); let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &rescuer_key, + &signer, call, None, execution_mode, @@ -262,9 +262,9 @@ pub async fn handle_recovery_command( } => { use quantus_subxt::api::runtime_types::pallet_balances::pallet::Call as BalancesCall; - let rescuer_key = - crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; - let rescuer_addr = rescuer_key.to_account_id_ss58check(); + let signer = + crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; + let rescuer_addr = signer.account_id_ss58check(); log_print!("🔑 Rescuer: {}", rescuer); log_print!("🔑 Rescuer address: {}", rescuer_addr); @@ -334,7 +334,7 @@ pub async fn handle_recovery_command( let tx_hash = match crate::cli::common::submit_transaction( &quantus_client, - &rescuer_key, + &signer, call, None, execution_mode, @@ -361,10 +361,10 @@ pub async fn handle_recovery_command( } => { use quantus_subxt::api::runtime_types::pallet_balances::pallet::Call as BalancesCall; - let rescuer_key = - crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; + let signer = + crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; - let rescuer_addr = rescuer_key.to_account_id_ss58check(); + let rescuer_addr = signer.account_id_ss58check(); log_print!("🔑 Rescuer: {}", rescuer); log_print!("🔑 Rescuer address: {}", rescuer_addr); @@ -433,7 +433,7 @@ pub async fn handle_recovery_command( let tx_hash = match crate::cli::common::submit_transaction( &quantus_client, - &rescuer_key, + &signer, call, None, execution_mode, @@ -450,14 +450,14 @@ pub async fn handle_recovery_command( }, RecoveryCommands::Close { lost, rescuer, password, password_file } => { - let lost_key = crate::wallet::load_keypair_from_wallet(&lost, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&lost, password, password_file)?; let rescuer_id = resolve_to_subxt_account_id(&rescuer)?; let call = quantus_subxt::api::tx() .recovery() .close_recovery(subxt::ext::subxt_core::utils::MultiAddress::Id(rescuer_id)); let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &lost_key, + &signer, call, None, execution_mode, @@ -474,15 +474,15 @@ pub async fn handle_recovery_command( }, RecoveryCommands::CancelProxy { rescuer, lost, password, password_file } => { - let rescuer_key = - crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; + let signer = + crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; let lost_id = resolve_to_subxt_account_id(&lost)?; let call = quantus_subxt::api::tx() .recovery() .cancel_recovered(subxt::ext::subxt_core::utils::MultiAddress::Id(lost_id)); let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &rescuer_key, + &signer, call, None, execution_mode, diff --git a/src/cli/reversible.rs b/src/cli/reversible.rs index a57f96d..1ce36c1 100644 --- a/src/cli/reversible.rs +++ b/src/cli/reversible.rs @@ -108,13 +108,13 @@ pub enum ReversibleCommands { /// Schedule a transfer with default delay pub async fn schedule_transfer( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + from_signer: &crate::wallet::WalletSigner, to_address: &str, amount: u128, execution_mode: crate::cli::common::ExecutionMode, ) -> Result { log_verbose!("🔄 Creating reversible transfer..."); - log_verbose!(" From: {}", from_keypair.to_account_id_ss58check().bright_cyan()); + log_verbose!(" From: {}", from_signer.account_id_ss58check().bright_cyan()); log_verbose!(" To: {}", to_address.bright_green()); log_verbose!(" Amount: {}", amount); @@ -138,7 +138,7 @@ pub async fn schedule_transfer( // Submit the transaction let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + from_signer, transfer_call, None, execution_mode, @@ -153,7 +153,7 @@ pub async fn schedule_transfer( /// Cancel a pending reversible transaction pub async fn cancel_transaction( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + from_signer: &crate::wallet::WalletSigner, tx_id: &str, execution_mode: crate::cli::common::ExecutionMode, ) -> Result { @@ -173,7 +173,7 @@ pub async fn cancel_transaction( // Submit the transaction let tx_hash_result = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + from_signer, cancel_call, None, execution_mode, @@ -188,7 +188,7 @@ pub async fn cancel_transaction( /// Schedule a transfer with custom delay pub async fn schedule_transfer_with_delay( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + from_signer: &crate::wallet::WalletSigner, to_address: &str, amount: u128, delay: u64, @@ -197,7 +197,7 @@ pub async fn schedule_transfer_with_delay( ) -> Result { let unit_str = if unit_blocks { "blocks" } else { "seconds" }; log_verbose!("🔄 Creating reversible transfer with custom delay ..."); - log_verbose!(" From: {}", from_keypair.to_account_id_ss58check().bright_cyan()); + log_verbose!(" From: {}", from_signer.account_id_ss58check().bright_cyan()); log_verbose!(" To: {}", to_address.bright_green()); log_verbose!(" Amount: {}", amount); log_verbose!(" Delay: {} {}", delay, unit_str); @@ -230,7 +230,7 @@ pub async fn schedule_transfer_with_delay( // Submit the transaction let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + from_signer, transfer_call, None, execution_mode, @@ -278,12 +278,12 @@ pub async fn handle_reversible_command( // Get password securely for decryption log_verbose!("📦 Using wallet: {}", from.bright_blue().bold()); - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Submit transaction let tx_hash = schedule_transfer( &quantus_client, - &keypair, + &signer, &resolved_address, raw_amount, execution_mode, @@ -307,11 +307,11 @@ pub async fn handle_reversible_command( // Get password securely for decryption log_verbose!("📦 Using wallet: {}", from.bright_blue().bold()); - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Submit cancel transaction let tx_hash = - cancel_transaction(&quantus_client, &keypair, &tx_id, execution_mode).await?; + cancel_transaction(&quantus_client, &signer, &tx_id, execution_mode).await?; log_print!( "✅ {} Cancel transaction submitted! Hash: {:?}", @@ -351,12 +351,12 @@ pub async fn handle_reversible_command( // Get password securely for decryption log_verbose!("📦 Using wallet: {}", from.bright_blue().bold()); - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Submit transaction let tx_hash = schedule_transfer_with_delay( &quantus_client, - &keypair, + &signer, &resolved_address, raw_amount, delay, @@ -397,9 +397,9 @@ async fn list_pending_transactions( }, (None, Some(wallet)) => { // Load wallet and get its address - let keypair = - crate::wallet::load_keypair_from_wallet(&wallet, password, password_file)?; - keypair.to_account_id_ss58check() + let signer = + crate::wallet::load_signer_from_wallet(&wallet, password, password_file)?; + signer.account_id_ss58check() }, (None, None) => { return Err(crate::error::QuantusError::Generic( diff --git a/src/cli/runtime.rs b/src/cli/runtime.rs index 302dfd4..c47d1ad 100644 --- a/src/cli/runtime.rs +++ b/src/cli/runtime.rs @@ -1,7 +1,7 @@ //! `quantus runtime` subcommand - runtime management use crate::{ chain::quantus_subxt, error::QuantusError, log_print, log_success, log_verbose, - wallet::QuantumKeyPair, + wallet::WalletSigner, }; use clap::Subcommand; use colored::Colorize; @@ -47,7 +47,7 @@ pub enum RuntimeCommands { pub async fn update_runtime( quantus_client: &crate::chain::client::QuantusClient, wasm_code: Vec, - from_keypair: &QuantumKeyPair, + signer: &WalletSigner, force: bool, execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -102,7 +102,7 @@ pub async fn update_runtime( // Submit preimage and wait for inclusion so the referendum tx gets a fresh nonce crate::cli::common::submit_preimage( quantus_client, - from_keypair, + signer, encoded_call.clone(), execution_mode, ) @@ -143,7 +143,7 @@ pub async fn update_runtime( let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + signer, submit_call, None, execution_mode, @@ -220,8 +220,8 @@ pub async fn handle_runtime_command( } } - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Read WASM file log_verbose!("📖 Reading WASM file..."); @@ -231,7 +231,7 @@ pub async fn handle_runtime_command( log_print!("📊 WASM file size: {} bytes", wasm_code.len()); // Update runtime - update_runtime(&quantus_client, wasm_code, &keypair, force, execution_mode).await?; + update_runtime(&quantus_client, wasm_code, &signer, force, execution_mode).await?; log_success!("🎉 Runtime update completed!"); log_print!( diff --git a/src/cli/send.rs b/src/cli/send.rs index bdf4aa1..16762c4 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -276,13 +276,34 @@ pub(crate) fn build_batch_transfer_call( pub async fn estimate_transaction_partial_fee( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, call: &Call, tip: Option, ) -> Result where Call: subxt::tx::Payload, { + // Cold wallets can't sign locally; the fixed-length Dilithium signature + // means a dummy-signature estimate is just as accurate. + if let crate::wallet::WalletSigner::Cold { address, .. } = signer { + use sp_core::crypto::Ss58Codec; + let account = sp_core::crypto::AccountId32::from_ss58check_with_version(address) + .map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid cold wallet address: {e:?}")) + })? + .0; + return crate::cli::cold_signing::estimate_cold_fee( + quantus_client, + call, + &account, + tip.unwrap_or(0), + ) + .await + .ok_or(crate::error::QuantusError::NetworkError( + "Failed to estimate transaction fee".to_string(), + )); + } + let from_keypair = signer.as_hot().expect("cold arm returned above"); let signer = from_keypair.to_subxt_signer().map_err(|e| { crate::error::QuantusError::NetworkError(format!("Failed to convert keypair: {e:?}")) })?; @@ -313,7 +334,7 @@ where pub(crate) async fn ensure_balance_covers_call( quantus_client: &QuantusClient, - keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, call: &Call, balance: u128, exact_required: u128, @@ -330,7 +351,7 @@ where }); } - match estimate_transaction_partial_fee(quantus_client, keypair, call, submit_tip).await { + match estimate_transaction_partial_fee(quantus_client, signer, call, submit_tip).await { Ok(estimated_fee) => { let estimated_total = checked_add(exact_required, estimated_fee, "required balance including fee")?; @@ -375,7 +396,7 @@ where async fn submit_transfer_call( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, transfer_call: Call, submit_tip: Option, nonce: Option, @@ -388,7 +409,7 @@ where log_verbose!("🔢 Using manual nonce: {}", manual_nonce); crate::cli::common::submit_transaction_with_nonce( quantus_client, - from_keypair, + signer, transfer_call, submit_tip, manual_nonce, @@ -398,7 +419,7 @@ where } else { crate::cli::common::submit_transaction( quantus_client, - from_keypair, + signer, transfer_call, submit_tip, execution_mode, @@ -452,7 +473,7 @@ pub async fn transfer_with_nonce( // Submit the transaction with optional manual nonce let tx_hash = submit_transfer_call( quantus_client, - from_keypair, + &crate::wallet::WalletSigner::Hot(from_keypair.clone()), transfer_call, submit_tip, nonce, @@ -467,11 +488,11 @@ pub async fn transfer_with_nonce( pub(crate) async fn validate_batch_transfer_request( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, transfers: &[(String, u128)], ) -> Result<()> { log_verbose!("🚀 Preparing batch transfer transaction with {} transfers...", transfers.len()); - log_verbose!(" From: {}", from_keypair.to_account_id_ss58check().bright_cyan()); + log_verbose!(" From: {}", signer.account_id_ss58check().bright_cyan()); if transfers.is_empty() { return Err(crate::error::QuantusError::Generic( @@ -510,7 +531,7 @@ pub(crate) async fn validate_batch_transfer_request( pub(crate) async fn submit_prebuilt_batch_transfer_call( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, transfers: &[(String, u128)], batch_call: Call, tip: Option, @@ -523,7 +544,7 @@ where let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + signer, batch_call, positive_tip_amount(tip), execution_mode, @@ -543,12 +564,13 @@ pub async fn batch_transfer( tip: Option, execution_mode: crate::cli::common::ExecutionMode, ) -> Result { - validate_batch_transfer_request(quantus_client, from_keypair, &transfers).await?; + let signer = crate::wallet::WalletSigner::Hot(from_keypair.clone()); + validate_batch_transfer_request(quantus_client, &signer, &transfers).await?; log_verbose!("✍️ Creating batch extrinsic with {} calls...", transfers.len()); let batch_call = build_batch_transfer_call(&transfers)?; submit_prebuilt_batch_transfer_call( quantus_client, - from_keypair, + &signer, &transfers, batch_call, tip, @@ -571,7 +593,6 @@ pub async fn handle_send_command( tip: Option, nonce: Option, execution_mode: crate::cli::common::ExecutionMode, - cold_io: crate::cli::cold_signing::ColdIo, ) -> Result<()> { // Create quantus chain client let quantus_client = QuantusClient::new(node_url).await?; @@ -593,30 +614,12 @@ pub async fn handle_send_command( log_verbose!("📦 Using wallet: {}", from_wallet.bright_blue().bold()); - // Cold (watch-only) wallets sign over QR codes instead of a local key. - let wallet_manager = crate::wallet::WalletManager::new()?; - if wallet_manager.wallet_type(&from_wallet)? == Some(crate::wallet::WalletType::Cold) { - if password.is_some() || password_file.is_some() { - log_print!("⚠️ Cold wallets have no password; ignoring --password/--password-file"); - } - return handle_cold_send( - &quantus_client, - &from_wallet, - to_account_id, - amount, - &tip, - nonce, - execution_mode, - &cold_io, - ) - .await; - } - - // Get password securely for decryption - let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?; + // Hot wallets decrypt locally; cold (watch-only) wallets sign over QR in + // the submit stage — this command doesn't need to know which it is. + let signer = crate::wallet::load_signer_from_wallet(&from_wallet, password, password_file)?; // Get account information - let from_account_id = keypair.to_account_id_ss58check(); + let from_account_id = signer.account_id_ss58check(); let balance = get_balance(&quantus_client, &from_account_id).await?; // Get formatted balance with proper decimals @@ -635,7 +638,7 @@ pub async fn handle_send_command( let transfer_call = build_transfer_call_for_account_id(to_account_id, amount); ensure_balance_covers_call( &quantus_client, - &keypair, + &signer, &transfer_call, balance, exact_required, @@ -648,79 +651,15 @@ pub async fn handle_send_command( log_verbose!("✍️ {} Signing transaction...", "SIGN".bright_magenta().bold()); // Submit transaction - let tx_hash = submit_transfer_call( - &quantus_client, - &keypair, - transfer_call, - submit_tip, - nonce, - execution_mode, - ) - .await?; + let tx_hash = + submit_transfer_call(&quantus_client, &signer, transfer_call, submit_tip, nonce, execution_mode) + .await?; print_send_result(&quantus_client, &from_account_id, balance, amount, tx_hash, execution_mode) .await } -/// Send from a cold (watch-only) wallet: builds the transfer call, runs the QR -/// signing flow, submits, and prints the same result summary as the hot path. -#[allow(clippy::too_many_arguments)] -async fn handle_cold_send( - quantus_client: &QuantusClient, - from_wallet: &str, - to_account_id: SubxtAccountId32, - amount: u128, - tip: &Option, - nonce: Option, - execution_mode: crate::cli::common::ExecutionMode, - cold_io: &crate::cli::cold_signing::ColdIo, -) -> Result<()> { - let wallet_manager = crate::wallet::WalletManager::new()?; - let from_address = wallet_manager - .find_wallet_address(from_wallet)? - .ok_or(crate::error::WalletError::NotFound)?; - - let balance = get_balance(quantus_client, &from_address).await?; - let formatted_balance = format_balance_with_symbol(quantus_client, balance).await?; - log_verbose!("💰 Current balance: {}", formatted_balance.bright_yellow()); - - let tip_amount = if let Some(tip_str) = tip { - Some(parse_amount(quantus_client, tip_str).await?) - } else { - None - }; - let effective_tip = effective_tip_amount(tip_amount); - let submit_tip = positive_tip_amount(tip_amount); - let exact_required = checked_add(amount, effective_tip, "required send balance")?; - - // No fee preflight here (fee estimation needs a signer); check amount + tip - // and let the chain reject if fees can't be covered. - if balance < exact_required { - return Err(crate::error::QuantusError::InsufficientBalance { - available: balance, - required: exact_required, - }); - } - - let transfer_call = build_transfer_call_for_account_id(to_account_id, amount); - - let tx_hash = crate::cli::cold_signing::sign_and_submit_cold( - quantus_client, - from_wallet, - &from_address, - &transfer_call, - submit_tip, - nonce, - execution_mode, - cold_io, - ) - .await?; - - print_send_result(quantus_client, &from_address, balance, amount, tx_hash, execution_mode).await -} - -/// Print the post-submission summary (status, new balance, fee) shared by hot -/// and cold send paths. +/// Print the post-submission summary (status, new balance, fee). async fn print_send_result( quantus_client: &QuantusClient, from_account_id: &str, diff --git a/src/cli/tech_collective.rs b/src/cli/tech_collective.rs index 5db10da..83d6acd 100644 --- a/src/cli/tech_collective.rs +++ b/src/cli/tech_collective.rs @@ -103,7 +103,7 @@ pub enum TechCollectiveCommands { /// Add a member to the Tech Collective pub async fn add_member( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, who_address: &str, execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -126,7 +126,7 @@ pub async fn add_member( let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + signer, add_member_call, None, execution_mode, @@ -141,7 +141,7 @@ pub async fn add_member( /// Remove a member from the Tech Collective pub async fn remove_member( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, who_address: &str, execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -165,7 +165,7 @@ pub async fn remove_member( let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + signer, remove_member_call, None, execution_mode, @@ -180,7 +180,7 @@ pub async fn remove_member( /// Vote on a Tech Referenda proposal pub async fn vote_on_referendum( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, referendum_index: u32, aye: bool, execution_mode: crate::cli::common::ExecutionMode, @@ -199,7 +199,7 @@ pub async fn vote_on_referendum( let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + signer, vote_call, None, wait_mode, @@ -327,10 +327,10 @@ pub async fn handle_tech_collective_command( log_print!(" 🔑 Signed by: {}", from.bright_yellow()); // Load wallet - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Submit transaction - let tx_hash = add_member(&quantus_client, &keypair, &who, execution_mode).await?; + let tx_hash = add_member(&quantus_client, &signer, &who, execution_mode).await?; log_print!( "✅ {} Add member transaction submitted! Hash: {:?}", @@ -345,10 +345,10 @@ pub async fn handle_tech_collective_command( log_print!(" 🔑 Signed by: {}", from.bright_yellow()); // Load wallet - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Submit transaction - let tx_hash = remove_member(&quantus_client, &keypair, &who, execution_mode).await?; + let tx_hash = remove_member(&quantus_client, &signer, &who, execution_mode).await?; log_print!( "✅ {} Remove member transaction submitted! Hash: {:?}", @@ -366,11 +366,11 @@ pub async fn handle_tech_collective_command( ); log_print!(" 🔑 Signed by: {}", from.bright_yellow()); - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; let tx_hash = vote_on_referendum( &quantus_client, - &keypair, + &signer, referendum_index, aye, execution_mode, diff --git a/src/cli/tech_referenda.rs b/src/cli/tech_referenda.rs index d3e172b..caef872 100644 --- a/src/cli/tech_referenda.rs +++ b/src/cli/tech_referenda.rs @@ -258,8 +258,8 @@ async fn submit_runtime_upgrade( let preimage_hash_parsed: sp_core::H256 = sp_core::H256::from_str(hash_str) .map_err(|_| QuantusError::Generic("Invalid preimage hash format".to_string()))?; - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from, password, password_file)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from, password, password_file)?; // Check if preimage exists and get its length log_print!("🔍 Checking preimage status..."); @@ -321,7 +321,7 @@ async fn submit_runtime_upgrade( .submit(origin_caller, proposal, enactment); let tx_hash = - submit_transaction(quantus_client, &keypair, submit_call, None, execution_mode).await?; + submit_transaction(quantus_client, &signer, submit_call, None, execution_mode).await?; log_print!( "✅ {} Runtime upgrade proposal submitted! Hash: {:?}", "SUCCESS".bright_green().bold(), @@ -363,8 +363,8 @@ async fn submit_runtime_upgrade_with_preimage( log_print!("📊 WASM file size: {} bytes", wasm_code.len()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from, password, password_file)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from, password, password_file)?; // Build a static payload for System::set_code and encode full call data (pallet + call + args) let set_code_payload = quantus_subxt::api::tx().system().set_code(wasm_code.clone()); @@ -380,7 +380,7 @@ async fn submit_runtime_upgrade_with_preimage( log_print!("🔗 Preimage hash: {:?}", preimage_hash); let call_len = encoded_call.len() as u32; - crate::cli::common::submit_preimage(quantus_client, &keypair, encoded_call, execution_mode) + crate::cli::common::submit_preimage(quantus_client, &signer, encoded_call, execution_mode) .await?; // Build TechReferenda::submit call using Lookup preimage reference @@ -411,7 +411,7 @@ async fn submit_runtime_upgrade_with_preimage( .submit(origin_caller, proposal, enactment); let tx_hash = - submit_transaction(quantus_client, &keypair, submit_call, None, execution_mode).await?; + submit_transaction(quantus_client, &signer, submit_call, None, execution_mode).await?; log_success!("Runtime upgrade proposal submitted! Hash: {:?}", tx_hash); log_print!("💡 Use 'quantus tech-referenda list' to see active proposals"); @@ -437,8 +437,8 @@ async fn submit_treasury_portion_with_preimage( ); log_print!(" 🔑 Submitted by: {}", from.bright_yellow()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from, password, password_file)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from, password, password_file)?; // Build a static payload for TreasuryPallet::set_treasury_portion and encode full call data // Note: runtime_types::Permill is a tuple struct (u32 parts-per-million). @@ -458,7 +458,7 @@ async fn submit_treasury_portion_with_preimage( log_print!("🔗 Preimage hash: {:?}", preimage_hash); let call_len = encoded_call.len() as u32; - crate::cli::common::submit_preimage(quantus_client, &keypair, encoded_call, execution_mode) + crate::cli::common::submit_preimage(quantus_client, &signer, encoded_call, execution_mode) .await?; // Build TechReferenda::submit call using Lookup preimage reference @@ -489,7 +489,7 @@ async fn submit_treasury_portion_with_preimage( .submit(origin_caller, proposal, enactment); let tx_hash = - submit_transaction(quantus_client, &keypair, submit_call, None, execution_mode).await?; + submit_transaction(quantus_client, &signer, submit_call, None, execution_mode).await?; log_success!("Treasury portion proposal submitted! Hash: {:?}", tx_hash); log_print!("💡 Use 'quantus tech-referenda list' to see active proposals"); @@ -627,11 +627,11 @@ async fn place_decision_deposit( log_print!("📋 Placing decision deposit for Tech Referendum #{}", index); log_print!(" 🔑 Placed by: {}", from.bright_yellow()); - let keypair = crate::wallet::load_keypair_from_wallet(from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(from, password, password_file)?; let deposit_call = quantus_subxt::api::tx().tech_referenda().place_decision_deposit(index); let tx_hash = - submit_transaction(quantus_client, &keypair, deposit_call, None, execution_mode).await?; + submit_transaction(quantus_client, &signer, deposit_call, None, execution_mode).await?; log_success!("✅ Decision deposit placed! Hash: {:?}", tx_hash.to_string().bright_yellow()); Ok(()) } @@ -687,14 +687,14 @@ async fn refund_submission_deposit( log_print!("💰 Refunding submission deposit for Tech Referendum #{}", index); log_print!(" 🔑 Refund to: {}", from.bright_yellow()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from, password, password_file)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from, password, password_file)?; // Create refund_submission_deposit call for TechReferenda instance let refund_call = quantus_subxt::api::tx().tech_referenda().refund_submission_deposit(index); let tx_hash = - submit_transaction(quantus_client, &keypair, refund_call, None, execution_mode).await?; + submit_transaction(quantus_client, &signer, refund_call, None, execution_mode).await?; log_print!( "✅ {} Refund transaction submitted! Hash: {:?}", "SUCCESS".bright_green().bold(), @@ -718,14 +718,14 @@ async fn refund_decision_deposit( log_print!("💰 Refunding decision deposit for Tech Referendum #{}", index); log_print!(" 🔑 Refund to: {}", from.bright_yellow()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from, password, password_file)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from, password, password_file)?; // Create refund_decision_deposit call for TechReferenda instance let refund_call = quantus_subxt::api::tx().tech_referenda().refund_decision_deposit(index); let tx_hash = - submit_transaction(quantus_client, &keypair, refund_call, None, execution_mode).await?; + submit_transaction(quantus_client, &signer, refund_call, None, execution_mode).await?; log_print!( "✅ {} Refund transaction submitted! Hash: {:?}", "SUCCESS".bright_green().bold(), diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 4470354..4c04475 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -381,7 +381,7 @@ pub async fn handle_wallet_command( log_success!("Key type: {}", wallet_info.key_type.bright_yellow()); log_success!("✅ Cold wallet imported (watch-only)"); log_print!( - "ℹ️ Sign transfers with `quantus send --from {name} …`: the CLI shows a QR for the cold wallet and scans its signed response." + "ℹ️ Use `{name}` with any extrinsic command (`--from {name}`): the CLI shows a QR for the cold wallet and scans its signed response." ); Ok(()) }, @@ -836,22 +836,9 @@ pub async fn handle_wallet_command( .map_err(|e| QuantusError::Generic(format!("Invalid address: {e:?}")))?; addr }, - (None, Some(wallet_name)) => { - let wallet_manager = WalletManager::new()?; - if wallet_manager.wallet_type(&wallet_name)? == - Some(crate::wallet::WalletType::Cold) - { - // Watch-only: the address is stored unencrypted, no keys needed - wallet_manager - .find_wallet_address(&wallet_name)? - .ok_or(crate::error::WalletError::NotFound)? - } else { - // Load wallet and get its address - let keypair = - crate::wallet::load_keypair_from_wallet(&wallet_name, password, None)?; - keypair.to_account_id_ss58check() - } - }, + (None, Some(wallet_name)) => + crate::wallet::load_signer_from_wallet(&wallet_name, password, None)? + .account_id_ss58check(), (None, None) => { // This case should be prevented by clap's `required_unless_present` unreachable!("Either --address or --wallet must be provided"); diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index c1b1e82..fe934ec 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -2055,7 +2055,7 @@ async fn execute_initial_transfers( submit_transaction( quantus_client, - &quantum_keypair, + &crate::wallet::WalletSigner::Hot(quantum_keypair.clone()), batch_tx, None, ExecutionMode { finalized: false, wait_for_transaction: true }, @@ -3351,7 +3351,7 @@ async fn run_dissolve( submit_transaction( &quantus_client, - &quantum_keypair, + &crate::wallet::WalletSigner::Hot(quantum_keypair.clone()), transfer_tx, None, ExecutionMode { finalized: false, wait_for_transaction: true }, diff --git a/src/error.rs b/src/error.rs index b73e6b2..69f917e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -65,7 +65,7 @@ pub enum WalletError { #[error("Decryption failed. Check your password.")] Decryption, - #[error("'{0}' is a cold (watch-only) wallet with no local key material. Transfers sign via QR (`quantus send --from {0}`); other signing commands need a hot wallet")] + #[error("'{0}' is a cold (watch-only) wallet with no local key material. Extrinsics sign via QR; this operation needs a hot wallet")] ColdWalletNoKeys(String), } diff --git a/src/main.rs b/src/main.rs index 93a1acc..e9b10ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,19 @@ struct Cli { /// Default: false #[arg(long, global = true, default_value = "false")] wait_for_transaction: bool, + + /// (cold wallets) Also write the sign-request UR parts to this file + #[arg(long, global = true, hide = true)] + cold_request_out: Option, + + /// (cold wallets) Read the signature response UR parts from this file, or "-" for stdin, + /// instead of scanning with the camera + #[arg(long, global = true, hide = true)] + cold_response_in: Option, + + /// Camera device index for cold-wallet QR scanning + #[arg(long, global = true, default_value_t = 0)] + camera_index: u32, } #[tokio::main] @@ -79,6 +92,15 @@ async fn main() -> Result<(), QuantusError> { wait_for_transaction: cli.wait_for_transaction, }; + // Cold-wallet QR I/O config for the submit stage (used only when the + // signing wallet is watch-only). + cli::cold_signing::ColdIo::from_flags( + cli.cold_request_out, + cli.cold_response_in, + cli.camera_index, + ) + .install(); + // Warm the update-version cache in the background so it runs concurrently // with the command and never adds latency: we never block the command on // the network. The notice itself is printed only after the command finishes diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 0808b1f..e7e67d9 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -9,7 +9,7 @@ pub mod keystore; pub mod password; use crate::error::{Result, WalletError}; -pub use keystore::{Keystore, QuantumKeyPair, WalletData, WalletType}; +pub use keystore::{Keystore, QuantumKeyPair, WalletData}; use qp_dilithium_crypto::DilithiumPair; use qp_rusty_crystals_hdwallet::{ derive_key_from_mnemonic, generate_mnemonic, mnemonic_to_seed, SensitiveBytes32, @@ -597,6 +597,55 @@ pub fn load_keypair_from_wallet( Ok(keypair) } +/// How an extrinsic gets signed: locally from key material, or air-gapped over +/// QR codes by a cold (watch-only) wallet. The submit stage in +/// `cli::common` branches on this; commands never do. +#[derive(Debug, Clone)] +pub enum WalletSigner { + Hot(QuantumKeyPair), + /// Watch-only: address stored unencrypted, signing happens over QR. + Cold { name: String, address: String }, +} + +impl WalletSigner { + /// SS58 address of the signing account, regardless of kind. + pub fn account_id_ss58check(&self) -> String { + match self { + WalletSigner::Hot(keypair) => keypair.to_account_id_ss58check(), + WalletSigner::Cold { address, .. } => address.clone(), + } + } + + /// The hot keypair, if this is a hot signer. Used only by paths that + /// genuinely need key material (e.g. HD derivation). + pub fn as_hot(&self) -> Option<&QuantumKeyPair> { + match self { + WalletSigner::Hot(keypair) => Some(keypair), + WalletSigner::Cold { .. } => None, + } + } +} + +/// Resolve a wallet into a signer without assuming it has local key material. +/// Cold wallets resolve without a password prompt; hot wallets decrypt as usual. +pub fn load_signer_from_wallet( + wallet_name: &str, + password: Option, + password_file: Option, +) -> Result { + let wallet_manager = WalletManager::new()?; + if wallet_manager.wallet_type(wallet_name)? == Some(keystore::WalletType::Cold) { + if password.is_some() || password_file.is_some() { + crate::log_print!("⚠️ Cold wallets have no password; ignoring --password/--password-file"); + } + let address = wallet_manager + .find_wallet_address(wallet_name)? + .ok_or(WalletError::NotFound)?; + return Ok(WalletSigner::Cold { name: wallet_name.to_string(), address }); + } + Ok(WalletSigner::Hot(load_keypair_from_wallet(wallet_name, password, password_file)?)) +} + #[cfg(test)] mod tests { use super::*; @@ -1076,8 +1125,14 @@ mod tests { // Address resolution and type probe work assert_eq!(wallet_manager.find_wallet_address("frosty").unwrap(), Some(address)); - assert_eq!(wallet_manager.wallet_type("frosty").unwrap(), Some(WalletType::Cold)); - assert_eq!(wallet_manager.wallet_type("hot-one").unwrap(), Some(WalletType::Hot)); + assert_eq!( + wallet_manager.wallet_type("frosty").unwrap(), + Some(keystore::WalletType::Cold) + ); + assert_eq!( + wallet_manager.wallet_type("hot-one").unwrap(), + Some(keystore::WalletType::Hot) + ); assert_eq!(wallet_manager.wallet_type("nope").unwrap(), None); // get_wallet without password shows full public info @@ -1148,6 +1203,6 @@ mod tests { "created_at": "2025-01-01T00:00:00Z" }"#; let wallet: keystore::EncryptedWallet = serde_json::from_str(legacy).unwrap(); - assert_eq!(wallet.wallet_type, WalletType::Hot); + assert_eq!(wallet.wallet_type, keystore::WalletType::Hot); } } From 626c0011c3f6849828b89b4f888a87b0491c837b Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 30 Jul 2026 11:54:09 +0800 Subject: [PATCH 03/13] formatting --- src/cli/recovery.rs | 18 ++++++------------ src/cli/reversible.rs | 3 +-- src/cli/send.rs | 12 +++++++++--- src/cli/tech_collective.rs | 22 ++++++---------------- src/wallet/mod.rs | 24 +++++++++++------------- 5 files changed, 33 insertions(+), 46 deletions(-) diff --git a/src/cli/recovery.rs b/src/cli/recovery.rs index b57b6ee..769f709 100644 --- a/src/cli/recovery.rs +++ b/src/cli/recovery.rs @@ -177,8 +177,7 @@ pub async fn handle_recovery_command( match command { RecoveryCommands::Initiate { rescuer, lost, password, password_file } => { - let signer = - crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; let rescuer_addr = signer.account_id_ss58check(); log_print!("🔑 Rescuer: {}", rescuer); log_print!("🔑 Rescuer address: {}", rescuer_addr); @@ -204,8 +203,7 @@ pub async fn handle_recovery_command( }, RecoveryCommands::Vouch { friend, lost, rescuer, password, password_file } => { - let signer = - crate::wallet::load_signer_from_wallet(&friend, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&friend, password, password_file)?; let lost_id = resolve_to_subxt_account_id(&lost)?; let rescuer_id = resolve_to_subxt_account_id(&rescuer)?; let call = quantus_subxt::api::tx().recovery().vouch_recovery( @@ -229,8 +227,7 @@ pub async fn handle_recovery_command( }, RecoveryCommands::Claim { rescuer, lost, password, password_file } => { - let signer = - crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; let lost_id = resolve_to_subxt_account_id(&lost)?; let call = quantus_subxt::api::tx() .recovery() @@ -262,8 +259,7 @@ pub async fn handle_recovery_command( } => { use quantus_subxt::api::runtime_types::pallet_balances::pallet::Call as BalancesCall; - let signer = - crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; let rescuer_addr = signer.account_id_ss58check(); log_print!("🔑 Rescuer: {}", rescuer); log_print!("🔑 Rescuer address: {}", rescuer_addr); @@ -361,8 +357,7 @@ pub async fn handle_recovery_command( } => { use quantus_subxt::api::runtime_types::pallet_balances::pallet::Call as BalancesCall; - let signer = - crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; let rescuer_addr = signer.account_id_ss58check(); log_print!("🔑 Rescuer: {}", rescuer); @@ -474,8 +469,7 @@ pub async fn handle_recovery_command( }, RecoveryCommands::CancelProxy { rescuer, lost, password, password_file } => { - let signer = - crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; let lost_id = resolve_to_subxt_account_id(&lost)?; let call = quantus_subxt::api::tx() .recovery() diff --git a/src/cli/reversible.rs b/src/cli/reversible.rs index 1ce36c1..ce2de79 100644 --- a/src/cli/reversible.rs +++ b/src/cli/reversible.rs @@ -397,8 +397,7 @@ async fn list_pending_transactions( }, (None, Some(wallet)) => { // Load wallet and get its address - let signer = - crate::wallet::load_signer_from_wallet(&wallet, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&wallet, password, password_file)?; signer.account_id_ss58check() }, (None, None) => { diff --git a/src/cli/send.rs b/src/cli/send.rs index 16762c4..c2a2819 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -651,9 +651,15 @@ pub async fn handle_send_command( log_verbose!("✍️ {} Signing transaction...", "SIGN".bright_magenta().bold()); // Submit transaction - let tx_hash = - submit_transfer_call(&quantus_client, &signer, transfer_call, submit_tip, nonce, execution_mode) - .await?; + let tx_hash = submit_transfer_call( + &quantus_client, + &signer, + transfer_call, + submit_tip, + nonce, + execution_mode, + ) + .await?; print_send_result(&quantus_client, &from_account_id, balance, amount, tx_hash, execution_mode) .await diff --git a/src/cli/tech_collective.rs b/src/cli/tech_collective.rs index 83d6acd..f7311f3 100644 --- a/src/cli/tech_collective.rs +++ b/src/cli/tech_collective.rs @@ -197,14 +197,9 @@ pub async fn vote_on_referendum( let wait_mode = crate::cli::common::ExecutionMode { wait_for_transaction: true, ..execution_mode }; - let tx_hash = crate::cli::common::submit_transaction( - quantus_client, - signer, - vote_call, - None, - wait_mode, - ) - .await?; + let tx_hash = + crate::cli::common::submit_transaction(quantus_client, signer, vote_call, None, wait_mode) + .await?; log_verbose!("📋 Vote transaction confirmed: {:?}", tx_hash); @@ -368,14 +363,9 @@ pub async fn handle_tech_collective_command( let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; - let tx_hash = vote_on_referendum( - &quantus_client, - &signer, - referendum_index, - aye, - execution_mode, - ) - .await?; + let tx_hash = + vote_on_referendum(&quantus_client, &signer, referendum_index, aye, execution_mode) + .await?; log_print!( "✅ {} Vote confirmed in block! Hash: {:?}", diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index e7e67d9..7334d93 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -604,7 +604,10 @@ pub fn load_keypair_from_wallet( pub enum WalletSigner { Hot(QuantumKeyPair), /// Watch-only: address stored unencrypted, signing happens over QR. - Cold { name: String, address: String }, + Cold { + name: String, + address: String, + }, } impl WalletSigner { @@ -636,11 +639,12 @@ pub fn load_signer_from_wallet( let wallet_manager = WalletManager::new()?; if wallet_manager.wallet_type(wallet_name)? == Some(keystore::WalletType::Cold) { if password.is_some() || password_file.is_some() { - crate::log_print!("⚠️ Cold wallets have no password; ignoring --password/--password-file"); + crate::log_print!( + "⚠️ Cold wallets have no password; ignoring --password/--password-file" + ); } - let address = wallet_manager - .find_wallet_address(wallet_name)? - .ok_or(WalletError::NotFound)?; + let address = + wallet_manager.find_wallet_address(wallet_name)?.ok_or(WalletError::NotFound)?; return Ok(WalletSigner::Cold { name: wallet_name.to_string(), address }); } Ok(WalletSigner::Hot(load_keypair_from_wallet(wallet_name, password, password_file)?)) @@ -1125,14 +1129,8 @@ mod tests { // Address resolution and type probe work assert_eq!(wallet_manager.find_wallet_address("frosty").unwrap(), Some(address)); - assert_eq!( - wallet_manager.wallet_type("frosty").unwrap(), - Some(keystore::WalletType::Cold) - ); - assert_eq!( - wallet_manager.wallet_type("hot-one").unwrap(), - Some(keystore::WalletType::Hot) - ); + assert_eq!(wallet_manager.wallet_type("frosty").unwrap(), Some(keystore::WalletType::Cold)); + assert_eq!(wallet_manager.wallet_type("hot-one").unwrap(), Some(keystore::WalletType::Hot)); assert_eq!(wallet_manager.wallet_type("nope").unwrap(), None); // get_wallet without password shows full public info From 844d125a918f4a03ab446b9753384118fa5a8173 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 13 Aug 2026 21:25:33 +0800 Subject: [PATCH 04/13] Route every signed submit through the hot/cold WalletSigner fork Library transfer/multisig helpers took a QuantumKeyPair and always signed locally. They now take WalletSigner, so a cold wallet takes the QR path in submit_transaction the same way CLI commands already do. Examples that called subxt sign_and_submit_then_watch_default now use the shared submit helper instead of a second signing path. --- LIBRARY_USAGE.md | 28 +++++++-------- examples/basic_usage.rs | 43 +++++++----------------- examples/multisig_library_usage.rs | 15 ++++++--- examples/service.rs | 36 +++++++------------- examples/wallet_ops.rs | 37 +++++++------------- examples/wormhole_sdk_e2e.rs | 2 +- src/cli/exercise/scenarios/balances.rs | 20 +++++++---- src/cli/exercise/scenarios/multisig.rs | 2 +- src/cli/exercise/scenarios/reversible.rs | 4 +-- src/cli/exercise/scenarios/utility.rs | 2 +- src/cli/multisig.rs | 35 ++++++------------- src/cli/send.rs | 18 +++++----- src/wallet/mod.rs | 8 +++++ 13 files changed, 105 insertions(+), 145 deletions(-) diff --git a/LIBRARY_USAGE.md b/LIBRARY_USAGE.md index 2907514..95a7b16 100644 --- a/LIBRARY_USAGE.md +++ b/LIBRARY_USAGE.md @@ -175,20 +175,18 @@ use quantus_cli::{ }; async fn send_transaction() -> Result<(), Box> { - let wallet_manager = WalletManager::new()?; let client = QuantusClient::new("ws://127.0.0.1:9944").await?; - - // Load sender wallet - let wallet_data = wallet_manager.load_wallet("my_wallet", "password")?; - let keypair = wallet_data.keypair; - + + // Hot wallets decrypt locally; cold (watch-only) wallets sign over QR. + let signer = quantus_cli::wallet::load_signer_from_wallet("my_wallet", None, None)?; + // Recipient address let to_address = "qzkeicNBtW2AG2E7USjDcLzAL8d9WxTZnV2cbtXoDzWxzpHC2"; // Submit and wait for inclusion in a best block let tx_hash = transfer( &client, - &keypair, + &signer, to_address, 1_000_000_000_000, // raw units, e.g. 1 token on a 12-decimal chain None, @@ -338,7 +336,7 @@ use quantus_cli::{create_multisig, predict_multisig_address, QuantusClient}; async fn create_multisig_example() -> Result<(), Box> { let client = QuantusClient::new("ws://127.0.0.1:9944").await?; - let keypair = quantus_cli::wallet::load_keypair_from_wallet("alice", None, None)?; + let signer = quantus_cli::wallet::load_signer_from_wallet("alice", None, None)?; // Parse signer addresses let alice_account = parse_address("qzkaf...")?; @@ -356,7 +354,7 @@ async fn create_multisig_example() -> Result<(), Box> { // Create multisig (wait_for_inclusion=true to get address from event) let (tx_hash, multisig_address) = create_multisig( &client, - &keypair, + &signer, signers, threshold, nonce, // NEW: nonce parameter for deterministic addresses @@ -400,7 +398,7 @@ use quantus_cli::{propose_transfer, parse_multisig_amount}; async fn create_proposal() -> Result<(), Box> { let client = QuantusClient::new("ws://127.0.0.1:9944").await?; - let keypair = quantus_cli::wallet::load_keypair_from_wallet("alice", None, None)?; + let signer = quantus_cli::wallet::load_signer_from_wallet("alice", None, None)?; let multisig_account = parse_address("qz...")?; let recipient = parse_address("qzmqr...")?; @@ -412,7 +410,7 @@ async fn create_proposal() -> Result<(), Box> { let tx_hash = propose_transfer( &client, - &keypair, + &signer, multisig_account, recipient, amount, @@ -433,14 +431,14 @@ use quantus_cli::approve_proposal; async fn approve_example() -> Result<(), Box> { let client = QuantusClient::new("ws://127.0.0.1:9944").await?; - let keypair = quantus_cli::wallet::load_keypair_from_wallet("bob", None, None)?; + let signer = quantus_cli::wallet::load_signer_from_wallet("bob", None, None)?; let multisig_account = parse_address("qz...")?; let proposal_id = 0u32; let tx_hash = approve_proposal( &client, - &keypair, + &signer, multisig_account, proposal_id ).await?; @@ -514,14 +512,14 @@ use quantus_cli::cancel_proposal; async fn cancel_example() -> Result<(), Box> { let client = QuantusClient::new("ws://127.0.0.1:9944").await?; - let keypair = quantus_cli::wallet::load_keypair_from_wallet("alice", None, None)?; + let signer = quantus_cli::wallet::load_signer_from_wallet("alice", None, None)?; let multisig_account = parse_address("qz...")?; let proposal_id = 0u32; let tx_hash = cancel_proposal( &client, - &keypair, + &signer, multisig_account, proposal_id ).await?; diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index 63bf126..31669e3 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -7,10 +7,8 @@ //! 4. Query blockchain data use quantus_cli::{ - chain::client::QuantusClient, - error::Result, - wallet::{QuantumKeyPair, WalletManager}, - AccountId32, + chain::client::QuantusClient, cli::common::ExecutionMode, error::Result, transfer, + wallet::WalletManager, AccountId32, }; use sp_core::crypto::Ss58Codec; @@ -79,36 +77,19 @@ async fn get_account_balance(client: &QuantusClient, account_id: &AccountId32) - #[allow(dead_code)] async fn send_tokens( client: &QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &quantus_cli::wallet::QuantumKeyPair, to_address: &str, amount: u128, ) -> Result { - use quantus_cli::chain::quantus_subxt::api; - - // Parse recipient address - let to_account_id = AccountId32::from_ss58check(to_address) - .map_err(|e| quantus_cli::error::QuantusError::Generic(format!("Invalid address: {e}")))?; - let to_account_bytes: [u8; 32] = *to_account_id.as_ref(); - let to_subxt_account_id = subxt::utils::AccountId32::from(to_account_bytes); - - // Create transfer call - let transfer_call = - api::tx().balances().transfer_allow_death(to_subxt_account_id.into(), amount); - - // Convert QuantumKeyPair to DilithiumPair for signing - let dilithium_pair = from_keypair.to_subxt_signer()?; - - // Submit transaction - let tx_hash = client - .client() - .tx() - .sign_and_submit_then_watch_default(&transfer_call, &dilithium_pair) - .await? - .wait_for_finalized_success() - .await? - .extrinsic_hash(); - - Ok(tx_hash) + transfer( + client, + &from_keypair.as_signer(), + to_address, + amount, + None, + ExecutionMode { wait_for_transaction: true, finalized: true }, + ) + .await } /// Example of creating a wallet from mnemonic diff --git a/examples/multisig_library_usage.rs b/examples/multisig_library_usage.rs index 48f0cd3..521128f 100644 --- a/examples/multisig_library_usage.rs +++ b/examples/multisig_library_usage.rs @@ -64,8 +64,15 @@ async fn main() -> Result<()> { let predicted_address = predict_multisig_address(signers.clone(), threshold, nonce); println!("📍 Predicted address: {}", predicted_address); - let (tx_hash, multisig_address) = - create_multisig(&quantus_client, &alice_keypair, signers, threshold, nonce, true).await?; + let (tx_hash, multisig_address) = create_multisig( + &quantus_client, + &alice_keypair.as_signer(), + signers, + threshold, + nonce, + true, + ) + .await?; println!("✅ Multisig created!"); println!(" Tx hash: 0x{}", hex::encode(tx_hash)); @@ -109,7 +116,7 @@ async fn main() -> Result<()> { let propose_tx_hash = propose_transfer( &quantus_client, - &alice_keypair, + &alice_keypair.as_signer(), multisig_account.clone(), bob_account.clone(), amount, @@ -157,7 +164,7 @@ async fn main() -> Result<()> { println!("✅ Approving proposal #{}...", proposal_id); let approve_tx_hash = approve_proposal( &quantus_client, - &bob_keypair, + &bob_keypair.as_signer(), multisig_account.clone(), proposal_id, ) diff --git a/examples/service.rs b/examples/service.rs index 6b1d5b1..726c3fa 100644 --- a/examples/service.rs +++ b/examples/service.rs @@ -8,7 +8,9 @@ use quantus_cli::{ chain::client::QuantusClient, + cli::common::ExecutionMode, error::{QuantusError, Result}, + transfer, wallet::{QuantumKeyPair, WalletManager}, AccountId32, }; @@ -195,30 +197,16 @@ impl WalletService { to_account_id: &AccountId32, amount: u128, ) -> Result { - use quantus_cli::chain::quantus_subxt::api; - - // Convert recipient to subxt format - let to_account_bytes: [u8; 32] = *to_account_id.as_ref(); - let to_subxt_account_id = subxt::utils::AccountId32::from(to_account_bytes); - - // Create transfer call - let transfer_call = - api::tx().balances().transfer_allow_death(to_subxt_account_id.into(), amount); - - // Convert QuantumKeyPair to DilithiumPair for signing - let dilithium_pair = from_keypair.to_subxt_signer()?; - - // Submit transaction - let tx_hash = client - .client() - .tx() - .sign_and_submit_then_watch_default(&transfer_call, &dilithium_pair) - .await? - .wait_for_finalized_success() - .await? - .extrinsic_hash(); - - Ok(tx_hash) + let to_address = to_account_id.to_ss58check(); + transfer( + client, + &from_keypair.as_signer(), + &to_address, + amount, + None, + ExecutionMode { wait_for_transaction: true, finalized: true }, + ) + .await } } diff --git a/examples/wallet_ops.rs b/examples/wallet_ops.rs index 534f9c8..5727939 100644 --- a/examples/wallet_ops.rs +++ b/examples/wallet_ops.rs @@ -9,7 +9,9 @@ use quantus_cli::{ chain::client::QuantusClient, + cli::common::ExecutionMode, error::{QuantusError, Result}, + transfer, wallet::{QuantumKeyPair, WalletManager}, AccountId32, }; @@ -139,31 +141,16 @@ impl QuantusApp { to_account_id: &AccountId32, amount: u128, ) -> Result { - use quantus_cli::chain::quantus_subxt::api; - - // Convert recipient to subxt format - let to_account_bytes: [u8; 32] = *to_account_id.as_ref(); - let to_subxt_account_id = subxt::utils::AccountId32::from(to_account_bytes); - - // Create transfer call - let transfer_call = - api::tx().balances().transfer_allow_death(to_subxt_account_id.into(), amount); - - // Convert QuantumKeyPair to DilithiumPair for signing - let dilithium_pair = from_keypair.to_subxt_signer()?; - - // Submit transaction - let tx_hash = self - .client - .client() - .tx() - .sign_and_submit_then_watch_default(&transfer_call, &dilithium_pair) - .await? - .wait_for_finalized_success() - .await? - .extrinsic_hash(); - - Ok(tx_hash) + let to_address = to_account_id.to_ss58check(); + transfer( + &self.client, + &from_keypair.as_signer(), + &to_address, + amount, + None, + ExecutionMode { wait_for_transaction: true, finalized: true }, + ) + .await } } diff --git a/examples/wormhole_sdk_e2e.rs b/examples/wormhole_sdk_e2e.rs index b232101..68a621e 100644 --- a/examples/wormhole_sdk_e2e.rs +++ b/examples/wormhole_sdk_e2e.rs @@ -175,7 +175,7 @@ async fn main() -> Result<()> { println!("[2/9] depositing {} planck -> wormhole address...", amount_planck); let tx_hash = transfer( &client, - &funder_kp, + &funder_kp.as_signer(), &wh_ss58, amount_planck, None, diff --git a/src/cli/exercise/scenarios/balances.rs b/src/cli/exercise/scenarios/balances.rs index 068257f..ee571a6 100644 --- a/src/cli/exercise/scenarios/balances.rs +++ b/src/cli/exercise/scenarios/balances.rs @@ -47,7 +47,7 @@ async fn scheme_transfer(ctx: &mut ExerciseCtx, scheme: DilithiumScheme) -> Resu let before = ctx.free_balance(&recipient_ss58).await?; crate::cli::send::transfer( &ctx.client, - &sender, + &sender.as_signer(), &recipient_ss58, amount, None, @@ -72,7 +72,7 @@ async fn single_transfer(ctx: &mut ExerciseCtx) -> Result { let before = ctx.free_balance(&recipient_ss58).await?; crate::cli::send::transfer( &ctx.client, - &sender, + &sender.as_signer(), &recipient_ss58, amount, None, @@ -98,8 +98,14 @@ async fn batch_transfer(ctx: &mut ExerciseCtx) -> Result { let transfers: Vec<(String, u128)> = recipients.iter().map(|r| (r.clone(), amount)).collect(); let sender = ctx.eph[0].clone(); - crate::cli::send::batch_transfer(&ctx.client, &sender, transfers, None, ctx.wait_mode()) - .await?; + crate::cli::send::batch_transfer( + &ctx.client, + &sender.as_signer(), + transfers, + None, + ctx.wait_mode(), + ) + .await?; for recipient in &recipients { let balance = ctx.free_balance(recipient).await?; @@ -119,7 +125,7 @@ async fn transfer_with_tip(ctx: &mut ExerciseCtx) -> Result { let sender = ctx.eph[1].clone(); crate::cli::send::transfer( &ctx.client, - &sender, + &sender.as_signer(), &recipient, amount, Some(tip), @@ -142,7 +148,7 @@ async fn transfer_manual_nonce(ctx: &mut ExerciseCtx) -> Result { let recipient = ctx.fresh_keypair()?.try_to_account_id_ss58check()?; crate::cli::send::transfer_with_nonce( &ctx.client, - &sender, + &sender.as_signer(), &recipient, ctx.test_unit, None, @@ -190,7 +196,7 @@ async fn transfer_all(ctx: &mut ExerciseCtx) -> Result { let funder = ctx.eph[1].clone(); crate::cli::send::transfer( &ctx.client, - &funder, + &funder.as_signer(), &sender_ss58, 20 * ctx.test_unit, None, diff --git a/src/cli/exercise/scenarios/multisig.rs b/src/cli/exercise/scenarios/multisig.rs index 4451065..065ed06 100644 --- a/src/cli/exercise/scenarios/multisig.rs +++ b/src/cli/exercise/scenarios/multisig.rs @@ -50,7 +50,7 @@ async fn lifecycle(ctx: &mut ExerciseCtx) -> Result { crate::cli::send::transfer( &ctx.client, - &signer_a, + &signer_a.as_signer(), &multisig_ss58, 20 * ctx.test_unit, None, diff --git a/src/cli/exercise/scenarios/reversible.rs b/src/cli/exercise/scenarios/reversible.rs index 23816ac..785a612 100644 --- a/src/cli/exercise/scenarios/reversible.rs +++ b/src/cli/exercise/scenarios/reversible.rs @@ -110,7 +110,7 @@ async fn set_high_security(ctx: &mut ExerciseCtx) -> Result { let funder = ctx.eph[3].clone(); crate::cli::send::transfer( &ctx.client, - &funder, + &funder.as_signer(), &account_ss58, 10 * ctx.test_unit, None, @@ -185,7 +185,7 @@ async fn guardian_recover_funds(ctx: &mut ExerciseCtx) -> Result { let funder = ctx.eph[3].clone(); crate::cli::send::transfer( &ctx.client, - &funder, + &funder.as_signer(), &account_ss58, 20 * ctx.test_unit, None, diff --git a/src/cli/exercise/scenarios/utility.rs b/src/cli/exercise/scenarios/utility.rs index f2a7c86..431d21c 100644 --- a/src/cli/exercise/scenarios/utility.rs +++ b/src/cli/exercise/scenarios/utility.rs @@ -119,7 +119,7 @@ async fn as_derivative(ctx: &mut ExerciseCtx) -> Result { let funding = 5 * ctx.test_unit; crate::cli::send::transfer( &ctx.client, - &sender, + &sender.as_signer(), &derivative_ss58, funding, None, diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index 5a1e03a..96cbd0d 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -472,14 +472,6 @@ pub fn predict_multisig_address( account_id.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)) } -fn keypair_to_subxt_account_id( - keypair: &crate::wallet::QuantumKeyPair, -) -> crate::error::Result { - let account_id = keypair.try_to_account_id_32()?; - let account_bytes: [u8; 32] = *account_id.as_ref(); - Ok(SubxtAccountId32::from(account_bytes)) -} - fn signer_to_subxt_account_id( signer: &crate::wallet::WalletSigner, ) -> crate::error::Result { @@ -549,7 +541,7 @@ fn find_matching_multisig_created_address<'a>( #[allow(dead_code)] pub async fn create_multisig( quantus_client: &crate::chain::client::QuantusClient, - creator_keypair: &crate::wallet::QuantumKeyPair, + creator: &crate::wallet::WalletSigner, signers: Vec, threshold: u32, nonce: u64, @@ -562,13 +554,12 @@ pub async fn create_multisig( .create_multisig(signers.clone(), threshold, nonce); // Submit transaction - let creator_account_id = keypair_to_subxt_account_id(creator_keypair)?; + let creator_account_id = signer_to_subxt_account_id(creator)?; let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: wait_for_inclusion }; - let signer = crate::wallet::WalletSigner::Hot(creator_keypair.clone()); let (tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( quantus_client, - &signer, + creator, create_tx, None, execution_mode, @@ -623,7 +614,7 @@ pub async fn create_multisig( #[allow(dead_code)] pub async fn propose_transfer( quantus_client: &crate::chain::client::QuantusClient, - proposer_keypair: &crate::wallet::QuantumKeyPair, + proposer: &crate::wallet::WalletSigner, multisig_address: subxt::ext::subxt_core::utils::AccountId32, to_address: subxt::ext::subxt_core::utils::AccountId32, amount: u128, @@ -655,10 +646,9 @@ pub async fn propose_transfer( // Submit transaction let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; - let signer = crate::wallet::WalletSigner::Hot(proposer_keypair.clone()); let tx_hash = crate::cli::common::submit_transaction( quantus_client, - &signer, + proposer, propose_tx, None, execution_mode, @@ -675,7 +665,7 @@ pub async fn propose_transfer( #[allow(dead_code)] pub async fn propose_custom( quantus_client: &crate::chain::client::QuantusClient, - proposer_keypair: &crate::wallet::QuantumKeyPair, + proposer: &crate::wallet::WalletSigner, multisig_address: subxt::ext::subxt_core::utils::AccountId32, call_data: Vec, expiry: u32, @@ -689,10 +679,9 @@ pub async fn propose_custom( // Submit transaction let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; - let signer = crate::wallet::WalletSigner::Hot(proposer_keypair.clone()); let tx_hash = crate::cli::common::submit_transaction( quantus_client, - &signer, + proposer, propose_tx, None, execution_mode, @@ -709,7 +698,7 @@ pub async fn propose_custom( #[allow(dead_code)] pub async fn approve_proposal( quantus_client: &crate::chain::client::QuantusClient, - approver_keypair: &crate::wallet::QuantumKeyPair, + approver: &crate::wallet::WalletSigner, multisig_address: subxt::ext::subxt_core::utils::AccountId32, proposal_id: u32, ) -> crate::error::Result { @@ -730,10 +719,9 @@ pub async fn approve_proposal( .approve(multisig_address, proposal_id, proposal.call); let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; - let signer = crate::wallet::WalletSigner::Hot(approver_keypair.clone()); let tx_hash = crate::cli::common::submit_transaction( quantus_client, - &signer, + approver, approve_tx, None, execution_mode, @@ -750,17 +738,16 @@ pub async fn approve_proposal( #[allow(dead_code)] pub async fn cancel_proposal( quantus_client: &crate::chain::client::QuantusClient, - proposer_keypair: &crate::wallet::QuantumKeyPair, + proposer: &crate::wallet::WalletSigner, multisig_address: subxt::ext::subxt_core::utils::AccountId32, proposal_id: u32, ) -> crate::error::Result { let cancel_tx = quantus_subxt::api::tx().multisig().cancel(multisig_address, proposal_id); let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; - let signer = crate::wallet::WalletSigner::Hot(proposer_keypair.clone()); let tx_hash = crate::cli::common::submit_transaction( quantus_client, - &signer, + proposer, cancel_tx, None, execution_mode, diff --git a/src/cli/send.rs b/src/cli/send.rs index 50e5ce0..782d529 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -438,14 +438,13 @@ where #[allow(dead_code)] // Used by external libraries via lib.rs export pub async fn transfer( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, to_address: &str, amount: u128, tip: Option, execution_mode: crate::cli::common::ExecutionMode, ) -> Result { - transfer_with_nonce(quantus_client, from_keypair, to_address, amount, tip, None, execution_mode) - .await + transfer_with_nonce(quantus_client, signer, to_address, amount, tip, None, execution_mode).await } /// Transfer tokens with manual nonce override. @@ -453,7 +452,7 @@ pub async fn transfer( /// Pass `Some(0)` or `None` to omit a tip. pub async fn transfer_with_nonce( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, to_address: &str, amount: u128, tip: Option, @@ -461,7 +460,7 @@ pub async fn transfer_with_nonce( execution_mode: crate::cli::common::ExecutionMode, ) -> Result { log_verbose!("🚀 Creating transfer transaction..."); - log_verbose!(" From: {}", from_keypair.try_to_account_id_ss58check()?.bright_cyan()); + log_verbose!(" From: {}", signer.try_account_id_ss58check()?.bright_cyan()); log_verbose!(" To: {}", to_address.bright_green()); log_verbose!(" Amount: {}", amount); @@ -477,7 +476,7 @@ pub async fn transfer_with_nonce( // Submit the transaction with optional manual nonce let tx_hash = submit_transfer_call( quantus_client, - &crate::wallet::WalletSigner::Hot(from_keypair.clone()), + signer, transfer_call, submit_tip, nonce, @@ -562,18 +561,17 @@ where /// Batch transfer tokens to multiple recipients in a single transaction pub async fn batch_transfer( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, transfers: Vec<(String, u128)>, // (to_address, amount) pairs tip: Option, execution_mode: crate::cli::common::ExecutionMode, ) -> Result { - let signer = crate::wallet::WalletSigner::Hot(from_keypair.clone()); - validate_batch_transfer_request(quantus_client, &signer, &transfers).await?; + validate_batch_transfer_request(quantus_client, signer, &transfers).await?; log_verbose!("✍️ Creating batch extrinsic with {} calls...", transfers.len()); let batch_call = build_batch_transfer_call(&transfers)?; submit_prebuilt_batch_transfer_call( quantus_client, - &signer, + signer, &transfers, batch_call, tip, diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 69da1bf..aeb0cc2 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -845,6 +845,14 @@ impl WalletSigner { } } +impl QuantumKeyPair { + /// Wrap this keypair as a local signer. Submission still goes through the + /// shared hot/cold fork in `cli::common`. + pub fn as_signer(&self) -> WalletSigner { + WalletSigner::Hot(self.clone()) + } +} + /// Resolve a wallet into a signer without assuming it has local key material. /// Cold wallets resolve without a password prompt; hot wallets decrypt as usual. pub fn load_signer_from_wallet( From e0df8f262b9d6baed990474921910304da1e7d84 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 13 Aug 2026 21:30:06 +0800 Subject: [PATCH 05/13] Scan cold-wallet UR QRs at the camera's full frame rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The laptop scanner asked for max resolution and then downsampled every frame to 1280px via get_pixel so rqrr could keep up with ~5 fps. The cold wallet animates UR fragments at 15–50 fps, so that path missed frames. Open the camera at its highest frame rate, decode native-resolution frames, and convert RGB to luma in a tight loop. --- src/qr/scanner.rs | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/qr/scanner.rs b/src/qr/scanner.rs index f90c0e7..f362014 100644 --- a/src/qr/scanner.rs +++ b/src/qr/scanner.rs @@ -161,8 +161,21 @@ mod camera { } } - /// Blocking camera loop: grab frames, decode QR codes with rqrr, feed every - /// decoded string to `sink` until it returns `Some(result)`. + fn rgb_to_luma(raw: &[u8], width: u32, height: u32) -> (usize, usize, Vec) { + let w = width as usize; + let h = height as usize; + let mut luma = Vec::with_capacity(w * h); + for px in raw.chunks_exact(3) { + luma.push( + ((u16::from(px[0]) * 77 + u16::from(px[1]) * 150 + u16::from(px[2]) * 29) >> 8) + as u8, + ); + } + (w, h, luma) + } + + /// Grab frames at the camera's highest frame rate and decode each at native + /// resolution. Cold-wallet UR animations run at 15–50 fps. fn scan_with_camera( index: u32, timeout: Duration, @@ -179,7 +192,7 @@ mod camera { let mut camera = Camera::new( CameraIndex::Index(index), - RequestedFormat::new::(RequestedFormatType::AbsoluteHighestResolution), + RequestedFormat::new::(RequestedFormatType::AbsoluteHighestFrameRate), ) .map_err(|e| { QuantusError::Generic(format!( @@ -189,6 +202,13 @@ mod camera { camera .open_stream() .map_err(|e| QuantusError::Generic(format!("Failed to start camera stream: {e}")))?; + let fmt = camera.camera_format(); + crate::log_verbose!( + "📷 Camera {}x{} @ {} fps", + fmt.width(), + fmt.height(), + fmt.frame_rate() + ); let deadline = Instant::now() + timeout; let result = 'scan: loop { @@ -201,7 +221,6 @@ mod camera { )); } - // frame() blocks until the sensor delivers — natural pacing. let frame = match camera.frame() { Ok(frame) => frame, Err(e) => { @@ -217,15 +236,12 @@ mod camera { }, }; - // Point-sample down to ~1280px so rqrr keeps up with the ~5 fps QR - // animation even on high-resolution cameras. - let (w, h) = (img.width() as usize, img.height() as usize); - let scale = (w.max(h) / 1280).max(1); - let (sw, sh) = (w / scale, h / scale); - let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(sw, sh, |x, y| { - let p = img.get_pixel((x * scale) as u32, (y * scale) as u32).0; - ((p[0] as u16 * 299 + p[1] as u16 * 587 + p[2] as u16 * 114) / 1000) as u8 - }); + let (w, h, luma) = rgb_to_luma(img.as_raw(), img.width(), img.height()); + if luma.len() != w.saturating_mul(h) { + continue; + } + let mut prepared = + rqrr::PreparedImage::prepare_from_greyscale(w, h, |x, y| luma[y * w + x]); for grid in prepared.detect_grids() { if let Ok((_meta, content)) = grid.decode() { From 3e478e9c82ee8856dc01d92b09140a8bc97602fe Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 13 Aug 2026 21:47:17 +0800 Subject: [PATCH 06/13] Fix cold-wallet address QR scan and show a camera preview The cold wallet app draws white modules on a transparent (dark) background. rqrr only looks for dark-on-light codes, so a static address QR that a phone reads instantly never decoded. Try both polarities, a half-scale pass, and swallow rqrr panics on bad frames. Open a live preview window of the camera feed so the QR can be aimed instead of scanning blind. --- Cargo.lock | 252 ++++++++++++++++++++++++++++++++++++- Cargo.toml | 3 +- src/qr/scanner.rs | 308 +++++++++++++++++++++++++++++++++++++++------- 3 files changed, 514 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 003d344..353fdac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1176,6 +1176,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if 1.0.4", + "wasm-bindgen", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1687,6 +1697,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "docify" version = "0.2.9" @@ -2910,6 +2929,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if 1.0.4", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "integer-sqrt" version = "0.1.5" @@ -3356,7 +3387,10 @@ version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ + "bitflags 2.13.1", "libc", + "plain", + "redox_syscall 0.9.2", ] [[package]] @@ -3485,6 +3519,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memory-db" version = "0.34.0" @@ -3549,6 +3592,35 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "minifb" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1a093126f2ed9012fc0b146934c97eb0273e54983680a8bf5309b6b4a365b32" +dependencies = [ + "cc", + "console_error_panic_hook", + "dlib", + "futures", + "instant", + "js-sys", + "lazy_static", + "libc", + "orbclient", + "raw-window-handle", + "serde", + "serde_derive", + "tempfile", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-client", + "wayland-cursor", + "wayland-protocols", + "web-sys", + "winapi", + "x11-dl", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3659,6 +3731,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if 1.0.4", + "libc", + "memoffset", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -3962,6 +4046,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", + "sdl2", +] + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -4015,7 +4110,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if 1.0.4", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -4174,6 +4269,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plonky2_maybe_rayon" version = "1.0.0" @@ -4727,6 +4828,7 @@ dependencies = [ "indicatif", "jsonrpsee", "libc", + "minifb", "nokhwa", "parity-scale-codec", "qp-dilithium-crypto", @@ -4957,6 +5059,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "rayon" version = "1.12.0" @@ -4986,6 +5094,15 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_syscall" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93da5bb2c5d4e6c0ef7abeead62c89169a0a4882bfb83ac892f2423aea2fe" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -5561,12 +5678,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdl2" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "libc", + "sdl2-sys", +] + +[[package]] +name = "sdl2-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "version-compare", +] + [[package]] name = "sec1" version = "0.7.3" @@ -7512,6 +7658,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" + [[package]] name = "version_check" version = "0.9.5" @@ -7629,6 +7781,8 @@ dependencies = [ "cfg-if 1.0.4", "once_cell", "rustversion", + "serde", + "serde_json", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -7725,6 +7879,79 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "wayland-client" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3b068c05a039c9f755f881dc50f01732214f5685e379829759088967c46715" +dependencies = [ + "bitflags 1.3.2", + "downcast-rs", + "libc", + "nix", + "scoped-tls", + "wayland-commons", + "wayland-scanner", + "wayland-sys", +] + +[[package]] +name = "wayland-commons" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8691f134d584a33a6606d9d717b95c4fa20065605f798a3f350d78dced02a902" +dependencies = [ + "nix", + "once_cell", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-cursor" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6865c6b66f13d6257bef1cd40cbfe8ef2f150fb8ebbdb1e8e873455931377661" +dependencies = [ + "nix", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b950621f9354b322ee817a23474e479b34be96c2e909c14f7bc0100e9a970bc6" +dependencies = [ + "bitflags 1.3.2", + "wayland-client", + "wayland-commons", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4303d8fa22ab852f789e75a967f0a2cdc430a607751c0499bada3e451cbd53" +dependencies = [ + "proc-macro2", + "quote", + "xml-rs", +] + +[[package]] +name = "wayland-sys" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be12ce1a3c39ec7dba25594b97b42cb3195d54953ddb9d3d95a7c3902bc6e9d4" +dependencies = [ + "dlib", + "lazy_static", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -8132,6 +8359,17 @@ dependencies = [ "tap", ] +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -8154,6 +8392,18 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + [[package]] name = "yap" version = "0.12.0" diff --git a/Cargo.toml b/Cargo.toml index ca75f06..32205d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ path = "src/main.rs" default = ["camera"] # Camera capture + QR decoding for cold-wallet signing. Disable for headless # builds; UR input via --cold-response-in keeps working without it. -camera = ["dep:nokhwa", "dep:rqrr"] +camera = ["dep:minifb", "dep:nokhwa", "dep:rqrr"] [dependencies] # CLI and async runtime @@ -36,6 +36,7 @@ quantus_ur = { git = "https://github.com/Quantus-Network/quantus_ur.git", tag = # Terminal QR rendering (string renderers only; image/svg not needed) qrcode = { version = "0.14", default-features = false } # Camera capture (AVFoundation / MSMF / V4L2) + pure-Rust QR decoding +minifb = { version = "0.28", optional = true } nokhwa = { version = "0.10", features = ["input-native"], optional = true } rqrr = { version = "0.8", optional = true } diff --git a/src/qr/scanner.rs b/src/qr/scanner.rs index f362014..12a10c8 100644 --- a/src/qr/scanner.rs +++ b/src/qr/scanner.rs @@ -143,6 +143,9 @@ mod camera { time::Instant, }; + const PREVIEW_W: usize = 960; + const PREVIEW_H: usize = 540; + /// Ask for camera permission. On macOS this pops the TCC dialog (attributed /// to the terminal app, not `quantus`); on other platforms it's a no-op. fn ensure_permission() -> Result<()> { @@ -174,12 +177,107 @@ mod camera { (w, h, luma) } + fn downscale2(luma: &[u8], w: usize, h: usize) -> Option<(usize, usize, Vec)> { + let nw = w / 2; + let nh = h / 2; + if nw < 21 || nh < 21 { + return None; + } + let mut out = Vec::with_capacity(nw * nh); + for y in 0..nh { + for x in 0..nw { + let i = (2 * y) * w + (2 * x); + let avg = (u16::from(luma[i]) + + u16::from(luma[i + 1]) + + u16::from(luma[i + w]) + + u16::from(luma[i + w + 1])) / + 4; + out.push(avg as u8); + } + } + Some((nw, nh, out)) + } + + fn feed_decoded( + luma: &[u8], + w: usize, + h: usize, + mut sink: impl FnMut(&str) -> Option, + ) -> Option { + let mut run = |luma: &[u8], w: usize, h: usize, invert: bool| -> Option { + if w < 21 || h < 21 || luma.len() != w.saturating_mul(h) { + return None; + } + let decoded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(w, h, |x, y| { + let v = luma[y * w + x]; + if invert { + 255 - v + } else { + v + } + }); + let mut found = Vec::new(); + for grid in prepared.detect_grids() { + if let Ok((_meta, content)) = grid.decode() { + found.push(content); + } + } + found + })) + .unwrap_or_default(); + for content in decoded { + if let Some(hit) = sink(&content) { + return Some(hit); + } + } + None + }; + + if let Some(hit) = run(luma, w, h, false) { + return Some(hit); + } + if let Some(hit) = run(luma, w, h, true) { + return Some(hit); + } + if let Some((nw, nh, half)) = downscale2(luma, w, h) { + if let Some(hit) = run(&half, nw, nh, false) { + return Some(hit); + } + if let Some(hit) = run(&half, nw, nh, true) { + return Some(hit); + } + } + None + } + + fn rgb_preview_u32(raw: &[u8], sw: u32, sh: u32, dw: usize, dh: usize) -> Vec { + let mut out = vec![0u32; dw * dh]; + if sw == 0 || sh == 0 { + return out; + } + for y in 0..dh { + let sy = y * sh as usize / dh; + for x in 0..dw { + let sx = x * sw as usize / dw; + let i = (sy * sw as usize + sx) * 3; + if i + 2 < raw.len() { + out[y * dw + x] = u32::from(raw[i]) << 16 | + u32::from(raw[i + 1]) << 8 | + u32::from(raw[i + 2]); + } + } + } + out + } + /// Grab frames at the camera's highest frame rate and decode each at native /// resolution. Cold-wallet UR animations run at 15–50 fps. fn scan_with_camera( index: u32, timeout: Duration, stop: Arc, + mut on_status: impl FnMut(&str), mut sink: impl FnMut(&str) -> Option>, ) -> Result { use nokhwa::{ @@ -209,15 +307,39 @@ mod camera { fmt.height(), fmt.frame_rate() ); + on_status(&format!( + "📷 Live {}x{} @ {} fps — opening preview…", + fmt.width(), + fmt.height(), + fmt.frame_rate() + )); + + let mut window = minifb::Window::new( + "Quantus camera — hold the QR in this view", + PREVIEW_W, + PREVIEW_H, + minifb::WindowOptions { resize: false, ..minifb::WindowOptions::default() }, + ) + .map_err(|e| { + crate::log_verbose!("📷 Preview window unavailable: {e}"); + e + }) + .ok(); + if window.is_some() { + crate::log_print!( + "🖼️ Camera preview window opened — aim the QR so it is sharp in that view" + ); + } let deadline = Instant::now() + timeout; + let mut frames: u64 = 0; let result = 'scan: loop { if stop.load(Ordering::Relaxed) { break Err(QuantusError::Generic("Aborted by user".to_string())); } if Instant::now() >= deadline { break Err(QuantusError::Generic( - "Timed out scanning. Keep the QR steady in front of the camera, raise the device's screen brightness (15-25 cm distance works best), or use --cold-response-in ".to_string(), + "Timed out scanning. Keep the QR sharp in the preview window (15-25 cm, high brightness), or use --cold-response-in / --address".to_string(), )); } @@ -235,20 +357,41 @@ mod camera { continue; }, }; + frames += 1; + if frames == 1 || frames % 15 == 0 { + on_status(&format!( + "📷 Live {}x{} @ {} fps — {} frames, no QR yet", + img.width(), + img.height(), + fmt.frame_rate(), + frames + )); + } + + if let Some(win) = window.as_mut() { + if !win.is_open() { + window = None; + } else { + let buf = rgb_preview_u32( + img.as_raw(), + img.width(), + img.height(), + PREVIEW_W, + PREVIEW_H, + ); + if let Err(e) = win.update_with_buffer(&buf, PREVIEW_W, PREVIEW_H) { + crate::log_verbose!("📷 Preview update failed: {e}"); + window = None; + } + } + } let (w, h, luma) = rgb_to_luma(img.as_raw(), img.width(), img.height()); if luma.len() != w.saturating_mul(h) { continue; } - let mut prepared = - rqrr::PreparedImage::prepare_from_greyscale(w, h, |x, y| luma[y * w + x]); - - for grid in prepared.detect_grids() { - if let Ok((_meta, content)) = grid.decode() { - if let Some(result) = sink(&content) { - break 'scan result; - } - } + if let Some(result) = feed_decoded(&luma, w, h, |content| sink(content)) { + break 'scan result; } }; @@ -260,12 +403,14 @@ mod camera { async fn run_scan( index: u32, timeout: Duration, + on_status: impl FnMut(&str) + Send + 'static, sink: impl FnMut(&str) -> Option> + Send + 'static, ) -> Result { let stop = Arc::new(AtomicBool::new(false)); let stop_flag = stop.clone(); - let handle = - tokio::task::spawn_blocking(move || scan_with_camera(index, timeout, stop_flag, sink)); + let handle = tokio::task::spawn_blocking(move || { + scan_with_camera(index, timeout, stop_flag, on_status, sink) + }); tokio::select! { result = handle => result @@ -296,33 +441,39 @@ mod camera { /// decoder has a complete payload. pub(super) async fn scan_ur_with_camera(index: u32, timeout: Duration) -> Result> { let spinner = scan_spinner("📷 Point the camera at the QR on the cold wallet…"); + let status = spinner.clone(); let progress = spinner.clone(); let mut seen: HashSet = HashSet::new(); let mut parts: Vec = Vec::new(); let mut total: Option = None; - let result = run_scan(index, timeout, move |content| { - if !is_ur_line(content) { - return None; - } - let part = content.trim().to_string(); - if !seen.insert(part.clone()) { - return None; - } - if let Some((_, t)) = ur_progress(&part) { - total = Some(t); - } - parts.push(part); - let of_total = total.map(|t| format!(" (sequence of {t})")).unwrap_or_default(); - progress.set_message(format!( - "📷 Captured {} UR part{}{}…", - parts.len(), - if parts.len() == 1 { "" } else { "s" }, - of_total - )); - decode_if_complete(&parts).transpose() - }) + let result = run_scan( + index, + timeout, + move |msg| status.set_message(msg.to_string()), + move |content| { + if !is_ur_line(content) { + return None; + } + let part = content.trim().to_string(); + if !seen.insert(part.clone()) { + return None; + } + if let Some((_, t)) = ur_progress(&part) { + total = Some(t); + } + parts.push(part); + let of_total = total.map(|t| format!(" (sequence of {t})")).unwrap_or_default(); + progress.set_message(format!( + "📷 Captured {} UR part{}{}…", + parts.len(), + if parts.len() == 1 { "" } else { "s" }, + of_total + )); + decode_if_complete(&parts).transpose() + }, + ) .await; spinner.finish_and_clear(); @@ -334,27 +485,90 @@ mod camera { use sp_core::crypto::{AccountId32, Ss58Codec}; let spinner = scan_spinner("📷 Point the camera at the address QR…"); + let status = spinner.clone(); let progress = spinner.clone(); - let result = run_scan(index, Duration::from_secs(120), move |content| { - let trimmed = content.trim(); - match AccountId32::from_ss58check_with_version(trimmed) { - Ok((_, format)) if format == crate::cli::address_format::quantus_ss58_format() => - Some(Ok(trimmed.to_string())), - _ => { - progress.set_message(format!( - "📷 Saw a QR that is not a Quantus address ({}…) — keep looking…", - trimmed.chars().take(16).collect::() - )); - None - }, - } - }) + let result = run_scan( + index, + Duration::from_secs(120), + move |msg| status.set_message(msg.to_string()), + move |content| { + let trimmed = content.trim(); + match AccountId32::from_ss58check_with_version(trimmed) { + Ok((_, format)) + if format == crate::cli::address_format::quantus_ss58_format() => + Some(Ok(trimmed.to_string())), + _ => { + progress.set_message(format!( + "📷 Saw a QR that is not a Quantus address ({}…) — keep looking…", + trimmed.chars().take(16).collect::() + )); + None + }, + } + }, + ) .await; spinner.finish_and_clear(); result } + + #[cfg(test)] + mod tests { + use super::*; + + fn render_qr_luma(data: &str, invert: bool) -> (usize, usize, Vec) { + use qrcode::{types::Color, QrCode}; + let code = QrCode::new(data.as_bytes()).expect("qr"); + const SCALE: usize = 8; + let qz = 4 * SCALE; + let inner = code.width() * SCALE; + let w = inner + qz * 2; + let bg = if invert { 0u8 } else { 255u8 }; + let mut luma = vec![bg; w * w]; + for y in 0..code.width() { + for x in 0..code.width() { + let dark = code[(x, y)] == Color::Dark; + let v = match (dark, invert) { + (true, false) | (false, true) => 0u8, + _ => 255, + }; + for dy in 0..SCALE { + for dx in 0..SCALE { + luma[(y * SCALE + qz + dy) * w + (x * SCALE + qz + dx)] = v; + } + } + } + } + (w, w, luma) + } + + #[test] + fn decodes_standard_and_inverted_qr() { + let data = "qzkeicNBtW2AG2E7USjDcLzAL8d9WxTZnV2cbtXoDzWxzpHC2"; + let collect = |luma: &[u8], w: usize, h: usize| { + let mut found = Vec::new(); + feed_decoded(luma, w, h, |s| { + found.push(s.to_string()); + None::<()> + }); + found + }; + let (w, h, normal) = render_qr_luma(data, false); + assert!( + collect(&normal, w, h).iter().any(|s| s == data), + "standard polarity: {:?}", + collect(&normal, w, h) + ); + let (w, h, inverted) = render_qr_luma(data, true); + assert!( + collect(&inverted, w, h).iter().any(|s| s == data), + "inverted polarity (cold-wallet address QR): {:?}", + collect(&inverted, w, h) + ); + } + } } #[cfg(test)] From 27f33c1ec94404e127e51827be27cfc39c697369 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 13 Aug 2026 21:52:17 +0800 Subject: [PATCH 07/13] Create the camera preview on the macOS main thread minifb/AppKit aborted when the preview window was opened from tokio's blocking pool (NSMenu must be set on the main thread). Build the window on the block_on task and only push scaled frames from the capture loop. --- src/qr/scanner.rs | 110 +++++++++++++++++++++++++++------------------- 1 file changed, 65 insertions(+), 45 deletions(-) diff --git a/src/qr/scanner.rs b/src/qr/scanner.rs index 12a10c8..be49dfc 100644 --- a/src/qr/scanner.rs +++ b/src/qr/scanner.rs @@ -138,7 +138,7 @@ mod camera { collections::HashSet, sync::{ atomic::{AtomicBool, Ordering}, - mpsc, Arc, + mpsc, Arc, Mutex, }, time::Instant, }; @@ -273,10 +273,13 @@ mod camera { /// Grab frames at the camera's highest frame rate and decode each at native /// resolution. Cold-wallet UR animations run at 15–50 fps. + /// Preview pixels are published for the main-thread window; NSWindow must + /// not be created here (tokio blocking pool is not the macOS main thread). fn scan_with_camera( index: u32, timeout: Duration, stop: Arc, + preview: Arc>>>, mut on_status: impl FnMut(&str), mut sink: impl FnMut(&str) -> Option>, ) -> Result { @@ -308,29 +311,12 @@ mod camera { fmt.frame_rate() ); on_status(&format!( - "📷 Live {}x{} @ {} fps — opening preview…", + "📷 Live {}x{} @ {} fps — waiting for QR…", fmt.width(), fmt.height(), fmt.frame_rate() )); - let mut window = minifb::Window::new( - "Quantus camera — hold the QR in this view", - PREVIEW_W, - PREVIEW_H, - minifb::WindowOptions { resize: false, ..minifb::WindowOptions::default() }, - ) - .map_err(|e| { - crate::log_verbose!("📷 Preview window unavailable: {e}"); - e - }) - .ok(); - if window.is_some() { - crate::log_print!( - "🖼️ Camera preview window opened — aim the QR so it is sharp in that view" - ); - } - let deadline = Instant::now() + timeout; let mut frames: u64 = 0; let result = 'scan: loop { @@ -368,22 +354,14 @@ mod camera { )); } - if let Some(win) = window.as_mut() { - if !win.is_open() { - window = None; - } else { - let buf = rgb_preview_u32( - img.as_raw(), - img.width(), - img.height(), - PREVIEW_W, - PREVIEW_H, - ); - if let Err(e) = win.update_with_buffer(&buf, PREVIEW_W, PREVIEW_H) { - crate::log_verbose!("📷 Preview update failed: {e}"); - window = None; - } - } + if let Ok(mut slot) = preview.lock() { + *slot = Some(rgb_preview_u32( + img.as_raw(), + img.width(), + img.height(), + PREVIEW_W, + PREVIEW_H, + )); } let (w, h, luma) = rgb_to_luma(img.as_raw(), img.width(), img.height()); @@ -400,6 +378,8 @@ mod camera { } /// Drive the blocking camera loop from async code, aborting on Ctrl-C. + /// The preview window is created and pumped on this task (the tokio + /// `block_on` / process main thread) so macOS AppKit is not touched off-main. async fn run_scan( index: u32, timeout: Duration, @@ -408,18 +388,58 @@ mod camera { ) -> Result { let stop = Arc::new(AtomicBool::new(false)); let stop_flag = stop.clone(); - let handle = tokio::task::spawn_blocking(move || { - scan_with_camera(index, timeout, stop_flag, on_status, sink) + let preview = Arc::new(Mutex::new(None)); + let preview_cam = preview.clone(); + let mut window = minifb::Window::new( + "Quantus camera — hold the QR in this view", + PREVIEW_W, + PREVIEW_H, + minifb::WindowOptions { resize: false, ..minifb::WindowOptions::default() }, + ) + .map_err(|e| { + crate::log_verbose!("📷 Preview window unavailable: {e}"); + e + }) + .ok(); + if window.is_some() { + crate::log_print!( + "🖼️ Camera preview window opened — aim the QR so it is sharp in that view" + ); + } + + let mut handle = tokio::task::spawn_blocking(move || { + scan_with_camera(index, timeout, stop_flag, preview_cam, on_status, sink) }); - tokio::select! { - result = handle => result - .map_err(|e| QuantusError::Generic(format!("Camera task failed: {e}")))?, - _ = tokio::signal::ctrl_c() => { - stop.store(true, Ordering::Relaxed); - println!(); - Err(QuantusError::Generic("Aborted by user".to_string())) - }, + loop { + tokio::select! { + result = &mut handle => { + return result + .map_err(|e| QuantusError::Generic(format!("Camera task failed: {e}")))?; + }, + _ = tokio::time::sleep(Duration::from_millis(16)) => { + if let Some(win) = window.as_mut() { + if !win.is_open() { + window = None; + } else { + let frame = preview.lock().ok().and_then(|mut slot| slot.take()); + if let Some(buf) = frame { + if let Err(e) = win.update_with_buffer(&buf, PREVIEW_W, PREVIEW_H) { + crate::log_verbose!("📷 Preview update failed: {e}"); + window = None; + } + } else { + win.update(); + } + } + } + }, + _ = tokio::signal::ctrl_c() => { + stop.store(true, Ordering::Relaxed); + println!(); + return Err(QuantusError::Generic("Aborted by user".to_string())); + }, + } } } From 5579f9c59b2ae79d2e6e14074fec7243be768687 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 13 Aug 2026 21:58:52 +0800 Subject: [PATCH 08/13] Fix Mac camera NV12 decode and preview use-after-free Apple cameras deliver NV12; nokhwa tags that as YUYV so RGB conversion produced the doubled, posterized preview and unreadable QRs. Detect NV12 by buffer size and convert it as NV12. The preview also handed AppKit a pointer to a Vec that was dropped before the next paint, which flashed garbage. Keep the last frame alive until it is replaced. --- src/qr/scanner.rs | 82 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/src/qr/scanner.rs b/src/qr/scanner.rs index be49dfc..dbd2367 100644 --- a/src/qr/scanner.rs +++ b/src/qr/scanner.rs @@ -251,6 +251,38 @@ mod camera { None } + /// Mac cameras typically deliver NV12 (420 bi-planar). nokhwa mis-tags that + /// as YUYV, and YUYV→RGB on NV12 bytes produces the doubled/posterized mess + /// in the preview. Detect NV12 by buffer size and convert it correctly. + fn frame_to_rgb(frame: &nokhwa::Buffer) -> std::result::Result<(u32, u32, Vec), String> { + use nokhwa::{pixel_format::RgbFormat, utils::nv12_to_rgb}; + let res = frame.resolution(); + let w = res.width(); + let h = res.height(); + let raw = frame.buffer(); + let nv12_len = (w as usize).saturating_mul(h as usize).saturating_mul(3) / 2; + let rgb = if raw.len() == nv12_len { + nv12_to_rgb(res, raw, false).map_err(|e| format!("{e}"))? + } else { + frame + .decode_image::() + .map(|img| img.into_raw()) + .map_err(|e| format!("{e}"))? + }; + let expected = (w as usize).saturating_mul(h as usize).saturating_mul(3); + if rgb.len() != expected { + return Err(format!( + "RGB size {} != {}x{}x3 (src {:?}, raw {})", + rgb.len(), + w, + h, + frame.source_frame_format(), + raw.len() + )); + } + Ok((w, h, rgb)) + } + fn rgb_preview_u32(raw: &[u8], sw: u32, sh: u32, dw: usize, dh: usize) -> Vec { let mut out = vec![0u32; dw * dh]; if sw == 0 || sh == 0 { @@ -336,35 +368,35 @@ mod camera { continue; }, }; - let img = match frame.decode_image::() { - Ok(img) => img, + let (img_w, img_h, rgb) = match frame_to_rgb(&frame) { + Ok(rgb) => rgb, Err(e) => { crate::log_verbose!("📷 Failed to decode camera frame: {e}"); continue; }, }; frames += 1; + if frames == 1 { + crate::log_verbose!( + "📷 Frame format {:?} raw {} bytes → RGB {}x{}", + frame.source_frame_format(), + frame.buffer().len(), + img_w, + img_h + ); + } if frames == 1 || frames % 15 == 0 { on_status(&format!( - "📷 Live {}x{} @ {} fps — {} frames, no QR yet", - img.width(), - img.height(), - fmt.frame_rate(), - frames + "📷 Live {img_w}x{img_h} @ {} fps — {frames} frames, no QR yet", + fmt.frame_rate() )); } if let Ok(mut slot) = preview.lock() { - *slot = Some(rgb_preview_u32( - img.as_raw(), - img.width(), - img.height(), - PREVIEW_W, - PREVIEW_H, - )); + *slot = Some(rgb_preview_u32(&rgb, img_w, img_h, PREVIEW_W, PREVIEW_H)); } - let (w, h, luma) = rgb_to_luma(img.as_raw(), img.width(), img.height()); + let (w, h, luma) = rgb_to_luma(&rgb, img_w, img_h); if luma.len() != w.saturating_mul(h) { continue; } @@ -410,6 +442,9 @@ mod camera { let mut handle = tokio::task::spawn_blocking(move || { scan_with_camera(index, timeout, stop_flag, preview_cam, on_status, sink) }); + // AppKit draws asynchronously; keep the last buffer alive so the view + // never paints a freed pointer (that flashed as garbage). + let mut displayed = vec![0u32; PREVIEW_W * PREVIEW_H]; loop { tokio::select! { @@ -422,14 +457,15 @@ mod camera { if !win.is_open() { window = None; } else { - let frame = preview.lock().ok().and_then(|mut slot| slot.take()); - if let Some(buf) = frame { - if let Err(e) = win.update_with_buffer(&buf, PREVIEW_W, PREVIEW_H) { - crate::log_verbose!("📷 Preview update failed: {e}"); - window = None; - } - } else { - win.update(); + if let Some(buf) = preview.lock().ok().and_then(|mut slot| slot.take()) + { + displayed = buf; + } + if let Err(e) = + win.update_with_buffer(&displayed, PREVIEW_W, PREVIEW_H) + { + crate::log_verbose!("📷 Preview update failed: {e}"); + window = None; } } } From efdc311fd6a07132096b7cce4470818a4852ecea Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 13 Aug 2026 22:27:49 +0800 Subject: [PATCH 09/13] Decode camera QRs with rxing instead of rqrr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan pipeline was verified end to end: frames arrive as real YUYV, luma is correct, and rqrr decodes clean synthetic QRs — but rqrr gives up under the defocus a fixed-focus Mac camera produces at phone distance, so nothing ever scanned. rxing's adaptive binarizer decodes those blurred frames (and is ~4x faster per 1080p pass with the QR-only hint). Defocus regression test added at a blur level rqrr fails. --- Cargo.lock | 186 +++++++++++++++++++++++++++++++++++----------- Cargo.toml | 8 +- src/qr/scanner.rs | 67 ++++++++++++----- 3 files changed, 195 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 353fdac..6d99195 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -757,6 +757,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitcoin-private" version = "0.1.0" @@ -1026,6 +1041,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1124,6 +1149,15 @@ dependencies = [ "objc", ] +[[package]] +name = "codepage-437" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40c1169585d8d08e5675a39f2fc056cd19a258fc4cba5e3bbf4a9c1026de535" +dependencies = [ + "csv", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1474,6 +1508,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1946,6 +2001,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastbloom" version = "0.17.0" @@ -2258,34 +2324,6 @@ dependencies = [ "slab", ] -[[package]] -name = "g2gen" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" -dependencies = [ - "g2poly", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "g2p" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" -dependencies = [ - "g2gen", - "g2poly", -] - -[[package]] -name = "g2poly" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" - [[package]] name = "generic-array" version = "0.14.9" @@ -3304,7 +3342,7 @@ dependencies = [ "bitcoin_hashes 0.12.0", "crc", "minicbor", - "phf", + "phf 0.11.3", "rand_xoshiro", ] @@ -3695,6 +3733,15 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +dependencies = [ + "serde", +] + [[package]] name = "nanorand" version = "0.7.0" @@ -4181,7 +4228,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", ] [[package]] @@ -4190,7 +4246,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.6", ] @@ -4201,7 +4257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.119", @@ -4216,6 +4272,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -4513,7 +4578,7 @@ dependencies = [ "itertools 0.10.5", "lazy_static", "log", - "multimap", + "multimap 0.8.3", "petgraph", "prettyplease 0.1.25", "prost", @@ -4851,8 +4916,8 @@ dependencies = [ "rand 0.9.5", "reqwest 0.12.28", "rpassword", - "rqrr", "rustls-webpki", + "rxing", "self-replace", "self_update", "serde", @@ -5299,17 +5364,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rqrr" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48eaf9c75f2a8f231b09036c115a45a9845313f7faa6a39fa45a2a2bd06a27c7" -dependencies = [ - "g2p", - "image", - "lru", -] - [[package]] name = "rtoolbox" version = "0.0.5" @@ -5489,6 +5543,38 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +[[package]] +name = "rxing" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc56bde1cf7f3c9cc908ef2670efede68bbad283acb6674b709aa3befdab451d" +dependencies = [ + "chrono", + "chrono-tz", + "codepage-437", + "encoding_rs", + "fancy-regex", + "multimap 0.10.1", + "num", + "once_cell", + "regex", + "rxing-one-d-proc-derive", + "thiserror 2.0.18", + "unicode-segmentation", + "uriparse", + "urlencoding", +] + +[[package]] +name = "rxing-one-d-proc-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7e5f136594d614d0d8e44f63508abd25c8fe0280655bb1d477b177bf718d0a4" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "ryu" version = "1.0.23" @@ -7590,6 +7676,16 @@ dependencies = [ "log", ] +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + [[package]] name = "url" version = "2.5.8" diff --git a/Cargo.toml b/Cargo.toml index 32205d7..2e4df32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ path = "src/main.rs" default = ["camera"] # Camera capture + QR decoding for cold-wallet signing. Disable for headless # builds; UR input via --cold-response-in keeps working without it. -camera = ["dep:minifb", "dep:nokhwa", "dep:rqrr"] +camera = ["dep:minifb", "dep:nokhwa", "dep:rxing"] [dependencies] # CLI and async runtime @@ -35,10 +35,12 @@ tokio = { version = "1.46", features = ["full"] } quantus_ur = { git = "https://github.com/Quantus-Network/quantus_ur.git", tag = "1.6.0" } # Terminal QR rendering (string renderers only; image/svg not needed) qrcode = { version = "0.14", default-features = false } -# Camera capture (AVFoundation / MSMF / V4L2) + pure-Rust QR decoding +# Camera capture (AVFoundation / MSMF / V4L2) + pure-Rust QR decoding. +# rxing (ZXing port) over rqrr: its adaptive binarizer decodes the defocused +# frames a fixed-focus Mac camera produces at phone-scanning distance. minifb = { version = "0.28", optional = true } nokhwa = { version = "0.10", features = ["input-native"], optional = true } -rqrr = { version = "0.8", optional = true } +rxing = { version = "0.8", default-features = false, features = ["encoding_rs"], optional = true } # Serialization and configuration serde = { version = "1.0", features = ["derive"] } diff --git a/src/qr/scanner.rs b/src/qr/scanner.rs index dbd2367..b0c6efb 100644 --- a/src/qr/scanner.rs +++ b/src/qr/scanner.rs @@ -208,26 +208,19 @@ mod camera { if w < 21 || h < 21 || luma.len() != w.saturating_mul(h) { return None; } + let buf: Vec = + if invert { luma.iter().map(|v| 255 - v).collect() } else { luma.to_vec() }; let decoded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(w, h, |x, y| { - let v = luma[y * w + x]; - if invert { - 255 - v - } else { - v - } - }); - let mut found = Vec::new(); - for grid in prepared.detect_grids() { - if let Ok((_meta, content)) = grid.decode() { - found.push(content); - } - } - found + let mut hints = rxing::DecodeHints::default(); + hints.PossibleFormats = Some(HashSet::from([rxing::BarcodeFormat::QR_CODE])); + rxing::helpers::detect_multiple_in_luma_with_hints( + buf, w as u32, h as u32, &mut hints, + ) + .unwrap_or_default() })) .unwrap_or_default(); - for content in decoded { - if let Some(hit) = sink(&content) { + for result in decoded { + if let Some(hit) = sink(result.getText()) { return Some(hit); } } @@ -357,7 +350,7 @@ mod camera { } if Instant::now() >= deadline { break Err(QuantusError::Generic( - "Timed out scanning. Keep the QR sharp in the preview window (15-25 cm, high brightness), or use --cold-response-in / --address".to_string(), + "Timed out scanning. Keep the QR large and steady in the preview window (high screen brightness helps), or use --cold-response-in / --address".to_string(), )); } @@ -600,6 +593,44 @@ mod camera { (w, w, luma) } + fn box_blur(luma: &[u8], w: usize, h: usize, radius: usize) -> Vec { + let r = radius as i32; + let mut out = vec![0u8; w * h]; + for y in 0..h as i32 { + for x in 0..w as i32 { + let mut sum = 0u32; + let mut cnt = 0u32; + for dy in -r..=r { + for dx in -r..=r { + let (nx, ny) = (x + dx, y + dy); + if nx >= 0 && ny >= 0 && nx < w as i32 && ny < h as i32 { + sum += u32::from(luma[(ny as usize) * w + nx as usize]); + cnt += 1; + } + } + } + out[(y as usize) * w + x as usize] = (sum / cnt) as u8; + } + } + out + } + + /// A fixed-focus Mac camera at phone-scanning distance defocuses the QR + /// well past what quirc-style decoders tolerate; this blur level is the + /// regression guard for that (rqrr failed it, rxing decodes it). + #[test] + fn decodes_defocused_qr() { + let data = "qzkeicNBtW2AG2E7USjDcLzAL8d9WxTZnV2cbtXoDzWxzpHC2"; + let (w, h, luma) = render_qr_luma(data, false); + let blurred = box_blur(&luma, w, h, 5); + let mut found = Vec::new(); + feed_decoded(&blurred, w, h, |s| { + found.push(s.to_string()); + None::<()> + }); + assert!(found.iter().any(|s| s == data), "defocused QR: {found:?}"); + } + #[test] fn decodes_standard_and_inverted_qr() { let data = "qzkeicNBtW2AG2E7USjDcLzAL8d9WxTZnV2cbtXoDzWxzpHC2"; From 9d4030bd9348a69055fd8ff0e0e46b08ed06710e Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 13 Aug 2026 22:52:46 +0800 Subject: [PATCH 10/13] Recover UR scans from stale streams and keep scan logs linear The fountain decoder locks onto the first captured part's stream; if the cold wallet was still animating the previous signature when the camera opened, every part of the new animation was silently rejected and the scan never completed despite capturing everything. Completion now retries on every suffix of the capture, so dropping the oldest parts recovers the newest consistent stream (regression-tested). Scan output is now linear: each captured part prints a persistent 'Part 7/25' line, the live frame counter no longer overwrites capture progress, and a failed scan reports exactly which fragments are missing (or that a stale stream poisoned an otherwise complete capture). --- src/qr/scanner.rs | 132 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 111 insertions(+), 21 deletions(-) diff --git a/src/qr/scanner.rs b/src/qr/scanner.rs index b0c6efb..8e7c906 100644 --- a/src/qr/scanner.rs +++ b/src/qr/scanner.rs @@ -46,6 +46,21 @@ fn decode_if_complete(parts: &[String]) -> Result>> { Ok(Some(bytes)) } +/// The fountain decoder locks onto the first part's stream (seqLen, checksum) +/// and silently rejects every part of a different stream. A stale part +/// captured first — e.g. the previous signature still animating on the device +/// — would wedge the scan forever even though all current parts arrive. Retry +/// completion on every suffix so dropping the oldest parts recovers the newest +/// consistent stream. +fn decode_any_suffix(parts: &[String]) -> Result>> { + for start in 0..parts.len() { + if let Some(bytes) = decode_if_complete(&parts[start..])? { + return Ok(Some(bytes)); + } + } + Ok(None) +} + /// Collect UR parts from `source` until the fountain decoder reports a complete /// payload, then decode it. pub async fn scan_ur(source: &UrSource, timeout: Duration) -> Result> { @@ -70,7 +85,7 @@ async fn scan_ur_from_file(path: &std::path::Path, timeout: Duration) -> Result< .filter(|l| is_ur_line(l)) .map(|l| l.trim().to_string()) .collect(); - if let Some(bytes) = decode_if_complete(&parts)? { + if let Some(bytes) = decode_any_suffix(&parts)? { return Ok(bytes); } } @@ -102,7 +117,7 @@ async fn scan_ur_from_stdin() -> Result> { let line = line?; if is_ur_line(&line) { parts.push(line.trim().to_string()); - if let Some(bytes) = decode_if_complete(&parts)? { + if let Some(bytes) = decode_any_suffix(&parts)? { return Ok(bytes); } } @@ -380,7 +395,7 @@ mod camera { } if frames == 1 || frames % 15 == 0 { on_status(&format!( - "📷 Live {img_w}x{img_h} @ {} fps — {frames} frames, no QR yet", + "📷 Live {img_w}x{img_h} @ {} fps — {frames} frames scanned", fmt.frame_rate() )); } @@ -486,46 +501,104 @@ mod camera { pb } + #[derive(Default)] + struct UrCapture { + parts: Vec, + seen: HashSet, + seqs: HashSet, + total: Option, + } + /// Scan an animated (or static) UR from the camera until the fountain - /// decoder has a complete payload. + /// decoder has a complete payload. Every captured part is printed as a + /// persistent line (the live status stays on the spinner and never + /// overwrites capture progress), and an incomplete scan reports exactly + /// which fragments are still missing. pub(super) async fn scan_ur_with_camera(index: u32, timeout: Duration) -> Result> { let spinner = scan_spinner("📷 Point the camera at the QR on the cold wallet…"); let status = spinner.clone(); let progress = spinner.clone(); - - let mut seen: HashSet = HashSet::new(); - let mut parts: Vec = Vec::new(); - let mut total: Option = None; + let state = Arc::new(Mutex::new(UrCapture::default())); + let status_state = state.clone(); + let sink_state = state.clone(); let result = run_scan( index, timeout, - move |msg| status.set_message(msg.to_string()), + move |msg| { + let no_parts_yet = + status_state.lock().map(|st| st.parts.is_empty()).unwrap_or(false); + if no_parts_yet { + status.set_message(msg.to_string()); + } + }, move |content| { if !is_ur_line(content) { return None; } let part = content.trim().to_string(); - if !seen.insert(part.clone()) { + let mut st = sink_state.lock().expect("UR capture state poisoned"); + if !st.seen.insert(part.clone()) { return None; } - if let Some((_, t)) = ur_progress(&part) { - total = Some(t); + let seq = ur_progress(&part); + if let Some((s, t)) = seq { + st.total = Some(t); + st.seqs.insert(s); } - parts.push(part); - let of_total = total.map(|t| format!(" (sequence of {t})")).unwrap_or_default(); - progress.set_message(format!( - "📷 Captured {} UR part{}{}…", - parts.len(), - if parts.len() == 1 { "" } else { "s" }, - of_total - )); - decode_if_complete(&parts).transpose() + st.parts.push(part); + let line = match (seq, st.total) { + (Some((s, _)), Some(t)) => + format!("📥 Part {s}/{t} — {}/{t} collected", st.seqs.len()), + _ => format!("📥 UR part captured ({} total)", st.parts.len()), + }; + progress.println(line); + progress.set_message(match st.total { + Some(t) => + format!("📷 Collecting signature QR — {}/{t} parts…", st.seqs.len()), + None => + format!("📷 Collecting signature QR — {} parts…", st.parts.len()), + }); + decode_any_suffix(&st.parts).transpose() }, ) .await; spinner.finish_and_clear(); + let st = state.lock().expect("UR capture state poisoned"); + if result.is_err() && !st.parts.is_empty() { + match st.total { + Some(t) => { + let missing: Vec = (1..=t) + .filter(|s| !st.seqs.contains(s)) + .map(|s| s.to_string()) + .collect(); + if missing.is_empty() { + crate::log_print!( + "⚠️ All {t} fragments were captured but never formed a consistent payload — was an older signature QR still animating when the scan started?" + ); + } else { + crate::log_print!( + "⚠️ Scan ended with {}/{t} parts — missing: {}", + st.seqs.len(), + if missing.len() > 20 { + format!( + "{} … ({} more)", + missing[..20].join(", "), + missing.len() - 20 + ) + } else { + missing.join(", ") + } + ); + } + }, + None => crate::log_print!( + "⚠️ Scan ended with {} parts captured but an incomplete payload", + st.parts.len() + ), + } + } result } @@ -684,6 +757,23 @@ mod tests { assert!(!is_ur_line("")); } + #[test] + fn test_decode_any_suffix_recovers_from_stale_stream() { + let old_payload: Vec = (0..7500u32).map(|i| (i * 31 % 249) as u8).collect(); + let new_payload: Vec = (0..7500u32).map(|i| (i * 37 % 251) as u8).collect(); + let old_parts = quantus_ur::encode_bytes(&old_payload).unwrap(); + let new_parts = quantus_ur::encode_bytes(&new_payload).unwrap(); + + let mut captured = old_parts[..3].to_vec(); + captured.extend(new_parts.iter().cloned()); + assert!( + !quantus_ur::is_complete(&captured), + "stale-first capture must wedge the plain decoder for this test to mean anything" + ); + let bytes = decode_any_suffix(&captured).unwrap().expect("suffix recovery"); + assert_eq!(bytes, new_payload); + } + #[tokio::test] async fn test_scan_ur_from_file_roundtrip() { let dir = tempfile::tempdir().unwrap(); From 44804efa5493c88b39486f762a9fd82987f6f8a5 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 13 Aug 2026 23:01:08 +0800 Subject: [PATCH 11/13] fmt --- src/qr/scanner.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/qr/scanner.rs b/src/qr/scanner.rs index 8e7c906..4cc7e2e 100644 --- a/src/qr/scanner.rs +++ b/src/qr/scanner.rs @@ -554,10 +554,8 @@ mod camera { }; progress.println(line); progress.set_message(match st.total { - Some(t) => - format!("📷 Collecting signature QR — {}/{t} parts…", st.seqs.len()), - None => - format!("📷 Collecting signature QR — {} parts…", st.parts.len()), + Some(t) => format!("📷 Collecting signature QR — {}/{t} parts…", st.seqs.len()), + None => format!("📷 Collecting signature QR — {} parts…", st.parts.len()), }); decode_any_suffix(&st.parts).transpose() }, @@ -569,10 +567,8 @@ mod camera { if result.is_err() && !st.parts.is_empty() { match st.total { Some(t) => { - let missing: Vec = (1..=t) - .filter(|s| !st.seqs.contains(s)) - .map(|s| s.to_string()) - .collect(); + let missing: Vec = + (1..=t).filter(|s| !st.seqs.contains(s)).map(|s| s.to_string()).collect(); if missing.is_empty() { crate::log_print!( "⚠️ All {t} fragments were captured but never formed a consistent payload — was an older signature QR still animating when the scan started?" From 33b1f2b26f9e3b5708d80482a233f21a2d7c3860 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 14 Aug 2026 16:52:07 +0800 Subject: [PATCH 12/13] Address review: creation race, stale file sessions, scanner panics, clippy - create_cold_wallet now takes the per-name creation lock and saves with the atomic no-replace save_new_wallet, like every other creation path; a concurrent import can no longer replace an existing wallet file. Regression covers both the racing-creators case and that an existing hot wallet's encrypted keys survive an import attempt untouched. - The file transport gets per-roundtrip freshness: scan_ur_from_file consumes the file after a successful read (covers both the CLI response read and the simulator request read), and cold signing removes any response file that predates its own request. Regression runs two consecutive exchanges over the same configured path. - is_ur_line compares raw bytes so untrusted input starting with multi-byte characters can no longer panic the scanner on a non-UTF-8 slice boundary. - Clippy gate clean again: DecodeHints built with struct-update syntax, frames.is_multiple_of(15), and an unused import dropped from the basic_usage example. --- examples/basic_usage.rs | 1 - src/cli/cold_signing.rs | 11 +++++++ src/qr/scanner.rs | 67 ++++++++++++++++++++++++++++++++++++----- src/wallet/mod.rs | 37 ++++++++++++++++++++++- 4 files changed, 107 insertions(+), 9 deletions(-) diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index 31669e3..9208f19 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -10,7 +10,6 @@ use quantus_cli::{ chain::client::QuantusClient, cli::common::ExecutionMode, error::Result, transfer, wallet::WalletManager, AccountId32, }; -use sp_core::crypto::Ss58Codec; #[tokio::main] async fn main() -> Result<()> { diff --git a/src/cli/cold_signing.rs b/src/cli/cold_signing.rs index a61e52d..5c55f85 100644 --- a/src/cli/cold_signing.rs +++ b/src/cli/cold_signing.rs @@ -331,6 +331,17 @@ pub async fn sign_and_submit_cold( let parts = quantus_ur::encode_bytes(&raw_payload) .map_err(|e| QuantusError::Generic(format!("Failed to UR-encode payload: {e:?}")))?; + // A response file existing before this request is handed out is necessarily + // from an earlier session (the response depends on this request); together + // with consume-on-read in the scanner this keeps every roundtrip fresh. + if let Some(UrSource::File(path)) = &io.response_in { + match std::fs::remove_file(path) { + Ok(()) => log_print!("🧹 Removed stale response file {}", path.display()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}, + Err(e) => return Err(e.into()), + } + } + if let Some(path) = &io.request_out { let content = parts.join("\n") + "\n"; std::fs::write(path, content)?; diff --git a/src/qr/scanner.rs b/src/qr/scanner.rs index 4cc7e2e..3a79ed8 100644 --- a/src/qr/scanner.rs +++ b/src/qr/scanner.rs @@ -15,7 +15,9 @@ pub enum UrSource { #[cfg(feature = "camera")] Camera { index: u32 }, /// Read UR parts (one per line) from a file, polling until it exists and - /// holds a complete set. Enables scripted/headless flows. + /// holds a complete set. The file is consumed (deleted) after a successful + /// read so a later roundtrip on the same path can never pick up a stale + /// session. Enables scripted/headless flows. File(PathBuf), /// Read UR parts from stdin, one per line, until complete or EOF. StdinLines, @@ -33,8 +35,10 @@ pub(crate) fn ur_progress(part: &str) -> Option<(u32, u32)> { } fn is_ur_line(line: &str) -> bool { - let trimmed = line.trim(); - trimmed.len() > 3 && trimmed[..3].eq_ignore_ascii_case("ur:") + // Byte-wise prefix check: QR/file input is untrusted and may start with + // multi-byte characters, so `trimmed[..3]` could slice off a char boundary. + let trimmed = line.trim().as_bytes(); + trimmed.len() > 3 && trimmed[..3].eq_ignore_ascii_case(b"ur:") } fn decode_if_complete(parts: &[String]) -> Result>> { @@ -75,7 +79,9 @@ pub async fn scan_ur(source: &UrSource, timeout: Duration) -> Result> { } } -/// Poll `path` until it contains a complete set of UR parts (one per line). +/// Poll `path` until it contains a complete set of UR parts (one per line), +/// then consume the file so the same path cannot serve a stale session to the +/// next roundtrip. async fn scan_ur_from_file(path: &std::path::Path, timeout: Duration) -> Result> { let deadline = tokio::time::Instant::now() + timeout; loop { @@ -86,6 +92,12 @@ async fn scan_ur_from_file(path: &std::path::Path, timeout: Duration) -> Result< .map(|l| l.trim().to_string()) .collect(); if let Some(bytes) = decode_any_suffix(&parts)? { + if let Err(e) = std::fs::remove_file(path) { + crate::log_print!( + "⚠️ Could not consume UR file {}: {e} — remove it manually before the next roundtrip", + path.display() + ); + } return Ok(bytes); } } @@ -226,8 +238,10 @@ mod camera { let buf: Vec = if invert { luma.iter().map(|v| 255 - v).collect() } else { luma.to_vec() }; let decoded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let mut hints = rxing::DecodeHints::default(); - hints.PossibleFormats = Some(HashSet::from([rxing::BarcodeFormat::QR_CODE])); + let mut hints = rxing::DecodeHints { + PossibleFormats: Some(HashSet::from([rxing::BarcodeFormat::QR_CODE])), + ..Default::default() + }; rxing::helpers::detect_multiple_in_luma_with_hints( buf, w as u32, h as u32, &mut hints, ) @@ -393,7 +407,7 @@ mod camera { img_h ); } - if frames == 1 || frames % 15 == 0 { + if frames == 1 || frames.is_multiple_of(15) { on_status(&format!( "📷 Live {img_w}x{img_h} @ {} fps — {frames} frames scanned", fmt.frame_rate() @@ -753,6 +767,15 @@ mod tests { assert!(!is_ur_line("")); } + /// Untrusted QR/file input may start with multi-byte characters; a byte + /// slice off a UTF-8 boundary must not panic the scanner. + #[test] + fn test_is_ur_line_non_ascii_no_panic() { + assert!(!is_ur_line("ééééé")); + assert!(!is_ur_line("🧊🧊")); + assert!(!is_ur_line("日本語のテキスト")); + } + #[test] fn test_decode_any_suffix_recovers_from_stale_stream() { let old_payload: Vec = (0..7500u32).map(|i| (i * 31 % 249) as u8).collect(); @@ -783,6 +806,36 @@ mod tests { assert_eq!(decoded, payload); } + /// Two consecutive exchanges over the same configured path: the first read + /// consumes the file, and the second must wait for a fresh session instead + /// of replaying the stale one. + #[tokio::test] + async fn test_scan_ur_from_file_consumes_file_between_sessions() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("response.ur"); + + let payload_a: Vec = (0..7219u32).map(|i| (i % 251) as u8).collect(); + let parts_a = quantus_ur::encode_bytes(&payload_a).unwrap(); + std::fs::write(&path, parts_a.join("\n")).unwrap(); + let got = scan_ur(&UrSource::File(path.clone()), Duration::from_secs(5)).await.unwrap(); + assert_eq!(got, payload_a); + assert!(!path.exists(), "file must be consumed after a successful read"); + + let payload_b: Vec = (0..7219u32).map(|i| ((i * 3) % 251) as u8).collect(); + let parts_b = quantus_ur::encode_bytes(&payload_b).unwrap(); + let writer = { + let path = path.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(300)).await; + std::fs::write(&path, parts_b.join("\n")).unwrap(); + }) + }; + let got = scan_ur(&UrSource::File(path.clone()), Duration::from_secs(5)).await.unwrap(); + assert_eq!(got, payload_b, "second exchange must return the fresh session"); + writer.await.unwrap(); + assert!(!path.exists()); + } + #[tokio::test] async fn test_scan_ur_from_file_times_out() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index aeb0cc2..1083b47 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -269,6 +269,7 @@ impl WalletManager { use sp_core::crypto::{AccountId32, Ss58Codec}; let keystore = Keystore::new(&self.wallets_dir); + let _create_guard = keystore.lock_wallet_create(name)?; if keystore.load_wallet(name)?.is_some() { return Err(WalletError::AlreadyExists.into()); } @@ -288,7 +289,7 @@ impl WalletManager { } let encrypted_wallet = keystore::EncryptedWallet::new_cold(name, address.trim()); - keystore.save_wallet(&encrypted_wallet)?; + keystore.save_new_wallet(&encrypted_wallet)?; Ok(WalletInfo { name: name.to_string(), @@ -1550,6 +1551,40 @@ mod tests { )); } + /// Cold import must never replace a wallet created concurrently: the + /// creation lock plus the no-replace save make exactly one creator win, + /// and an existing wallet's file (with its encrypted keys) stays intact. + #[tokio::test] + async fn test_cold_import_cannot_replace_concurrent_creation() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let address = cold_test_address(); + + wallet_manager.create_wallet("victim", Some("pw")).await.unwrap(); + let file = wallet_manager.wallets_dir.join("victim.json"); + let before = fs::read_to_string(&file).unwrap(); + assert!(matches!( + wallet_manager.create_cold_wallet("victim", &address), + Err(crate::error::QuantusError::Wallet(WalletError::AlreadyExists)) + )); + assert_eq!( + fs::read_to_string(&file).unwrap(), + before, + "cold import must not touch the existing wallet file" + ); + wallet_manager + .load_wallet("victim", "pw") + .expect("hot wallet keys must survive"); + + let outcomes: Vec = std::thread::scope(|s| { + let handles: Vec<_> = (0..8) + .map(|_| s.spawn(|| wallet_manager.create_cold_wallet("racer", &address).is_ok())) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + assert_eq!(outcomes.iter().filter(|&&ok| ok).count(), 1, "exactly one creator may win"); + assert_eq!(wallet_manager.wallet_type("racer").unwrap(), Some(keystore::WalletType::Cold)); + } + #[test] fn test_legacy_wallet_json_defaults_to_hot() { // Files written before wallet_type existed must deserialize as Hot From 38b51ab74f2dc5cf5d429db193c67d2e2ac0f920 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 14 Aug 2026 17:30:55 +0800 Subject: [PATCH 13/13] Prefer the newest complete UR stream and store canonical cold addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decode_any_suffix iterated oldest-first, so a capture holding a complete stale signature ahead of the current one returned the stale payload — the opposite of the recovery it claimed. Iterate shortest suffix first so the newest complete stream always wins; regression extended with the complete-stale + complete-new case. create_cold_wallet now stores the canonical SS58 re-encoding rather than the pasted string, which load_wallet would reject if the input was a valid but non-canonical encoding. Also un-hide --cold-request-out / --cold-response-in now that README documents them. --- src/main.rs | 4 ++-- src/qr/scanner.rs | 14 +++++++++++--- src/wallet/mod.rs | 9 +++++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index e9b10ca..6b59d5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,12 +55,12 @@ struct Cli { wait_for_transaction: bool, /// (cold wallets) Also write the sign-request UR parts to this file - #[arg(long, global = true, hide = true)] + #[arg(long, global = true)] cold_request_out: Option, /// (cold wallets) Read the signature response UR parts from this file, or "-" for stdin, /// instead of scanning with the camera - #[arg(long, global = true, hide = true)] + #[arg(long, global = true)] cold_response_in: Option, /// Camera device index for cold-wallet QR scanning diff --git a/src/qr/scanner.rs b/src/qr/scanner.rs index 3a79ed8..df91314 100644 --- a/src/qr/scanner.rs +++ b/src/qr/scanner.rs @@ -54,10 +54,10 @@ fn decode_if_complete(parts: &[String]) -> Result>> { /// and silently rejects every part of a different stream. A stale part /// captured first — e.g. the previous signature still animating on the device /// — would wedge the scan forever even though all current parts arrive. Retry -/// completion on every suffix so dropping the oldest parts recovers the newest -/// consistent stream. +/// completion on every suffix, shortest (newest) first: when both a stale and +/// a current stream are complete in the capture, the newest must win. fn decode_any_suffix(parts: &[String]) -> Result>> { - for start in 0..parts.len() { + for start in (0..parts.len()).rev() { if let Some(bytes) = decode_if_complete(&parts[start..])? { return Ok(Some(bytes)); } @@ -791,6 +791,14 @@ mod tests { ); let bytes = decode_any_suffix(&captured).unwrap().expect("suffix recovery"); assert_eq!(bytes, new_payload); + + // A COMPLETE stale stream followed by a complete current one: the + // newest stream must win, not the first complete suffix. + let mut captured = old_parts.clone(); + captured.extend(new_parts.iter().cloned()); + assert!(quantus_ur::is_complete(&captured[..old_parts.len()])); + let bytes = decode_any_suffix(&captured).unwrap().expect("both streams complete"); + assert_eq!(bytes, new_payload, "newest complete stream must win over the stale one"); } #[tokio::test] diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1083b47..662558b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -274,7 +274,7 @@ impl WalletManager { return Err(WalletError::AlreadyExists.into()); } - let (_, format) = + let (account, format) = AccountId32::from_ss58check_with_version(address.trim()).map_err(|_| { crate::error::QuantusError::Generic(format!( "'{}' is not a valid SS58 address", @@ -288,7 +288,12 @@ impl WalletManager { ))); } - let encrypted_wallet = keystore::EncryptedWallet::new_cold(name, address.trim()); + // Store the canonical re-encoding: load_wallet rejects non-canonical + // SS58, so persisting the pasted string verbatim could create a wallet + // that can never be loaded. + let canonical = + account.to_ss58check_with_version(crate::cli::address_format::quantus_ss58_format()); + let encrypted_wallet = keystore::EncryptedWallet::new_cold(name, &canonical); keystore.save_new_wallet(&encrypted_wallet)?; Ok(WalletInfo {