diff --git a/Cargo.lock b/Cargo.lock index 833f513..6d99195 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", @@ -537,13 +543,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", ] @@ -582,10 +588,10 @@ dependencies = [ "async-signal", "async-task", "blocking", - "cfg-if", + "cfg-if 1.0.4", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -597,10 +603,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", @@ -671,7 +677,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", @@ -714,13 +720,36 @@ 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", @@ -728,6 +757,36 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitcoin-private" +version = "0.1.0" +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" @@ -800,11 +859,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" @@ -855,6 +920,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ + "sha2 0.10.9", "tinyvec", ] @@ -870,12 +936,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" @@ -891,7 +969,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -900,6 +978,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" @@ -918,7 +1011,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", ] @@ -929,7 +1022,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", ] @@ -948,6 +1041,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -958,6 +1061,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" @@ -986,7 +1100,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", @@ -1007,6 +1121,43 @@ dependencies = [ "cc", ] +[[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 = "codepage-437" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40c1169585d8d08e5675a39f2fc056cd19a258fc4cba5e3bbf4a9c1026de535" +dependencies = [ + "csv", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1059,6 +1210,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if 1.0.4", + "wasm-bindgen", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1136,13 +1297,23 @@ 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.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] @@ -1152,16 +1323,70 @@ 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" @@ -1180,13 +1405,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]] @@ -1268,6 +1508,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1283,7 +1544,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", @@ -1339,6 +1600,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" @@ -1485,6 +1752,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "docify" version = "0.2.9" @@ -1645,7 +1921,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]] @@ -1719,12 +1995,23 @@ dependencies = [ "blake2", "file-guard", "fs-err", - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastbloom" version = "0.17.0" @@ -1775,7 +2062,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", ] @@ -1806,6 +2093,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" @@ -1817,6 +2110,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" @@ -1881,7 +2186,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", @@ -2036,7 +2341,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", @@ -2049,7 +2354,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", @@ -2061,7 +2366,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", @@ -2095,6 +2400,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" @@ -2236,6 +2547,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" @@ -2299,6 +2616,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" @@ -2431,7 +2757,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", @@ -2557,6 +2883,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" @@ -2629,6 +2967,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if 1.0.4", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "integer-sqrt" version = "0.1.5" @@ -2727,7 +3077,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", @@ -2742,7 +3092,7 @@ version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "combine", "jni-macros", "jni-sys 0.4.1", @@ -2810,7 +3160,7 @@ 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", ] @@ -2869,7 +3219,7 @@ dependencies = [ "http-body-util", "jsonrpsee-types", "pin-project", - "rustc-hash", + "rustc-hash 2.1.3", "serde", "serde_json", "thiserror 1.0.69", @@ -2946,7 +3296,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", @@ -2983,6 +3333,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 0.11.3", + "rand_xoshiro", +] + [[package]] name = "konst" version = "0.2.20" @@ -3004,6 +3367,12 @@ 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" @@ -3011,28 +3380,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "libm" -version = "0.2.16" +name = "libflate" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "85f7ef5c7e3c2ed51f0fbc40e016c66558b699f16593521f30b98713bbb99cb8" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] [[package]] -name = "libredox" -version = "0.1.18" +name = "libflate_lz77" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" dependencies = [ - "libc", + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", ] [[package]] -name = "libsecp256k1" -version = "0.7.2" +name = "libloading" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "arrayref", - "base64", + "cfg-if 1.0.4", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.2", +] + +[[package]] +name = "libsecp256k1" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +dependencies = [ + "arrayref", + "base64", "digest 0.9.0", "hmac-drbg", "libsecp256k1-core", @@ -3073,6 +3479,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" @@ -3121,6 +3533,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" @@ -3136,6 +3557,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memory-db" version = "0.34.0" @@ -3159,12 +3589,82 @@ 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 = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[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 = "minifb" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1a093126f2ed9012fc0b146934c97eb0273e54983680a8bf5309b6b4a365b32" +dependencies = [ + "cc", + "console_error_panic_hook", + "dlib", + "futures", + "instant", + "js-sys", + "lazy_static", + "libc", + "orbclient", + "raw-window-handle", + "serde", + "serde_derive", + "tempfile", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-client", + "wayland-cursor", + "wayland-protocols", + "web-sys", + "winapi", + "x11-dl", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3186,12 +3686,81 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +dependencies = [ + "serde", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +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 = "native-tls" version = "0.2.18" @@ -3209,6 +3778,27 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if 1.0.4", + "libc", + "memoffset", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3221,6 +3811,83 @@ 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 = "b1cba20bebd3bd9ae22f9273ade5bbe49da3e047c8512b53fbaf8b4b9c80d496" +dependencies = [ + "bytes", + "image", + "mozjpeg", + "thiserror 2.0.18", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -3327,6 +3994,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" @@ -3365,7 +4051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags 2.13.1", - "cfg-if", + "cfg-if 1.0.4", "foreign-types", "libc", "openssl-macros", @@ -3407,6 +4093,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", + "sdl2", +] + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -3458,9 +4155,9 @@ 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", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -3493,6 +4190,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" @@ -3508,6 +4211,76 @@ 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 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "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 0.11.3", + "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 = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -3561,6 +4334,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plonky2_maybe_rayon" version = "1.0.0" @@ -3619,11 +4398,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", ] @@ -3644,7 +4423,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", @@ -3680,6 +4459,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" @@ -3760,7 +4549,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", @@ -3768,6 +4557,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 0.8.3", + "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.6.1" @@ -4020,6 +4869,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quantus-cli" version = "2.1.1" @@ -4038,6 +4893,8 @@ dependencies = [ "indicatif", "jsonrpsee", "libc", + "minifb", + "nokhwa", "parity-scale-codec", "qp-dilithium-crypto", "qp-human-checkphrase", @@ -4053,11 +4910,14 @@ dependencies = [ "qp-wormhole-prover", "qp-wormhole-verifier", "qp-zk-circuits-common", + "qrcode", + "quantus_ur", "quinn-proto", "rand 0.9.5", "reqwest 0.12.28", "rpassword", "rustls-webpki", + "rxing", "self-replace", "self_update", "serde", @@ -4074,6 +4934,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.6.0#0b9bcc586a6ccb845c4d476ffa0880b0478b284a" +dependencies = [ + "hex", + "keystone-ur", + "minicbor", + "ur-parse-lib", +] + [[package]] name = "quinn" version = "0.11.11" @@ -4085,7 +4956,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2", "thiserror 2.0.18", @@ -4108,7 +4979,7 @@ dependencies = [ "rand 0.10.1", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", @@ -4244,6 +5115,21 @@ 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 = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "rayon" version = "1.12.0" @@ -4273,6 +5159,15 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_syscall" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93da5bb2c5d4e6c0ef7abeead62c89169a0a4882bfb83ac892f2423aea2fe" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4429,6 +5324,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" @@ -4436,13 +5340,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" @@ -4470,6 +5380,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" @@ -4491,6 +5407,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" @@ -4500,7 +5429,7 @@ dependencies = [ "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -4549,7 +5478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ "core-foundation 0.10.1", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "jni 0.21.1", "log", "once_cell", @@ -4570,7 +5499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "jni 0.22.4", "log", "once_cell", @@ -4614,6 +5543,38 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +[[package]] +name = "rxing" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc56bde1cf7f3c9cc908ef2670efede68bbad283acb6674b709aa3befdab451d" +dependencies = [ + "chrono", + "chrono-tz", + "codepage-437", + "encoding_rs", + "fancy-regex", + "multimap 0.10.1", + "num", + "once_cell", + "regex", + "rxing-one-d-proc-derive", + "thiserror 2.0.18", + "unicode-segmentation", + "uriparse", + "urlencoding", +] + +[[package]] +name = "rxing-one-d-proc-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7e5f136594d614d0d8e44f63508abd25c8fe0280655bb1d477b177bf718d0a4" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "ryu" version = "1.0.23" @@ -4703,7 +5664,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", @@ -4780,7 +5741,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", ] @@ -4803,12 +5764,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdl2" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "libc", + "sdl2-sys", +] + +[[package]] +name = "sdl2-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "version-compare", +] + [[package]] name = "sec1" version = "0.7.3" @@ -4859,7 +5849,7 @@ checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.1", "core-foundation 0.10.1", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", "security-framework-sys", ] @@ -4870,7 +5860,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", ] @@ -5046,7 +6036,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", ] @@ -5058,7 +6048,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", @@ -5070,7 +6060,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", ] @@ -5094,6 +6084,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" @@ -5128,9 +6124,9 @@ checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.2.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" dependencies = [ "rustc_version", "simdutf8", @@ -5212,7 +6208,7 @@ dependencies = [ "libm", "libsecp256k1", "merlin", - "nom", + "nom 8.0.0", "num-bigint", "num-rational", "num-traits", @@ -5646,6 +6642,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" @@ -5705,7 +6704,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", @@ -5789,7 +6788,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", @@ -5984,7 +6983,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] @@ -6014,7 +7013,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6045,6 +7044,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" @@ -6073,7 +7092,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]] @@ -6478,7 +7497,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", @@ -6591,6 +7610,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" @@ -6625,6 +7676,16 @@ dependencies = [ "log", ] +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + [[package]] name = "url" version = "2.5.8" @@ -6661,6 +7722,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" @@ -6673,6 +7754,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" + [[package]] name = "version_check" version = "0.9.5" @@ -6787,9 +7874,11 @@ 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", + "serde", + "serde_json", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -6886,6 +7975,79 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "wayland-client" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3b068c05a039c9f755f881dc50f01732214f5685e379829759088967c46715" +dependencies = [ + "bitflags 1.3.2", + "downcast-rs", + "libc", + "nix", + "scoped-tls", + "wayland-commons", + "wayland-scanner", + "wayland-sys", +] + +[[package]] +name = "wayland-commons" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8691f134d584a33a6606d9d717b95c4fa20065605f798a3f350d78dced02a902" +dependencies = [ + "nix", + "once_cell", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-cursor" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6865c6b66f13d6257bef1cd40cbfe8ef2f150fb8ebbdb1e8e873455931377661" +dependencies = [ + "nix", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b950621f9354b322ee817a23474e479b34be96c2e909c14f7bc0100e9a970bc6" +dependencies = [ + "bitflags 1.3.2", + "wayland-client", + "wayland-commons", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4303d8fa22ab852f789e75a967f0a2cdc430a607751c0499bada3e451cbd53" +dependencies = [ + "proc-macro2", + "quote", + "xml-rs", +] + +[[package]] +name = "wayland-sys" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be12ce1a3c39ec7dba25594b97b42cb3195d54953ddb9d3d95a7c3902bc6e9d4" +dependencies = [ + "dlib", + "lazy_static", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -6933,6 +8095,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" @@ -6964,6 +8138,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" @@ -6977,6 +8172,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" @@ -7005,6 +8211,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-registry" version = "0.6.1" @@ -7101,6 +8317,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" @@ -7230,6 +8455,17 @@ dependencies = [ "tap", ] +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -7249,9 +8485,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + [[package]] name = "yap" version = "0.12.0" diff --git a/Cargo.toml b/Cargo.toml index a96316a..2e4df32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,11 +19,29 @@ 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:minifb", "dep:nokhwa", "dep:rxing"] + [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 +# (1.6.0). Keystone firmware still encodes 1.4.0; the wire format is compatible. +quantus_ur = { git = "https://github.com/Quantus-Network/quantus_ur.git", tag = "1.6.0" } +# Terminal QR rendering (string renderers only; image/svg not needed) +qrcode = { version = "0.14", default-features = false } +# Camera capture (AVFoundation / MSMF / V4L2) + pure-Rust QR decoding. +# rxing (ZXing port) over rqrr: its adaptive binarizer decodes the defocused +# frames a fixed-focus Mac camera produces at phone-scanning distance. +minifb = { version = "0.28", optional = true } +nokhwa = { version = "0.10", features = ["input-native"], optional = true } +rxing = { version = "0.8", default-features = false, features = ["encoding_rs"], optional = true } + # Serialization and configuration serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/LIBRARY_USAGE.md b/LIBRARY_USAGE.md index 2907514..95a7b16 100644 --- a/LIBRARY_USAGE.md +++ b/LIBRARY_USAGE.md @@ -175,20 +175,18 @@ use quantus_cli::{ }; async fn send_transaction() -> Result<(), Box> { - let wallet_manager = WalletManager::new()?; let client = QuantusClient::new("ws://127.0.0.1:9944").await?; - - // Load sender wallet - let wallet_data = wallet_manager.load_wallet("my_wallet", "password")?; - let keypair = wallet_data.keypair; - + + // Hot wallets decrypt locally; cold (watch-only) wallets sign over QR. + let signer = quantus_cli::wallet::load_signer_from_wallet("my_wallet", None, None)?; + // Recipient address let to_address = "qzkeicNBtW2AG2E7USjDcLzAL8d9WxTZnV2cbtXoDzWxzpHC2"; // Submit and wait for inclusion in a best block let tx_hash = transfer( &client, - &keypair, + &signer, to_address, 1_000_000_000_000, // raw units, e.g. 1 token on a 12-decimal chain None, @@ -338,7 +336,7 @@ use quantus_cli::{create_multisig, predict_multisig_address, QuantusClient}; async fn create_multisig_example() -> Result<(), Box> { let client = QuantusClient::new("ws://127.0.0.1:9944").await?; - let keypair = quantus_cli::wallet::load_keypair_from_wallet("alice", None, None)?; + let signer = quantus_cli::wallet::load_signer_from_wallet("alice", None, None)?; // Parse signer addresses let alice_account = parse_address("qzkaf...")?; @@ -356,7 +354,7 @@ async fn create_multisig_example() -> Result<(), Box> { // Create multisig (wait_for_inclusion=true to get address from event) let (tx_hash, multisig_address) = create_multisig( &client, - &keypair, + &signer, signers, threshold, nonce, // NEW: nonce parameter for deterministic addresses @@ -400,7 +398,7 @@ use quantus_cli::{propose_transfer, parse_multisig_amount}; async fn create_proposal() -> Result<(), Box> { let client = QuantusClient::new("ws://127.0.0.1:9944").await?; - let keypair = quantus_cli::wallet::load_keypair_from_wallet("alice", None, None)?; + let signer = quantus_cli::wallet::load_signer_from_wallet("alice", None, None)?; let multisig_account = parse_address("qz...")?; let recipient = parse_address("qzmqr...")?; @@ -412,7 +410,7 @@ async fn create_proposal() -> Result<(), Box> { let tx_hash = propose_transfer( &client, - &keypair, + &signer, multisig_account, recipient, amount, @@ -433,14 +431,14 @@ use quantus_cli::approve_proposal; async fn approve_example() -> Result<(), Box> { let client = QuantusClient::new("ws://127.0.0.1:9944").await?; - let keypair = quantus_cli::wallet::load_keypair_from_wallet("bob", None, None)?; + let signer = quantus_cli::wallet::load_signer_from_wallet("bob", None, None)?; let multisig_account = parse_address("qz...")?; let proposal_id = 0u32; let tx_hash = approve_proposal( &client, - &keypair, + &signer, multisig_account, proposal_id ).await?; @@ -514,14 +512,14 @@ use quantus_cli::cancel_proposal; async fn cancel_example() -> Result<(), Box> { let client = QuantusClient::new("ws://127.0.0.1:9944").await?; - let keypair = quantus_cli::wallet::load_keypair_from_wallet("alice", None, None)?; + let signer = quantus_cli::wallet::load_signer_from_wallet("alice", None, None)?; let multisig_account = parse_address("qz...")?; let proposal_id = 0u32; let tx_hash = cancel_proposal( &client, - &keypair, + &signer, multisig_account, proposal_id ).await?; diff --git a/README.md b/README.md index 91c530f..f3ddd0e 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 @@ -398,6 +399,63 @@ 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... + +# Any extrinsic command works with a cold wallet: the CLI shows the +# transaction as a QR, then scans the device's animated signature QR +quantus send --from my_cold --to
--amount 10.5 --wait-for-transaction +quantus multisig approve --from my_cold --address qz... --proposal-id 0 +``` + +The signing flow (identical for every command — commands don't know whether +the wallet is hot or cold; the shared submit stage branches on the wallet +type): + +1. The CLI displays the transaction as a `ur:quantus-sign-request` QR + (animated if the payload is large). Scan it with the cold wallet, review + 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. +- Commands that submit several extrinsics (e.g. `runtime update`, + `tech-referenda submit-with-preimage`) do one QR roundtrip per extrinsic. +- Wormhole operations are not cold-compatible: they derive secrets from the + wallet's mnemonic and submit unsigned extrinsics. +- **macOS camera permission**: the permission prompt is attributed to your + terminal app (Terminal, iTerm, VS Code, …) — grant it under System + Settings > Privacy & Security > Camera. +- **Headless / no camera**: `--cold-response-in ` (or `-` for stdin) + reads the response UR parts (one per line) instead of scanning, and the + hidden `--cold-request-out ` writes the request UR parts for + scripted flows. Both are global flags that work with any command. Builds + without the default `camera` feature (`cargo build --no-default-features`) + support only this path. +- Real devices only sign for known networks (Planck / Heisenberg genesis + hashes) and whitelisted calls — balance transfers, reversible transfers, + and (cold wallet app only) multisig. Test against a dev node with the + hidden `quantus developer cold-sign-sim` command, which plays the cold + wallet side using a local hot wallet. + +--- + ### Sending Tokens ```bash diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index 63bf126..9208f19 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -7,12 +7,9 @@ //! 4. Query blockchain data use quantus_cli::{ - chain::client::QuantusClient, - error::Result, - wallet::{QuantumKeyPair, WalletManager}, - AccountId32, + chain::client::QuantusClient, cli::common::ExecutionMode, error::Result, transfer, + wallet::WalletManager, AccountId32, }; -use sp_core::crypto::Ss58Codec; #[tokio::main] async fn main() -> Result<()> { @@ -79,36 +76,19 @@ async fn get_account_balance(client: &QuantusClient, account_id: &AccountId32) - #[allow(dead_code)] async fn send_tokens( client: &QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &quantus_cli::wallet::QuantumKeyPair, to_address: &str, amount: u128, ) -> Result { - use quantus_cli::chain::quantus_subxt::api; - - // Parse recipient address - let to_account_id = AccountId32::from_ss58check(to_address) - .map_err(|e| quantus_cli::error::QuantusError::Generic(format!("Invalid address: {e}")))?; - let to_account_bytes: [u8; 32] = *to_account_id.as_ref(); - let to_subxt_account_id = subxt::utils::AccountId32::from(to_account_bytes); - - // Create transfer call - let transfer_call = - api::tx().balances().transfer_allow_death(to_subxt_account_id.into(), amount); - - // Convert QuantumKeyPair to DilithiumPair for signing - let dilithium_pair = from_keypair.to_subxt_signer()?; - - // Submit transaction - let tx_hash = client - .client() - .tx() - .sign_and_submit_then_watch_default(&transfer_call, &dilithium_pair) - .await? - .wait_for_finalized_success() - .await? - .extrinsic_hash(); - - Ok(tx_hash) + transfer( + client, + &from_keypair.as_signer(), + to_address, + amount, + None, + ExecutionMode { wait_for_transaction: true, finalized: true }, + ) + .await } /// Example of creating a wallet from mnemonic diff --git a/examples/multisig_library_usage.rs b/examples/multisig_library_usage.rs index 48f0cd3..521128f 100644 --- a/examples/multisig_library_usage.rs +++ b/examples/multisig_library_usage.rs @@ -64,8 +64,15 @@ async fn main() -> Result<()> { let predicted_address = predict_multisig_address(signers.clone(), threshold, nonce); println!("📍 Predicted address: {}", predicted_address); - let (tx_hash, multisig_address) = - create_multisig(&quantus_client, &alice_keypair, signers, threshold, nonce, true).await?; + let (tx_hash, multisig_address) = create_multisig( + &quantus_client, + &alice_keypair.as_signer(), + signers, + threshold, + nonce, + true, + ) + .await?; println!("✅ Multisig created!"); println!(" Tx hash: 0x{}", hex::encode(tx_hash)); @@ -109,7 +116,7 @@ async fn main() -> Result<()> { let propose_tx_hash = propose_transfer( &quantus_client, - &alice_keypair, + &alice_keypair.as_signer(), multisig_account.clone(), bob_account.clone(), amount, @@ -157,7 +164,7 @@ async fn main() -> Result<()> { println!("✅ Approving proposal #{}...", proposal_id); let approve_tx_hash = approve_proposal( &quantus_client, - &bob_keypair, + &bob_keypair.as_signer(), multisig_account.clone(), proposal_id, ) diff --git a/examples/multisig_usage.rs b/examples/multisig_usage.rs index bfd0e49..43eecfe 100644 --- a/examples/multisig_usage.rs +++ b/examples/multisig_usage.rs @@ -76,7 +76,7 @@ async fn main() -> Result<()> { let tx_hash = quantus_cli::cli::common::submit_transaction( &quantus_client, - &alice_keypair, + &quantus_cli::wallet::WalletSigner::Hot(alice_keypair), create_tx, None, execution_mode, diff --git a/examples/service.rs b/examples/service.rs index 6b1d5b1..726c3fa 100644 --- a/examples/service.rs +++ b/examples/service.rs @@ -8,7 +8,9 @@ use quantus_cli::{ chain::client::QuantusClient, + cli::common::ExecutionMode, error::{QuantusError, Result}, + transfer, wallet::{QuantumKeyPair, WalletManager}, AccountId32, }; @@ -195,30 +197,16 @@ impl WalletService { to_account_id: &AccountId32, amount: u128, ) -> Result { - use quantus_cli::chain::quantus_subxt::api; - - // Convert recipient to subxt format - let to_account_bytes: [u8; 32] = *to_account_id.as_ref(); - let to_subxt_account_id = subxt::utils::AccountId32::from(to_account_bytes); - - // Create transfer call - let transfer_call = - api::tx().balances().transfer_allow_death(to_subxt_account_id.into(), amount); - - // Convert QuantumKeyPair to DilithiumPair for signing - let dilithium_pair = from_keypair.to_subxt_signer()?; - - // Submit transaction - let tx_hash = client - .client() - .tx() - .sign_and_submit_then_watch_default(&transfer_call, &dilithium_pair) - .await? - .wait_for_finalized_success() - .await? - .extrinsic_hash(); - - Ok(tx_hash) + let to_address = to_account_id.to_ss58check(); + transfer( + client, + &from_keypair.as_signer(), + &to_address, + amount, + None, + ExecutionMode { wait_for_transaction: true, finalized: true }, + ) + .await } } diff --git a/examples/wallet_ops.rs b/examples/wallet_ops.rs index 534f9c8..5727939 100644 --- a/examples/wallet_ops.rs +++ b/examples/wallet_ops.rs @@ -9,7 +9,9 @@ use quantus_cli::{ chain::client::QuantusClient, + cli::common::ExecutionMode, error::{QuantusError, Result}, + transfer, wallet::{QuantumKeyPair, WalletManager}, AccountId32, }; @@ -139,31 +141,16 @@ impl QuantusApp { to_account_id: &AccountId32, amount: u128, ) -> Result { - use quantus_cli::chain::quantus_subxt::api; - - // Convert recipient to subxt format - let to_account_bytes: [u8; 32] = *to_account_id.as_ref(); - let to_subxt_account_id = subxt::utils::AccountId32::from(to_account_bytes); - - // Create transfer call - let transfer_call = - api::tx().balances().transfer_allow_death(to_subxt_account_id.into(), amount); - - // Convert QuantumKeyPair to DilithiumPair for signing - let dilithium_pair = from_keypair.to_subxt_signer()?; - - // Submit transaction - let tx_hash = self - .client - .client() - .tx() - .sign_and_submit_then_watch_default(&transfer_call, &dilithium_pair) - .await? - .wait_for_finalized_success() - .await? - .extrinsic_hash(); - - Ok(tx_hash) + let to_address = to_account_id.to_ss58check(); + transfer( + &self.client, + &from_keypair.as_signer(), + &to_address, + amount, + None, + ExecutionMode { wait_for_transaction: true, finalized: true }, + ) + .await } } diff --git a/examples/wormhole_sdk_e2e.rs b/examples/wormhole_sdk_e2e.rs index b232101..68a621e 100644 --- a/examples/wormhole_sdk_e2e.rs +++ b/examples/wormhole_sdk_e2e.rs @@ -175,7 +175,7 @@ async fn main() -> Result<()> { println!("[2/9] depositing {} planck -> wormhole address...", amount_planck); let tx_hash = transfer( &client, - &funder_kp, + &funder_kp.as_signer(), &wh_ss58, amount_planck, None, diff --git a/src/cli/batch.rs b/src/cli/batch.rs index d6743cc..bece446 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -146,9 +146,9 @@ async fn handle_batch_send_command( }; // Load wallet - let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?; - let from_account_id = keypair.try_to_account_id_ss58check()?; - validate_batch_transfer_request(&quantus_client, &keypair, &transfers).await?; + let signer = crate::wallet::load_signer_from_wallet(&from_wallet, password, password_file)?; + let from_account_id = signer.try_account_id_ss58check()?; + validate_batch_transfer_request(&quantus_client, &signer, &transfers).await?; let effective_tip = crate::cli::send::effective_tip_amount(tip_amount); let submit_tip = crate::cli::send::positive_tip_amount(tip_amount); @@ -164,7 +164,7 @@ async fn handle_batch_send_command( let batch_call = build_batch_transfer_call(&transfers)?; ensure_balance_covers_call( &quantus_client, - &keypair, + &signer, &batch_call, balance, exact_required, @@ -176,7 +176,7 @@ async fn handle_batch_send_command( // Submit batch transaction let tx_hash = submit_prebuilt_batch_transfer_call( &quantus_client, - &keypair, + &signer, &transfers, batch_call, tip_amount, diff --git a/src/cli/cold_signing.rs b/src/cli/cold_signing.rs new file mode 100644 index 0000000..5c55f85 --- /dev/null +++ b/src/cli/cold_signing.rs @@ -0,0 +1,745 @@ +//! 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::{Dilithium87SignatureWithPublic, DilithiumSignatureScheme}; +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 = Dilithium87SignatureWithPublic::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. +#[derive(Default)] +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, +} + +static COLD_IO: std::sync::OnceLock = std::sync::OnceLock::new(); + +impl ColdIo { + /// Build from the global CLI flags. `"-"` for `response_in` means stdin. + pub fn from_flags( + request_out: Option, + response_in: Option, + camera_index: u32, + ) -> Self { + Self { + request_out: request_out.map(PathBuf::from), + response_in: response_in.map(|s| { + if s == "-" { + UrSource::StdinLines + } else { + UrSource::File(PathBuf::from(s)) + } + }), + camera_index, + } + } + + /// Install the process-wide I/O config. Called once from `main` with the + /// global CLI flags so the submit stage can reach it without threading it + /// through every command. + pub fn install(self) { + let _ = COLD_IO.set(self); + } + + /// The installed config, or the default (camera 0, no file overrides) for + /// library and test users that never call `install`. + pub fn global() -> &'static ColdIo { + COLD_IO.get_or_init(ColdIo::default) + } + + fn has_overrides(&self) -> bool { + self.request_out.is_some() || self.response_in.is_some() || self.camera_index != 0 + } +} + +/// Warn when global cold-signing flags were passed with a hot `--from`. +pub fn warn_if_cold_flags_unused() { + if ColdIo::global().has_overrides() { + log_print!( + "⚠️ --cold-request-out / --cold-response-in / --camera-index are ignored for hot wallets" + ); + } +} + +fn require_interactive_or_request_out(interactive: bool, io: &ColdIo) -> Result<()> { + if !interactive && io.request_out.is_none() { + return Err(QuantusError::Generic( + "Non-interactive cold signing requires --cold-request-out so the sign request can be handed to the device".to_string(), + )); + } + Ok(()) +} + +/// 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 = Dilithium87SignatureWithPublic::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::Dilithium87(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; the shared submit stage in `cli::common` +/// routes every extrinsic here when the signer is a cold (watch-only) wallet. +#[allow(clippy::too_many_arguments)] +pub async fn sign_and_submit_cold( + client: &QuantusClient, + wallet_name: &str, + cold_address_ss58: &str, + call: &Call, + tip: Option, + nonce_override: Option, + execution_mode: crate::cli::common::ExecutionMode, + io: &ColdIo, +) -> Result<(subxt::utils::H256, Option)> { + 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:?}")))?; + + // A response file existing before this request is handed out is necessarily + // from an earlier session (the response depends on this request); together + // with consume-on-read in the scanner this keeps every roundtrip fresh. + if let Some(UrSource::File(path)) = &io.response_in { + match std::fs::remove_file(path) { + Ok(()) => log_print!("🧹 Removed stale response file {}", path.display()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}, + Err(e) => return Err(e.into()), + } + } + + if let Some(path) = &io.request_out { + let content = parts.join("\n") + "\n"; + std::fs::write(path, content)?; + 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(); + require_interactive_or_request_out(interactive, io)?; + 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::Dilithium87(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 = Dilithium87SignatureWithPublic::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::Dilithium87(dummy)); + tx.partial_fee_estimate().await.ok() +} + +/// Fee estimate for a cold wallet when no [`TxContext`] exists yet (balance +/// preflight before signing). Same dummy-signature trick; the fee depends on +/// length and weight, not on nonce or signature content. +pub(crate) async fn estimate_cold_fee( + client: &QuantusClient, + call: &Call, + account: &AccountId32, + tip: u128, +) -> Option { + let ctx = capture_tx_context(client, account, tip, None).await.ok()?; + estimate_fee_with_dummy_signature(client, call, &ctx, account).await +} + +fn default_response_source(io: &ColdIo) -> Result { + #[cfg(feature = "camera")] + { + 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)?; + if keypair.scheme != crate::wallet::DilithiumScheme::MlDsa87 { + return Err(QuantusError::Generic( + "cold-sign-sim and real devices sign ML-DSA-87 only; use an ML-DSA-87 wallet" + .to_string(), + )); + } + 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) + .try_to_account_id_32() + .expect("valid keypair") + } + + 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::Dilithium87(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"); + } + + #[test] + fn non_interactive_cold_signing_requires_request_out() { + let io = ColdIo::default(); + assert!(require_interactive_or_request_out(false, &io).is_err()); + assert!(require_interactive_or_request_out(true, &io).is_ok()); + + let with_out = + ColdIo { request_out: Some(PathBuf::from("/tmp/request.ur")), ..ColdIo::default() }; + assert!(require_interactive_or_request_out(false, &with_out).is_ok()); + } +} diff --git a/src/cli/common.rs b/src/cli/common.rs index c35cd80..5bbba6e 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -461,9 +461,13 @@ pub async fn get_fresh_nonce_with_client( /// By default, returns immediately after the node accepts the transaction submission. /// With `wait_for_transaction=true`, waits until the transaction is in a best block. /// With `finalized=true`, waits until the transaction is in a finalized block. +/// +/// Cold (watch-only) signers are routed to the QR signing flow instead of local +/// signing; there is no retry loop there, since a QR-signed extrinsic can never +/// be silently rebuilt. pub async fn submit_transaction( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, call: Call, tip: Option, execution_mode: ExecutionMode, @@ -471,14 +475,9 @@ pub async fn submit_transaction( where Call: subxt::tx::Payload, { - let (tx_hash, _included_in) = submit_transaction_with_inclusion_block( - quantus_client, - from_keypair, - call, - tip, - execution_mode, - ) - .await?; + let (tx_hash, _included_in) = + submit_transaction_with_inclusion_block(quantus_client, signer, call, tip, execution_mode) + .await?; Ok(tx_hash) } @@ -503,7 +502,7 @@ async fn ensure_keypair_scheme_supported( /// block by the time the watch returns. pub async fn submit_transaction_with_inclusion_block( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, call: Call, tip: Option, execution_mode: ExecutionMode, @@ -511,8 +510,25 @@ pub async fn submit_transaction_with_inclusion_block( where Call: subxt::tx::Payload, { + let from_keypair = match signer { + crate::wallet::WalletSigner::Hot(keypair) => { + crate::cli::cold_signing::warn_if_cold_flags_unused(); + keypair + }, + crate::wallet::WalletSigner::Cold { name, address } => + return crate::cli::cold_signing::sign_and_submit_cold( + quantus_client, + name, + address, + &call, + tip, + None, + execution_mode, + crate::cli::cold_signing::ColdIo::global(), + ) + .await, + }; ensure_keypair_scheme_supported(quantus_client, from_keypair).await?; - let signer = from_keypair.to_subxt_signer().map_err(|e| { crate::error::QuantusError::NetworkError(format!("Failed to convert keypair: {e:?}")) })?; @@ -620,10 +636,54 @@ 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<(subxt::utils::H256, Option)> { + 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(); + let included_in = wait_tx_inclusion( + &mut tx_progress, + quantus_client.client(), + &tx_hash, + execution_mode.transaction_stage(), + ) + .await?; + Ok((tx_hash, Some(included_in))) + } else { + let tx_hash = tx.submit().await.map_err(map_err)?; + crate::log_print!("✅ Transaction submitted: {:?}", tx_hash); + Ok((tx_hash, None)) + } +} + /// Submit transaction with manual nonce (no retry logic - use exact nonce provided) pub async fn submit_transaction_with_nonce( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, call: Call, tip: Option, nonce: u32, @@ -632,8 +692,26 @@ pub async fn submit_transaction_with_nonce( where Call: subxt::tx::Payload, { + let from_keypair = match signer { + crate::wallet::WalletSigner::Hot(keypair) => { + crate::cli::cold_signing::warn_if_cold_flags_unused(); + keypair + }, + crate::wallet::WalletSigner::Cold { name, address } => + return crate::cli::cold_signing::sign_and_submit_cold( + quantus_client, + name, + address, + &call, + tip, + Some(nonce), + execution_mode, + crate::cli::cold_signing::ColdIo::global(), + ) + .await + .map(|(tx_hash, _included_in)| tx_hash), + }; ensure_keypair_scheme_supported(quantus_client, from_keypair).await?; - let signer = from_keypair.to_subxt_signer().map_err(|e| { crate::error::QuantusError::NetworkError(format!("Failed to convert keypair: {e:?}")) })?; @@ -951,7 +1029,7 @@ async fn verify_preimage_on_chain( pub async fn submit_preimage( quantus_client: &crate::chain::client::QuantusClient, - keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, encoded_call: Vec, execution_mode: ExecutionMode, ) -> Result<()> { @@ -966,7 +1044,7 @@ pub async fn submit_preimage( match submit_transaction_with_inclusion_block( quantus_client, - keypair, + signer, note_preimage_tx, None, wait_mode, diff --git a/src/cli/exercise/runner.rs b/src/cli/exercise/runner.rs index e2eee97..dc12686 100644 --- a/src/cli/exercise/runner.rs +++ b/src/cli/exercise/runner.rs @@ -85,9 +85,13 @@ impl ExerciseCtx { Call: subxt::tx::Payload, { if from.try_to_account_id_ss58check()? == self.root_ss58 { - let fee = - crate::cli::send::estimate_transaction_partial_fee(&self.client, from, &call, None) - .await?; + let fee = crate::cli::send::estimate_transaction_partial_fee( + &self.client, + &crate::wallet::WalletSigner::Hot(from.clone()), + &call, + None, + ) + .await?; self.reserve(reserved.saturating_add(fee)).await?; } submit_ok(self, from, call).await @@ -201,7 +205,14 @@ pub async fn submit_ok( where Call: subxt::tx::Payload, { - crate::cli::common::submit_transaction(&ctx.client, from, call, None, ctx.wait_mode()).await + crate::cli::common::submit_transaction( + &ctx.client, + &crate::wallet::WalletSigner::Hot(from.clone()), + call, + None, + ctx.wait_mode(), + ) + .await } pub async fn submit_expect_failure( @@ -213,8 +224,14 @@ pub async fn submit_expect_failure( where Call: subxt::tx::Payload, { - match crate::cli::common::submit_transaction(&ctx.client, from, call, None, ctx.wait_mode()) - .await + match crate::cli::common::submit_transaction( + &ctx.client, + &crate::wallet::WalletSigner::Hot(from.clone()), + call, + None, + ctx.wait_mode(), + ) + .await { Ok(hash) => Err(QuantusError::Generic(format!( "expected rejection but transaction succeeded ({hash:?})" diff --git a/src/cli/exercise/scenarios/balances.rs b/src/cli/exercise/scenarios/balances.rs index 068257f..ee571a6 100644 --- a/src/cli/exercise/scenarios/balances.rs +++ b/src/cli/exercise/scenarios/balances.rs @@ -47,7 +47,7 @@ async fn scheme_transfer(ctx: &mut ExerciseCtx, scheme: DilithiumScheme) -> Resu let before = ctx.free_balance(&recipient_ss58).await?; crate::cli::send::transfer( &ctx.client, - &sender, + &sender.as_signer(), &recipient_ss58, amount, None, @@ -72,7 +72,7 @@ async fn single_transfer(ctx: &mut ExerciseCtx) -> Result { let before = ctx.free_balance(&recipient_ss58).await?; crate::cli::send::transfer( &ctx.client, - &sender, + &sender.as_signer(), &recipient_ss58, amount, None, @@ -98,8 +98,14 @@ async fn batch_transfer(ctx: &mut ExerciseCtx) -> Result { let transfers: Vec<(String, u128)> = recipients.iter().map(|r| (r.clone(), amount)).collect(); let sender = ctx.eph[0].clone(); - crate::cli::send::batch_transfer(&ctx.client, &sender, transfers, None, ctx.wait_mode()) - .await?; + crate::cli::send::batch_transfer( + &ctx.client, + &sender.as_signer(), + transfers, + None, + ctx.wait_mode(), + ) + .await?; for recipient in &recipients { let balance = ctx.free_balance(recipient).await?; @@ -119,7 +125,7 @@ async fn transfer_with_tip(ctx: &mut ExerciseCtx) -> Result { let sender = ctx.eph[1].clone(); crate::cli::send::transfer( &ctx.client, - &sender, + &sender.as_signer(), &recipient, amount, Some(tip), @@ -142,7 +148,7 @@ async fn transfer_manual_nonce(ctx: &mut ExerciseCtx) -> Result { let recipient = ctx.fresh_keypair()?.try_to_account_id_ss58check()?; crate::cli::send::transfer_with_nonce( &ctx.client, - &sender, + &sender.as_signer(), &recipient, ctx.test_unit, None, @@ -190,7 +196,7 @@ async fn transfer_all(ctx: &mut ExerciseCtx) -> Result { let funder = ctx.eph[1].clone(); crate::cli::send::transfer( &ctx.client, - &funder, + &funder.as_signer(), &sender_ss58, 20 * ctx.test_unit, None, diff --git a/src/cli/exercise/scenarios/fuzz.rs b/src/cli/exercise/scenarios/fuzz.rs index 2045b3a..f2fc2b8 100644 --- a/src/cli/exercise/scenarios/fuzz.rs +++ b/src/cli/exercise/scenarios/fuzz.rs @@ -101,9 +101,14 @@ async fn fuzz_transfer(ctx: &mut ExerciseCtx) -> Result { let call = quantus_subxt::api::tx() .balances() .transfer_allow_death(subxt::ext::subxt_core::utils::MultiAddress::Id(to), amount); - let result = - crate::cli::common::submit_transaction(&ctx.client, &sender, call, None, ctx.wait_mode()) - .await; + let result = crate::cli::common::submit_transaction( + &ctx.client, + &crate::wallet::WalletSigner::Hot(sender.clone()), + call, + None, + ctx.wait_mode(), + ) + .await; classify(result, &format!("transfer of {amount}")) } @@ -113,9 +118,14 @@ async fn fuzz_remark(ctx: &mut ExerciseCtx) -> Result { let mut payload = vec![0u8; len]; ctx.rng.fill(payload.as_mut_slice()); let call = quantus_subxt::api::tx().system().remark(payload); - let result = - crate::cli::common::submit_transaction(&ctx.client, &sender, call, None, ctx.wait_mode()) - .await; + let result = crate::cli::common::submit_transaction( + &ctx.client, + &crate::wallet::WalletSigner::Hot(sender.clone()), + call, + None, + ctx.wait_mode(), + ) + .await; classify(result, &format!("remark of {len} bytes")) } @@ -135,9 +145,14 @@ async fn fuzz_batch(ctx: &mut ExerciseCtx) -> Result { })); } let call = quantus_subxt::api::tx().utility().batch(calls); - let result = - crate::cli::common::submit_transaction(&ctx.client, &sender, call, None, ctx.wait_mode()) - .await; + let result = crate::cli::common::submit_transaction( + &ctx.client, + &crate::wallet::WalletSigner::Hot(sender.clone()), + call, + None, + ctx.wait_mode(), + ) + .await; // Batch succeeds even when inner calls fail. classify(result, &format!("batch of {n} transfers")) } @@ -159,9 +174,14 @@ async fn fuzz_reversible(ctx: &mut ExerciseCtx) -> Result { amount, delay, ); - let result = - crate::cli::common::submit_transaction(&ctx.client, &sender, call, None, ctx.wait_mode()) - .await; + let result = crate::cli::common::submit_transaction( + &ctx.client, + &crate::wallet::WalletSigner::Hot(sender.clone()), + call, + None, + ctx.wait_mode(), + ) + .await; classify(result, &format!("reversible transfer of {amount}")) } diff --git a/src/cli/exercise/scenarios/governance.rs b/src/cli/exercise/scenarios/governance.rs index 7e70b42..437f8a2 100644 --- a/src/cli/exercise/scenarios/governance.rs +++ b/src/cli/exercise/scenarios/governance.rs @@ -56,7 +56,13 @@ async fn referendum_flow(ctx: &mut ExerciseCtx) -> Result { let preimage_hash: sp_core::H256 = BlakeTwo256::hash(&encoded); let call_len = encoded.len() as u32; - crate::cli::common::submit_preimage(&ctx.client, &ctx.alice, encoded, ctx.wait_mode()).await?; + crate::cli::common::submit_preimage( + &ctx.client, + &crate::wallet::WalletSigner::Hot(ctx.alice.clone()), + encoded, + ctx.wait_mode(), + ) + .await?; let count_addr = quantus_subxt::api::storage().tech_referenda().referendum_count(); let index = storage_at.fetch(&count_addr).await?.unwrap_or(0); @@ -71,7 +77,7 @@ async fn referendum_flow(ctx: &mut ExerciseCtx) -> Result { for voter in [ctx.alice.clone(), ctx.bob.clone(), ctx.charlie.clone()] { crate::cli::tech_collective::vote_on_referendum( &ctx.client, - &voter, + &crate::wallet::WalletSigner::Hot(voter.clone()), index, true, ctx.wait_mode(), @@ -143,7 +149,13 @@ async fn referendum_metadata(ctx: &mut ExerciseCtx) -> Result { .map_err(|e| QuantusError::Generic(format!("failed to encode remark call: {e:?}")))?; let proposal_hash: sp_core::H256 = BlakeTwo256::hash(&encoded); let call_len = encoded.len() as u32; - crate::cli::common::submit_preimage(&ctx.client, &ctx.alice, encoded, ctx.wait_mode()).await?; + crate::cli::common::submit_preimage( + &ctx.client, + &crate::wallet::WalletSigner::Hot(ctx.alice.clone()), + encoded, + ctx.wait_mode(), + ) + .await?; let latest = ctx.client.get_latest_block().await?; let count_addr = quantus_subxt::api::storage().tech_referenda().referendum_count(); @@ -157,7 +169,7 @@ async fn referendum_metadata(ctx: &mut ExerciseCtx) -> Result { let metadata_hash: sp_core::H256 = BlakeTwo256::hash(&metadata); crate::cli::common::submit_preimage( &ctx.client, - &ctx.alice, + &crate::wallet::WalletSigner::Hot(ctx.alice.clone()), metadata.to_vec(), ctx.wait_mode(), ) diff --git a/src/cli/exercise/scenarios/multisig.rs b/src/cli/exercise/scenarios/multisig.rs index 4451065..065ed06 100644 --- a/src/cli/exercise/scenarios/multisig.rs +++ b/src/cli/exercise/scenarios/multisig.rs @@ -50,7 +50,7 @@ async fn lifecycle(ctx: &mut ExerciseCtx) -> Result { crate::cli::send::transfer( &ctx.client, - &signer_a, + &signer_a.as_signer(), &multisig_ss58, 20 * ctx.test_unit, None, diff --git a/src/cli/exercise/scenarios/negative.rs b/src/cli/exercise/scenarios/negative.rs index f50641f..0f29569 100644 --- a/src/cli/exercise/scenarios/negative.rs +++ b/src/cli/exercise/scenarios/negative.rs @@ -91,7 +91,7 @@ async fn stale_nonce(ctx: &mut ExerciseCtx) -> Result { let call = transfer_call(account_id_of(&recipient)?, ctx.test_unit); match crate::cli::common::submit_transaction_with_nonce( &ctx.client, - &sender, + &crate::wallet::WalletSigner::Hot(sender.clone()), call, None, 0, diff --git a/src/cli/exercise/scenarios/preimage.rs b/src/cli/exercise/scenarios/preimage.rs index d3ccdad..7ff544a 100644 --- a/src/cli/exercise/scenarios/preimage.rs +++ b/src/cli/exercise/scenarios/preimage.rs @@ -34,7 +34,13 @@ async fn note_and_verify(ctx: &mut ExerciseCtx) -> Result { let expected_hash: sp_core::H256 = BlakeTwo256::hash(&encoded); let keypair = ctx.eph[3].clone(); - crate::cli::common::submit_preimage(&ctx.client, &keypair, encoded, ctx.wait_mode()).await?; + crate::cli::common::submit_preimage( + &ctx.client, + &crate::wallet::WalletSigner::Hot(keypair.clone()), + encoded, + ctx.wait_mode(), + ) + .await?; let status_addr = quantus_subxt::api::storage().preimage().request_status_for(expected_hash); let latest = ctx.client.get_latest_block().await?; @@ -60,7 +66,13 @@ async fn unnote_preimage(ctx: &mut ExerciseCtx) -> Result { let hash: sp_core::H256 = BlakeTwo256::hash(&encoded); let keypair = ctx.eph[3].clone(); - crate::cli::common::submit_preimage(&ctx.client, &keypair, encoded, ctx.wait_mode()).await?; + crate::cli::common::submit_preimage( + &ctx.client, + &crate::wallet::WalletSigner::Hot(keypair.clone()), + encoded, + ctx.wait_mode(), + ) + .await?; if !preimage_status_exists(ctx, hash).await? { return Err(QuantusError::Generic(format!( "preimage {hash:?} missing from storage before unnote" @@ -84,7 +96,13 @@ async fn ensure_updated(ctx: &mut ExerciseCtx) -> Result { let encoded = unique_remark_call_data(ctx)?; let hash: sp_core::H256 = BlakeTwo256::hash(&encoded); let keypair = ctx.eph[3].clone(); - crate::cli::common::submit_preimage(&ctx.client, &keypair, encoded, ctx.wait_mode()).await?; + crate::cli::common::submit_preimage( + &ctx.client, + &crate::wallet::WalletSigner::Hot(keypair.clone()), + encoded, + ctx.wait_mode(), + ) + .await?; let call = quantus_subxt::api::tx().preimage().ensure_updated(vec![hash]); submit_ok(ctx, &keypair, call).await?; diff --git a/src/cli/exercise/scenarios/reversible.rs b/src/cli/exercise/scenarios/reversible.rs index d10bd59..785a612 100644 --- a/src/cli/exercise/scenarios/reversible.rs +++ b/src/cli/exercise/scenarios/reversible.rs @@ -45,7 +45,7 @@ async fn schedule_and_cancel(ctx: &mut ExerciseCtx) -> Result { crate::cli::reversible::schedule_transfer_with_delay( &ctx.client, - &sender, + &crate::wallet::WalletSigner::Hot(sender.clone()), &recipient, amount, 50, @@ -81,7 +81,7 @@ async fn schedule_with_delay(ctx: &mut ExerciseCtx) -> Result { crate::cli::reversible::schedule_transfer_with_delay( &ctx.client, - &sender, + &crate::wallet::WalletSigner::Hot(sender.clone()), &recipient, ctx.test_unit, delay_blocks, @@ -110,7 +110,7 @@ async fn set_high_security(ctx: &mut ExerciseCtx) -> Result { let funder = ctx.eph[3].clone(); crate::cli::send::transfer( &ctx.client, - &funder, + &funder.as_signer(), &account_ss58, 10 * ctx.test_unit, None, @@ -146,7 +146,7 @@ async fn set_high_security(ctx: &mut ExerciseCtx) -> Result { let recipient = ctx.fresh_keypair()?.try_to_account_id_ss58check()?; crate::cli::reversible::schedule_transfer( &ctx.client, - &account, + &crate::wallet::WalletSigner::Hot(account.clone()), &recipient, ctx.test_unit, ctx.wait_mode(), @@ -185,7 +185,7 @@ async fn guardian_recover_funds(ctx: &mut ExerciseCtx) -> Result { let funder = ctx.eph[3].clone(); crate::cli::send::transfer( &ctx.client, - &funder, + &funder.as_signer(), &account_ss58, 20 * ctx.test_unit, None, @@ -205,7 +205,7 @@ async fn guardian_recover_funds(ctx: &mut ExerciseCtx) -> Result { let recipient = ctx.fresh_keypair()?.try_to_account_id_ss58check()?; crate::cli::reversible::schedule_transfer( &ctx.client, - &account, + &crate::wallet::WalletSigner::Hot(account.clone()), &recipient, ctx.test_unit, ctx.wait_mode(), diff --git a/src/cli/exercise/scenarios/upgrade.rs b/src/cli/exercise/scenarios/upgrade.rs index 8a14ae4..563dafd 100644 --- a/src/cli/exercise/scenarios/upgrade.rs +++ b/src/cli/exercise/scenarios/upgrade.rs @@ -68,8 +68,13 @@ pub async fn run( async fn submit_root_referendum(ctx: &mut ExerciseCtx, encoded_call: Vec) -> Result { let preimage_hash: sp_core::H256 = BlakeTwo256::hash(&encoded_call); let call_len = encoded_call.len() as u32; - crate::cli::common::submit_preimage(&ctx.client, &ctx.alice, encoded_call, ctx.wait_mode()) - .await?; + crate::cli::common::submit_preimage( + &ctx.client, + &crate::wallet::WalletSigner::Hot(ctx.alice.clone()), + encoded_call, + ctx.wait_mode(), + ) + .await?; let latest = ctx.client.get_latest_block().await?; let count_addr = quantus_subxt::api::storage().tech_referenda().referendum_count(); @@ -102,6 +107,9 @@ async fn cast_collective_ayes(ctx: &ExerciseCtx, index: u32) -> Result<()> { let charlie = ctx.charlie.clone(); let mode = ctx.wait_mode(); + let alice = crate::wallet::WalletSigner::Hot(alice); + let bob = crate::wallet::WalletSigner::Hot(bob); + let charlie = crate::wallet::WalletSigner::Hot(charlie); let (r_a, r_b, r_c) = tokio::join!( crate::cli::tech_collective::vote_on_referendum(client, &alice, index, true, mode), crate::cli::tech_collective::vote_on_referendum(client, &bob, index, true, mode), diff --git a/src/cli/exercise/scenarios/utility.rs b/src/cli/exercise/scenarios/utility.rs index f2a7c86..431d21c 100644 --- a/src/cli/exercise/scenarios/utility.rs +++ b/src/cli/exercise/scenarios/utility.rs @@ -119,7 +119,7 @@ async fn as_derivative(ctx: &mut ExerciseCtx) -> Result { let funding = 5 * ctx.test_unit; crate::cli::send::transfer( &ctx.client, - &sender, + &sender.as_signer(), &derivative_ss58, funding, None, diff --git a/src/cli/generic_call.rs b/src/cli/generic_call.rs index f8a98ee..a80a4b9 100644 --- a/src/cli/generic_call.rs +++ b/src/cli/generic_call.rs @@ -1,7 +1,7 @@ //! `quantus call` subcommand - generic pallet calls use crate::{ chain::quantus_subxt, error::QuantusError, log_error, log_print, log_success, log_verbose, - wallet::QuantumKeyPair, + wallet::WalletSigner, }; use colored::Colorize; use serde_json::Value; @@ -57,23 +57,18 @@ pub async fn execute_generic_call( pallet: &str, call: &str, args: Vec, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, tip: Option, execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { log_print!("🚀 Executing generic call"); log_print!("Pallet: {}", pallet.bright_green()); log_print!("Call: {}", call.bright_cyan()); - log_print!("From: {}", from_keypair.try_to_account_id_ss58check()?.bright_yellow()); + log_print!("From: {}", from_keypair.try_account_id_ss58check()?.bright_yellow()); if let Some(tip) = &tip { log_print!("Tip: {}", tip.bright_magenta()); } - // Convert our QuantumKeyPair to subxt Signer - let _signer = from_keypair - .to_subxt_signer() - .map_err(|e| QuantusError::NetworkError(format!("Failed to convert keypair: {e:?}")))?; - // Validate pallet/call exists in metadata let metadata = quantus_client.client().metadata(); let pallet_metadata = metadata @@ -170,7 +165,7 @@ pub async fn execute_generic_call( /// Submit balance transfer async fn submit_balance_transfer( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, args: &[Value], keep_alive: bool, tip: Option, @@ -229,7 +224,7 @@ async fn submit_balance_transfer( /// Submit system remark async fn submit_system_remark( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, args: &[Value], tip: Option, execution_mode: crate::cli::common::ExecutionMode, @@ -259,7 +254,7 @@ async fn submit_system_remark( /// Submit tech collective add member async fn submit_tech_collective_add_member( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, args: &[Value], execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -292,7 +287,7 @@ async fn submit_tech_collective_add_member( /// Submit tech collective remove member async fn submit_tech_collective_remove_member( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, args: &[Value], execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -336,7 +331,7 @@ async fn submit_tech_collective_remove_member( /// Submit tech collective vote async fn submit_tech_collective_vote( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, args: &[Value], execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -364,7 +359,7 @@ async fn submit_tech_collective_vote( /// Submit reversible transfer async fn submit_reversible_transfer( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &QuantumKeyPair, + from_keypair: &WalletSigner, args: &[Value], execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -408,7 +403,7 @@ pub async fn handle_generic_call( pallet: &str, call: &str, args: Vec, - keypair: &QuantumKeyPair, + keypair: &WalletSigner, tip: Option, node_url: &str, execution_mode: crate::cli::common::ExecutionMode, diff --git a/src/cli/high_security.rs b/src/cli/high_security.rs index 11b46d1..0087c8d 100644 --- a/src/cli/high_security.rs +++ b/src/cli/high_security.rs @@ -130,7 +130,7 @@ pub async fn handle_high_security_command( } => { log_print!("🛡️ Set High Security"); log_verbose!("📦 Using wallet: {}", from.bright_blue().bold()); - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Resolve interceptor: allow wallet name or SS58 address let interceptor_resolved = crate::cli::common::resolve_address(&interceptor)?; @@ -169,7 +169,7 @@ pub async fn handle_high_security_command( let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, tx_call, None, execution_mode, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5aa8247..71b2df0 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; @@ -317,6 +318,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 @@ -515,7 +543,7 @@ async fn handle_generic_call_command( return Ok(()); } - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; let args_vec = if let Some(args_str) = args { serde_json::from_str(&args_str).map_err(|e| { @@ -529,7 +557,7 @@ async fn handle_generic_call_command( &pallet, &call, args_vec, - &keypair, + &signer, tip, node_url, execution_mode, @@ -589,6 +617,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/multisend.rs b/src/cli/multisend.rs index 1337a59..f3154ed 100644 --- a/src/cli/multisend.rs +++ b/src/cli/multisend.rs @@ -8,8 +8,9 @@ use crate::{ cli::{ common::{resolve_address, ExecutionMode}, send::{ - batch_transfer, format_balance, format_balance_with_symbol, get_balance, - get_chain_properties, parse_amount, + build_batch_transfer_call, format_balance, format_balance_with_symbol, get_balance, + get_chain_properties, parse_amount, submit_prebuilt_batch_transfer_call, + validate_batch_transfer_request, }, }, error::{QuantusError, Result}, @@ -278,8 +279,8 @@ pub async fn handle_multisend_command( log_info!("Preparing multisend transaction..."); // Load wallet - let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?; - let from_account_id = keypair.try_to_account_id_ss58check()?; + let signer = crate::wallet::load_signer_from_wallet(&from_wallet, password, password_file)?; + let from_account_id = signer.try_account_id_ss58check()?; // Check balance let balance = get_balance(&quantus_client, &from_account_id).await?; @@ -303,8 +304,18 @@ pub async fn handle_multisend_command( }; // Submit batch transaction - let tx_hash = - batch_transfer(&quantus_client, &keypair, transfers, tip_amount, execution_mode).await?; + validate_batch_transfer_request(&quantus_client, &signer, &transfers).await?; + log_verbose!("✍️ Creating batch extrinsic with {} calls...", transfers.len()); + let batch_call = build_batch_transfer_call(&transfers)?; + let tx_hash = submit_prebuilt_batch_transfer_call( + &quantus_client, + &signer, + &transfers, + batch_call, + tip_amount, + execution_mode, + ) + .await?; log_print!( "{} Multisend transaction submitted! Hash: {:?}", diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index 5c8b2e4..96cbd0d 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -472,12 +472,13 @@ pub fn predict_multisig_address( account_id.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)) } -fn keypair_to_subxt_account_id( - keypair: &crate::wallet::QuantumKeyPair, +fn signer_to_subxt_account_id( + signer: &crate::wallet::WalletSigner, ) -> crate::error::Result { - let account_id = keypair.try_to_account_id_32()?; - let account_bytes: [u8; 32] = *account_id.as_ref(); - Ok(SubxtAccountId32::from(account_bytes)) + let ss58 = signer.try_account_id_ss58check()?; + let (account_id, _) = SpAccountId32::from_ss58check_with_version(&ss58) + .map_err(|_| crate::error::WalletError::InvalidAddress)?; + Ok(SubxtAccountId32::from(<[u8; 32]>::from(account_id))) } fn sorted_account_ids_equal(left: &[SubxtAccountId32], right: &[SubxtAccountId32]) -> bool { @@ -540,7 +541,7 @@ fn find_matching_multisig_created_address<'a>( #[allow(dead_code)] pub async fn create_multisig( quantus_client: &crate::chain::client::QuantusClient, - creator_keypair: &crate::wallet::QuantumKeyPair, + creator: &crate::wallet::WalletSigner, signers: Vec, threshold: u32, nonce: u64, @@ -553,12 +554,12 @@ pub async fn create_multisig( .create_multisig(signers.clone(), threshold, nonce); // Submit transaction - let creator_account_id = keypair_to_subxt_account_id(creator_keypair)?; + let creator_account_id = signer_to_subxt_account_id(creator)?; let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: wait_for_inclusion }; let (tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( quantus_client, - creator_keypair, + creator, create_tx, None, execution_mode, @@ -613,7 +614,7 @@ pub async fn create_multisig( #[allow(dead_code)] pub async fn propose_transfer( quantus_client: &crate::chain::client::QuantusClient, - proposer_keypair: &crate::wallet::QuantumKeyPair, + proposer: &crate::wallet::WalletSigner, multisig_address: subxt::ext::subxt_core::utils::AccountId32, to_address: subxt::ext::subxt_core::utils::AccountId32, amount: u128, @@ -647,7 +648,7 @@ pub async fn propose_transfer( let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; let tx_hash = crate::cli::common::submit_transaction( quantus_client, - proposer_keypair, + proposer, propose_tx, None, execution_mode, @@ -664,7 +665,7 @@ pub async fn propose_transfer( #[allow(dead_code)] pub async fn propose_custom( quantus_client: &crate::chain::client::QuantusClient, - proposer_keypair: &crate::wallet::QuantumKeyPair, + proposer: &crate::wallet::WalletSigner, multisig_address: subxt::ext::subxt_core::utils::AccountId32, call_data: Vec, expiry: u32, @@ -680,7 +681,7 @@ pub async fn propose_custom( let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; let tx_hash = crate::cli::common::submit_transaction( quantus_client, - proposer_keypair, + proposer, propose_tx, None, execution_mode, @@ -697,7 +698,7 @@ pub async fn propose_custom( #[allow(dead_code)] pub async fn approve_proposal( quantus_client: &crate::chain::client::QuantusClient, - approver_keypair: &crate::wallet::QuantumKeyPair, + approver: &crate::wallet::WalletSigner, multisig_address: subxt::ext::subxt_core::utils::AccountId32, proposal_id: u32, ) -> crate::error::Result { @@ -720,7 +721,7 @@ pub async fn approve_proposal( let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; let tx_hash = crate::cli::common::submit_transaction( quantus_client, - approver_keypair, + approver, approve_tx, None, execution_mode, @@ -737,7 +738,7 @@ pub async fn approve_proposal( #[allow(dead_code)] pub async fn cancel_proposal( quantus_client: &crate::chain::client::QuantusClient, - proposer_keypair: &crate::wallet::QuantumKeyPair, + proposer: &crate::wallet::WalletSigner, multisig_address: subxt::ext::subxt_core::utils::AccountId32, proposal_id: u32, ) -> crate::error::Result { @@ -746,7 +747,7 @@ pub async fn cancel_proposal( let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; let tx_hash = crate::cli::common::submit_transaction( quantus_client, - proposer_keypair, + proposer, cancel_tx, None, execution_mode, @@ -1168,9 +1169,9 @@ async fn handle_create_multisig( log_print!(" {}", predicted_address.bright_cyan().bold()); log_print!(""); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; - let creator_account_id = keypair_to_subxt_account_id(&keypair)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; + let creator_account_id = signer_to_subxt_account_id(&signer)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -1190,7 +1191,7 @@ async fn handle_create_multisig( let (_tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( &quantus_client, - &keypair, + &signer, create_tx, None, create_execution_mode, @@ -1634,8 +1635,8 @@ async fn handle_propose( log_verbose!("Call data size: {} bytes", call_data.len()); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Build transaction let propose_tx = quantus_subxt::api::tx().multisig().propose( @@ -1650,7 +1651,7 @@ async fn handle_propose( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, propose_tx, None, propose_execution_mode, @@ -1703,8 +1704,8 @@ async fn handle_propose_with_call_data( log_verbose!("Current block: {}, expiry valid", current_block_number); log_verbose!("Call data size: {} bytes", call_data.len()); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Build transaction let propose_tx = quantus_subxt::api::tx().multisig().propose( @@ -1719,7 +1720,7 @@ async fn handle_propose_with_call_data( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( quantus_client, - &keypair, + &signer, propose_tx, None, propose_execution_mode, @@ -1756,8 +1757,8 @@ async fn handle_approve( log_verbose!("Multisig: {}", multisig_ss58); log_verbose!("Proposal ID: {}", proposal_id); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -1827,7 +1828,7 @@ async fn handle_approve( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, approve_tx, None, approve_execution_mode, @@ -1868,8 +1869,8 @@ async fn handle_execute( log_verbose!("Multisig: {}", multisig_ss58); log_verbose!("Proposal ID: {}", proposal_id); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -1909,7 +1910,7 @@ async fn handle_execute( crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, execute_tx, None, exec_execution_mode, @@ -1944,8 +1945,8 @@ async fn handle_cancel( log_verbose!("Proposal ID: {}", proposal_id); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -1992,7 +1993,7 @@ async fn handle_cancel( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, cancel_tx, None, cancel_execution_mode, @@ -2028,8 +2029,8 @@ async fn handle_remove_expired( log_verbose!("Proposal ID: {}", proposal_id); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -2045,7 +2046,7 @@ async fn handle_remove_expired( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, remove_tx, None, remove_execution_mode, @@ -2077,8 +2078,8 @@ async fn handle_claim_deposits( let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); let multisig_address = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -2092,7 +2093,7 @@ async fn handle_claim_deposits( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, claim_tx, None, claim_execution_mode, @@ -3137,8 +3138,8 @@ async fn handle_high_security_set( log_print!(""); } - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Build propose transaction let propose_tx = quantus_subxt::api::tx().multisig().propose( @@ -3153,7 +3154,7 @@ async fn handle_high_security_set( // Submit transaction and wait for on-chain confirmation crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, propose_tx, None, propose_execution_mode, diff --git a/src/cli/preimage.rs b/src/cli/preimage.rs index 3b736c2..a3da839 100644 --- a/src/cli/preimage.rs +++ b/src/cli/preimage.rs @@ -309,8 +309,8 @@ async fn request_preimage( log_print!("🚀 Requesting preimage for hash: {}", hash_str.bright_cyan()); log_print!(" 👤 From: {}", from_str.bright_yellow()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from_str, None, None)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from_str, None, None)?; // Create request_preimage call let request_call = quantus_subxt::api::tx().preimage().request_preimage(preimage_hash); @@ -318,7 +318,7 @@ async fn request_preimage( // Submit transaction let tx_hash = crate::cli::common::submit_transaction( quantus_client, - &keypair, + &signer, request_call, None, execution_mode, @@ -346,8 +346,8 @@ async fn note_preimage( log_print!("📝 Noting preimage for content length: {} bytes", content.len()); log_print!(" 👤 From: {}", from_str.bright_yellow()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from_str, None, None)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from_str, None, None)?; // Create note_preimage call let note_call = quantus_subxt::api::tx().preimage().note_preimage(content); @@ -355,7 +355,7 @@ async fn note_preimage( // Submit transaction let tx_hash = crate::cli::common::submit_transaction( quantus_client, - &keypair, + &signer, note_call, None, execution_mode, @@ -392,8 +392,8 @@ async fn create_preimage( log_print!("📊 WASM file size: {} bytes", wasm_code.len()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from_str, password, password_file)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from_str, password, password_file)?; // Build a static payload for System::set_code and encode full call data (pallet + call + args) let set_code_payload = quantus_subxt::api::tx().system().set_code(wasm_code.clone()); @@ -416,7 +416,7 @@ async fn create_preimage( let note_preimage_tx = quantus_subxt::api::tx().preimage().note_preimage(bounded_bytes); let preimage_tx_hash = crate::cli::common::submit_transaction( quantus_client, - &keypair, + &signer, note_preimage_tx, None, execution_mode, diff --git a/src/cli/recovery.rs b/src/cli/recovery.rs index 7ac49ba..f4f619d 100644 --- a/src/cli/recovery.rs +++ b/src/cli/recovery.rs @@ -177,9 +177,8 @@ pub async fn handle_recovery_command( match command { RecoveryCommands::Initiate { rescuer, lost, password, password_file } => { - let rescuer_key = - crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; - let rescuer_addr = rescuer_key.try_to_account_id_ss58check()?; + let signer = crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; + let rescuer_addr = signer.try_account_id_ss58check()?; log_print!("🔑 Rescuer: {}", rescuer); log_print!("🔑 Rescuer address: {}", rescuer_addr); let lost_id = resolve_to_subxt_account_id(&lost)?; @@ -189,7 +188,7 @@ pub async fn handle_recovery_command( let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &rescuer_key, + &signer, call, None, execution_mode, @@ -204,8 +203,7 @@ pub async fn handle_recovery_command( }, RecoveryCommands::Vouch { friend, lost, rescuer, password, password_file } => { - let friend_key = - crate::wallet::load_keypair_from_wallet(&friend, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&friend, password, password_file)?; let lost_id = resolve_to_subxt_account_id(&lost)?; let rescuer_id = resolve_to_subxt_account_id(&rescuer)?; let call = quantus_subxt::api::tx().recovery().vouch_recovery( @@ -214,7 +212,7 @@ pub async fn handle_recovery_command( ); let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &friend_key, + &signer, call, None, execution_mode, @@ -229,15 +227,14 @@ pub async fn handle_recovery_command( }, RecoveryCommands::Claim { rescuer, lost, password, password_file } => { - let rescuer_key = - crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; let lost_id = resolve_to_subxt_account_id(&lost)?; let call = quantus_subxt::api::tx() .recovery() .claim_recovery(subxt::ext::subxt_core::utils::MultiAddress::Id(lost_id)); let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &rescuer_key, + &signer, call, None, execution_mode, @@ -262,9 +259,8 @@ pub async fn handle_recovery_command( } => { use quantus_subxt::api::runtime_types::pallet_balances::pallet::Call as BalancesCall; - let rescuer_key = - crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; - let rescuer_addr = rescuer_key.try_to_account_id_ss58check()?; + let signer = crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; + let rescuer_addr = signer.try_account_id_ss58check()?; log_print!("🔑 Rescuer: {}", rescuer); log_print!("🔑 Rescuer address: {}", rescuer_addr); @@ -334,7 +330,7 @@ pub async fn handle_recovery_command( let tx_hash = match crate::cli::common::submit_transaction( &quantus_client, - &rescuer_key, + &signer, call, None, execution_mode, @@ -361,10 +357,9 @@ pub async fn handle_recovery_command( } => { use quantus_subxt::api::runtime_types::pallet_balances::pallet::Call as BalancesCall; - let rescuer_key = - crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; - let rescuer_addr = rescuer_key.try_to_account_id_ss58check()?; + let rescuer_addr = signer.try_account_id_ss58check()?; log_print!("🔑 Rescuer: {}", rescuer); log_print!("🔑 Rescuer address: {}", rescuer_addr); @@ -433,7 +428,7 @@ pub async fn handle_recovery_command( let tx_hash = match crate::cli::common::submit_transaction( &quantus_client, - &rescuer_key, + &signer, call, None, execution_mode, @@ -450,14 +445,14 @@ pub async fn handle_recovery_command( }, RecoveryCommands::Close { lost, rescuer, password, password_file } => { - let lost_key = crate::wallet::load_keypair_from_wallet(&lost, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&lost, password, password_file)?; let rescuer_id = resolve_to_subxt_account_id(&rescuer)?; let call = quantus_subxt::api::tx() .recovery() .close_recovery(subxt::ext::subxt_core::utils::MultiAddress::Id(rescuer_id)); let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &lost_key, + &signer, call, None, execution_mode, @@ -474,15 +469,14 @@ pub async fn handle_recovery_command( }, RecoveryCommands::CancelProxy { rescuer, lost, password, password_file } => { - let rescuer_key = - crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&rescuer, password, password_file)?; let lost_id = resolve_to_subxt_account_id(&lost)?; let call = quantus_subxt::api::tx() .recovery() .cancel_recovered(subxt::ext::subxt_core::utils::MultiAddress::Id(lost_id)); let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &rescuer_key, + &signer, call, None, execution_mode, diff --git a/src/cli/reversible.rs b/src/cli/reversible.rs index 588e152..75cd557 100644 --- a/src/cli/reversible.rs +++ b/src/cli/reversible.rs @@ -111,13 +111,13 @@ pub enum ReversibleCommands { /// Schedule a transfer with default delay pub async fn schedule_transfer( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + from_signer: &crate::wallet::WalletSigner, to_address: &str, amount: u128, execution_mode: crate::cli::common::ExecutionMode, ) -> Result { log_verbose!("🔄 Creating reversible transfer..."); - log_verbose!(" From: {}", from_keypair.try_to_account_id_ss58check()?.bright_cyan()); + log_verbose!(" From: {}", from_signer.try_account_id_ss58check()?.bright_cyan()); log_verbose!(" To: {}", to_address.bright_green()); log_verbose!(" Amount: {}", amount); @@ -141,7 +141,7 @@ pub async fn schedule_transfer( // Submit the transaction let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + from_signer, transfer_call, None, execution_mode, @@ -156,7 +156,7 @@ pub async fn schedule_transfer( /// Cancel a pending reversible transaction pub async fn cancel_transaction( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + from_signer: &crate::wallet::WalletSigner, tx_id: &str, execution_mode: crate::cli::common::ExecutionMode, ) -> Result { @@ -176,7 +176,7 @@ pub async fn cancel_transaction( // Submit the transaction let tx_hash_result = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + from_signer, cancel_call, None, execution_mode, @@ -191,7 +191,7 @@ pub async fn cancel_transaction( /// Schedule a transfer with custom delay pub async fn schedule_transfer_with_delay( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + from_signer: &crate::wallet::WalletSigner, to_address: &str, amount: u128, delay: u64, @@ -200,7 +200,7 @@ pub async fn schedule_transfer_with_delay( ) -> Result { let unit_str = if unit_blocks { "blocks" } else { "seconds" }; log_verbose!("🔄 Creating reversible transfer with custom delay ..."); - log_verbose!(" From: {}", from_keypair.try_to_account_id_ss58check()?.bright_cyan()); + log_verbose!(" From: {}", from_signer.try_account_id_ss58check()?.bright_cyan()); log_verbose!(" To: {}", to_address.bright_green()); log_verbose!(" Amount: {}", amount); log_verbose!(" Delay: {} {}", delay, unit_str); @@ -234,7 +234,7 @@ pub async fn schedule_transfer_with_delay( // Submit the transaction let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + from_signer, transfer_call, None, execution_mode, @@ -282,12 +282,12 @@ pub async fn handle_reversible_command( // Get password securely for decryption log_verbose!("📦 Using wallet: {}", from.bright_blue().bold()); - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Submit transaction let tx_hash = schedule_transfer( &quantus_client, - &keypair, + &signer, &resolved_address, raw_amount, execution_mode, @@ -311,11 +311,11 @@ pub async fn handle_reversible_command( // Get password securely for decryption log_verbose!("📦 Using wallet: {}", from.bright_blue().bold()); - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Submit cancel transaction let tx_hash = - cancel_transaction(&quantus_client, &keypair, &tx_id, execution_mode).await?; + cancel_transaction(&quantus_client, &signer, &tx_id, execution_mode).await?; log_print!( "✅ {} Cancel transaction submitted! Hash: {:?}", @@ -355,12 +355,12 @@ pub async fn handle_reversible_command( // Get password securely for decryption log_verbose!("📦 Using wallet: {}", from.bright_blue().bold()); - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Submit transaction let tx_hash = schedule_transfer_with_delay( &quantus_client, - &keypair, + &signer, &resolved_address, raw_amount, delay, @@ -401,9 +401,8 @@ async fn list_pending_transactions( }, (None, Some(wallet)) => { // Load wallet and get its address - let keypair = - crate::wallet::load_keypair_from_wallet(&wallet, password, password_file)?; - keypair.try_to_account_id_ss58check()? + let signer = crate::wallet::load_signer_from_wallet(&wallet, password, password_file)?; + signer.try_account_id_ss58check()? }, (None, None) => { return Err(crate::error::QuantusError::Generic( diff --git a/src/cli/runtime.rs b/src/cli/runtime.rs index 43c2933..c7a3a07 100644 --- a/src/cli/runtime.rs +++ b/src/cli/runtime.rs @@ -1,7 +1,7 @@ //! `quantus runtime` subcommand - runtime management use crate::{ chain::quantus_subxt, error::QuantusError, log_print, log_success, log_verbose, - wallet::QuantumKeyPair, + wallet::WalletSigner, }; use clap::Subcommand; use colored::Colorize; @@ -47,7 +47,7 @@ pub enum RuntimeCommands { pub async fn update_runtime( quantus_client: &crate::chain::client::QuantusClient, wasm_code: Vec, - from_keypair: &QuantumKeyPair, + signer: &WalletSigner, force: bool, execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -102,7 +102,7 @@ pub async fn update_runtime( // Submit preimage and wait for inclusion so the referendum tx gets a fresh nonce crate::cli::common::submit_preimage( quantus_client, - from_keypair, + signer, encoded_call.clone(), execution_mode, ) @@ -143,7 +143,7 @@ pub async fn update_runtime( let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + signer, submit_call, None, execution_mode, @@ -220,8 +220,8 @@ pub async fn handle_runtime_command( } } - // Load keypair - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + // Load signer + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Read WASM file log_verbose!("📖 Reading WASM file..."); @@ -231,7 +231,7 @@ pub async fn handle_runtime_command( log_print!("📊 WASM file size: {} bytes", wasm_code.len()); // Update runtime - update_runtime(&quantus_client, wasm_code, &keypair, force, execution_mode).await?; + update_runtime(&quantus_client, wasm_code, &signer, force, execution_mode).await?; log_success!("🎉 Runtime update completed!"); log_print!( diff --git a/src/cli/send.rs b/src/cli/send.rs index 6d73f32..782d529 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -280,13 +280,34 @@ pub(crate) fn build_batch_transfer_call( pub async fn estimate_transaction_partial_fee( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, call: &Call, tip: Option, ) -> Result where Call: subxt::tx::Payload, { + // Cold wallets can't sign locally; the fixed-length Dilithium signature + // means a dummy-signature estimate is just as accurate. + if let crate::wallet::WalletSigner::Cold { address, .. } = signer { + use sp_core::crypto::Ss58Codec; + let account = sp_core::crypto::AccountId32::from_ss58check_with_version(address) + .map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid cold wallet address: {e:?}")) + })? + .0; + return crate::cli::cold_signing::estimate_cold_fee( + quantus_client, + call, + &account, + tip.unwrap_or(0), + ) + .await + .ok_or(crate::error::QuantusError::NetworkError( + "Failed to estimate transaction fee".to_string(), + )); + } + let from_keypair = signer.as_hot().expect("cold arm returned above"); let signer = from_keypair.to_subxt_signer().map_err(|e| { crate::error::QuantusError::NetworkError(format!("Failed to convert keypair: {e:?}")) })?; @@ -317,7 +338,7 @@ where pub(crate) async fn ensure_balance_covers_call( quantus_client: &QuantusClient, - keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, call: &Call, balance: u128, exact_required: u128, @@ -334,7 +355,7 @@ where }); } - match estimate_transaction_partial_fee(quantus_client, keypair, call, submit_tip).await { + match estimate_transaction_partial_fee(quantus_client, signer, call, submit_tip).await { Ok(estimated_fee) => { let estimated_total = checked_add(exact_required, estimated_fee, "required balance including fee")?; @@ -379,7 +400,7 @@ where async fn submit_transfer_call( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, transfer_call: Call, submit_tip: Option, nonce: Option, @@ -392,7 +413,7 @@ where log_verbose!("🔢 Using manual nonce: {}", manual_nonce); crate::cli::common::submit_transaction_with_nonce( quantus_client, - from_keypair, + signer, transfer_call, submit_tip, manual_nonce, @@ -402,7 +423,7 @@ where } else { crate::cli::common::submit_transaction( quantus_client, - from_keypair, + signer, transfer_call, submit_tip, execution_mode, @@ -417,14 +438,13 @@ where #[allow(dead_code)] // Used by external libraries via lib.rs export pub async fn transfer( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, to_address: &str, amount: u128, tip: Option, execution_mode: crate::cli::common::ExecutionMode, ) -> Result { - transfer_with_nonce(quantus_client, from_keypair, to_address, amount, tip, None, execution_mode) - .await + transfer_with_nonce(quantus_client, signer, to_address, amount, tip, None, execution_mode).await } /// Transfer tokens with manual nonce override. @@ -432,7 +452,7 @@ pub async fn transfer( /// Pass `Some(0)` or `None` to omit a tip. pub async fn transfer_with_nonce( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, to_address: &str, amount: u128, tip: Option, @@ -440,7 +460,7 @@ pub async fn transfer_with_nonce( execution_mode: crate::cli::common::ExecutionMode, ) -> Result { log_verbose!("🚀 Creating transfer transaction..."); - log_verbose!(" From: {}", from_keypair.try_to_account_id_ss58check()?.bright_cyan()); + log_verbose!(" From: {}", signer.try_account_id_ss58check()?.bright_cyan()); log_verbose!(" To: {}", to_address.bright_green()); log_verbose!(" Amount: {}", amount); @@ -456,7 +476,7 @@ pub async fn transfer_with_nonce( // Submit the transaction with optional manual nonce let tx_hash = submit_transfer_call( quantus_client, - from_keypair, + signer, transfer_call, submit_tip, nonce, @@ -471,11 +491,11 @@ pub async fn transfer_with_nonce( pub(crate) async fn validate_batch_transfer_request( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, transfers: &[(String, u128)], ) -> Result<()> { log_verbose!("🚀 Preparing batch transfer transaction with {} transfers...", transfers.len()); - log_verbose!(" From: {}", from_keypair.try_to_account_id_ss58check()?.bright_cyan()); + log_verbose!(" From: {}", signer.try_account_id_ss58check()?.bright_cyan()); if transfers.is_empty() { return Err(crate::error::QuantusError::Generic( @@ -513,7 +533,7 @@ pub(crate) async fn validate_batch_transfer_request( pub(crate) async fn submit_prebuilt_batch_transfer_call( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, transfers: &[(String, u128)], batch_call: Call, tip: Option, @@ -526,7 +546,7 @@ where let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + signer, batch_call, positive_tip_amount(tip), execution_mode, @@ -541,17 +561,17 @@ where /// Batch transfer tokens to multiple recipients in a single transaction pub async fn batch_transfer( quantus_client: &QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, transfers: Vec<(String, u128)>, // (to_address, amount) pairs tip: Option, execution_mode: crate::cli::common::ExecutionMode, ) -> Result { - validate_batch_transfer_request(quantus_client, from_keypair, &transfers).await?; + validate_batch_transfer_request(quantus_client, signer, &transfers).await?; log_verbose!("✍️ Creating batch extrinsic with {} calls...", transfers.len()); let batch_call = build_batch_transfer_call(&transfers)?; submit_prebuilt_batch_transfer_call( quantus_client, - from_keypair, + signer, &transfers, batch_call, tip, @@ -563,6 +583,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, @@ -592,12 +613,14 @@ pub async fn handle_send_command( resolved_address.bright_green() ); - // Get password securely for decryption log_verbose!("📦 Using wallet: {}", from_wallet.bright_blue().bold()); - let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?; + + // Hot wallets decrypt locally; cold (watch-only) wallets sign over QR in + // the submit stage — this command doesn't need to know which it is. + let signer = crate::wallet::load_signer_from_wallet(&from_wallet, password, password_file)?; // Get account information - let from_account_id = keypair.try_to_account_id_ss58check()?; + let from_account_id = signer.try_account_id_ss58check()?; let balance = get_balance(&quantus_client, &from_account_id).await?; // Get formatted balance with proper decimals @@ -616,7 +639,7 @@ pub async fn handle_send_command( let transfer_call = build_transfer_call_for_account_id(to_account_id, amount); ensure_balance_covers_call( &quantus_client, - &keypair, + &signer, &transfer_call, balance, exact_required, @@ -631,7 +654,7 @@ pub async fn handle_send_command( // Submit transaction let tx_hash = submit_transfer_call( &quantus_client, - &keypair, + &signer, transfer_call, submit_tip, nonce, @@ -639,6 +662,19 @@ pub async fn handle_send_command( ) .await?; + print_send_result(&quantus_client, &from_account_id, balance, amount, tx_hash, execution_mode) + .await +} + +/// Print the post-submission summary (status, new balance, fee). +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: {:?}", @@ -662,13 +698,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/tech_collective.rs b/src/cli/tech_collective.rs index d8cbe4c..576fae6 100644 --- a/src/cli/tech_collective.rs +++ b/src/cli/tech_collective.rs @@ -107,7 +107,7 @@ pub enum TechCollectiveCommands { /// Add a member to the Tech Collective pub async fn add_member( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, who_address: &str, execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { @@ -130,7 +130,7 @@ pub async fn add_member( let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + signer, add_member_call, None, execution_mode, @@ -145,7 +145,7 @@ pub async fn add_member( /// Remove a member from the Tech Collective pub async fn remove_member( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, who_address: &str, min_rank: u16, execution_mode: crate::cli::common::ExecutionMode, @@ -171,7 +171,7 @@ pub async fn remove_member( let tx_hash = crate::cli::common::submit_transaction( quantus_client, - from_keypair, + signer, remove_member_call, None, execution_mode, @@ -186,7 +186,7 @@ pub async fn remove_member( /// Vote on a Tech Referenda proposal pub async fn vote_on_referendum( quantus_client: &crate::chain::client::QuantusClient, - from_keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, referendum_index: u32, aye: bool, execution_mode: crate::cli::common::ExecutionMode, @@ -203,14 +203,9 @@ pub async fn vote_on_referendum( let wait_mode = crate::cli::common::ExecutionMode { wait_for_transaction: true, ..execution_mode }; - let tx_hash = crate::cli::common::submit_transaction( - quantus_client, - from_keypair, - vote_call, - None, - wait_mode, - ) - .await?; + let tx_hash = + crate::cli::common::submit_transaction(quantus_client, signer, vote_call, None, wait_mode) + .await?; log_verbose!("📋 Vote transaction confirmed: {:?}", tx_hash); @@ -333,10 +328,10 @@ pub async fn handle_tech_collective_command( log_print!(" 🔑 Signed by: {}", from.bright_yellow()); // Load wallet - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Submit transaction - let tx_hash = add_member(&quantus_client, &keypair, &who, execution_mode).await?; + let tx_hash = add_member(&quantus_client, &signer, &who, execution_mode).await?; log_print!( "✅ {} Add member transaction submitted! Hash: {:?}", @@ -352,11 +347,11 @@ pub async fn handle_tech_collective_command( log_print!(" 🔑 Signed by: {}", from.bright_yellow()); // Load wallet - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; // Submit transaction let tx_hash = - remove_member(&quantus_client, &keypair, &who, min_rank, execution_mode).await?; + remove_member(&quantus_client, &signer, &who, min_rank, execution_mode).await?; log_print!( "✅ {} Remove member transaction submitted! Hash: {:?}", @@ -374,16 +369,11 @@ pub async fn handle_tech_collective_command( ); log_print!(" 🔑 Signed by: {}", from.bright_yellow()); - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; - let tx_hash = vote_on_referendum( - &quantus_client, - &keypair, - referendum_index, - aye, - execution_mode, - ) - .await?; + let tx_hash = + vote_on_referendum(&quantus_client, &signer, referendum_index, aye, execution_mode) + .await?; log_print!( "✅ {} Vote confirmed in block! Hash: {:?}", diff --git a/src/cli/tech_referenda.rs b/src/cli/tech_referenda.rs index a2f7d1b..2215d71 100644 --- a/src/cli/tech_referenda.rs +++ b/src/cli/tech_referenda.rs @@ -258,8 +258,8 @@ async fn submit_runtime_upgrade( let preimage_hash_parsed: sp_core::H256 = sp_core::H256::from_str(hash_str) .map_err(|_| QuantusError::Generic("Invalid preimage hash format".to_string()))?; - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from, password, password_file)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from, password, password_file)?; // Check if preimage exists and get its length log_print!("🔍 Checking preimage status..."); @@ -321,7 +321,7 @@ async fn submit_runtime_upgrade( .submit(origin_caller, proposal, enactment); let tx_hash = - submit_transaction(quantus_client, &keypair, submit_call, None, execution_mode).await?; + submit_transaction(quantus_client, &signer, submit_call, None, execution_mode).await?; log_print!( "✅ {} Runtime upgrade proposal submitted! Hash: {:?}", "SUCCESS".bright_green().bold(), @@ -363,8 +363,8 @@ async fn submit_runtime_upgrade_with_preimage( log_print!("📊 WASM file size: {} bytes", wasm_code.len()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from, password, password_file)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from, password, password_file)?; // Build a static payload for System::set_code and encode full call data (pallet + call + args) let set_code_payload = quantus_subxt::api::tx().system().set_code(wasm_code.clone()); @@ -380,7 +380,7 @@ async fn submit_runtime_upgrade_with_preimage( log_print!("🔗 Preimage hash: {:?}", preimage_hash); let call_len = encoded_call.len() as u32; - crate::cli::common::submit_preimage(quantus_client, &keypair, encoded_call, execution_mode) + crate::cli::common::submit_preimage(quantus_client, &signer, encoded_call, execution_mode) .await?; // Build TechReferenda::submit call using Lookup preimage reference @@ -411,7 +411,7 @@ async fn submit_runtime_upgrade_with_preimage( .submit(origin_caller, proposal, enactment); let tx_hash = - submit_transaction(quantus_client, &keypair, submit_call, None, execution_mode).await?; + submit_transaction(quantus_client, &signer, submit_call, None, execution_mode).await?; log_success!("Runtime upgrade proposal submitted! Hash: {:?}", tx_hash); log_print!("💡 Use 'quantus tech-referenda list' to see active proposals"); @@ -437,8 +437,8 @@ async fn submit_treasury_portion_with_preimage( ); log_print!(" 🔑 Submitted by: {}", from.bright_yellow()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from, password, password_file)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from, password, password_file)?; // Build a static payload for TreasuryPallet::set_treasury_portion and encode full call data // Note: runtime_types::Permill is a tuple struct (u32 parts-per-million). @@ -458,7 +458,7 @@ async fn submit_treasury_portion_with_preimage( log_print!("🔗 Preimage hash: {:?}", preimage_hash); let call_len = encoded_call.len() as u32; - crate::cli::common::submit_preimage(quantus_client, &keypair, encoded_call, execution_mode) + crate::cli::common::submit_preimage(quantus_client, &signer, encoded_call, execution_mode) .await?; // Build TechReferenda::submit call using Lookup preimage reference @@ -489,7 +489,7 @@ async fn submit_treasury_portion_with_preimage( .submit(origin_caller, proposal, enactment); let tx_hash = - submit_transaction(quantus_client, &keypair, submit_call, None, execution_mode).await?; + submit_transaction(quantus_client, &signer, submit_call, None, execution_mode).await?; log_success!("Treasury portion proposal submitted! Hash: {:?}", tx_hash); log_print!("💡 Use 'quantus tech-referenda list' to see active proposals"); @@ -944,11 +944,11 @@ async fn place_decision_deposit( log_print!("📋 Placing decision deposit for Tech Referendum #{}", index); log_print!(" 🔑 Placed by: {}", from.bright_yellow()); - let keypair = crate::wallet::load_keypair_from_wallet(from, password, password_file)?; + let signer = crate::wallet::load_signer_from_wallet(from, password, password_file)?; let deposit_call = quantus_subxt::api::tx().tech_referenda().place_decision_deposit(index); let tx_hash = - submit_transaction(quantus_client, &keypair, deposit_call, None, execution_mode).await?; + submit_transaction(quantus_client, &signer, deposit_call, None, execution_mode).await?; log_success!("✅ Decision deposit placed! Hash: {:?}", tx_hash.to_string().bright_yellow()); Ok(()) } @@ -1013,14 +1013,14 @@ async fn refund_submission_deposit( log_print!("💰 Refunding submission deposit for Tech Referendum #{}", index); log_print!(" 🔑 Refund to: {}", from.bright_yellow()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from, password, password_file)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from, password, password_file)?; // Create refund_submission_deposit call for TechReferenda instance let refund_call = quantus_subxt::api::tx().tech_referenda().refund_submission_deposit(index); let tx_hash = - submit_transaction(quantus_client, &keypair, refund_call, None, execution_mode).await?; + submit_transaction(quantus_client, &signer, refund_call, None, execution_mode).await?; log_print!( "✅ {} Refund transaction submitted! Hash: {:?}", "SUCCESS".bright_green().bold(), @@ -1044,14 +1044,14 @@ async fn refund_decision_deposit( log_print!("💰 Refunding decision deposit for Tech Referendum #{}", index); log_print!(" 🔑 Refund to: {}", from.bright_yellow()); - // Load wallet keypair - let keypair = crate::wallet::load_keypair_from_wallet(from, password, password_file)?; + // Load wallet signer + let signer = crate::wallet::load_signer_from_wallet(from, password, password_file)?; // Create refund_decision_deposit call for TechReferenda instance let refund_call = quantus_subxt::api::tx().tech_referenda().refund_decision_deposit(index); let tx_hash = - submit_transaction(quantus_client, &keypair, refund_call, None, execution_mode).await?; + submit_transaction(quantus_client, &signer, refund_call, None, execution_mode).await?; log_print!( "✅ {} Refund transaction submitted! Hash: {:?}", "SUCCESS".bright_green().bold(), diff --git a/src/cli/vesting.rs b/src/cli/vesting.rs index 8a6de97..9f32701 100644 --- a/src/cli/vesting.rs +++ b/src/cli/vesting.rs @@ -168,8 +168,8 @@ pub async fn handle_vesting_command( VestingCommands::List { beneficiary } => list_schedules(&quantus_client, beneficiary).await, VestingCommands::Show { schedule_id } => show_schedule(&quantus_client, schedule_id).await, VestingCommands::Claim { schedule_id, from, password, password_file } => { - let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; - claim(&quantus_client, &keypair, schedule_id, execution_mode).await + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; + claim(&quantus_client, &signer, schedule_id, execution_mode).await }, VestingCommands::CreateSchedule { beneficiary, @@ -200,13 +200,13 @@ pub async fn handle_vesting_command( if call_data_only { return print_call_data(&quantus_client, &call); } - let keypair = load_admin_keypair(from, password, password_file)?; + let signer = load_admin_signer(from, password, password_file)?; log_print!("🪙 Creating vesting schedule..."); log_print!(" Beneficiary: {}", beneficiary.bright_cyan()); log_print!(" Start: {} Cliff: {} End: {}", start_ms, cliff_ms, end_ms); let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, call, None, execution_mode, @@ -226,11 +226,11 @@ pub async fn handle_vesting_command( if call_data_only { return print_call_data(&quantus_client, &call); } - let keypair = load_admin_keypair(from, password, password_file)?; + let signer = load_admin_signer(from, password, password_file)?; log_print!("🪙 Ending vesting schedule #{schedule_id}..."); let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, call, None, execution_mode, @@ -255,14 +255,14 @@ pub async fn handle_vesting_command( if call_data_only { return print_call_data(&quantus_client, &call); } - let keypair = load_admin_keypair(from, password, password_file)?; + let signer = load_admin_signer(from, password, password_file)?; log_print!( "🪙 Retargeting schedule #{schedule_id} to {}...", new_beneficiary.bright_cyan() ); let tx_hash = crate::cli::common::submit_transaction( &quantus_client, - &keypair, + &signer, call, None, execution_mode, @@ -274,17 +274,17 @@ pub async fn handle_vesting_command( } } -fn load_admin_keypair( +fn load_admin_signer( from: Option, password: Option, password_file: Option, -) -> Result { +) -> Result { let from = from.ok_or_else(|| { QuantusError::Generic( "--from is required when submitting (or pass --call-data-only)".into(), ) })?; - crate::wallet::load_keypair_from_wallet(&from, password, password_file) + crate::wallet::load_signer_from_wallet(&from, password, password_file) } fn print_call_data( @@ -467,7 +467,7 @@ async fn print_schedule( async fn claim( quantus_client: &crate::chain::client::QuantusClient, - keypair: &crate::wallet::QuantumKeyPair, + signer: &crate::wallet::WalletSigner, schedule_id: u64, execution_mode: ExecutionMode, ) -> Result<()> { @@ -477,7 +477,7 @@ async fn claim( let wait_mode = ExecutionMode { wait_for_transaction: true, ..execution_mode }; let (tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( quantus_client, - keypair, + signer, call, None, wait_mode, diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 2355d53..dea4ea8 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -115,6 +115,24 @@ pub enum WalletCommands { scheme: DilithiumScheme, }, + /// 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 @@ -423,6 +441,47 @@ 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()); + if let Some(checkphrase) = checkphrase_line(&wallet_info.address) { + log_success!("Checkphrase: {}", checkphrase); + } + log_success!("Key type: {}", wallet_info.key_type.bright_yellow()); + log_success!("✅ Cold wallet imported (watch-only)"); + log_print!( + "ℹ️ Use `{name}` with any extrinsic command (`--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..."); @@ -939,12 +998,9 @@ pub async fn handle_wallet_command( .map_err(|e| QuantusError::Generic(format!("Invalid address: {e:?}")))?; addr }, - (None, Some(wallet_name)) => { - // Load wallet and get its address - let keypair = - crate::wallet::load_keypair_from_wallet(&wallet_name, password, None)?; - keypair.try_to_account_id_ss58check()? - }, + (None, Some(wallet_name)) => + crate::wallet::load_signer_from_wallet(&wallet_name, password, None)? + .try_account_id_ss58check()?, (None, None) => { // This case should be prevented by clap's `required_unless_present` unreachable!("Either --address or --wallet must be provided"); diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index e31ffb2..111934f 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -2300,7 +2300,7 @@ async fn execute_initial_transfers( let balance_before = get_balance(quantus_client, &wallet.wallet_address).await?; crate::cli::send::ensure_balance_covers_call( quantus_client, - &wallet.keypair, + &crate::wallet::WalletSigner::Hot(wallet.keypair.clone()), &batch_tx, balance_before, amount, @@ -2346,7 +2346,7 @@ async fn execute_initial_transfers( let wait_mode = wormhole_inclusion_mode(execution_mode); let (_tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( quantus_client, - &wallet.keypair, + &crate::wallet::WalletSigner::Hot(wallet.keypair.clone()), batch_tx, None, wait_mode, @@ -3804,7 +3804,7 @@ async fn run_dissolve( let (_tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( &quantus_client, - &quantum_keypair, + &crate::wallet::WalletSigner::Hot(quantum_keypair.clone()), transfer_tx, None, wormhole_inclusion_mode(execution_mode), diff --git a/src/error.rs b/src/error.rs index 9d988e5..c20d6b2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -74,6 +74,9 @@ pub enum WalletError { #[error("Decryption failed. Check your password.")] Decryption, + #[error("'{0}' is a cold (watch-only) wallet with no local key material. Extrinsics sign via QR; this operation needs a hot wallet")] + ColdWalletNoKeys(String), + #[error("Wallet integrity check failed: {0}")] Integrity(String), } diff --git a/src/lib.rs b/src/lib.rs index febcfb4..a9df6d4 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..6b59d5a 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; @@ -52,6 +53,19 @@ struct Cli { /// Default: false #[arg(long, global = true, default_value = "false")] wait_for_transaction: bool, + + /// (cold wallets) Also write the sign-request UR parts to this file + #[arg(long, global = true)] + cold_request_out: Option, + + /// (cold wallets) Read the signature response UR parts from this file, or "-" for stdin, + /// instead of scanning with the camera + #[arg(long, global = true)] + cold_response_in: Option, + + /// Camera device index for cold-wallet QR scanning + #[arg(long, global = true, default_value_t = 0)] + camera_index: u32, } #[tokio::main] @@ -78,6 +92,15 @@ async fn main() -> Result<(), QuantusError> { wait_for_transaction: cli.wait_for_transaction, }; + // Cold-wallet QR I/O config for the submit stage (used only when the + // signing wallet is watch-only). + cli::cold_signing::ColdIo::from_flags( + cli.cold_request_out, + cli.cold_response_in, + cli.camera_index, + ) + .install(); + // Warm the update-version cache in the background so it runs concurrently // with the command and never adds latency: we never block the command on // the network. The notice itself is printed only after the command finishes diff --git a/src/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..df91314 --- /dev/null +++ b/src/qr/scanner.rs @@ -0,0 +1,854 @@ +//! 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. The file is consumed (deleted) after a successful + /// read so a later roundtrip on the same path can never pick up a stale + /// session. Enables scripted/headless flows. + File(PathBuf), + /// Read UR parts from stdin, one per line, until complete or EOF. + StdinLines, +} + +/// 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 { + // Byte-wise prefix check: QR/file input is untrusted and may start with + // multi-byte characters, so `trimmed[..3]` could slice off a char boundary. + let trimmed = line.trim().as_bytes(); + trimmed.len() > 3 && trimmed[..3].eq_ignore_ascii_case(b"ur:") +} + +fn decode_if_complete(parts: &[String]) -> Result>> { + 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)) +} + +/// The fountain decoder locks onto the first part's stream (seqLen, checksum) +/// and silently rejects every part of a different stream. A stale part +/// captured first — e.g. the previous signature still animating on the device +/// — would wedge the scan forever even though all current parts arrive. Retry +/// completion on every suffix, shortest (newest) first: when both a stale and +/// a current stream are complete in the capture, the newest must win. +fn decode_any_suffix(parts: &[String]) -> Result>> { + for start in (0..parts.len()).rev() { + if let Some(bytes) = decode_if_complete(&parts[start..])? { + return Ok(Some(bytes)); + } + } + Ok(None) +} + +/// Collect UR parts from `source` until the fountain decoder reports a complete +/// payload, then decode it. +pub async fn scan_ur(source: &UrSource, timeout: Duration) -> Result> { + 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 => + tokio::time::timeout(timeout, scan_ur_from_stdin()).await.map_err(|_| { + QuantusError::Generic("Timed out waiting for a complete UR on stdin".to_string()) + })?, + } +} + +/// Poll `path` until it contains a complete set of UR parts (one per line), +/// then consume the file so the same path cannot serve a stale session to the +/// next roundtrip. +async fn scan_ur_from_file(path: &std::path::Path, timeout: Duration) -> Result> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + 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_any_suffix(&parts)? { + if let Err(e) = std::fs::remove_file(path) { + crate::log_print!( + "⚠️ Could not consume UR file {}: {e} — remove it manually before the next roundtrip", + path.display() + ); + } + return Ok(bytes); + } + } + + 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_any_suffix(&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, Mutex, + }, + time::Instant, + }; + + const PREVIEW_W: usize = 960; + const PREVIEW_H: usize = 540; + + /// Ask for camera permission. On macOS this pops the TCC dialog (attributed + /// to the terminal app, not `quantus`); on other platforms it's a no-op. + fn ensure_permission() -> Result<()> { + 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(), + )), + } + } + + fn rgb_to_luma(raw: &[u8], width: u32, height: u32) -> (usize, usize, Vec) { + let w = width as usize; + let h = height as usize; + let mut luma = Vec::with_capacity(w * h); + for px in raw.chunks_exact(3) { + luma.push( + ((u16::from(px[0]) * 77 + u16::from(px[1]) * 150 + u16::from(px[2]) * 29) >> 8) + as u8, + ); + } + (w, h, luma) + } + + fn downscale2(luma: &[u8], w: usize, h: usize) -> Option<(usize, usize, Vec)> { + let nw = w / 2; + let nh = h / 2; + if nw < 21 || nh < 21 { + return None; + } + let mut out = Vec::with_capacity(nw * nh); + for y in 0..nh { + for x in 0..nw { + let i = (2 * y) * w + (2 * x); + let avg = (u16::from(luma[i]) + + u16::from(luma[i + 1]) + + u16::from(luma[i + w]) + + u16::from(luma[i + w + 1])) / + 4; + out.push(avg as u8); + } + } + Some((nw, nh, out)) + } + + fn feed_decoded( + luma: &[u8], + w: usize, + h: usize, + mut sink: impl FnMut(&str) -> Option, + ) -> Option { + let mut run = |luma: &[u8], w: usize, h: usize, invert: bool| -> Option { + if w < 21 || h < 21 || luma.len() != w.saturating_mul(h) { + return None; + } + let buf: Vec = + if invert { luma.iter().map(|v| 255 - v).collect() } else { luma.to_vec() }; + let decoded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut hints = rxing::DecodeHints { + PossibleFormats: Some(HashSet::from([rxing::BarcodeFormat::QR_CODE])), + ..Default::default() + }; + rxing::helpers::detect_multiple_in_luma_with_hints( + buf, w as u32, h as u32, &mut hints, + ) + .unwrap_or_default() + })) + .unwrap_or_default(); + for result in decoded { + if let Some(hit) = sink(result.getText()) { + return Some(hit); + } + } + None + }; + + if let Some(hit) = run(luma, w, h, false) { + return Some(hit); + } + if let Some(hit) = run(luma, w, h, true) { + return Some(hit); + } + if let Some((nw, nh, half)) = downscale2(luma, w, h) { + if let Some(hit) = run(&half, nw, nh, false) { + return Some(hit); + } + if let Some(hit) = run(&half, nw, nh, true) { + return Some(hit); + } + } + None + } + + /// Mac cameras typically deliver NV12 (420 bi-planar). nokhwa mis-tags that + /// as YUYV, and YUYV→RGB on NV12 bytes produces the doubled/posterized mess + /// in the preview. Detect NV12 by buffer size and convert it correctly. + fn frame_to_rgb(frame: &nokhwa::Buffer) -> std::result::Result<(u32, u32, Vec), String> { + use nokhwa::{pixel_format::RgbFormat, utils::nv12_to_rgb}; + let res = frame.resolution(); + let w = res.width(); + let h = res.height(); + let raw = frame.buffer(); + let nv12_len = (w as usize).saturating_mul(h as usize).saturating_mul(3) / 2; + let rgb = if raw.len() == nv12_len { + nv12_to_rgb(res, raw, false).map_err(|e| format!("{e}"))? + } else { + frame + .decode_image::() + .map(|img| img.into_raw()) + .map_err(|e| format!("{e}"))? + }; + let expected = (w as usize).saturating_mul(h as usize).saturating_mul(3); + if rgb.len() != expected { + return Err(format!( + "RGB size {} != {}x{}x3 (src {:?}, raw {})", + rgb.len(), + w, + h, + frame.source_frame_format(), + raw.len() + )); + } + Ok((w, h, rgb)) + } + + fn rgb_preview_u32(raw: &[u8], sw: u32, sh: u32, dw: usize, dh: usize) -> Vec { + let mut out = vec![0u32; dw * dh]; + if sw == 0 || sh == 0 { + return out; + } + for y in 0..dh { + let sy = y * sh as usize / dh; + for x in 0..dw { + let sx = x * sw as usize / dw; + let i = (sy * sw as usize + sx) * 3; + if i + 2 < raw.len() { + out[y * dw + x] = u32::from(raw[i]) << 16 | + u32::from(raw[i + 1]) << 8 | + u32::from(raw[i + 2]); + } + } + } + out + } + + /// Grab frames at the camera's highest frame rate and decode each at native + /// resolution. Cold-wallet UR animations run at 15–50 fps. + /// Preview pixels are published for the main-thread window; NSWindow must + /// not be created here (tokio blocking pool is not the macOS main thread). + fn scan_with_camera( + index: u32, + timeout: Duration, + stop: Arc, + preview: Arc>>>, + mut on_status: impl FnMut(&str), + mut sink: impl FnMut(&str) -> Option>, + ) -> Result { + use nokhwa::{ + pixel_format::RgbFormat, + utils::{CameraIndex, RequestedFormat, RequestedFormatType}, + Camera, + }; + + ensure_permission()?; + + let mut camera = Camera::new( + CameraIndex::Index(index), + RequestedFormat::new::(RequestedFormatType::AbsoluteHighestFrameRate), + ) + .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 fmt = camera.camera_format(); + crate::log_verbose!( + "📷 Camera {}x{} @ {} fps", + fmt.width(), + fmt.height(), + fmt.frame_rate() + ); + on_status(&format!( + "📷 Live {}x{} @ {} fps — waiting for QR…", + fmt.width(), + fmt.height(), + fmt.frame_rate() + )); + + let deadline = Instant::now() + timeout; + let mut frames: u64 = 0; + let result = 'scan: loop { + if stop.load(Ordering::Relaxed) { + break Err(QuantusError::Generic("Aborted by user".to_string())); + } + if Instant::now() >= deadline { + break Err(QuantusError::Generic( + "Timed out scanning. Keep the QR large and steady in the preview window (high screen brightness helps), or use --cold-response-in / --address".to_string(), + )); + } + + let frame = match camera.frame() { + Ok(frame) => frame, + Err(e) => { + crate::log_verbose!("📷 Dropped camera frame: {e}"); + continue; + }, + }; + let (img_w, img_h, rgb) = match frame_to_rgb(&frame) { + Ok(rgb) => rgb, + Err(e) => { + crate::log_verbose!("📷 Failed to decode camera frame: {e}"); + continue; + }, + }; + frames += 1; + if frames == 1 { + crate::log_verbose!( + "📷 Frame format {:?} raw {} bytes → RGB {}x{}", + frame.source_frame_format(), + frame.buffer().len(), + img_w, + img_h + ); + } + if frames == 1 || frames.is_multiple_of(15) { + on_status(&format!( + "📷 Live {img_w}x{img_h} @ {} fps — {frames} frames scanned", + fmt.frame_rate() + )); + } + + if let Ok(mut slot) = preview.lock() { + *slot = Some(rgb_preview_u32(&rgb, img_w, img_h, PREVIEW_W, PREVIEW_H)); + } + + let (w, h, luma) = rgb_to_luma(&rgb, img_w, img_h); + if luma.len() != w.saturating_mul(h) { + continue; + } + if let Some(result) = feed_decoded(&luma, w, h, |content| sink(content)) { + break 'scan result; + } + }; + + let _ = camera.stop_stream(); + result + } + + /// Drive the blocking camera loop from async code, aborting on Ctrl-C. + /// The preview window is created and pumped on this task (the tokio + /// `block_on` / process main thread) so macOS AppKit is not touched off-main. + async fn run_scan( + index: u32, + timeout: Duration, + on_status: impl FnMut(&str) + Send + 'static, + sink: impl FnMut(&str) -> Option> + Send + 'static, + ) -> Result { + let stop = Arc::new(AtomicBool::new(false)); + let stop_flag = stop.clone(); + let preview = Arc::new(Mutex::new(None)); + let preview_cam = preview.clone(); + let mut window = minifb::Window::new( + "Quantus camera — hold the QR in this view", + PREVIEW_W, + PREVIEW_H, + minifb::WindowOptions { resize: false, ..minifb::WindowOptions::default() }, + ) + .map_err(|e| { + crate::log_verbose!("📷 Preview window unavailable: {e}"); + e + }) + .ok(); + if window.is_some() { + crate::log_print!( + "🖼️ Camera preview window opened — aim the QR so it is sharp in that view" + ); + } + + let mut handle = tokio::task::spawn_blocking(move || { + scan_with_camera(index, timeout, stop_flag, preview_cam, on_status, sink) + }); + // AppKit draws asynchronously; keep the last buffer alive so the view + // never paints a freed pointer (that flashed as garbage). + let mut displayed = vec![0u32; PREVIEW_W * PREVIEW_H]; + + loop { + tokio::select! { + result = &mut handle => { + return result + .map_err(|e| QuantusError::Generic(format!("Camera task failed: {e}")))?; + }, + _ = tokio::time::sleep(Duration::from_millis(16)) => { + if let Some(win) = window.as_mut() { + if !win.is_open() { + window = None; + } else { + if let Some(buf) = preview.lock().ok().and_then(|mut slot| slot.take()) + { + displayed = buf; + } + if let Err(e) = + win.update_with_buffer(&displayed, PREVIEW_W, PREVIEW_H) + { + crate::log_verbose!("📷 Preview update failed: {e}"); + window = None; + } + } + } + }, + _ = tokio::signal::ctrl_c() => { + stop.store(true, Ordering::Relaxed); + println!(); + return 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 + } + + #[derive(Default)] + struct UrCapture { + parts: Vec, + seen: HashSet, + seqs: HashSet, + total: Option, + } + + /// Scan an animated (or static) UR from the camera until the fountain + /// decoder has a complete payload. Every captured part is printed as a + /// persistent line (the live status stays on the spinner and never + /// overwrites capture progress), and an incomplete scan reports exactly + /// which fragments are still missing. + pub(super) async fn scan_ur_with_camera(index: u32, timeout: Duration) -> Result> { + let spinner = scan_spinner("📷 Point the camera at the QR on the cold wallet…"); + let status = spinner.clone(); + let progress = spinner.clone(); + let state = Arc::new(Mutex::new(UrCapture::default())); + let status_state = state.clone(); + let sink_state = state.clone(); + + let result = run_scan( + index, + timeout, + move |msg| { + let no_parts_yet = + status_state.lock().map(|st| st.parts.is_empty()).unwrap_or(false); + if no_parts_yet { + status.set_message(msg.to_string()); + } + }, + move |content| { + if !is_ur_line(content) { + return None; + } + let part = content.trim().to_string(); + let mut st = sink_state.lock().expect("UR capture state poisoned"); + if !st.seen.insert(part.clone()) { + return None; + } + let seq = ur_progress(&part); + if let Some((s, t)) = seq { + st.total = Some(t); + st.seqs.insert(s); + } + st.parts.push(part); + let line = match (seq, st.total) { + (Some((s, _)), Some(t)) => + format!("📥 Part {s}/{t} — {}/{t} collected", st.seqs.len()), + _ => format!("📥 UR part captured ({} total)", st.parts.len()), + }; + progress.println(line); + progress.set_message(match st.total { + Some(t) => format!("📷 Collecting signature QR — {}/{t} parts…", st.seqs.len()), + None => format!("📷 Collecting signature QR — {} parts…", st.parts.len()), + }); + decode_any_suffix(&st.parts).transpose() + }, + ) + .await; + + spinner.finish_and_clear(); + let st = state.lock().expect("UR capture state poisoned"); + if result.is_err() && !st.parts.is_empty() { + match st.total { + Some(t) => { + let missing: Vec = + (1..=t).filter(|s| !st.seqs.contains(s)).map(|s| s.to_string()).collect(); + if missing.is_empty() { + crate::log_print!( + "⚠️ All {t} fragments were captured but never formed a consistent payload — was an older signature QR still animating when the scan started?" + ); + } else { + crate::log_print!( + "⚠️ Scan ended with {}/{t} parts — missing: {}", + st.seqs.len(), + if missing.len() > 20 { + format!( + "{} … ({} more)", + missing[..20].join(", "), + missing.len() - 20 + ) + } else { + missing.join(", ") + } + ); + } + }, + None => crate::log_print!( + "⚠️ Scan ended with {} parts captured but an incomplete payload", + st.parts.len() + ), + } + } + result + } + + /// 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 status = spinner.clone(); + let progress = spinner.clone(); + + let result = run_scan( + index, + Duration::from_secs(120), + move |msg| status.set_message(msg.to_string()), + move |content| { + let trimmed = content.trim(); + match AccountId32::from_ss58check_with_version(trimmed) { + Ok((_, format)) + if format == crate::cli::address_format::quantus_ss58_format() => + Some(Ok(trimmed.to_string())), + _ => { + progress.set_message(format!( + "📷 Saw a QR that is not a Quantus address ({}…) — keep looking…", + trimmed.chars().take(16).collect::() + )); + None + }, + } + }, + ) + .await; + + spinner.finish_and_clear(); + result + } + + #[cfg(test)] + mod tests { + use super::*; + + fn render_qr_luma(data: &str, invert: bool) -> (usize, usize, Vec) { + use qrcode::{types::Color, QrCode}; + let code = QrCode::new(data.as_bytes()).expect("qr"); + const SCALE: usize = 8; + let qz = 4 * SCALE; + let inner = code.width() * SCALE; + let w = inner + qz * 2; + let bg = if invert { 0u8 } else { 255u8 }; + let mut luma = vec![bg; w * w]; + for y in 0..code.width() { + for x in 0..code.width() { + let dark = code[(x, y)] == Color::Dark; + let v = match (dark, invert) { + (true, false) | (false, true) => 0u8, + _ => 255, + }; + for dy in 0..SCALE { + for dx in 0..SCALE { + luma[(y * SCALE + qz + dy) * w + (x * SCALE + qz + dx)] = v; + } + } + } + } + (w, w, luma) + } + + fn box_blur(luma: &[u8], w: usize, h: usize, radius: usize) -> Vec { + let r = radius as i32; + let mut out = vec![0u8; w * h]; + for y in 0..h as i32 { + for x in 0..w as i32 { + let mut sum = 0u32; + let mut cnt = 0u32; + for dy in -r..=r { + for dx in -r..=r { + let (nx, ny) = (x + dx, y + dy); + if nx >= 0 && ny >= 0 && nx < w as i32 && ny < h as i32 { + sum += u32::from(luma[(ny as usize) * w + nx as usize]); + cnt += 1; + } + } + } + out[(y as usize) * w + x as usize] = (sum / cnt) as u8; + } + } + out + } + + /// A fixed-focus Mac camera at phone-scanning distance defocuses the QR + /// well past what quirc-style decoders tolerate; this blur level is the + /// regression guard for that (rqrr failed it, rxing decodes it). + #[test] + fn decodes_defocused_qr() { + let data = "qzkeicNBtW2AG2E7USjDcLzAL8d9WxTZnV2cbtXoDzWxzpHC2"; + let (w, h, luma) = render_qr_luma(data, false); + let blurred = box_blur(&luma, w, h, 5); + let mut found = Vec::new(); + feed_decoded(&blurred, w, h, |s| { + found.push(s.to_string()); + None::<()> + }); + assert!(found.iter().any(|s| s == data), "defocused QR: {found:?}"); + } + + #[test] + fn decodes_standard_and_inverted_qr() { + let data = "qzkeicNBtW2AG2E7USjDcLzAL8d9WxTZnV2cbtXoDzWxzpHC2"; + let collect = |luma: &[u8], w: usize, h: usize| { + let mut found = Vec::new(); + feed_decoded(luma, w, h, |s| { + found.push(s.to_string()); + None::<()> + }); + found + }; + let (w, h, normal) = render_qr_luma(data, false); + assert!( + collect(&normal, w, h).iter().any(|s| s == data), + "standard polarity: {:?}", + collect(&normal, w, h) + ); + let (w, h, inverted) = render_qr_luma(data, true); + assert!( + collect(&inverted, w, h).iter().any(|s| s == data), + "inverted polarity (cold-wallet address QR): {:?}", + collect(&inverted, w, h) + ); + } + } +} + +#[cfg(test)] +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("")); + } + + /// Untrusted QR/file input may start with multi-byte characters; a byte + /// slice off a UTF-8 boundary must not panic the scanner. + #[test] + fn test_is_ur_line_non_ascii_no_panic() { + assert!(!is_ur_line("ééééé")); + assert!(!is_ur_line("🧊🧊")); + assert!(!is_ur_line("日本語のテキスト")); + } + + #[test] + fn test_decode_any_suffix_recovers_from_stale_stream() { + let old_payload: Vec = (0..7500u32).map(|i| (i * 31 % 249) as u8).collect(); + let new_payload: Vec = (0..7500u32).map(|i| (i * 37 % 251) as u8).collect(); + let old_parts = quantus_ur::encode_bytes(&old_payload).unwrap(); + let new_parts = quantus_ur::encode_bytes(&new_payload).unwrap(); + + let mut captured = old_parts[..3].to_vec(); + captured.extend(new_parts.iter().cloned()); + assert!( + !quantus_ur::is_complete(&captured), + "stale-first capture must wedge the plain decoder for this test to mean anything" + ); + let bytes = decode_any_suffix(&captured).unwrap().expect("suffix recovery"); + assert_eq!(bytes, new_payload); + + // A COMPLETE stale stream followed by a complete current one: the + // newest stream must win, not the first complete suffix. + let mut captured = old_parts.clone(); + captured.extend(new_parts.iter().cloned()); + assert!(quantus_ur::is_complete(&captured[..old_parts.len()])); + let bytes = decode_any_suffix(&captured).unwrap().expect("both streams complete"); + assert_eq!(bytes, new_payload, "newest complete stream must win over the stale one"); + } + + #[tokio::test] + 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); + } + + /// Two consecutive exchanges over the same configured path: the first read + /// consumes the file, and the second must wait for a fresh session instead + /// of replaying the stale one. + #[tokio::test] + async fn test_scan_ur_from_file_consumes_file_between_sessions() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("response.ur"); + + let payload_a: Vec = (0..7219u32).map(|i| (i % 251) as u8).collect(); + let parts_a = quantus_ur::encode_bytes(&payload_a).unwrap(); + std::fs::write(&path, parts_a.join("\n")).unwrap(); + let got = scan_ur(&UrSource::File(path.clone()), Duration::from_secs(5)).await.unwrap(); + assert_eq!(got, payload_a); + assert!(!path.exists(), "file must be consumed after a successful read"); + + let payload_b: Vec = (0..7219u32).map(|i| ((i * 3) % 251) as u8).collect(); + let parts_b = quantus_ur::encode_bytes(&payload_b).unwrap(); + let writer = { + let path = path.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(300)).await; + std::fs::write(&path, parts_b.join("\n")).unwrap(); + }) + }; + let got = scan_ur(&UrSource::File(path.clone()), Duration::from_secs(5)).await.unwrap(); + assert_eq!(got, payload_b, "second exchange must return the fresh session"); + writer.await.unwrap(); + assert!(!path.exists()); + } + + #[tokio::test] + async fn test_scan_ur_from_file_times_out() { + let dir = tempfile::tempdir().unwrap(); + 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 715424d..9439078 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -394,11 +394,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, PartialEq)] 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 @@ -411,6 +427,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(Serialize, Deserialize)] pub struct WalletData { @@ -704,6 +739,7 @@ impl Keystore { Ok(EncryptedWallet { name: data.name.clone(), address: data.keypair.try_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 @@ -721,6 +757,10 @@ impl Keystore { encrypted: &EncryptedWallet, password: &str, ) -> Result { + if encrypted.wallet_type == WalletType::Cold { + return Err(WalletError::ColdWalletNoKeys(encrypted.name.clone()).into()); + } + // Only known wallet encryption formats may be decrypted with these rules. match encrypted.encryption_version { 1 | 2 => {}, @@ -1395,6 +1435,7 @@ mod tests { EncryptedWallet { name: data.name.clone(), address: data.keypair.try_to_account_id_ss58check().expect("valid keypair"), + wallet_type: WalletType::Hot, encrypted_data, kyber_ciphertext: vec![], kyber_public_key: vec![], @@ -1508,6 +1549,7 @@ mod tests { EncryptedWallet { name: data.name.clone(), address: data.keypair.try_to_account_id_ss58check().expect("valid keypair"), + 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 eb1549c..662558b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -61,6 +61,9 @@ fn pair_from_master_seed(seed: &[u8], scheme: DilithiumScheme) -> Result) -> 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)?; @@ -255,6 +263,55 @@ impl WalletManager { .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); + let _create_guard = keystore.lock_wallet_create(name)?; + if keystore.load_wallet(name)?.is_some() { + return Err(WalletError::AlreadyExists.into()); + } + + let (account, 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() + ))); + } + + // Store the canonical re-encoding: load_wallet rejects non-canonical + // SS58, so persisting the pasted string verbatim could create a wallet + // that can never be loaded. + let canonical = + account.to_ss58check_with_version(crate::cli::address_format::quantus_ss58_format()); + let encrypted_wallet = keystore::EncryptedWallet::new_cold(name, &canonical); + keystore.save_new_wallet(&encrypted_wallet)?; + + Ok(WalletInfo { + 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); @@ -269,6 +326,19 @@ impl WalletManager { continue; }; + // Cold wallets are watch-only: there is no encrypted keypair to + // authenticate the address against, so the stored address is the wallet. + if encrypted_wallet.wallet_type == keystore::WalletType::Cold { + wallets.push(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(), + }); + continue; + } + // The envelope address is public and SS58-validated at load; the scheme // is only known after decryption. let wallet_info = match keystore.decrypt_wallet_data(&encrypted_wallet, "") { @@ -587,6 +657,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) { @@ -680,6 +760,11 @@ impl WalletManager { let keystore = Keystore::new(&self.wallets_dir); if let Some(encrypted_wallet) = keystore.load_wallet(name)? { + // Cold wallets are watch-only: the stored address is the wallet, and there + // is no encrypted keypair to authenticate it against. + if encrypted_wallet.wallet_type == keystore::WalletType::Cold { + return Ok(WalletAddressLookup::Address(encrypted_wallet.address)); + } // Wallet-name resolution must not trust the plaintext envelope address. // Only empty-password wallets can be authenticated without prompting. match keystore.decrypt_wallet_data(&encrypted_wallet, "") { @@ -725,11 +810,79 @@ 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 mut wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?; Ok(wallet_data.take_keypair()) } +/// How an extrinsic gets signed: locally from key material, or air-gapped over +/// QR codes by a cold (watch-only) wallet. The submit stage in +/// `cli::common` branches on this; commands never do. +#[derive(Debug, Clone)] +pub enum WalletSigner { + Hot(QuantumKeyPair), + /// Watch-only: address stored unencrypted, signing happens over QR. + Cold { + name: String, + address: String, + }, +} + +impl WalletSigner { + /// SS58 address of the signing account, regardless of kind. + pub fn try_account_id_ss58check(&self) -> Result { + match self { + WalletSigner::Hot(keypair) => keypair.try_to_account_id_ss58check(), + WalletSigner::Cold { address, .. } => Ok(address.clone()), + } + } + + /// The hot keypair, if this is a hot signer. Used only by paths that + /// genuinely need key material (e.g. HD derivation). + pub fn as_hot(&self) -> Option<&QuantumKeyPair> { + match self { + WalletSigner::Hot(keypair) => Some(keypair), + WalletSigner::Cold { .. } => None, + } + } +} + +impl QuantumKeyPair { + /// Wrap this keypair as a local signer. Submission still goes through the + /// shared hot/cold fork in `cli::common`. + pub fn as_signer(&self) -> WalletSigner { + WalletSigner::Hot(self.clone()) + } +} + +/// Resolve a wallet into a signer without assuming it has local key material. +/// Cold wallets resolve without a password prompt; hot wallets decrypt as usual. +pub fn load_signer_from_wallet( + wallet_name: &str, + password: Option, + password_file: Option, +) -> Result { + let wallet_manager = WalletManager::new()?; + if wallet_manager.wallet_type(wallet_name)? == Some(keystore::WalletType::Cold) { + if password.is_some() || password_file.is_some() { + crate::log_print!( + "⚠️ Cold wallets have no password; ignoring --password/--password-file" + ); + } + let WalletAddressLookup::Address(address) = + wallet_manager.find_wallet_address(wallet_name)? + else { + return Err(WalletError::NotFound.into()); + }; + return Ok(WalletSigner::Cold { name: wallet_name.to_string(), address }); + } + Ok(WalletSigner::Hot(load_keypair_from_wallet(wallet_name, password, password_file)?)) +} + #[cfg(test)] mod tests { use super::*; @@ -1313,6 +1466,149 @@ 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) + .try_to_account_id_ss58check() + .unwrap() + } + + #[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"); + + // Address resolution and type probe work + assert_eq!( + wallet_manager.find_wallet_address("frosty").unwrap(), + WalletAddressLookup::Address(address) + ); + assert_eq!(wallet_manager.wallet_type("frosty").unwrap(), Some(keystore::WalletType::Cold)); + assert_eq!(wallet_manager.wallet_type("hot-one").unwrap(), Some(keystore::WalletType::Hot)); + assert_eq!(wallet_manager.wallet_type("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)) + )); + } + + /// Cold import must never replace a wallet created concurrently: the + /// creation lock plus the no-replace save make exactly one creator win, + /// and an existing wallet's file (with its encrypted keys) stays intact. + #[tokio::test] + async fn test_cold_import_cannot_replace_concurrent_creation() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let address = cold_test_address(); + + wallet_manager.create_wallet("victim", Some("pw")).await.unwrap(); + let file = wallet_manager.wallets_dir.join("victim.json"); + let before = fs::read_to_string(&file).unwrap(); + assert!(matches!( + wallet_manager.create_cold_wallet("victim", &address), + Err(crate::error::QuantusError::Wallet(WalletError::AlreadyExists)) + )); + assert_eq!( + fs::read_to_string(&file).unwrap(), + before, + "cold import must not touch the existing wallet file" + ); + wallet_manager + .load_wallet("victim", "pw") + .expect("hot wallet keys must survive"); + + let outcomes: Vec = std::thread::scope(|s| { + let handles: Vec<_> = (0..8) + .map(|_| s.spawn(|| wallet_manager.create_cold_wallet("racer", &address).is_ok())) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + assert_eq!(outcomes.iter().filter(|&&ok| ok).count(), 1, "exactly one creator may win"); + assert_eq!(wallet_manager.wallet_type("racer").unwrap(), Some(keystore::WalletType::Cold)); + } + + #[test] + fn test_legacy_wallet_json_defaults_to_hot() { + // Files written before wallet_type existed must deserialize as Hot + 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, keystore::WalletType::Hot); + } + /// Corrupt wallet files must remain deletable: delete works on the file /// itself and must not require the JSON to parse. #[tokio::test]