From cbf0553afeb331104eb8527dbe7d4a8bdaac5c47 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 27 Jul 2026 16:00:26 +0800 Subject: [PATCH] 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); + } }