diff --git a/Cargo.lock b/Cargo.lock index 11c0d08..365c1e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,31 +37,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "ahash" version = "0.8.11" @@ -160,18 +135,6 @@ version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures", - "password-hash", -] - [[package]] name = "ark-bls12-377" version = "0.4.0" @@ -555,22 +518,13 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bip39" version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes 0.13.0", + "bitcoin_hashes 0.14.1", "serde", "unicode-normalization", ] @@ -626,7 +580,7 @@ dependencies = [ "anyhow", "async-trait", "chrono", - "futures 0.3.31", + "futures", "hex", "home", "parity-scale-codec", @@ -635,7 +589,7 @@ dependencies = [ "serde_json", "sha2 0.10.8", "sp-core", - "sp-runtime 38.0.1", + "sp-runtime", "subxt", "subxt-codegen", "subxt-metadata", @@ -648,31 +602,6 @@ dependencies = [ "wiremock", ] -[[package]] -name = "bittensor-wallet" -version = "0.1.0" -dependencies = [ - "aes-gcm", - "argon2", - "bincode", - "bip39", - "clap", - "env_logger", - "log", - "pyo3", - "pyo3-asyncio", - "pyo3-build-config", - "rand", - "schnorrkel", - "serde", - "sp-core", - "sp-runtime 39.0.5", - "subxt", - "tempfile", - "thiserror 1.0.69", - "tokio", -] - [[package]] name = "bitvec" version = "1.0.1" @@ -780,20 +709,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" [[package]] -name = "byteorder" -version = "1.5.0" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] -name = "bytes" -version = "0.4.12" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206fdffcfa2df7cbe15601ef46c813fce0965eb3286db6b56c583b814b51c81c" -dependencies = [ - "byteorder", - "iovec", -] +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" @@ -927,7 +852,7 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ - "bytes 1.6.1", + "bytes", "memchr", ] @@ -959,6 +884,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1132,15 +1070,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1316,6 +1245,19 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "digest" version = "0.9.0" @@ -1498,6 +1440,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "env_filter" version = "0.1.0" @@ -1672,17 +1620,6 @@ dependencies = [ "serde", ] -[[package]] -name = "fs" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94befb4c82414e638647f3f6fe8f908c39a7f2f40d556d318adb803ef263154" -dependencies = [ - "bytes 0.4.12", - "futures 0.1.31", - "futures-cpupool", -] - [[package]] name = "fs-err" version = "2.11.0" @@ -1698,12 +1635,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futures" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" - [[package]] name = "futures" version = "0.3.31" @@ -1735,16 +1666,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" -[[package]] -name = "futures-cpupool" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab90cde24b3319636588d0c35fe03b1333857621051837ed769faefb4c2162e4" -dependencies = [ - "futures 0.1.31", - "num_cpus", -] - [[package]] name = "futures-executor" version = "0.3.31" @@ -1855,16 +1776,6 @@ dependencies = [ "rand_core", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "gimli" version = "0.29.0" @@ -1889,7 +1800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab" dependencies = [ "atomic-waker", - "bytes 1.6.1", + "bytes", "fnv", "futures-core", "futures-sink", @@ -2034,7 +1945,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ - "bytes 1.6.1", + "bytes", "fnv", "itoa", ] @@ -2045,7 +1956,7 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ - "bytes 1.6.1", + "bytes", "http", ] @@ -2055,7 +1966,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ - "bytes 1.6.1", + "bytes", "futures-util", "http", "http-body", @@ -2086,7 +1997,7 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" dependencies = [ - "bytes 1.6.1", + "bytes", "futures-channel", "futures-util", "h2", @@ -2124,7 +2035,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ab92f4f49ee4fb4f997c784b7a2e0fa70050211e0b6a287f898c3c9785ca956" dependencies = [ - "bytes 1.6.1", + "bytes", "futures-channel", "futures-util", "http", @@ -2235,10 +2146,17 @@ dependencies = [ ] [[package]] -name = "indoc" -version = "2.0.5" +name = "indicatif" +version = "0.17.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.2", + "web-time", +] [[package]] name = "inout" @@ -2258,15 +2176,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "iovec" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" -dependencies = [ - "libc", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.0" @@ -2390,7 +2299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "348ee569eaed52926b5e740aae20863762b16596476e943c9e415a6479021622" dependencies = [ "async-trait", - "bytes 1.6.1", + "bytes", "futures-timer", "futures-util", "http", @@ -2570,23 +2479,33 @@ dependencies = [ name = "lightning-tensor" version = "0.1.0" dependencies = [ + "anyhow", + "async-trait", "bittensor-rs", - "bittensor-wallet", + "chrono", "clap", + "console", "crossterm", + "dialoguer", "dirs", "env_logger", - "fs", - "futures 0.3.31", + "futures", "hex", + "indicatif", "log", "parity-scale-codec", + "rand", "ratatui", + "serde", + "serde_json", "simplelog", "sp-core", + "tabled", "thiserror 1.0.69", "tokio", + "tokio-test", "toml", + "tracing", ] [[package]] @@ -2635,15 +2554,6 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "memory-db" version = "0.32.0" @@ -2808,6 +2718,12 @@ dependencies = [ "libc", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "object" version = "0.36.1" @@ -2847,6 +2763,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "papergrid" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ad43c07024ef767f9160710b3a6773976194758c7919b17e63b863db0bdf7fb" +dependencies = [ + "bytecount", + "fnv", + "unicode-width 0.1.13", +] + [[package]] name = "parity-bip39" version = "2.0.1" @@ -2869,7 +2796,7 @@ dependencies = [ "arrayvec 0.7.6", "bitvec", "byte-slice-cast", - "bytes 1.6.1", + "bytes", "const_format", "impl-trait-for-tuples", "parity-scale-codec-derive", @@ -3068,18 +2995,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", -] - [[package]] name = "portable-atomic" version = "1.6.0" @@ -3144,110 +3059,58 @@ dependencies = [ ] [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "proc-macro-error" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" dependencies = [ + "proc-macro-error-attr", "proc-macro2", "quote", + "syn 1.0.109", + "version_check", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "proc-macro-error-attr" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" dependencies = [ - "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.111", -] - -[[package]] -name = "proc-macro2" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "pyo3" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53bdbb96d49157e65d45cc287af5f32ffadd5f4761438b527b055fb0d4bb8233" -dependencies = [ - "cfg-if", - "indoc", - "libc", - "memoffset", - "parking_lot", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", - "unindent", -] - -[[package]] -name = "pyo3-asyncio" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea6b68e93db3622f3bb3bf363246cf948ed5375afe7abff98ccbdd50b184995" -dependencies = [ - "futures 0.3.31", - "once_cell", - "pin-project-lite", - "pyo3", - "tokio", -] - -[[package]] -name = "pyo3-build-config" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deaa5745de3f5231ce10517a1f5dd97d53e5a2fd77aa6b5842292085831d48d7" -dependencies = [ - "once_cell", - "target-lexicon", + "version_check", ] [[package]] -name = "pyo3-ffi" -version = "0.20.3" +name = "proc-macro-error-attr2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b42531d03e08d4ef1f6e85a2ed422eb678b8cd62b762e53891c05faf0d4afa" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" dependencies = [ - "libc", - "pyo3-build-config", + "proc-macro2", + "quote", ] [[package]] -name = "pyo3-macros" -version = "0.20.3" +name = "proc-macro-error2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7305c720fa01b8055ec95e484a6eca7a83c841267f0dd5280f0c8b8551d2c158" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" dependencies = [ + "proc-macro-error-attr2", "proc-macro2", - "pyo3-macros-backend", "quote", "syn 2.0.111", ] [[package]] -name = "pyo3-macros-backend" -version = "0.20.3" +name = "proc-macro2" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c7e9b68bb9c3149c5b0cade5d07f953d6d125eb4337723c4ccdb665f1f96185" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" dependencies = [ - "heck 0.4.1", - "proc-macro2", - "pyo3-build-config", - "quote", - "syn 2.0.111", + "unicode-ident", ] [[package]] @@ -3313,7 +3176,7 @@ dependencies = [ "strum_macros", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -3992,6 +3855,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -4210,8 +4079,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37468c595637c10857701c990f93a40ce0e357cedb0953d1c26c8d8027f9bb53" dependencies = [ "base64 0.22.1", - "bytes 1.6.1", - "futures 0.3.31", + "bytes", + "futures", "httparse", "log", "rand", @@ -4228,23 +4097,10 @@ dependencies = [ "scale-info", "serde", "sp-core", - "sp-io 37.0.0", + "sp-io", "sp-std", ] -[[package]] -name = "sp-application-crypto" -version = "38.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8133012faa5f75b2f0b1619d9f720c1424ac477152c143e5f7dbde2fe1a958" -dependencies = [ - "parity-scale-codec", - "scale-info", - "serde", - "sp-core", - "sp-io 38.0.2", -] - [[package]] name = "sp-arithmetic" version = "26.0.0" @@ -4274,7 +4130,7 @@ dependencies = [ "bs58", "dyn-clonable", "ed25519-zebra", - "futures 0.3.31", + "futures", "hash-db", "hash256-std-hasher", "impl-serde 0.4.0", @@ -4350,7 +4206,7 @@ version = "37.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5036cad2e48d41f5caf6785226c8be1a7db15bec14a9fd7aa6cca84f34cf689f" dependencies = [ - "bytes 1.6.1", + "bytes", "ed25519-dalek", "libsecp256k1", "log", @@ -4363,37 +4219,10 @@ dependencies = [ "sp-externalities", "sp-keystore", "sp-runtime-interface", - "sp-state-machine 0.42.0", + "sp-state-machine", "sp-std", "sp-tracing", - "sp-trie 36.0.0", - "tracing", - "tracing-core", -] - -[[package]] -name = "sp-io" -version = "38.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e20e9d9fe236466c1e38add64b591237c58540a07408407869d52d0e79fd18" -dependencies = [ - "bytes 1.6.1", - "docify", - "ed25519-dalek", - "libsecp256k1", - "log", - "parity-scale-codec", - "polkavm-derive", - "rustversion", - "secp256k1 0.28.2", - "sp-core", - "sp-crypto-hashing", - "sp-externalities", - "sp-keystore", - "sp-runtime-interface", - "sp-state-machine 0.43.0", - "sp-tracing", - "sp-trie 37.0.0", + "sp-trie", "tracing", "tracing-core", ] @@ -4439,37 +4268,10 @@ dependencies = [ "scale-info", "serde", "simple-mermaid", - "sp-application-crypto 37.0.0", + "sp-application-crypto", "sp-arithmetic", "sp-core", - "sp-io 37.0.0", - "sp-std", - "sp-weights", - "tracing", -] - -[[package]] -name = "sp-runtime" -version = "39.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e00503b83cf48fffe48746b91b9b832d6785d4e2eeb0941558371eac6baac6" -dependencies = [ - "docify", - "either", - "hash256-std-hasher", - "impl-trait-for-tuples", - "log", - "num-traits", - "parity-scale-codec", - "paste", - "rand", - "scale-info", - "serde", - "simple-mermaid", - "sp-application-crypto 38.0.0", - "sp-arithmetic", - "sp-core", - "sp-io 38.0.2", + "sp-io", "sp-std", "sp-weights", "tracing", @@ -4481,7 +4283,7 @@ version = "28.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "985eb981f40c689c6a0012c937b68ed58dabb4341d06f2dfe4dfd5ed72fa4017" dependencies = [ - "bytes 1.6.1", + "bytes", "impl-trait-for-tuples", "parity-scale-codec", "polkavm-derive", @@ -4524,28 +4326,7 @@ dependencies = [ "sp-core", "sp-externalities", "sp-panic-handler", - "sp-trie 36.0.0", - "thiserror 1.0.69", - "tracing", - "trie-db", -] - -[[package]] -name = "sp-state-machine" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "930104d6ae882626e8880d9b1578da9300655d337a3ffb45e130c608b6c89660" -dependencies = [ - "hash-db", - "log", - "parity-scale-codec", - "parking_lot", - "rand", - "smallvec", - "sp-core", - "sp-externalities", - "sp-panic-handler", - "sp-trie 37.0.0", + "sp-trie", "thiserror 1.0.69", "tracing", "trie-db", @@ -4606,30 +4387,6 @@ dependencies = [ "trie-root", ] -[[package]] -name = "sp-trie" -version = "37.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6282aef9f4b6ecd95a67a45bcdb67a71f4a4155c09a53c10add4ffe823db18cd" -dependencies = [ - "ahash", - "hash-db", - "lazy_static", - "memory-db", - "nohash-hasher", - "parity-scale-codec", - "parking_lot", - "rand", - "scale-info", - "schnellru", - "sp-core", - "sp-externalities", - "thiserror 1.0.69", - "tracing", - "trie-db", - "trie-root", -] - [[package]] name = "sp-wasm-interface" version = "21.0.1" @@ -4761,7 +4518,7 @@ dependencies = [ "derive-where", "either", "frame-metadata", - "futures 0.3.31", + "futures", "hex", "jsonrpsee", "parity-scale-codec", @@ -4841,7 +4598,7 @@ version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64e02732a6c9ae46bc282c1a741b3d3e494021b3e87e7e92cfb3620116d92911" dependencies = [ - "futures 0.3.31", + "futures", "futures-util", "serde", "serde_json", @@ -4892,7 +4649,7 @@ checksum = "ab68a9c20ecedb0cb7d62d64f884e6add91bb70485783bf40aa8eac5c389c6e0" dependencies = [ "derive-where", "frame-metadata", - "futures 0.3.31", + "futures", "hex", "impl-serde 0.5.0", "jsonrpsee", @@ -4974,16 +4731,34 @@ dependencies = [ ] [[package]] -name = "tap" -version = "1.0.1" +name = "tabled" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +checksum = "4c998b0c8b921495196a48aabaf1901ff28be0760136e31604f7967b0792050e" +dependencies = [ + "papergrid", + "tabled_derive", + "unicode-width 0.1.13", +] [[package]] -name = "target-lexicon" -version = "0.12.15" +name = "tabled_derive" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4873307b7c257eddcb50c9bedf158eb669578359fb28428bef438fec8e6ba7c2" +checksum = "4c138f99377e5d653a371cdad263615634cfc8467685dfe8e73e2b8e98f44b17" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" @@ -5119,7 +4894,7 @@ version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "bytes 1.6.1", + "bytes", "libc", "mio 1.1.1", "parking_lot", @@ -5170,7 +4945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" dependencies = [ "async-stream", - "bytes 1.6.1", + "bytes", "futures-core", "tokio", "tokio-stream", @@ -5182,7 +4957,7 @@ version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ - "bytes 1.6.1", + "bytes", "futures-core", "futures-io", "futures-sink", @@ -5436,7 +5211,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools 0.13.0", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -5446,16 +5221,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" [[package]] -name = "unicode-xid" -version = "0.2.4" +name = "unicode-width" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] -name = "unindent" -version = "0.2.3" +name = "unicode-xid" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" +checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" [[package]] name = "universal-hash" @@ -6068,7 +5843,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "deadpool", - "futures 0.3.31", + "futures", "http", "http-body-util", "hyper", diff --git a/Cargo.toml b/Cargo.toml index c146751..6a554de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] -members = ["bittensor-rs", "bittensor-wallet", "lightning-tensor"] +members = ["bittensor-rs", "lightning-tensor"] +# Note: bittensor-wallet excluded due to pyo3 linking issues resolver = "2" [workspace.dependencies] @@ -7,7 +8,9 @@ clap = { version = "4.5.9", features = ["derive"] } codec = { package = "parity-scale-codec", version = "3.6.1", default-features = false, features = [ "derive", ] } -parity-scale-codec = { version = "3.6.1", default-features = false, features = ["derive"] } +parity-scale-codec = { version = "3.6.1", default-features = false, features = [ + "derive", +] } sp-core = "34.0.0" substrate-api-client = { version = "0.18.0", features = ["ws-client"] } thiserror = "1.0.62" diff --git a/bittensor-rs/build.rs b/bittensor-rs/build.rs index 7286ba2..f13c8a4 100644 --- a/bittensor-rs/build.rs +++ b/bittensor-rs/build.rs @@ -29,10 +29,7 @@ fn main() { if env::var("BITTENSOR_OFFLINE").is_ok() { println!("cargo:warning=BITTENSOR_OFFLINE set, skipping metadata fetch"); if !code_path.exists() { - panic!( - "Offline mode but no cached code exists at {:?}", - code_path - ); + panic!("Offline mode but no cached code exists at {:?}", code_path); } return; } diff --git a/bittensor-rs/src/bin/generate-metadata.rs b/bittensor-rs/src/bin/generate-metadata.rs index 14e44ec..0978a93 100644 --- a/bittensor-rs/src/bin/generate-metadata.rs +++ b/bittensor-rs/src/bin/generate-metadata.rs @@ -102,10 +102,10 @@ async fn generate_metadata(network: &str, endpoint: &str) { } async fn fetch_metadata_bytes(url: &str) -> Result, Box> { - use subxt::backend::rpc::RpcClient; use subxt::backend::legacy::LegacyRpcMethods; + use subxt::backend::rpc::RpcClient; use subxt::PolkadotConfig; - + // Connect via RPC and fetch raw metadata bytes let rpc_client = RpcClient::from_insecure_url(url).await?; let rpc = LegacyRpcMethods::::new(rpc_client); @@ -113,4 +113,3 @@ async fn fetch_metadata_bytes(url: &str) -> Result, Box, +) -> Result, BittensorError> { + let runtime_api = + client + .runtime_api() + .at_latest() + .await + .map_err(|e| BittensorError::RpcError { + message: format!("Failed to get runtime API: {}", e), + })?; + + let payload = api::apis().subnet_info_runtime_api().get_all_dynamic_info(); + let result = runtime_api + .call(payload) + .await + .map_err(|e| BittensorError::RpcError { + message: format!("Failed to call get_all_dynamic_info: {}", e), + })?; + + let subnets: Vec = result + .into_iter() + .flatten() + .map(|info| { + // Decode subnet name from compact bytes + let name = decode_compact_bytes(&info.subnet_name); + let symbol = decode_compact_bytes(&info.token_symbol); + + // Extract identity name if available (identity uses plain Vec) + let display_name = info + .subnet_identity + .as_ref() + .and_then(|id| { + let n = String::from_utf8_lossy(&id.subnet_name).to_string(); + if n.is_empty() { + None + } else { + Some(n) + } + }) + .unwrap_or_else(|| name.clone()); + + // Calculate price from pool ratio: tao_in / alpha_in + // This is the actual exchange rate (how much TAO per 1 Alpha) + let alpha_in_f = info.alpha_in as f64 / 1_000_000_000.0; // Convert RAO to TAO + let tao_in_f = info.tao_in as f64 / 1_000_000_000.0; + + let price_tao = if info.netuid == 0 { + 1.0 // Root subnet always 1:1 + } else if alpha_in_f > 0.0 { + tao_in_f / alpha_in_f + } else { + 0.0 + }; + + // Convert FixedI128 to f64 + // The type parameter from metadata is U32 (32 fractional bits) + // moving_price = bits / 2^32 + let moving_price = (info.moving_price.bits as f64) / ((1u64 << 32) as f64); + + DynamicSubnetInfo { + netuid: info.netuid, + name: if display_name.is_empty() { + format!("SN{}", info.netuid) + } else { + display_name + }, + symbol: if symbol.is_empty() { + "α".to_string() + } else { + symbol + }, + tao_in_emission: info.tao_in_emission, + moving_price, + price_tao, + alpha_in: info.alpha_in, + alpha_out: info.alpha_out, + tao_in: info.tao_in, + owner_hotkey: format!("{}", info.owner_hotkey), + owner_coldkey: format!("{}", info.owner_coldkey), + tempo: info.tempo, + registered_at: info.network_registered_at, + } + }) + .collect(); + + Ok(subnets) +} + +/// Decode compact bytes (Vec>) to String +fn decode_compact_bytes(bytes: &[codec::Compact]) -> String { + let raw: Vec = bytes.iter().map(|c| c.0).collect(); + String::from_utf8_lossy(&raw).to_string() +} + #[cfg(test)] mod tests { use super::*; diff --git a/bittensor-rs/src/service.rs b/bittensor-rs/src/service.rs index 421c5bf..3b72672 100644 --- a/bittensor-rs/src/service.rs +++ b/bittensor-rs/src/service.rs @@ -57,7 +57,7 @@ fn load_key_seed(path: &PathBuf) -> Result> { fn signer_from_seed(seed: &str) -> Result> { use subxt_signer::SecretUri; - + // Parse the seed as a SecretUri (handles mnemonic, hex seeds, etc.) let uri: SecretUri = seed.parse()?; let keypair = Keypair::from_uri(&uri)?; @@ -773,6 +773,40 @@ impl Service { self.config.netuid } + /// Get a healthy client from the connection pool for direct queries. + /// + /// This provides access to the underlying chain client for making + /// custom queries not directly supported by the Service API. + /// + /// # Returns + /// + /// * `Result>, BittensorError>` - The chain client + /// + /// # Example + /// + /// ```rust,no_run + /// # use bittensor::Service; + /// # use bittensor::config::BittensorConfig; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let config = BittensorConfig::default(); + /// # let service = Service::new(config).await?; + /// let client = service.client().await?; + /// // Use client for custom queries + /// # Ok(()) + /// # } + /// ``` + pub async fn client( + &self, + ) -> Result>, BittensorError> { + self.connection_pool + .get_healthy_client() + .await + .map_err(|e| BittensorError::NetworkError { + message: format!("Failed to get healthy client: {}", e), + }) + } + /// Sign data with the service's signer (hotkey) /// /// This method signs arbitrary data with the validator/miner's hotkey. diff --git a/bittensor-rs/src/utils.rs b/bittensor-rs/src/utils.rs index 8af6718..87c9e97 100644 --- a/bittensor-rs/src/utils.rs +++ b/bittensor-rs/src/utils.rs @@ -8,8 +8,8 @@ use crate::error::BittensorError; use crate::types::Hotkey; use crate::AccountId; -use std::str::FromStr; use sp_core::{sr25519, Pair}; +use std::str::FromStr; // Weight-related types diff --git a/bittensor-rs/src/wallet/keyfile.rs b/bittensor-rs/src/wallet/keyfile.rs index b76779b..1a39a33 100644 --- a/bittensor-rs/src/wallet/keyfile.rs +++ b/bittensor-rs/src/wallet/keyfile.rs @@ -9,8 +9,8 @@ use crate::error::BittensorError; use serde::{Deserialize, Serialize}; -use std::path::Path; use sp_core::{sr25519, Pair}; +use std::path::Path; use thiserror::Error; /// Errors that can occur when loading keyfiles diff --git a/bittensor-rs/src/wallet/mod.rs b/bittensor-rs/src/wallet/mod.rs index ec68f6d..f08bd4a 100644 --- a/bittensor-rs/src/wallet/mod.rs +++ b/bittensor-rs/src/wallet/mod.rs @@ -28,8 +28,8 @@ pub use signer::WalletSigner; use crate::error::BittensorError; use crate::types::Hotkey; use crate::AccountId; -use std::path::{Path, PathBuf}; use sp_core::{sr25519, Pair}; +use std::path::{Path, PathBuf}; /// Bittensor wallet for managing keys and signing transactions /// diff --git a/bittensor-rs/src/wallet/signer.rs b/bittensor-rs/src/wallet/signer.rs index 9ed4986..7f6666b 100644 --- a/bittensor-rs/src/wallet/signer.rs +++ b/bittensor-rs/src/wallet/signer.rs @@ -36,15 +36,15 @@ impl WalletSigner { // Get the seed bytes from the pair by converting to raw bytes // The secret key is the first 64 bytes (32 bytes key + 32 bytes nonce) let seed = pair.to_raw_vec(); - let keypair = Keypair::from_secret_key(seed[..32].try_into().unwrap()) - .expect("Valid 32-byte seed"); + let keypair = + Keypair::from_secret_key(seed[..32].try_into().unwrap()).expect("Valid 32-byte seed"); Self { inner: keypair } } /// Create a signer from a seed phrase (mnemonic or hex seed) pub fn from_seed(seed: &str) -> Result> { use subxt_signer::SecretUri; - + let uri: SecretUri = seed.parse()?; let keypair = Keypair::from_uri(&uri)?; Ok(Self { inner: keypair }) @@ -69,7 +69,6 @@ impl std::fmt::Debug for WalletSigner { } } - #[cfg(test)] mod tests { use super::*; diff --git a/lightning-tensor/Cargo.toml b/lightning-tensor/Cargo.toml index 33167df..fb1da8a 100644 --- a/lightning-tensor/Cargo.toml +++ b/lightning-tensor/Cargo.toml @@ -2,23 +2,59 @@ name = "lightning-tensor" version = "0.1.0" edition = "2021" +description = "High-performance CLI/TUI for Bittensor network" +license = "MIT" +[[bin]] +name = "lt" +path = "src/main.rs" [dependencies] +# Core Bittensor dependencies (wallet functionality via bittensor-rs::wallet) bittensor-rs = { path = "../bittensor-rs" } -bittensor-wallet = { path = "../bittensor-wallet" } + +# CLI framework clap = { workspace = true } + +# TUI framework crossterm = { version = "0.27", features = ["event-stream"] } ratatui = "0.27.0" -thiserror = { workspace = true } + +# Async runtime tokio = { workspace = true } +futures = { workspace = true } +async-trait = { workspace = true } + +# Error handling +thiserror = { workspace = true } +anyhow = { workspace = true } + +# Logging log = { workspace = true } env_logger = { workspace = true } simplelog = { workspace = true } -sp-core = { workspace = true } -dirs.workspace = true -futures.workspace = true -codec.workspace = true -hex.workspace = true +tracing = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } toml = "0.8.15" -fs = "0.0.5" + +# Crypto +sp-core = { workspace = true } +hex = { workspace = true } +codec = { workspace = true } + +# CLI UX +indicatif = "0.17" +dialoguer = "0.11" +console = "0.15" +tabled = "0.15" + +# Utilities +dirs = { workspace = true } +chrono = { workspace = true } +rand = { workspace = true } + +[dev-dependencies] +tokio-test = "0.4" diff --git a/lightning-tensor/src/app.rs b/lightning-tensor/src/app.rs index ce4923d..5930f54 100644 --- a/lightning-tensor/src/app.rs +++ b/lightning-tensor/src/app.rs @@ -1,6 +1,6 @@ use crate::errors::AppError; use bittensor_rs::{BittensorConfig, Service, SubnetInfo}; -use bittensor_wallet::Wallet; +use bittensor_rs::wallet::Wallet; use std::error::Error; use crate::ui::AnimationState; diff --git a/lightning-tensor/src/cli/crowd.rs b/lightning-tensor/src/cli/crowd.rs new file mode 100644 index 0000000..4574cd7 --- /dev/null +++ b/lightning-tensor/src/cli/crowd.rs @@ -0,0 +1,221 @@ +//! # Crowdfunding CLI Commands +//! +//! Command-line interface for crowdfunding operations. + +use super::OutputFormat; +use crate::context::AppContext; +use crate::errors::Result; +use clap::Subcommand; + +/// Crowdfunding subcommands +#[derive(Subcommand, Debug)] +pub enum CrowdCommand { + /// Create a crowdfunding campaign + Create { + /// Campaign name/description + name: String, + + /// Target amount in TAO + #[arg(short, long)] + target: f64, + + /// Duration in blocks + #[arg(short, long)] + duration: u32, + }, + + /// Contribute to a campaign + Contribute { + /// Campaign ID + campaign_id: u64, + + /// Amount in TAO + amount: f64, + }, + + /// View campaign details + View { + /// Campaign ID (show all if not specified) + campaign_id: Option, + }, + + /// Dissolve a campaign (creator only) + Dissolve { + /// Campaign ID + campaign_id: u64, + + /// Skip confirmation + #[arg(short = 'y', long)] + yes: bool, + }, + + /// Request refund from failed campaign + Refund { + /// Campaign ID + campaign_id: u64, + }, + + /// Update campaign details + Update { + /// Campaign ID + campaign_id: u64, + + /// New description + #[arg(long)] + description: Option, + }, +} + +/// Execute crowd command +pub async fn execute(ctx: &AppContext, cmd: CrowdCommand, format: OutputFormat) -> Result<()> { + match cmd { + CrowdCommand::Create { + name, + target, + duration, + } => create_campaign(ctx, &name, target, duration, format).await, + CrowdCommand::Contribute { + campaign_id, + amount, + } => contribute(ctx, campaign_id, amount, format).await, + CrowdCommand::View { campaign_id } => view_campaigns(ctx, campaign_id, format).await, + CrowdCommand::Dissolve { campaign_id, yes } => { + dissolve_campaign(ctx, campaign_id, yes, format).await + } + CrowdCommand::Refund { campaign_id } => request_refund(ctx, campaign_id, format).await, + CrowdCommand::Update { + campaign_id, + description, + } => update_campaign(ctx, campaign_id, description, format).await, + } +} + +async fn create_campaign( + _ctx: &AppContext, + name: &str, + target: f64, + duration: u32, + format: OutputFormat, +) -> Result<()> { + // TODO: Implement when crowdfunding extrinsics are available in bittensor-rs + match format { + OutputFormat::Text => { + println!("⚠️ Crowdfunding not yet implemented in bittensor-rs"); + println!(" Campaign: {}", name); + println!(" Target: {} TAO", target); + println!(" Duration: {} blocks", duration); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "error": "Crowdfunding not yet implemented" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + Ok(()) +} + +async fn contribute( + _ctx: &AppContext, + campaign_id: u64, + amount: f64, + format: OutputFormat, +) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Crowdfunding not yet implemented in bittensor-rs"); + println!(" Campaign ID: {}", campaign_id); + println!(" Amount: {} TAO", amount); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "error": "Crowdfunding not yet implemented" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + Ok(()) +} + +async fn view_campaigns( + _ctx: &AppContext, + campaign_id: Option, + format: OutputFormat, +) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Crowdfunding not yet implemented in bittensor-rs"); + if let Some(id) = campaign_id { + println!(" Campaign ID: {}", id); + } + } + OutputFormat::Json => { + let output = serde_json::json!({ + "error": "Crowdfunding not yet implemented" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + Ok(()) +} + +async fn dissolve_campaign( + _ctx: &AppContext, + campaign_id: u64, + _yes: bool, + format: OutputFormat, +) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Crowdfunding not yet implemented in bittensor-rs"); + println!(" Campaign ID: {}", campaign_id); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "error": "Crowdfunding not yet implemented" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + Ok(()) +} + +async fn request_refund(_ctx: &AppContext, campaign_id: u64, format: OutputFormat) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Crowdfunding not yet implemented in bittensor-rs"); + println!(" Campaign ID: {}", campaign_id); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "error": "Crowdfunding not yet implemented" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + Ok(()) +} + +async fn update_campaign( + _ctx: &AppContext, + campaign_id: u64, + description: Option, + format: OutputFormat, +) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Crowdfunding not yet implemented in bittensor-rs"); + println!(" Campaign ID: {}", campaign_id); + if let Some(desc) = description { + println!(" New description: {}", desc); + } + } + OutputFormat::Json => { + let output = serde_json::json!({ + "error": "Crowdfunding not yet implemented" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + Ok(()) +} diff --git a/lightning-tensor/src/cli/mod.rs b/lightning-tensor/src/cli/mod.rs new file mode 100644 index 0000000..b9329cd --- /dev/null +++ b/lightning-tensor/src/cli/mod.rs @@ -0,0 +1,127 @@ +//! # CLI Module +//! +//! Command-line interface implementation using clap. + +pub mod crowd; +pub mod root; +pub mod stake; +pub mod subnet; +pub mod transfer; +pub mod wallet; +pub mod weights; + +use crate::errors::Result; +use clap::{Parser, Subcommand}; + +/// Lightning Tensor - High-performance CLI for Bittensor +#[derive(Parser, Debug)] +#[command(name = "lt")] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +pub struct Cli { + /// Network to connect to: finney, test, local, or custom URL + #[arg(short, long, global = true, default_value = "finney")] + pub network: String, + + /// Wallet name to use + #[arg(short, long, global = true)] + pub wallet: Option, + + /// Hotkey name to use + #[arg(short = 'H', long, global = true)] + pub hotkey: Option, + + /// Subnet netuid + #[arg(short = 'u', long, global = true)] + pub netuid: Option, + + /// Verbose output + #[arg(short, long, global = true)] + pub verbose: bool, + + /// Output format: text, json + #[arg(long, global = true, default_value = "text")] + pub output: OutputFormat, + + #[command(subcommand)] + pub command: Commands, +} + +/// Output format for CLI commands +#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)] +pub enum OutputFormat { + #[default] + Text, + Json, +} + +/// Available subcommands +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Launch interactive TUI + Tui, + + /// Wallet operations + #[command(subcommand)] + Wallet(wallet::WalletCommand), + + /// Staking operations + #[command(subcommand)] + Stake(stake::StakeCommand), + + /// Transfer TAO + Transfer(transfer::TransferArgs), + + /// Subnet operations + #[command(subcommand)] + Subnet(subnet::SubnetCommand), + + /// Weight operations + #[command(subcommand)] + Weights(weights::WeightsCommand), + + /// Root network operations + #[command(subcommand)] + Root(root::RootCommand), + + /// Crowdfunding operations + #[command(subcommand)] + Crowd(crowd::CrowdCommand), +} + +impl Cli { + /// Execute the CLI command + pub async fn execute(self) -> Result<()> { + // Build context with CLI options + let mut builder = crate::context::AppContextBuilder::new().with_network(&self.network); + + if let Some(wallet) = &self.wallet { + builder = builder.with_wallet(wallet); + } + if let Some(hotkey) = &self.hotkey { + builder = builder.with_hotkey(hotkey); + } + if let Some(netuid) = self.netuid { + builder = builder.with_netuid(netuid); + } + + let ctx = builder.build()?; + + match self.command { + Commands::Tui => crate::tui::run(ctx).await, + Commands::Wallet(cmd) => wallet::execute(&ctx, cmd, self.output).await, + Commands::Stake(cmd) => stake::execute(&ctx, cmd, self.output).await, + Commands::Transfer(args) => transfer::execute(&ctx, args, self.output).await, + Commands::Subnet(cmd) => subnet::execute(&ctx, cmd, self.output).await, + Commands::Weights(cmd) => weights::execute(&ctx, cmd, self.output).await, + Commands::Root(cmd) => root::execute(&ctx, cmd, self.output).await, + Commands::Crowd(cmd) => crowd::execute(&ctx, cmd, self.output).await, + } + } +} + +/// Run the CLI +pub async fn run() -> Result<()> { + let cli = Cli::parse(); + cli.execute().await +} diff --git a/lightning-tensor/src/cli/root.rs b/lightning-tensor/src/cli/root.rs new file mode 100644 index 0000000..2c08433 --- /dev/null +++ b/lightning-tensor/src/cli/root.rs @@ -0,0 +1,153 @@ +//! # Root Network CLI Commands +//! +//! Command-line interface for root network operations. + +use super::OutputFormat; +use crate::context::AppContext; +use crate::errors::Result; +use clap::Subcommand; + +/// Root network subcommands +#[derive(Subcommand, Debug)] +pub enum RootCommand { + /// Register on root network + Register { + /// Skip confirmation + #[arg(short = 'y', long)] + yes: bool, + }, + + /// Set root weights + Weights { + /// Subnet netuids (comma-separated) + netuids: String, + + /// Weights (comma-separated, must match netuids count) + weights: String, + + /// Skip confirmation + #[arg(short = 'y', long)] + yes: bool, + }, + + /// Show root network status + Status, + + /// Show root validators + Validators { + /// Show full details + #[arg(long)] + full: bool, + }, +} + +/// Execute root command +pub async fn execute(_ctx: &AppContext, cmd: RootCommand, format: OutputFormat) -> Result<()> { + match cmd { + RootCommand::Register { yes: _ } => register_root(format).await, + RootCommand::Weights { + netuids, + weights, + yes: _, + } => set_root_weights(&netuids, &weights, format).await, + RootCommand::Status => show_root_status(format).await, + RootCommand::Validators { full } => show_root_validators(full, format).await, + } +} + +async fn register_root(format: OutputFormat) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Root registration requires network connection"); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn set_root_weights( + netuids: &str, + weights: &str, + format: OutputFormat, +) -> Result<()> { + // Validate netuids are comma-separated integers + let netuid_vec: Result, _> = netuids + .split(',') + .map(|s| s.trim().parse()) + .collect(); + let netuid_vec = netuid_vec.map_err(|_| + crate::errors::Error::config("Invalid netuid format: expected comma-separated integers"))?; + + // Validate weights are comma-separated floats + let weight_vec: Result, _> = weights + .split(',') + .map(|s| s.trim().parse()) + .collect(); + let weight_vec = weight_vec.map_err(|_| + crate::errors::Error::config("Invalid weight format: expected comma-separated numbers"))?; + + // Validate counts match + if netuid_vec.len() != weight_vec.len() { + return Err(crate::errors::Error::config( + format!("Mismatch: {} netuids but {} weights", netuid_vec.len(), weight_vec.len()) + )); + } + + match format { + OutputFormat::Text => { + println!("⚠️ Root weight setting requires network connection"); + println!(" Would set weights for netuids: {}", netuids); + println!(" Weights: {}", weights); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "netuids": netuids, + "weights": weights + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn show_root_status(format: OutputFormat) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Root status requires network connection"); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn show_root_validators(full: bool, format: OutputFormat) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Root validators require network connection"); + println!(" Full: {}", full); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "full": full + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} diff --git a/lightning-tensor/src/cli/stake.rs b/lightning-tensor/src/cli/stake.rs new file mode 100644 index 0000000..a25bf03 --- /dev/null +++ b/lightning-tensor/src/cli/stake.rs @@ -0,0 +1,316 @@ +//! # Stake CLI Commands +//! +//! Command-line interface for staking operations. + +use super::OutputFormat; +use crate::context::AppContext; +use crate::errors::Result; +use clap::Subcommand; + +/// Staking subcommands +#[derive(Subcommand, Debug)] +pub enum StakeCommand { + /// Add stake to a hotkey + Add { + /// Hotkey address or name + hotkey: String, + + /// Amount in TAO + amount: f64, + + /// Subnet netuid (uses default if not specified) + #[arg(short, long)] + netuid: Option, + }, + + /// Remove stake from a hotkey + Remove { + /// Hotkey address or name + hotkey: String, + + /// Amount in TAO + amount: f64, + + /// Subnet netuid + #[arg(short, long)] + netuid: Option, + }, + + /// List all stake positions + List { + /// Coldkey address (uses wallet if not specified) + #[arg(short, long)] + coldkey: Option, + }, + + /// Delegate stake to a validator + Delegate { + /// Validator hotkey address + validator: String, + + /// Amount in TAO + amount: f64, + + /// Subnet netuid + #[arg(short, long)] + netuid: Option, + }, + + /// Undelegate stake from a validator + Undelegate { + /// Validator hotkey address + validator: String, + + /// Amount in TAO + amount: f64, + + /// Subnet netuid + #[arg(short, long)] + netuid: Option, + }, + + /// Set children hotkeys + Children { + #[command(subcommand)] + action: ChildrenAction, + }, + + /// Show stake summary + Summary, +} + +/// Children hotkey actions +#[derive(Subcommand, Debug)] +pub enum ChildrenAction { + /// Set children hotkeys + Set { + /// Child hotkey addresses (comma-separated) + children: String, + + /// Proportions for each child (comma-separated, must sum to 1.0) + #[arg(short, long)] + proportions: String, + + /// Subnet netuid + #[arg(short, long)] + netuid: Option, + }, + + /// List current children hotkeys + List { + /// Hotkey to check + #[arg(short, long)] + hotkey: Option, + }, + + /// Revoke all children hotkeys + Revoke { + /// Subnet netuid + #[arg(short, long)] + netuid: Option, + }, + + /// Set childkey take percentage + SetTake { + /// Take percentage (0.0 to 1.0) + take: f64, + + /// Subnet netuid + #[arg(short, long)] + netuid: Option, + }, +} + +/// Execute stake command +pub async fn execute(ctx: &AppContext, cmd: StakeCommand, format: OutputFormat) -> Result<()> { + match cmd { + StakeCommand::Add { + hotkey, + amount, + netuid, + } => add_stake(ctx, &hotkey, amount, netuid, format).await, + StakeCommand::Remove { + hotkey, + amount, + netuid, + } => remove_stake(ctx, &hotkey, amount, netuid, format).await, + StakeCommand::List { coldkey } => list_stakes(ctx, coldkey.as_deref(), format).await, + StakeCommand::Delegate { + validator, + amount, + netuid, + } => delegate_stake(ctx, &validator, amount, netuid, format).await, + StakeCommand::Undelegate { + validator, + amount, + netuid, + } => undelegate_stake(ctx, &validator, amount, netuid, format).await, + StakeCommand::Children { action } => handle_children(ctx, action, format).await, + StakeCommand::Summary => show_summary(ctx, format).await, + } +} + +async fn add_stake( + _ctx: &AppContext, + hotkey: &str, + amount: f64, + netuid: Option, + format: OutputFormat, +) -> Result<()> { + // TODO: Implement when bittensor-rs exposes staking through Service + let netuid = netuid.unwrap_or(1); + + match format { + OutputFormat::Text => { + println!("⚠️ Staking operations require network connection"); + println!( + " Would stake {} TAO to {} on subnet {}", + amount, hotkey, netuid + ); + println!(" Use 'lt tui' for interactive staking"); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "hotkey": hotkey, + "amount_tao": amount, + "netuid": netuid + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn remove_stake( + _ctx: &AppContext, + hotkey: &str, + amount: f64, + netuid: Option, + format: OutputFormat, +) -> Result<()> { + let netuid = netuid.unwrap_or(1); + + match format { + OutputFormat::Text => { + println!("⚠️ Staking operations require network connection"); + println!( + " Would unstake {} TAO from {} on subnet {}", + amount, hotkey, netuid + ); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "hotkey": hotkey, + "amount_tao": amount, + "netuid": netuid + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn list_stakes(_ctx: &AppContext, coldkey: Option<&str>, format: OutputFormat) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Stake listing requires network connection"); + if let Some(ck) = coldkey { + println!(" Would show stakes for coldkey: {}", ck); + } + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "coldkey": coldkey + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn delegate_stake( + ctx: &AppContext, + validator: &str, + amount: f64, + netuid: Option, + format: OutputFormat, +) -> Result<()> { + add_stake(ctx, validator, amount, netuid, format).await +} + +async fn undelegate_stake( + ctx: &AppContext, + validator: &str, + amount: f64, + netuid: Option, + format: OutputFormat, +) -> Result<()> { + remove_stake(ctx, validator, amount, netuid, format).await +} + +async fn handle_children( + _ctx: &AppContext, + action: ChildrenAction, + format: OutputFormat, +) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Children operations require network connection"); + match action { + ChildrenAction::Set { + children, + proportions, + netuid, + } => { + println!( + " Would set children: {} with proportions: {} on subnet {:?}", + children, proportions, netuid + ); + } + ChildrenAction::List { hotkey } => { + println!(" Would list children for hotkey: {:?}", hotkey); + } + ChildrenAction::Revoke { netuid } => { + println!(" Would revoke children on subnet {:?}", netuid); + } + ChildrenAction::SetTake { take, netuid } => { + println!( + " Would set childkey take to {:.2}% on subnet {:?}", + take * 100.0, + netuid + ); + } + } + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn show_summary(_ctx: &AppContext, format: OutputFormat) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Stake summary requires network connection"); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} diff --git a/lightning-tensor/src/cli/subnet.rs b/lightning-tensor/src/cli/subnet.rs new file mode 100644 index 0000000..6b88d69 --- /dev/null +++ b/lightning-tensor/src/cli/subnet.rs @@ -0,0 +1,185 @@ +//! # Subnet CLI Commands +//! +//! Command-line interface for subnet operations. + +use super::OutputFormat; +use crate::context::AppContext; +use crate::errors::Result; +use clap::Subcommand; + +/// Subnet subcommands +#[derive(Subcommand, Debug)] +pub enum SubnetCommand { + /// List all subnets + List, + + /// Show subnet information + Info { + /// Subnet netuid + netuid: u16, + }, + + /// Show subnet metagraph + Metagraph { + /// Subnet netuid + netuid: u16, + + /// Show full details + #[arg(long)] + full: bool, + }, + + /// Show subnet hyperparameters + Hyperparams { + /// Subnet netuid + netuid: u16, + }, + + /// Register a new subnet + Register { + /// Skip confirmation + #[arg(short = 'y', long)] + yes: bool, + }, + + /// Register on a subnet (burn registration) + RegisterNeuron { + /// Subnet netuid + netuid: u16, + + /// Skip confirmation + #[arg(short = 'y', long)] + yes: bool, + }, +} + +/// Execute subnet command +pub async fn execute(ctx: &AppContext, cmd: SubnetCommand, format: OutputFormat) -> Result<()> { + match cmd { + SubnetCommand::List => list_subnets(ctx, format).await, + SubnetCommand::Info { netuid } => show_subnet_info(ctx, netuid, format).await, + SubnetCommand::Metagraph { netuid, full } => { + show_metagraph(ctx, netuid, full, format).await + } + SubnetCommand::Hyperparams { netuid } => show_hyperparams(ctx, netuid, format).await, + SubnetCommand::Register { yes: _ } => register_subnet(ctx, format).await, + SubnetCommand::RegisterNeuron { netuid, yes: _ } => { + register_neuron(ctx, netuid, format).await + } + } +} + +async fn list_subnets(_ctx: &AppContext, format: OutputFormat) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Subnet listing requires network connection"); + println!(" Use 'lt tui' for interactive subnet exploration"); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "note": "Requires network connection" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn show_subnet_info(_ctx: &AppContext, netuid: u16, format: OutputFormat) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Subnet info requires network connection"); + println!(" Would show info for subnet {}", netuid); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "netuid": netuid + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn show_metagraph( + _ctx: &AppContext, + netuid: u16, + full: bool, + format: OutputFormat, +) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Metagraph requires network connection"); + println!( + " Would show metagraph for subnet {} (full: {})", + netuid, full + ); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "netuid": netuid, + "full": full + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn show_hyperparams(_ctx: &AppContext, netuid: u16, format: OutputFormat) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Hyperparameters require network connection"); + println!(" Would show hyperparams for subnet {}", netuid); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "netuid": netuid + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn register_subnet(_ctx: &AppContext, format: OutputFormat) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Subnet registration requires network connection"); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn register_neuron(_ctx: &AppContext, netuid: u16, format: OutputFormat) -> Result<()> { + match format { + OutputFormat::Text => { + println!("⚠️ Neuron registration requires network connection"); + println!(" Would register on subnet {}", netuid); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "netuid": netuid + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} diff --git a/lightning-tensor/src/cli/transfer.rs b/lightning-tensor/src/cli/transfer.rs new file mode 100644 index 0000000..8d24f60 --- /dev/null +++ b/lightning-tensor/src/cli/transfer.rs @@ -0,0 +1,51 @@ +//! # Transfer CLI Commands +//! +//! Command-line interface for TAO transfers. + +use super::OutputFormat; +use crate::context::AppContext; +use crate::errors::Result; +use clap::Args; + +/// Transfer command arguments +#[derive(Args, Debug)] +pub struct TransferArgs { + /// Destination address + #[arg(short = 't', long)] + pub to: String, + + /// Amount in TAO + #[arg(short, long)] + pub amount: f64, + + /// Keep sender account alive (maintain existential deposit) + #[arg(long, default_value = "true")] + pub keep_alive: bool, + + /// Skip confirmation prompt + #[arg(short = 'y', long)] + pub yes: bool, +} + +/// Execute transfer command +pub async fn execute(_ctx: &AppContext, args: TransferArgs, format: OutputFormat) -> Result<()> { + // TODO: Implement when bittensor-rs exposes transfer through Service + match format { + OutputFormat::Text => { + println!("⚠️ Transfer operations require network connection"); + println!(" Would transfer {} TAO to {}", args.amount, args.to); + println!(" Keep alive: {}", args.keep_alive); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "destination": args.to, + "amount_tao": args.amount, + "keep_alive": args.keep_alive + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} diff --git a/lightning-tensor/src/cli/wallet.rs b/lightning-tensor/src/cli/wallet.rs new file mode 100644 index 0000000..56a1658 --- /dev/null +++ b/lightning-tensor/src/cli/wallet.rs @@ -0,0 +1,433 @@ +//! # Wallet CLI Commands +//! +//! Command-line interface for wallet operations. + +use super::OutputFormat; +use crate::context::AppContext; +use crate::errors::Result; +use crate::services::wallet::WalletService; +use clap::Subcommand; +use std::path::Path; + +/// Read coldkey address from coldkeypub.txt file +fn read_coldkey_address(wallet_path: &Path) -> String { + let coldkeypub_path = wallet_path.join("coldkeypub.txt"); + if coldkeypub_path.exists() { + match std::fs::read_to_string(&coldkeypub_path) { + Ok(content) => { + // Try to parse as JSON and extract ss58Address + if let Ok(json) = serde_json::from_str::(&content) { + if let Some(addr) = json.get("ss58Address").and_then(|v| v.as_str()) { + return addr.to_string(); + } + } + // Fallback: maybe it's just a plain address + content.trim().to_string() + } + Err(_) => "N/A".to_string(), + } + } else { + "N/A (coldkeypub.txt not found)".to_string() + } +} + +/// Wallet subcommands +#[derive(Subcommand, Debug)] +pub enum WalletCommand { + /// Create a new wallet + Create { + /// Wallet name + name: String, + + /// Number of mnemonic words (12 or 24) + #[arg(short, long, default_value = "12")] + words: u8, + + /// Skip password prompt (insecure) + #[arg(long)] + no_password: bool, + }, + + /// List all wallets + List, + + /// Show wallet balance + Balance { + /// Wallet name (uses default if not specified) + name: Option, + }, + + /// Show wallet details + Info { + /// Wallet name + name: String, + }, + + /// Sign a message + Sign { + /// Message to sign + message: String, + + /// Wallet name (uses default if not specified) + #[arg(short, long)] + wallet: Option, + }, + + /// Verify a signature + Verify { + /// Original message + message: String, + + /// Signature in hex format + signature: String, + + /// Public key or address + #[arg(short, long)] + pubkey: Option, + }, + + /// Regenerate wallet from mnemonic + Regen { + /// Wallet name + name: String, + + /// Mnemonic phrase (will prompt if not provided) + #[arg(short, long)] + mnemonic: Option, + }, + + /// Create a new hotkey + NewHotkey { + /// Wallet name + wallet: String, + + /// Hotkey name + name: String, + }, + + /// List hotkeys for a wallet + ListHotkeys { + /// Wallet name + wallet: String, + }, +} + +/// Execute wallet command +pub async fn execute(ctx: &AppContext, cmd: WalletCommand, format: OutputFormat) -> Result<()> { + let service = WalletService::new(ctx.wallet_dir().clone()); + + match cmd { + WalletCommand::Create { + name, + words, + no_password, + } => create_wallet(&service, &name, words, no_password, format).await, + WalletCommand::List => list_wallets(&service, format).await, + WalletCommand::Balance { name } => { + show_balance(ctx, &service, name.as_deref(), format).await + } + WalletCommand::Info { name } => show_wallet_info(&service, &name, format).await, + WalletCommand::Sign { message, wallet } => { + sign_message(&service, &message, wallet.as_deref(), format).await + } + WalletCommand::Verify { + message, + signature, + pubkey, + } => verify_signature(&service, &message, &signature, pubkey.as_deref(), format).await, + WalletCommand::Regen { name, mnemonic } => { + regen_wallet(&service, &name, mnemonic.as_deref(), format).await + } + WalletCommand::NewHotkey { wallet, name } => { + create_hotkey(&service, &wallet, &name, format).await + } + WalletCommand::ListHotkeys { wallet } => list_hotkeys(&service, &wallet, format).await, + } +} + +async fn create_wallet( + service: &WalletService, + name: &str, + words: u8, + no_password: bool, + format: OutputFormat, +) -> Result<()> { + let password = if no_password { + String::new() + } else { + prompt_password("Enter wallet password: ")? + }; + + let wallet = service.create_wallet(name, words, &password)?; + let address = wallet.hotkey().to_string(); // Hotkey address (coldkey needs unlock) + + match format { + OutputFormat::Text => { + println!("✓ Wallet '{}' created successfully", name); + println!(" Coldkey: {}", address); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "success": true, + "wallet": name, + "coldkey": address + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn list_wallets(service: &WalletService, format: OutputFormat) -> Result<()> { + let wallets = service.list_wallets()?; + + match format { + OutputFormat::Text => { + if wallets.is_empty() { + println!("No wallets found"); + } else { + println!("Wallets:"); + for wallet in wallets { + println!(" • {}", wallet); + } + } + } + OutputFormat::Json => { + let output = serde_json::json!({ + "wallets": wallets + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn show_balance( + ctx: &AppContext, + service: &WalletService, + name: Option<&str>, + format: OutputFormat, +) -> Result<()> { + let wallet_name = name + .map(String::from) + .or_else(|| ctx.config().wallet.default_wallet.clone()) + .ok_or_else(|| crate::errors::Error::wallet("No wallet specified"))?; + + let wallet = service.load_wallet(&wallet_name)?; + let address = read_coldkey_address(&wallet.path); + + // For balance, we need to connect to the network + // For now, just show the address + match format { + OutputFormat::Text => { + println!("Wallet: {}", wallet_name); + println!("Address: {}", address); + println!("Balance: Connect to network to fetch balance"); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "wallet": wallet_name, + "address": address, + "balance_tao": null, + "balance_rao": null, + "note": "Connect to network to fetch balance" + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn show_wallet_info(service: &WalletService, name: &str, format: OutputFormat) -> Result<()> { + let wallet = service.load_wallet(name)?; + let coldkey = read_coldkey_address(&wallet.path); + let hotkeys = service.list_hotkeys(name)?; + + match format { + OutputFormat::Text => { + println!("Wallet: {}", name); + println!("Coldkey: {}", coldkey); + println!("Hotkeys:"); + if hotkeys.is_empty() { + println!(" (none)"); + } else { + for hk in &hotkeys { + println!(" • {}", hk); + } + } + } + OutputFormat::Json => { + let output = serde_json::json!({ + "wallet": name, + "coldkey": coldkey, + "hotkeys": hotkeys + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn sign_message( + service: &WalletService, + message: &str, + wallet_name: Option<&str>, + format: OutputFormat, +) -> Result<()> { + let name = wallet_name.unwrap_or("default"); + let password = prompt_password("Enter wallet password: ")?; + + let signature = service.sign_message(name, message, &password)?; + + match format { + OutputFormat::Text => { + println!("Signature: {}", signature); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "message": message, + "signature": signature + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn verify_signature( + service: &WalletService, + message: &str, + signature: &str, + pubkey: Option<&str>, + format: OutputFormat, +) -> Result<()> { + let valid = service.verify_signature(message, signature, pubkey)?; + + match format { + OutputFormat::Text => { + if valid { + println!("✓ Signature is valid"); + } else { + println!("✗ Signature is invalid"); + } + } + OutputFormat::Json => { + let output = serde_json::json!({ + "valid": valid, + "message": message + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn regen_wallet( + service: &WalletService, + name: &str, + mnemonic: Option<&str>, + format: OutputFormat, +) -> Result<()> { + let mnemonic = match mnemonic { + Some(m) => m.to_string(), + None => prompt_input("Enter mnemonic phrase: ")?, + }; + + let password = prompt_password("Enter new wallet password: ")?; + + let wallet = service.regen_wallet(name, &mnemonic, &password)?; + let address = read_coldkey_address(&wallet.path); + + match format { + OutputFormat::Text => { + println!("✓ Wallet '{}' regenerated successfully", name); + println!(" Coldkey: {}", address); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "success": true, + "wallet": name, + "coldkey": address + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn create_hotkey( + service: &WalletService, + wallet: &str, + name: &str, + format: OutputFormat, +) -> Result<()> { + let password = prompt_password("Enter wallet password: ")?; + + let address = service.create_hotkey(wallet, name, &password)?; + + match format { + OutputFormat::Text => { + println!("✓ Hotkey '{}' created for wallet '{}'", name, wallet); + println!(" Address: {}", address); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "success": true, + "wallet": wallet, + "hotkey": name, + "address": address + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn list_hotkeys(service: &WalletService, wallet: &str, format: OutputFormat) -> Result<()> { + let hotkeys = service.list_hotkeys(wallet)?; + + match format { + OutputFormat::Text => { + if hotkeys.is_empty() { + println!("No hotkeys found for wallet '{}'", wallet); + } else { + println!("Hotkeys for '{}':", wallet); + for hk in hotkeys { + println!(" • {}", hk); + } + } + } + OutputFormat::Json => { + let output = serde_json::json!({ + "wallet": wallet, + "hotkeys": hotkeys + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +// Helper functions for prompts +fn prompt_password(prompt: &str) -> Result { + dialoguer::Password::new() + .with_prompt(prompt) + .interact() + .map_err(|e| crate::errors::Error::ui(e.to_string())) +} + +fn prompt_input(prompt: &str) -> Result { + dialoguer::Input::new() + .with_prompt(prompt) + .interact_text() + .map_err(|e| crate::errors::Error::ui(e.to_string())) +} diff --git a/lightning-tensor/src/cli/weights.rs b/lightning-tensor/src/cli/weights.rs new file mode 100644 index 0000000..2a1448d --- /dev/null +++ b/lightning-tensor/src/cli/weights.rs @@ -0,0 +1,186 @@ +//! # Weights CLI Commands +//! +//! Command-line interface for weight operations. + +use super::OutputFormat; +use crate::context::AppContext; +use crate::errors::Result; +use clap::Subcommand; + +/// Weights subcommands +#[derive(Subcommand, Debug)] +pub enum WeightsCommand { + /// Set weights directly + Set { + /// UIDs (comma-separated) + uids: String, + + /// Weights (comma-separated, must match UIDs count) + weights: String, + + /// Subnet netuid + #[arg(short, long)] + netuid: Option, + + /// Skip confirmation + #[arg(short = 'y', long)] + yes: bool, + }, + + /// Commit weights (first step of commit-reveal) + Commit { + /// UIDs (comma-separated) + uids: String, + + /// Weights (comma-separated) + weights: String, + + /// Subnet netuid + #[arg(short, long)] + netuid: Option, + + /// Salt for commitment (auto-generated if not provided) + #[arg(long)] + salt: Option, + }, + + /// Reveal weights (second step of commit-reveal) + Reveal { + /// UIDs (comma-separated) + uids: String, + + /// Weights (comma-separated) + weights: String, + + /// Subnet netuid + #[arg(short, long)] + netuid: Option, + + /// Salt used in commitment + salt: String, + }, +} + +/// Execute weights command +pub async fn execute(_ctx: &AppContext, cmd: WeightsCommand, format: OutputFormat) -> Result<()> { + match cmd { + WeightsCommand::Set { + uids, + weights, + netuid, + yes: _, + } => set_weights(&uids, &weights, netuid, format).await, + WeightsCommand::Commit { + uids, + weights, + netuid, + salt, + } => commit_weights(&uids, &weights, netuid, salt, format).await, + WeightsCommand::Reveal { + uids, + weights, + netuid, + salt, + } => reveal_weights(&uids, &weights, netuid, &salt, format).await, + } +} + +async fn set_weights( + uids: &str, + weights: &str, + netuid: Option, + format: OutputFormat, +) -> Result<()> { + let netuid = netuid.unwrap_or(1); + + match format { + OutputFormat::Text => { + println!("⚠️ Weight setting requires network connection"); + println!(" Would set weights on subnet {}", netuid); + println!(" UIDs: {}", uids); + println!(" Weights: {}", weights); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "netuid": netuid, + "uids": uids, + "weights": weights + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn commit_weights( + uids: &str, + weights: &str, + netuid: Option, + salt: Option, + format: OutputFormat, +) -> Result<()> { + let netuid = netuid.unwrap_or(1); + + // Generate salt if not provided + let salt_hex = salt.unwrap_or_else(|| { + use rand::Rng; + let salt_bytes: Vec = (0..32).map(|_| rand::thread_rng().gen::()).collect(); + hex::encode(salt_bytes) + }); + + match format { + OutputFormat::Text => { + println!("⚠️ Weight commitment requires network connection"); + println!(" Would commit weights on subnet {}", netuid); + println!(" UIDs: {}", uids); + println!(" Weights: {}", weights); + println!(" Salt: {}", salt_hex); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "netuid": netuid, + "uids": uids, + "weights": weights, + "salt": salt_hex + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + +async fn reveal_weights( + uids: &str, + weights: &str, + netuid: Option, + salt: &str, + format: OutputFormat, +) -> Result<()> { + let netuid = netuid.unwrap_or(1); + + match format { + OutputFormat::Text => { + println!("⚠️ Weight reveal requires network connection"); + println!(" Would reveal weights on subnet {}", netuid); + println!(" UIDs: {}", uids); + println!(" Weights: {}", weights); + println!(" Salt: {}", salt); + } + OutputFormat::Json => { + let output = serde_json::json!({ + "status": "not_implemented", + "netuid": netuid, + "uids": uids, + "weights": weights, + "salt": salt + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} diff --git a/lightning-tensor/src/config/mod.rs b/lightning-tensor/src/config/mod.rs new file mode 100644 index 0000000..11eebc6 --- /dev/null +++ b/lightning-tensor/src/config/mod.rs @@ -0,0 +1,33 @@ +//! # Configuration Module +//! +//! Application configuration management including network settings, +//! wallet defaults, and user preferences. + +mod settings; + +pub use settings::{Config, NetworkConfig, WalletConfig}; + +use crate::errors::{Error, Result}; +use std::path::PathBuf; + +/// Default config file name +pub const CONFIG_FILE_NAME: &str = "config.toml"; + +/// Get the default config directory +pub fn default_config_dir() -> Result { + dirs::config_dir() + .map(|p| p.join("lightning-tensor")) + .ok_or_else(|| Error::config("Could not determine config directory")) +} + +/// Get the default wallet directory +pub fn default_wallet_dir() -> Result { + dirs::home_dir() + .map(|p| p.join(".bittensor").join("wallets")) + .ok_or_else(|| Error::config("Could not determine home directory")) +} + +/// Get the config file path +pub fn config_file_path() -> Result { + default_config_dir().map(|p| p.join(CONFIG_FILE_NAME)) +} diff --git a/lightning-tensor/src/config/settings.rs b/lightning-tensor/src/config/settings.rs new file mode 100644 index 0000000..ec43a3e --- /dev/null +++ b/lightning-tensor/src/config/settings.rs @@ -0,0 +1,263 @@ +//! # Configuration Settings +//! +//! Defines configuration structures and loading/saving logic. + +use crate::errors::{Error, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Main application configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Default)] +pub struct Config { + /// Network configuration + #[serde(default)] + pub network: NetworkConfig, + + /// Wallet configuration + #[serde(default)] + pub wallet: WalletConfig, + + /// UI preferences + #[serde(default)] + pub ui: UiConfig, +} + + +impl Config { + /// Load configuration from file + pub fn load(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + + let content = std::fs::read_to_string(path)?; + let config: Config = toml::from_str(&content)?; + Ok(config) + } + + /// Save configuration to file + pub fn save(&self, path: &PathBuf) -> Result<()> { + // Create parent directory if it doesn't exist + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = toml::to_string_pretty(self) + .map_err(|e| Error::config(format!("Failed to serialize config: {}", e)))?; + std::fs::write(path, content)?; + Ok(()) + } + + /// Load from default location or create default + pub fn load_or_default() -> Result { + match super::config_file_path() { + Ok(path) => Self::load(&path), + Err(_) => Ok(Self::default()), + } + } + + /// Get bittensor-rs config for the current network + pub fn to_bittensor_config( + &self, + wallet_name: &str, + hotkey_name: &str, + netuid: u16, + ) -> bittensor_rs::BittensorConfig { + match self.network.network.as_str() { + "finney" => bittensor_rs::BittensorConfig::finney(wallet_name, hotkey_name, netuid), + "test" => bittensor_rs::BittensorConfig::testnet(wallet_name, hotkey_name, netuid), + "local" => bittensor_rs::BittensorConfig::local(wallet_name, hotkey_name, netuid), + custom_endpoint => bittensor_rs::BittensorConfig { + wallet_name: wallet_name.to_string(), + hotkey_name: hotkey_name.to_string(), + network: "custom".to_string(), + netuid, + chain_endpoint: Some(custom_endpoint.to_string()), + ..Default::default() + }, + } + } +} + +/// Network configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkConfig { + /// Network name: "finney", "test", "local", or custom URL + #[serde(default = "default_network")] + pub network: String, + + /// Connection timeout in seconds + #[serde(default = "default_timeout")] + pub timeout_secs: u64, + + /// Number of retry attempts + #[serde(default = "default_retries")] + pub retries: u32, + + /// Custom chain endpoints (optional) + #[serde(default)] + pub endpoints: Vec, +} + +fn default_network() -> String { + "finney".to_string() +} + +fn default_timeout() -> u64 { + 30 +} + +fn default_retries() -> u32 { + 3 +} + +impl Default for NetworkConfig { + fn default() -> Self { + Self { + network: default_network(), + timeout_secs: default_timeout(), + retries: default_retries(), + endpoints: Vec::new(), + } + } +} + +impl NetworkConfig { + /// Create config for Finney mainnet + pub fn finney() -> Self { + Self { + network: "finney".to_string(), + ..Default::default() + } + } + + /// Create config for testnet + pub fn testnet() -> Self { + Self { + network: "test".to_string(), + ..Default::default() + } + } + + /// Create config for local network + pub fn local() -> Self { + Self { + network: "local".to_string(), + ..Default::default() + } + } + + /// Create config for custom endpoint + pub fn custom(endpoint: impl Into) -> Self { + Self { + network: endpoint.into(), + ..Default::default() + } + } +} + +/// Wallet configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletConfig { + /// Default wallet name + #[serde(default)] + pub default_wallet: Option, + + /// Default hotkey name + #[serde(default)] + pub default_hotkey: Option, + + /// Wallet directory path (None = use default ~/.bittensor/wallets) + #[serde(default)] + pub wallet_dir: Option, + + /// Default subnet for operations + #[serde(default = "default_netuid")] + pub default_netuid: u16, +} + +fn default_netuid() -> u16 { + 1 +} + +impl Default for WalletConfig { + fn default() -> Self { + Self { + default_wallet: None, + default_hotkey: None, + wallet_dir: None, + default_netuid: default_netuid(), + } + } +} + +impl WalletConfig { + /// Get the wallet directory, using default if not specified + pub fn get_wallet_dir(&self) -> Result { + match &self.wallet_dir { + Some(dir) => Ok(dir.clone()), + None => super::default_wallet_dir(), + } + } +} + +/// UI configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UiConfig { + /// Enable colors in CLI output + #[serde(default = "default_true")] + pub colors: bool, + + /// Show spinners/progress bars + #[serde(default = "default_true")] + pub progress: bool, + + /// Verbose output + #[serde(default)] + pub verbose: bool, + + /// TUI refresh rate in milliseconds + #[serde(default = "default_refresh_rate")] + pub refresh_rate_ms: u64, +} + +fn default_true() -> bool { + true +} + +fn default_refresh_rate() -> u64 { + 100 +} + +impl Default for UiConfig { + fn default() -> Self { + Self { + colors: true, + progress: true, + verbose: false, + refresh_rate_ms: default_refresh_rate(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert_eq!(config.network.network, "finney"); + assert_eq!(config.network.timeout_secs, 30); + assert_eq!(config.wallet.default_netuid, 1); + } + + #[test] + fn test_config_serialization() { + let config = Config::default(); + let toml_str = toml::to_string(&config).unwrap(); + let parsed: Config = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.network.network, config.network.network); + } +} diff --git a/lightning-tensor/src/context.rs b/lightning-tensor/src/context.rs new file mode 100644 index 0000000..2a20a4a --- /dev/null +++ b/lightning-tensor/src/context.rs @@ -0,0 +1,290 @@ +//! # Application Context +//! +//! Shared context for CLI and TUI operations, providing access to +//! the Bittensor service, wallet, and configuration. + +use crate::config::Config; +use crate::errors::{Error, Result}; +use bittensor_rs::wallet::Wallet; +use bittensor_rs::Service; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Application context shared between CLI and TUI +/// +/// Provides thread-safe access to the Bittensor service, current wallet, +/// and application configuration. +pub struct AppContext { + /// Bittensor service for chain interactions (lazy initialized) + service: RwLock>>, + + /// Current active wallet (if any) + wallet: RwLock>, + + /// Application configuration + config: Config, + + /// Wallet directory path + wallet_dir: PathBuf, +} + +impl AppContext { + /// Create a new application context + pub fn new(config: Config) -> Result { + let wallet_dir = config.wallet.get_wallet_dir()?; + + Ok(Self { + service: RwLock::new(None), + wallet: RwLock::new(None), + config, + wallet_dir, + }) + } + + /// Create context with default configuration + pub fn with_defaults() -> Result { + let config = Config::load_or_default()?; + Self::new(config) + } + + /// Get the application configuration + pub fn config(&self) -> &Config { + &self.config + } + + /// Get the wallet directory + pub fn wallet_dir(&self) -> &PathBuf { + &self.wallet_dir + } + + /// Check if connected to the network + pub async fn is_connected(&self) -> bool { + self.service.read().await.is_some() + } + + /// Get the current service (if connected) + pub async fn service(&self) -> Option> { + self.service.read().await.clone() + } + + /// Get the current service or return error + pub async fn require_service(&self) -> Result> { + self.service + .read() + .await + .clone() + .ok_or_else(|| Error::network("Not connected to network. Run 'lt connect' first.")) + } + + /// Connect to the Bittensor network + /// + /// # Arguments + /// + /// * `wallet_name` - Name of the wallet to use for signing + /// * `hotkey_name` - Name of the hotkey to use + /// * `netuid` - Subnet ID for operations + pub async fn connect( + &self, + wallet_name: &str, + hotkey_name: &str, + netuid: u16, + ) -> Result> { + let bittensor_config = self + .config + .to_bittensor_config(wallet_name, hotkey_name, netuid); + + let service = Service::new(bittensor_config).await?; + let service = Arc::new(service); + + *self.service.write().await = Some(Arc::clone(&service)); + + Ok(service) + } + + /// Connect with default wallet settings from config + pub async fn connect_with_defaults(&self) -> Result> { + // Use defaults from config, or fallback to sensible defaults for read-only access + let wallet_name = self + .config + .wallet + .default_wallet.as_deref() + .unwrap_or("default"); + + let hotkey_name = self + .config + .wallet + .default_hotkey.as_deref() + .unwrap_or("default"); + + let netuid = self.config.wallet.default_netuid; + + self.connect(wallet_name, hotkey_name, netuid).await + } + + /// Disconnect from the network + pub async fn disconnect(&self) { + *self.service.write().await = None; + } + + /// Get the current wallet (if loaded) + pub async fn wallet(&self) -> Option { + self.wallet.read().await.clone() + } + + /// Get the current wallet or return error + pub async fn require_wallet(&self) -> Result { + self.wallet + .read() + .await + .clone() + .ok_or_else(|| Error::wallet("No wallet loaded. Use 'lt wallet load' first.")) + } + + /// Load a wallet by name with default hotkey + pub async fn load_wallet(&self, name: &str) -> Result { + self.load_wallet_with_hotkey(name, "default").await + } + + /// Load a wallet by name with specific hotkey + pub async fn load_wallet_with_hotkey(&self, name: &str, hotkey: &str) -> Result { + let wallet_path = self.wallet_dir.join(name); + + if !wallet_path.exists() { + return Err(Error::WalletNotFound { + name: name.to_string(), + }); + } + + let wallet = Wallet::load_from_path(name, hotkey, &self.wallet_dir) + .map_err(|e| Error::wallet(format!("Failed to load wallet: {}", e)))?; + *self.wallet.write().await = Some(wallet.clone()); + + Ok(wallet) + } + + /// Unload the current wallet + pub async fn unload_wallet(&self) { + *self.wallet.write().await = None; + } + + /// List available wallets + pub fn list_wallets(&self) -> Result> { + if !self.wallet_dir.exists() { + return Ok(Vec::new()); + } + + let mut wallets = Vec::new(); + + for entry in std::fs::read_dir(&self.wallet_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + if let Some(name) = entry.file_name().to_str() { + wallets.push(name.to_string()); + } + } + } + + wallets.sort(); + Ok(wallets) + } + + /// Get network name + pub fn network_name(&self) -> &str { + &self.config.network.network + } + + /// Get default netuid + pub fn default_netuid(&self) -> u16 { + self.config.wallet.default_netuid + } +} + +/// Builder for AppContext with fluent API +pub struct AppContextBuilder { + config: Config, + wallet_name: Option, + hotkey_name: Option, + netuid: Option, +} + +impl AppContextBuilder { + pub fn new() -> Self { + Self { + config: Config::default(), + wallet_name: None, + hotkey_name: None, + netuid: None, + } + } + + pub fn with_config(mut self, config: Config) -> Self { + self.config = config; + self + } + + pub fn with_network(mut self, network: impl Into) -> Self { + self.config.network.network = network.into(); + self + } + + pub fn with_wallet(mut self, wallet_name: impl Into) -> Self { + self.wallet_name = Some(wallet_name.into()); + self + } + + pub fn with_hotkey(mut self, hotkey_name: impl Into) -> Self { + self.hotkey_name = Some(hotkey_name.into()); + self + } + + pub fn with_netuid(mut self, netuid: u16) -> Self { + self.netuid = Some(netuid); + self + } + + pub fn build(self) -> Result { + let mut config = self.config; + + if let Some(wallet) = self.wallet_name { + config.wallet.default_wallet = Some(wallet); + } + if let Some(hotkey) = self.hotkey_name { + config.wallet.default_hotkey = Some(hotkey); + } + if let Some(netuid) = self.netuid { + config.wallet.default_netuid = netuid; + } + + AppContext::new(config) + } +} + +impl Default for AppContextBuilder { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_context_builder() { + let ctx = AppContextBuilder::new() + .with_network("test") + .with_wallet("my_wallet") + .with_hotkey("my_hotkey") + .with_netuid(18) + .build() + .unwrap(); + + assert_eq!(ctx.config.network.network, "test"); + assert_eq!( + ctx.config.wallet.default_wallet, + Some("my_wallet".to_string()) + ); + assert_eq!(ctx.config.wallet.default_netuid, 18); + } +} diff --git a/lightning-tensor/src/errors.rs b/lightning-tensor/src/errors.rs index 3b001c7..cd85e33 100644 --- a/lightning-tensor/src/errors.rs +++ b/lightning-tensor/src/errors.rs @@ -1,15 +1,192 @@ +//! # Unified Error Handling +//! +//! Centralized error types for the lightning-tensor application. + use thiserror::Error; +/// Main result type for lightning-tensor operations +pub type Result = std::result::Result; + +/// Unified error type for lightning-tensor #[derive(Error, Debug)] -pub enum AppError { +pub enum Error { + // ======================== + // Configuration Errors + // ======================== + #[error("Configuration error: {message}")] + Config { message: String }, + + #[error("Invalid network: {network}. Valid options: finney, test, local, or custom URL")] + InvalidNetwork { network: String }, + + // ======================== + // Wallet Errors + // ======================== + #[error("Wallet error: {message}")] + Wallet { message: String }, + + #[error("Wallet not found: {name}")] + WalletNotFound { name: String }, + + #[error("Wallet already exists: {name}")] + WalletAlreadyExists { name: String }, + + #[error("Invalid password")] + InvalidPassword, + + #[error("Hotkey not found: {name}")] + HotkeyNotFound { name: String }, + + // ======================== + // Network/RPC Errors + // ======================== + #[error("Network error: {message}")] + Network { message: String }, + + #[error("Connection failed: {endpoint}")] + ConnectionFailed { endpoint: String }, + + #[error("RPC error: {message}")] + Rpc { message: String }, + + #[error("Transaction failed: {message}")] + Transaction { message: String }, + + #[error("Transaction timeout after {seconds}s")] + TransactionTimeout { seconds: u64 }, + + // ======================== + // Staking Errors + // ======================== + #[error("Staking error: {message}")] + Staking { message: String }, + + #[error("Insufficient balance: required {required} TAO, available {available} TAO")] + InsufficientBalance { required: f64, available: f64 }, + + #[error("Invalid stake amount: {message}")] + InvalidStakeAmount { message: String }, + + // ======================== + // Subnet Errors + // ======================== + #[error("Subnet not found: netuid {netuid}")] + SubnetNotFound { netuid: u16 }, + + #[error("Subnet error: {message}")] + Subnet { message: String }, + + // ======================== + // Weights Errors + // ======================== + #[error("Invalid weights: {message}")] + InvalidWeights { message: String }, + + #[error("Weights error: {message}")] + Weights { message: String }, + + // ======================== + // UI Errors + // ======================== + #[error("UI error: {message}")] + Ui { message: String }, + + #[error("User cancelled operation")] + UserCancelled, + + // ======================== + // IO Errors + // ======================== #[error("IO error: {0}")] Io(#[from] std::io::Error), - #[error("Invalid input: {0}")] - InvalidInput(String), - #[error("Bittensor error: {0}")] - BittensorError(#[from] bittensor_rs::BittensorError), - #[error("Wallet error: {0}")] - WalletError(#[from] bittensor_wallet::WalletError), - #[error("Configuration error: {0}")] - ConfigError(String), + + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("TOML parse error: {0}")] + TomlParse(#[from] toml::de::Error), + + // ======================== + // External Library Errors + // ======================== + #[error("Bittensor SDK error: {0}")] + BittensorSdk(#[from] bittensor_rs::BittensorError), + + #[error("Wallet library error: {0}")] + WalletLib(#[from] bittensor_rs::wallet::KeyfileError), +} + +impl Error { + /// Create a config error + pub fn config(message: impl Into) -> Self { + Self::Config { + message: message.into(), + } + } + + /// Create a wallet error + pub fn wallet(message: impl Into) -> Self { + Self::Wallet { + message: message.into(), + } + } + + /// Create a network error + pub fn network(message: impl Into) -> Self { + Self::Network { + message: message.into(), + } + } + + /// Create a transaction error + pub fn transaction(message: impl Into) -> Self { + Self::Transaction { + message: message.into(), + } + } + + /// Create a staking error + pub fn staking(message: impl Into) -> Self { + Self::Staking { + message: message.into(), + } + } + + /// Create a subnet error + pub fn subnet(message: impl Into) -> Self { + Self::Subnet { + message: message.into(), + } + } + + /// Create a UI error + pub fn ui(message: impl Into) -> Self { + Self::Ui { + message: message.into(), + } + } + + /// Check if error is recoverable (can retry) + pub fn is_recoverable(&self) -> bool { + matches!( + self, + Error::Network { .. } + | Error::ConnectionFailed { .. } + | Error::Rpc { .. } + | Error::TransactionTimeout { .. } + ) + } + + /// Check if error is user-facing (show to user without stack trace) + pub fn is_user_facing(&self) -> bool { + matches!( + self, + Error::InvalidPassword + | Error::UserCancelled + | Error::WalletNotFound { .. } + | Error::WalletAlreadyExists { .. } + | Error::InsufficientBalance { .. } + | Error::InvalidNetwork { .. } + ) + } } diff --git a/lightning-tensor/src/handlers/wallet.rs b/lightning-tensor/src/handlers/wallet.rs index f3b1d7c..2ddb496 100644 --- a/lightning-tensor/src/handlers/wallet.rs +++ b/lightning-tensor/src/handlers/wallet.rs @@ -1,6 +1,6 @@ use crate::app::{App, AppState, WalletOperation}; use crate::errors::AppError; -use bittensor_wallet::Wallet; +use bittensor_rs::wallet::Wallet; use crossterm::event::KeyCode; use log::{debug, error}; use sp_core::sr25519::Signature as Sr25519Signature; diff --git a/lightning-tensor/src/lib.rs b/lightning-tensor/src/lib.rs new file mode 100644 index 0000000..4098988 --- /dev/null +++ b/lightning-tensor/src/lib.rs @@ -0,0 +1,29 @@ +//! # Lightning Tensor +//! +//! High-performance CLI/TUI for the Bittensor network. +//! +//! This crate provides both a command-line interface (CLI) and a terminal user interface (TUI) +//! for interacting with the Bittensor blockchain network. +//! +//! ## Features +//! +//! - **Wallet Management**: Create, manage, and sign with wallets +//! - **Staking**: Add, remove, and manage stake positions +//! - **Transfers**: Send TAO between accounts +//! - **Subnets**: View and interact with subnets +//! - **Weights**: Set, commit, and reveal weights +//! - **Root Network**: Root network registration and weights +//! - **Crowdfunding**: Create and manage crowdfunding campaigns + +pub mod cli; +pub mod config; +pub mod context; +pub mod errors; +pub mod models; +pub mod services; +pub mod tui; + +// Re-exports for convenience +pub use config::Config; +pub use context::AppContext; +pub use errors::{Error, Result}; diff --git a/lightning-tensor/src/main.rs b/lightning-tensor/src/main.rs index b2749dd..8d8c768 100644 --- a/lightning-tensor/src/main.rs +++ b/lightning-tensor/src/main.rs @@ -1,55 +1,31 @@ -//! This module contains the main entry point for the Lightning Tensor application. +//! # Lightning Tensor //! -//! It sets up the terminal for the TUI, runs the main application loop, -//! and restores the terminal state when the application exits. -mod app; -pub mod errors; -mod handlers; -mod ui; - -use errors::AppError; +//! High-performance CLI/TUI for the Bittensor network. +//! +//! ## Usage +//! +//! ```bash +//! # Launch TUI +//! lt tui +//! +//! # CLI commands +//! lt wallet list +//! lt stake add --hotkey --amount 1.0 +//! lt subnet list +//! lt transfer --to --amount 1.0 +//! ``` -use app::App; -use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use log::LevelFilter; -use ratatui::{backend::CrosstermBackend, Terminal}; -use simplelog::{Config, WriteLogger}; -use std::fs::File; -use std::io; +use lightning_tensor::cli; +use lightning_tensor::errors::Result; #[tokio::main] -async fn main() -> Result<(), AppError> { - // Set up logging - let log_file = File::create("bittensor-rs.log").expect("Failed to create log file"); - WriteLogger::init(LevelFilter::Debug, Config::default(), log_file) - .expect("Failed to initialize logger"); - // env_logger::init(); - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - // Create app and run it - let mut app = App::new()?; - let res = app.run(&mut terminal).await; - - // Restore terminal - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - - if let Err(err) = res { - println!("{:?}", err) +async fn main() -> Result<()> { + // Initialize logging + if std::env::var("RUST_LOG").is_err() { + std::env::set_var("RUST_LOG", "warn"); } + env_logger::init(); - Ok(()) + // Run CLI + cli::run().await } diff --git a/lightning-tensor/src/models/display.rs b/lightning-tensor/src/models/display.rs new file mode 100644 index 0000000..b73bffe --- /dev/null +++ b/lightning-tensor/src/models/display.rs @@ -0,0 +1,78 @@ +//! # Display Traits +//! +//! Formatting utilities for CLI and TUI output. + +use bittensor_rs::Balance; + +/// Format a balance for display +pub fn format_tao(balance: &Balance) -> String { + format!("{:.4} τ", balance.as_tao()) +} + +/// Format a balance in RAO for display +pub fn format_rao(balance: &Balance) -> String { + format!("{} ρ", balance.as_rao()) +} + +/// Truncate an address for display +pub fn truncate_address(address: &str, prefix_len: usize, suffix_len: usize) -> String { + if address.len() <= prefix_len + suffix_len + 3 { + return address.to_string(); + } + + format!( + "{}...{}", + &address[..prefix_len], + &address[address.len() - suffix_len..] + ) +} + +/// Format a u16 weight as percentage +pub fn format_weight_pct(weight: u16) -> String { + format!("{:.2}%", (weight as f64 / 65535.0) * 100.0) +} + +/// Format a u16 as normalized decimal (0.0 - 1.0) +pub fn format_normalized(value: u16) -> String { + format!("{:.4}", value as f64 / 65535.0) +} + +/// Format block number with commas +pub fn format_blocks(blocks: u64) -> String { + let s = blocks.to_string(); + let mut result = String::new(); + let chars: Vec = s.chars().rev().collect(); + + for (i, c) in chars.iter().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(*c); + } + + result.chars().rev().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_address() { + let addr = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; + assert_eq!(truncate_address(addr, 6, 4), "5Grwva...tQY"); + } + + #[test] + fn test_format_blocks() { + assert_eq!(format_blocks(1000), "1,000"); + assert_eq!(format_blocks(1000000), "1,000,000"); + assert_eq!(format_blocks(123), "123"); + } + + #[test] + fn test_format_normalized() { + assert_eq!(format_normalized(65535), "1.0000"); + assert_eq!(format_normalized(32767), "0.5000"); + } +} diff --git a/lightning-tensor/src/models/mod.rs b/lightning-tensor/src/models/mod.rs new file mode 100644 index 0000000..fa0f952 --- /dev/null +++ b/lightning-tensor/src/models/mod.rs @@ -0,0 +1,7 @@ +//! # Models Module +//! +//! Domain models and display traits for formatting output. + +pub mod display; + +pub use display::*; diff --git a/lightning-tensor/src/services/crowd.rs b/lightning-tensor/src/services/crowd.rs new file mode 100644 index 0000000..67f714e --- /dev/null +++ b/lightning-tensor/src/services/crowd.rs @@ -0,0 +1,18 @@ +//! # Crowd Service +//! +//! Business logic for crowdfunding operations. + +/// Service for crowdfunding operations +pub struct CrowdService; + +impl CrowdService { + pub fn new() -> Self { + Self + } +} + +impl Default for CrowdService { + fn default() -> Self { + Self::new() + } +} diff --git a/lightning-tensor/src/services/mod.rs b/lightning-tensor/src/services/mod.rs new file mode 100644 index 0000000..2984b57 --- /dev/null +++ b/lightning-tensor/src/services/mod.rs @@ -0,0 +1,20 @@ +//! # Services Module +//! +//! Business logic layer providing clean interfaces for wallet, staking, +//! transfer, subnet, and other operations. + +pub mod crowd; +pub mod root; +pub mod stake; +pub mod subnet; +pub mod transfer; +pub mod wallet; +pub mod weights; + +pub use crowd::CrowdService; +pub use root::RootService; +pub use stake::StakeService; +pub use subnet::SubnetService; +pub use transfer::TransferService; +pub use wallet::WalletService; +pub use weights::WeightsService; diff --git a/lightning-tensor/src/services/root.rs b/lightning-tensor/src/services/root.rs new file mode 100644 index 0000000..149b9d2 --- /dev/null +++ b/lightning-tensor/src/services/root.rs @@ -0,0 +1,18 @@ +//! # Root Service +//! +//! Business logic for root network operations. + +/// Service for root network operations +pub struct RootService; + +impl RootService { + pub fn new() -> Self { + Self + } +} + +impl Default for RootService { + fn default() -> Self { + Self::new() + } +} diff --git a/lightning-tensor/src/services/stake.rs b/lightning-tensor/src/services/stake.rs new file mode 100644 index 0000000..5e71781 --- /dev/null +++ b/lightning-tensor/src/services/stake.rs @@ -0,0 +1,18 @@ +//! # Stake Service +//! +//! Business logic for staking operations. + +/// Service for staking operations +pub struct StakeService; + +impl StakeService { + pub fn new() -> Self { + Self + } +} + +impl Default for StakeService { + fn default() -> Self { + Self::new() + } +} diff --git a/lightning-tensor/src/services/subnet.rs b/lightning-tensor/src/services/subnet.rs new file mode 100644 index 0000000..c4df06e --- /dev/null +++ b/lightning-tensor/src/services/subnet.rs @@ -0,0 +1,18 @@ +//! # Subnet Service +//! +//! Business logic for subnet operations. + +/// Service for subnet operations +pub struct SubnetService; + +impl SubnetService { + pub fn new() -> Self { + Self + } +} + +impl Default for SubnetService { + fn default() -> Self { + Self::new() + } +} diff --git a/lightning-tensor/src/services/transfer.rs b/lightning-tensor/src/services/transfer.rs new file mode 100644 index 0000000..88211a2 --- /dev/null +++ b/lightning-tensor/src/services/transfer.rs @@ -0,0 +1,18 @@ +//! # Transfer Service +//! +//! Business logic for TAO transfers. + +/// Service for transfer operations +pub struct TransferService; + +impl TransferService { + pub fn new() -> Self { + Self + } +} + +impl Default for TransferService { + fn default() -> Self { + Self::new() + } +} diff --git a/lightning-tensor/src/services/wallet.rs b/lightning-tensor/src/services/wallet.rs new file mode 100644 index 0000000..794beb6 --- /dev/null +++ b/lightning-tensor/src/services/wallet.rs @@ -0,0 +1,219 @@ +//! # Wallet Service +//! +//! Business logic for wallet operations using bittensor_rs::wallet. + +use crate::errors::{Error, Result}; +use bittensor_rs::wallet::Wallet; +use std::path::PathBuf; + +/// Service for wallet operations +pub struct WalletService { + wallet_dir: PathBuf, +} + +impl WalletService { + /// Create a new wallet service + pub fn new(wallet_dir: PathBuf) -> Self { + Self { wallet_dir } + } + + /// Get the wallet directory + pub fn wallet_dir(&self) -> &PathBuf { + &self.wallet_dir + } + + /// Create a new wallet with random mnemonic + /// Note: The new bittensor_rs wallet API creates a wallet with hotkey in one step + pub fn create_wallet(&self, name: &str, _words: u8, _password: &str) -> Result { + let wallet_path = self.wallet_dir.join(name); + + if wallet_path.exists() { + return Err(Error::WalletAlreadyExists { + name: name.to_string(), + }); + } + + // Create wallet directory structure + std::fs::create_dir_all(&wallet_path)?; + std::fs::create_dir_all(wallet_path.join("hotkeys"))?; + + // Create random wallet with default hotkey + let wallet = Wallet::create_random(name, "default") + .map_err(|e| Error::wallet(format!("Failed to create wallet: {}", e)))?; + + // Save the hotkey address to coldkeypub.txt for compatibility + // Note: bittensor_rs wallet stores hotkey, not coldkey by default + let coldkeypub_path = wallet_path.join("coldkeypub.txt"); + std::fs::write(&coldkeypub_path, wallet.hotkey().to_string()) + .map_err(|e| Error::wallet(format!("Failed to save coldkeypub: {}", e)))?; + + Ok(wallet) + } + + /// Load an existing wallet with default hotkey + pub fn load_wallet(&self, name: &str) -> Result { + self.load_wallet_with_hotkey(name, "default") + } + + /// Load an existing wallet with specific hotkey + pub fn load_wallet_with_hotkey(&self, name: &str, hotkey: &str) -> Result { + let wallet_path = self.wallet_dir.join(name); + + if !wallet_path.exists() { + return Err(Error::WalletNotFound { + name: name.to_string(), + }); + } + + Wallet::load_from_path(name, hotkey, &self.wallet_dir) + .map_err(|e| Error::wallet(format!("Failed to load wallet: {}", e))) + } + + /// List all wallets + pub fn list_wallets(&self) -> Result> { + if !self.wallet_dir.exists() { + return Ok(Vec::new()); + } + + let mut wallets = Vec::new(); + + for entry in std::fs::read_dir(&self.wallet_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + if let Some(name) = entry.file_name().to_str() { + wallets.push(name.to_string()); + } + } + } + + wallets.sort(); + Ok(wallets) + } + + /// List hotkeys for a wallet + pub fn list_hotkeys(&self, wallet_name: &str) -> Result> { + let hotkeys_dir = self.wallet_dir.join(wallet_name).join("hotkeys"); + + if !hotkeys_dir.exists() { + return Ok(Vec::new()); + } + + let mut hotkeys = Vec::new(); + + for entry in std::fs::read_dir(&hotkeys_dir)? { + let entry = entry?; + if entry.file_type()?.is_file() { + if let Some(name) = entry.file_name().to_str() { + hotkeys.push(name.to_string()); + } + } + } + + hotkeys.sort(); + Ok(hotkeys) + } + + /// Create a new hotkey for a wallet + pub fn create_hotkey( + &self, + wallet_name: &str, + hotkey_name: &str, + _password: &str, + ) -> Result { + // Create a new random wallet with the specified hotkey name + let wallet = Wallet::create_random(wallet_name, hotkey_name) + .map_err(|e| Error::wallet(format!("Failed to create hotkey: {}", e)))?; + + Ok(wallet.hotkey().to_string()) + } + + /// Sign a message with the wallet's hotkey + pub fn sign_message( + &self, + wallet_name: &str, + message: &str, + _password: &str, + ) -> Result { + let wallet = self.load_wallet(wallet_name)?; + let signature = wallet.sign(message.as_bytes()); + Ok(format!("0x{}", hex::encode(signature))) + } + + /// Verify a signature + pub fn verify_signature( + &self, + message: &str, + signature: &str, + pubkey: Option<&str>, + ) -> Result { + let sig_bytes = hex::decode(signature.trim_start_matches("0x")) + .map_err(|_| Error::wallet("Invalid signature hex"))?; + + if sig_bytes.len() != 64 { + return Err(Error::wallet("Signature must be 64 bytes")); + } + + let mut sig_array = [0u8; 64]; + sig_array.copy_from_slice(&sig_bytes); + + let signature = sp_core::sr25519::Signature::from_raw(sig_array); + + if let Some(pk) = pubkey { + // Decode public key + let pk_str = pk.trim_start_matches("0x"); + let pk_bytes = + hex::decode(pk_str).map_err(|_| Error::wallet("Invalid public key hex"))?; + + if pk_bytes.len() != 32 { + return Err(Error::wallet("Public key must be 32 bytes")); + } + + let mut pk_array = [0u8; 32]; + pk_array.copy_from_slice(&pk_bytes); + + let public = sp_core::sr25519::Public::from_raw(pk_array); + + use sp_core::Pair; + Ok(sp_core::sr25519::Pair::verify( + &signature, + message.as_bytes(), + &public, + )) + } else { + Err(Error::wallet("Public key required for verification")) + } + } + + /// Regenerate wallet from mnemonic + pub fn regen_wallet(&self, name: &str, mnemonic: &str, _password: &str) -> Result { + let wallet_path = self.wallet_dir.join(name); + + // Create wallet directory if needed + std::fs::create_dir_all(&wallet_path)?; + std::fs::create_dir_all(wallet_path.join("hotkeys"))?; + + let wallet = Wallet::from_mnemonic(name, "default", mnemonic) + .map_err(|e| Error::wallet(format!("Failed to regenerate wallet: {}", e)))?; + + // Save the hotkey address + let coldkeypub_path = wallet_path.join("coldkeypub.txt"); + std::fs::write(&coldkeypub_path, wallet.hotkey().to_string()) + .map_err(|e| Error::wallet(format!("Failed to save coldkeypub: {}", e)))?; + + Ok(wallet) + } + + /// Delete a wallet + pub fn delete_wallet(&self, name: &str) -> Result<()> { + let wallet_path = self.wallet_dir.join(name); + + if !wallet_path.exists() { + return Err(Error::WalletNotFound { + name: name.to_string(), + }); + } + + std::fs::remove_dir_all(wallet_path)?; + Ok(()) + } +} diff --git a/lightning-tensor/src/services/weights.rs b/lightning-tensor/src/services/weights.rs new file mode 100644 index 0000000..d275ed0 --- /dev/null +++ b/lightning-tensor/src/services/weights.rs @@ -0,0 +1,18 @@ +//! # Weights Service +//! +//! Business logic for weight operations. + +/// Service for weight operations +pub struct WeightsService; + +impl WeightsService { + pub fn new() -> Self { + Self + } +} + +impl Default for WeightsService { + fn default() -> Self { + Self::new() + } +} diff --git a/lightning-tensor/src/tui/app.rs b/lightning-tensor/src/tui/app.rs new file mode 100644 index 0000000..20c094d --- /dev/null +++ b/lightning-tensor/src/tui/app.rs @@ -0,0 +1,1060 @@ +//! # TUI Application State +//! +//! Main application state and event loop for the TUI. + +use crate::context::AppContext; +use crate::errors::Result; +use crate::tui::components::AnimationState; +use crate::tui::views; +use bittensor_rs::{DynamicSubnetInfo, NeuronDiscovery}; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use ratatui::backend::Backend; +use ratatui::widgets::{ListState, TableState}; +use ratatui::Terminal; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc::{channel, Receiver, Sender}; +use tokio::sync::Mutex; + +/// Application state enumeration +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AppState { + Home, + Wallet, + Stake, + Subnet, + Metagraph, + Transfer, + Weights, + Root, +} + +/// Wallet info for display (since Wallet from bittensor_wallet isn't available) +#[derive(Debug, Clone)] +pub struct WalletInfo { + pub name: String, + pub path: std::path::PathBuf, + pub coldkey_address: Option, + pub hotkeys: Vec, +} + +/// Stake info per subnet for display +#[derive(Debug, Clone)] +pub struct WalletStakeInfo { + pub netuid: u16, + pub hotkey: String, + pub stake_tao: f64, + pub emission_tao: f64, +} + +/// Neuron display info +#[derive(Debug, Clone)] +pub struct NeuronDisplay { + pub uid: u16, + pub hotkey: String, + pub coldkey: String, + pub stake: f64, + pub is_validator: bool, + pub ip: String, + pub port: u16, + pub incentive: f64, + pub emission: f64, + pub trust: f64, + pub consensus: f64, + pub dividends: f64, +} + +/// Async operation result +#[derive(Debug)] +pub enum AsyncResult { + Connected(std::result::Result<(), String>), + WalletLoaded(Result), + BalanceLoaded { + wallet_idx: usize, + balance: Result, + }, + StakeLoaded { + wallet_idx: usize, + stakes: Result>, + }, + SubnetsLoaded(Result>), + MetagraphLoaded { + netuid: u16, + neurons: Result>, + }, + Message(String), + Error(String), +} + +/// Main TUI application +pub struct App { + /// Application context (Arc wrapped for async sharing) + pub ctx: Arc, + + /// Current view state + pub state: AppState, + + /// Should quit the application + pub should_quit: bool, + + /// Input mode active + pub input_mode: bool, + + /// Current input buffer + pub input_buffer: String, + + /// Input prompt text + pub input_prompt: String, + + /// Is password input (masked) + pub is_password_input: bool, + + /// Status messages + pub messages: Arc>>, + + /// Loaded wallets + pub wallets: Vec, + + /// Wallet balances (indexed by wallet position) + pub wallet_balances: Vec>, + + /// Wallet stakes per subnet (indexed by wallet position) + pub wallet_stakes: Vec>, + + /// Wallet list state + pub wallet_list_state: ListState, + + /// Selected wallet index + pub selected_wallet: Option, + + /// Loaded subnets (with DTAO pricing info) + pub subnets: Vec, + + /// Subnet table state (for navigation) + pub subnet_list_state: TableState, + + /// Selected subnet index + pub selected_subnet: Option, + + /// Metagraph neurons for current subnet + pub metagraph_neurons: Vec, + + /// Metagraph table state (for scrolling) + pub metagraph_table_state: TableState, + + /// Animation state + pub animation_state: AnimationState, + + /// Async result channel + pub async_tx: Sender, + pub async_rx: Receiver, + + /// Loading indicator + pub is_loading: bool, + + /// Loading message + pub loading_message: String, + + /// Current netuid for operations + pub current_netuid: u16, + + /// Connected to network + pub is_connected: bool, + + /// Transfer destination address + pub transfer_dest: String, + + /// Transfer amount + pub transfer_amount: String, +} + +impl App { + /// Create a new TUI application + pub fn new(ctx: Arc) -> Result { + let (async_tx, async_rx) = channel(100); + + let wallet_dir = ctx.wallet_dir().clone(); + + // Load wallets + let wallets = load_wallets(&wallet_dir); + let wallet_balances = vec![None; wallets.len()]; + let wallet_stakes = vec![Vec::new(); wallets.len()]; + + let mut wallet_list_state = ListState::default(); + if !wallets.is_empty() { + wallet_list_state.select(Some(0)); + } + + let mut subnet_list_state = TableState::default(); + subnet_list_state.select(Some(0)); + + let mut metagraph_table_state = TableState::default(); + metagraph_table_state.select(Some(0)); + + Ok(Self { + ctx, + state: AppState::Home, + should_quit: false, + input_mode: false, + input_buffer: String::new(), + input_prompt: String::new(), + is_password_input: false, + messages: Arc::new(Mutex::new(Vec::new())), + wallets, + wallet_balances, + wallet_stakes, + wallet_list_state, + selected_wallet: None, + subnets: Vec::new(), + subnet_list_state, + selected_subnet: None, + metagraph_neurons: Vec::new(), + metagraph_table_state, + animation_state: AnimationState::new(), + async_tx, + async_rx, + is_loading: false, + loading_message: String::new(), + current_netuid: 1, + is_connected: false, + transfer_dest: String::new(), + transfer_amount: String::new(), + }) + } + + /// Get selected wallet info + pub fn selected_wallet_info(&self) -> Option<&WalletInfo> { + self.selected_wallet.and_then(|i| self.wallets.get(i)) + } + + /// Get selected wallet balance + pub fn selected_wallet_balance(&self) -> Option { + self.selected_wallet + .and_then(|i| self.wallet_balances.get(i).copied().flatten()) + } + + /// Add a message + pub async fn add_message(&self, msg: String) { + let mut messages = self.messages.lock().await; + messages.push(msg); + // Keep last 10 messages + if messages.len() > 10 { + messages.remove(0); + } + } + + /// Run the application event loop + pub async fn run(&mut self, terminal: &mut Terminal) -> Result<()> { + // Auto-connect on startup + if !self.is_connected && !self.is_loading { + self.auto_connect().await; + } + + loop { + // Draw UI + terminal.draw(|f| { + views::draw(f, self); + })?; + + // Handle events with timeout + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + // Global quit with Ctrl+C or Ctrl+Q + if key.modifiers.contains(KeyModifiers::CONTROL) { + match key.code { + KeyCode::Char('c') | KeyCode::Char('q') => { + self.should_quit = true; + } + _ => {} + } + } else if self.input_mode { + self.handle_input_mode(key.code).await; + } else { + self.handle_normal_mode(key.code).await?; + } + } + } + + // Check for async results + while let Ok(result) = self.async_rx.try_recv() { + self.handle_async_result(result).await; + } + + // Update animation + self.animation_state.update(); + + if self.should_quit { + return Ok(()); + } + } + } + + /// Handle input mode key events + async fn handle_input_mode(&mut self, key: KeyCode) { + match key { + KeyCode::Enter => { + let input = std::mem::take(&mut self.input_buffer); + self.input_mode = false; + self.is_password_input = false; + // Process input based on context + self.process_input(input).await; + } + KeyCode::Char(c) => { + self.input_buffer.push(c); + } + KeyCode::Backspace => { + self.input_buffer.pop(); + } + KeyCode::Esc => { + self.input_mode = false; + self.is_password_input = false; + self.input_buffer.clear(); + self.add_message("Cancelled".to_string()).await; + } + _ => {} + } + } + + /// Handle normal mode key events + async fn handle_normal_mode(&mut self, key: KeyCode) -> Result<()> { + match key { + KeyCode::Char('q') => { + if self.state == AppState::Home { + self.should_quit = true; + } else { + self.state = AppState::Home; + } + } + KeyCode::Esc => { + if self.state != AppState::Home { + self.state = AppState::Home; + } + } + _ => { + // Delegate to view-specific handlers + match self.state { + AppState::Home => self.handle_home_input(key).await, + AppState::Wallet => self.handle_wallet_input(key).await, + AppState::Stake => self.handle_stake_input(key).await, + AppState::Subnet => self.handle_subnet_input(key).await, + AppState::Metagraph => self.handle_metagraph_input(key).await, + AppState::Transfer => self.handle_transfer_input(key).await, + AppState::Weights => self.handle_weights_input(key).await, + AppState::Root => self.handle_root_input(key).await, + } + } + } + Ok(()) + } + + /// Handle home view input + async fn handle_home_input(&mut self, key: KeyCode) { + match key { + KeyCode::Char('w') => self.state = AppState::Wallet, + KeyCode::Char('s') => self.state = AppState::Stake, + KeyCode::Char('n') => { + self.state = AppState::Subnet; + if self.subnets.is_empty() && !self.is_loading { + self.fetch_subnets(); + } + } + KeyCode::Char('m') => { + self.state = AppState::Metagraph; + if self.metagraph_neurons.is_empty() && !self.is_loading { + self.fetch_metagraph(); + } + } + KeyCode::Char('t') => self.state = AppState::Transfer, + KeyCode::Char('g') => self.state = AppState::Weights, + KeyCode::Char('r') => self.state = AppState::Root, + KeyCode::Char('c') => { + // Connect to network + if !self.is_connected && !self.is_loading { + self.connect_to_network().await; + } + } + _ => {} + } + } + + /// Connect to network + async fn connect_to_network(&mut self) { + self.start_loading("Connecting to network..."); + match self.ctx.connect_with_defaults().await { + Ok(_) => { + self.is_connected = true; + self.add_message("✓ Connected to network".to_string()).await; + } + Err(e) => { + self.add_message(format!("✗ Connection failed: {}", e)) + .await; + } + } + self.is_loading = false; + } + + /// Auto-connect to network in background + async fn auto_connect(&mut self) { + // Check if we have wallets - if not, show guidance + if self.wallets.is_empty() { + self.add_message( + "No wallets found. Create one with 'lt wallet create '".to_string(), + ) + .await; + self.add_message("Press 'c' to try connecting after creating a wallet.".to_string()) + .await; + return; + } + + let tx = self.async_tx.clone(); + let ctx = Arc::clone(&self.ctx); + let network = ctx.network_name().to_string(); + + self.add_message(format!("⏳ Connecting to {}...", network)) + .await; + self.start_loading(&format!("Connecting to {}...", network)); + + tokio::spawn(async move { + match ctx.connect_with_defaults().await { + Ok(_) => { + let _ = tx.send(AsyncResult::Connected(Ok(()))).await; + } + Err(e) => { + let _ = tx.send(AsyncResult::Connected(Err(format!("{}", e)))).await; + } + } + }); + } + + /// Handle wallet view input + async fn handle_wallet_input(&mut self, key: KeyCode) { + match key { + KeyCode::Up | KeyCode::Char('k') => { + if let Some(selected) = self.wallet_list_state.selected() { + if selected > 0 { + self.wallet_list_state.select(Some(selected - 1)); + } else if !self.wallets.is_empty() { + self.wallet_list_state.select(Some(self.wallets.len() - 1)); + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(selected) = self.wallet_list_state.selected() { + if selected < self.wallets.len().saturating_sub(1) { + self.wallet_list_state.select(Some(selected + 1)); + } else { + self.wallet_list_state.select(Some(0)); + } + } + } + KeyCode::Enter => { + self.selected_wallet = self.wallet_list_state.selected(); + if let Some(idx) = self.selected_wallet { + if let Some(w) = self.wallets.get(idx) { + self.add_message(format!("✓ Selected: {}", w.name)).await; + } + } + } + KeyCode::Char('c') => { + self.input_mode = true; + self.input_prompt = "Enter wallet name: ".to_string(); + } + KeyCode::Char('b') => { + // Fetch balance for currently highlighted wallet + if let Some(idx) = self.wallet_list_state.selected() { + if !self.is_loading { + self.fetch_balance(idx); + } + } + } + KeyCode::Char('B') => { + // Fetch all balances + if !self.is_loading { + for i in 0..self.wallets.len() { + self.fetch_balance(i); + } + } + } + _ => {} + } + } + + /// Handle stake view input + async fn handle_stake_input(&mut self, key: KeyCode) { + match key { + KeyCode::Char('a') => { + self.input_mode = true; + self.input_prompt = "Amount to stake (TAO): ".to_string(); + } + KeyCode::Char('r') => { + self.input_mode = true; + self.input_prompt = "Amount to unstake (TAO): ".to_string(); + } + _ => {} + } + } + + /// Handle subnet view input + async fn handle_subnet_input(&mut self, key: KeyCode) { + match key { + KeyCode::Up | KeyCode::Char('k') => { + let i = match self.subnet_list_state.selected() { + Some(i) => { + if i == 0 { + self.subnets.len().saturating_sub(1) + } else { + i - 1 + } + } + None => 0, + }; + self.subnet_list_state.select(Some(i)); + } + KeyCode::Down | KeyCode::Char('j') => { + let i = match self.subnet_list_state.selected() { + Some(i) => { + if i >= self.subnets.len().saturating_sub(1) { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.subnet_list_state.select(Some(i)); + } + KeyCode::Enter => { + if let Some(idx) = self.subnet_list_state.selected() { + if let Some(subnet) = self.subnets.get(idx) { + self.current_netuid = subnet.netuid; + self.metagraph_neurons.clear(); + self.state = AppState::Metagraph; + self.fetch_metagraph(); + } + } + } + KeyCode::Char('r') | KeyCode::F(5) => { + if !self.is_loading { + self.fetch_subnets(); + } + } + _ => {} + } + } + + /// Handle metagraph view input + async fn handle_metagraph_input(&mut self, key: KeyCode) { + match key { + KeyCode::Up | KeyCode::Char('k') => { + let i = match self.metagraph_table_state.selected() { + Some(i) => i.saturating_sub(1), + None => 0, + }; + self.metagraph_table_state.select(Some(i)); + } + KeyCode::Down | KeyCode::Char('j') => { + let i = match self.metagraph_table_state.selected() { + Some(i) => { + if i >= self.metagraph_neurons.len().saturating_sub(1) { + i + } else { + i + 1 + } + } + None => 0, + }; + self.metagraph_table_state.select(Some(i)); + } + KeyCode::PageUp => { + let i = match self.metagraph_table_state.selected() { + Some(i) => i.saturating_sub(20), + None => 0, + }; + self.metagraph_table_state.select(Some(i)); + } + KeyCode::PageDown => { + let i = match self.metagraph_table_state.selected() { + Some(i) => { + std::cmp::min(i + 20, self.metagraph_neurons.len().saturating_sub(1)) + } + None => 0, + }; + self.metagraph_table_state.select(Some(i)); + } + KeyCode::Home => { + self.metagraph_table_state.select(Some(0)); + } + KeyCode::End => { + if !self.metagraph_neurons.is_empty() { + self.metagraph_table_state + .select(Some(self.metagraph_neurons.len() - 1)); + } + } + KeyCode::Char('r') | KeyCode::F(5) => { + if !self.is_loading { + self.fetch_metagraph(); + } + } + KeyCode::Char('/') => { + // TODO: Search + self.add_message("Search not yet implemented".to_string()) + .await; + } + _ => {} + } + } + + /// Handle transfer view input + async fn handle_transfer_input(&mut self, key: KeyCode) { + if let KeyCode::Char('t') = key { + self.input_mode = true; + self.input_prompt = "Destination address: ".to_string(); + } + } + + /// Handle weights view input + async fn handle_weights_input(&mut self, key: KeyCode) { + if let KeyCode::Char('s') = key { + self.input_mode = true; + self.input_prompt = "UIDs (comma-separated): ".to_string(); + } + } + + /// Handle root view input + async fn handle_root_input(&mut self, key: KeyCode) { + if let KeyCode::Char('r') = key { + self.add_message("Root registration...".to_string()).await; + } + } + + /// Process input after Enter + async fn process_input(&mut self, _input: String) { + // Context-specific input processing + self.add_message("Input received".to_string()).await; + } + + /// Handle async operation results + async fn handle_async_result(&mut self, result: AsyncResult) { + match result { + AsyncResult::Connected(res) => { + match res { + Ok(()) => { + self.is_connected = true; + self.add_message("✓ Connected to network".to_string()).await; + // Auto-load data after connecting + self.fetch_subnets(); + self.fetch_all_balances(); + self.fetch_all_stakes(); + } + Err(e) => { + self.add_message(format!("✗ Connection failed: {}", e)) + .await; + } + } + } + AsyncResult::Message(msg) => { + self.add_message(msg).await; + } + AsyncResult::Error(err) => { + self.add_message(format!("✗ {}", err)).await; + } + AsyncResult::WalletLoaded(res) => match res { + Ok(wallet) => { + self.add_message(format!("✓ Loaded wallet: {}", wallet.name)) + .await; + } + Err(e) => { + self.add_message(format!("✗ Failed to load wallet: {}", e)) + .await; + } + }, + AsyncResult::BalanceLoaded { + wallet_idx, + balance, + } => { + match balance { + Ok(bal) => { + if wallet_idx < self.wallet_balances.len() { + self.wallet_balances[wallet_idx] = Some(bal); + } + // Silent update - don't spam messages for batch loads + } + Err(_e) => { + // Silent failure for batch loads + } + } + self.stop_loading(); + } + AsyncResult::StakeLoaded { wallet_idx, stakes } => { + match stakes { + Ok(s) => { + if wallet_idx < self.wallet_stakes.len() { + self.wallet_stakes[wallet_idx] = s; + } + } + Err(_e) => { + // Silent failure + } + } + self.stop_loading(); + } + AsyncResult::SubnetsLoaded(res) => match res { + Ok(subnets) => { + let count = subnets.len(); + self.subnets = subnets; + if !self.subnets.is_empty() { + self.subnet_list_state.select(Some(0)); + } + self.add_message(format!("✓ Loaded {} subnets", count)) + .await; + } + Err(e) => { + self.add_message(format!("✗ Failed to load subnets: {}", e)) + .await; + } + }, + AsyncResult::MetagraphLoaded { netuid, neurons } => match neurons { + Ok(n) => { + let count = n.len(); + self.metagraph_neurons = n; + if !self.metagraph_neurons.is_empty() { + self.metagraph_table_state.select(Some(0)); + } + self.add_message(format!("✓ Loaded {} neurons for subnet {}", count, netuid)) + .await; + } + Err(e) => { + self.add_message(format!("✗ Failed to load metagraph: {}", e)) + .await; + } + }, + } + self.is_loading = false; + self.loading_message.clear(); + } + + /// Start loading with message + pub fn start_loading(&mut self, message: &str) { + self.is_loading = true; + self.loading_message = message.to_string(); + } + + /// Stop loading indicator + pub fn stop_loading(&mut self) { + self.is_loading = false; + self.loading_message.clear(); + } + + /// Fetch metagraph for current netuid + pub fn fetch_metagraph(&mut self) { + let netuid = self.current_netuid; + let tx = self.async_tx.clone(); + let ctx = Arc::clone(&self.ctx); + + self.start_loading(&format!("Loading metagraph for subnet {}...", netuid)); + + tokio::spawn(async move { + let result = fetch_metagraph_data(&ctx, netuid).await; + let _ = tx + .send(AsyncResult::MetagraphLoaded { + netuid, + neurons: result, + }) + .await; + }); + } + + /// Fetch subnets + pub fn fetch_subnets(&mut self) { + let tx = self.async_tx.clone(); + let ctx = Arc::clone(&self.ctx); + + self.start_loading("Loading subnets..."); + + tokio::spawn(async move { + let result = fetch_subnet_data(&ctx).await; + let _ = tx.send(AsyncResult::SubnetsLoaded(result)).await; + }); + } + + /// Fetch wallet balance + pub fn fetch_balance(&mut self, wallet_idx: usize) { + if wallet_idx >= self.wallets.len() { + return; + } + + let wallet = self.wallets[wallet_idx].clone(); + let tx = self.async_tx.clone(); + let ctx = Arc::clone(&self.ctx); + + self.start_loading(&format!("Fetching balance for {}...", wallet.name)); + + tokio::spawn(async move { + let result = fetch_wallet_balance(&ctx, &wallet).await; + let _ = tx + .send(AsyncResult::BalanceLoaded { + wallet_idx, + balance: result, + }) + .await; + }); + } + + /// Fetch all wallet balances in parallel (batched for performance) + pub fn fetch_all_balances(&mut self) { + if self.wallets.is_empty() { + return; + } + + let wallets: Vec<_> = self.wallets.iter().cloned().enumerate().collect(); + let tx = self.async_tx.clone(); + let ctx = Arc::clone(&self.ctx); + let count = wallets.len(); + + self.add_message_sync(format!("⏳ Fetching {} wallet balances...", count)); + + // Spawn parallel fetches for all wallets + tokio::spawn(async move { + use futures::future::join_all; + + let futures: Vec<_> = wallets + .into_iter() + .map(|(idx, wallet)| { + let ctx = Arc::clone(&ctx); + let tx = tx.clone(); + async move { + let result = fetch_wallet_balance(&ctx, &wallet).await; + let _ = tx + .send(AsyncResult::BalanceLoaded { + wallet_idx: idx, + balance: result, + }) + .await; + } + }) + .collect(); + + join_all(futures).await; + let _ = tx + .send(AsyncResult::Message(format!("✓ Loaded {} balances", count))) + .await; + }); + } + + /// Sync version of add_message for use in non-async context + fn add_message_sync(&self, msg: String) { + let messages = self.messages.clone(); + tokio::spawn(async move { + messages.lock().await.push(msg); + }); + } + + /// Fetch all wallet stakes in parallel + pub fn fetch_all_stakes(&mut self) { + if self.wallets.is_empty() { + return; + } + + let wallets: Vec<_> = self.wallets.iter().cloned().enumerate().collect(); + let tx = self.async_tx.clone(); + let ctx = Arc::clone(&self.ctx); + + // Spawn parallel fetches for all wallets + tokio::spawn(async move { + use futures::future::join_all; + + let futures: Vec<_> = wallets + .into_iter() + .map(|(idx, wallet)| { + let ctx = Arc::clone(&ctx); + let tx = tx.clone(); + async move { + let result = fetch_wallet_stakes(&ctx, &wallet).await; + let _ = tx + .send(AsyncResult::StakeLoaded { + wallet_idx: idx, + stakes: result, + }) + .await; + } + }) + .collect(); + + join_all(futures).await; + }); + } +} + +/// Load wallets from directory +fn load_wallets(wallet_dir: &std::path::PathBuf) -> Vec { + let mut wallets = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(wallet_dir) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_dir() { + let wallet_name = entry.file_name().to_string_lossy().into_owned(); + let wallet_path = entry.path(); + + // Load coldkey address from coldkeypub.txt + let coldkey_address = load_coldkey_address(&wallet_path); + + // Load hotkeys + let hotkeys_path = wallet_path.join("hotkeys"); + let hotkeys = if hotkeys_path.exists() { + std::fs::read_dir(&hotkeys_path) + .ok() + .map(|entries| { + entries + .flatten() + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .filter_map(|e| e.file_name().to_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default() + } else { + Vec::new() + }; + + wallets.push(WalletInfo { + name: wallet_name, + path: wallet_path, + coldkey_address, + hotkeys, + }); + } + } + } + } + + wallets.sort_by(|a, b| a.name.cmp(&b.name)); + wallets +} + +/// Load coldkey SS58 address from coldkeypub.txt +fn load_coldkey_address(wallet_path: &std::path::Path) -> Option { + let coldkeypub_path = wallet_path.join("coldkeypub.txt"); + if !coldkeypub_path.exists() { + return None; + } + + let content = std::fs::read_to_string(&coldkeypub_path).ok()?; + + // Parse JSON to extract ss58Address + if let Ok(json) = serde_json::from_str::(&content) { + json.get("ss58Address") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } else { + // Fallback: treat as plain address + Some(content.trim().to_string()) + } +} + +/// Fetch metagraph data from network +async fn fetch_metagraph_data(ctx: &AppContext, netuid: u16) -> Result> { + let service = ctx.require_service().await?; + let metagraph = service.get_metagraph(netuid).await?; + + let discovery = NeuronDiscovery::new(&metagraph); + let neurons = discovery.get_all_neurons().map_err(|e| { + crate::errors::Error::network(format!("Failed to discover neurons: {}", e)) + })?; + + let displays: Vec = neurons + .into_iter() + .map(|n| { + NeuronDisplay { + uid: n.uid, + hotkey: truncate_address(&n.hotkey, 8), + coldkey: truncate_address(&n.coldkey, 8), + stake: n.stake as f64 / 1_000_000_000.0, // Convert from rao to TAO + is_validator: n.is_validator, + ip: n + .axon_info + .as_ref() + .map(|a| a.ip.clone()) + .unwrap_or_default(), + port: n.axon_info.as_ref().map(|a| a.port).unwrap_or(0), + incentive: 0.0, // TODO: Extract from metagraph when available + emission: 0.0, + trust: 0.0, + consensus: 0.0, + dividends: 0.0, + } + }) + .collect(); + + Ok(displays) +} + +/// Fetch all subnet data from network in a single RPC call (FAST) +async fn fetch_subnet_data(ctx: &AppContext) -> Result> { + let service = ctx.require_service().await?; + let client = service.client().await?; + + // Single RPC call to get ALL subnet data with DTAO pricing + let subnets = bittensor_rs::queries::get_all_dynamic_info(&client) + .await + .map_err(|e| { + crate::errors::Error::network(format!("Failed to get dynamic info: {}", e)) + })?; + + Ok(subnets) +} + +/// Fetch wallet balance from chain +async fn fetch_wallet_balance(ctx: &AppContext, wallet: &WalletInfo) -> Result { + let address = wallet + .coldkey_address + .as_ref() + .ok_or_else(|| crate::errors::Error::wallet("No coldkey address found"))?; + + let service = ctx.require_service().await?; + let client = service.client().await?; + + let balance = bittensor_rs::queries::get_balance(&client, address) + .await + .map_err(|e| crate::errors::Error::network(format!("Failed to get balance: {}", e)))?; + + Ok(balance.as_tao()) +} + +/// Fetch wallet stakes from chain +async fn fetch_wallet_stakes( + ctx: &AppContext, + wallet: &WalletInfo, +) -> Result> { + let address = match wallet.coldkey_address.as_ref() { + Some(addr) => addr, + None => return Ok(Vec::new()), + }; + + let service = ctx.require_service().await?; + let client = service.client().await?; + + let stake_infos = bittensor_rs::queries::get_stake_info_for_coldkey(&client, address) + .await + .map_err(|e| crate::errors::Error::network(format!("Failed to get stakes: {}", e)))?; + + let stakes: Vec = stake_infos + .into_iter() + .map(|info| WalletStakeInfo { + netuid: info.netuid, + hotkey: format!("{}", info.hotkey), + stake_tao: info.stake.as_tao(), + emission_tao: info.emission.as_tao(), + }) + .collect(); + + Ok(stakes) +} + +/// Truncate address for display +fn truncate_address(addr: &str, len: usize) -> String { + if addr.len() <= len * 2 + 3 { + addr.to_string() + } else { + format!("{}...{}", &addr[..len], &addr[addr.len() - len..]) + } +} diff --git a/lightning-tensor/src/tui/components/animation.rs b/lightning-tensor/src/tui/components/animation.rs new file mode 100644 index 0000000..cabb398 --- /dev/null +++ b/lightning-tensor/src/tui/components/animation.rs @@ -0,0 +1,301 @@ +//! # Animation State +//! +//! Animation state management for TUI elements. +//! Provides multiple animation patterns for a dynamic interface. + +use std::time::{Duration, Instant}; + +/// Duration of each animation frame +const ANIMATION_FRAME_DURATION: Duration = Duration::from_millis(80); + +/// Braille spinner patterns +const SPINNER_BRAILLE: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; + +/// Dot spinner patterns +const SPINNER_DOTS: [char; 8] = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']; + +/// Moon phase spinner +#[allow(dead_code)] +const SPINNER_MOON: [char; 8] = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']; + +/// Node pulsing pattern +const NODE_PULSE: [char; 8] = ['○', '◔', '◑', '◕', '●', '◕', '◑', '◔']; + +/// Lightning pulse pattern +const LIGHTNING_PULSE: [&str; 6] = ["⚡", "⚡", "✦", "✧", "·", "·"]; + +/// Wave pattern for backgrounds +const WAVE_CHARS: [char; 8] = ['░', '▒', '▓', '█', '▓', '▒', '░', ' ']; + +/// Progress bar fill patterns +#[allow(dead_code)] +const PROGRESS_FILL: [&str; 4] = ["░", "▒", "▓", "█"]; + +/// Animation state for UI elements +pub struct AnimationState { + pub frame: usize, + pub last_update: Instant, + pub slow_frame: usize, + pub last_slow_update: Instant, +} + +impl Default for AnimationState { + fn default() -> Self { + Self::new() + } +} + +impl AnimationState { + pub fn new() -> Self { + Self { + frame: 0, + last_update: Instant::now(), + slow_frame: 0, + last_slow_update: Instant::now(), + } + } + + pub fn update(&mut self) { + let now = Instant::now(); + + // Fast animation (80ms per frame) + if now.duration_since(self.last_update) >= ANIMATION_FRAME_DURATION { + self.frame = (self.frame + 1) % 8; + self.last_update = now; + } + + // Slow animation (400ms per frame) for subtle effects + if now.duration_since(self.last_slow_update) >= Duration::from_millis(400) { + self.slow_frame = (self.slow_frame + 1) % 6; + self.last_slow_update = now; + } + } + + /// Get braille spinner character for current frame + pub fn spinner_char(&self) -> char { + SPINNER_BRAILLE[self.frame] + } + + /// Get dot spinner character + pub fn spinner_dots(&self) -> char { + SPINNER_DOTS[self.frame] + } + + /// Get rotating node character + pub fn node_char(&self) -> char { + NODE_PULSE[self.frame] + } + + /// Get lightning pulse string + pub fn lightning_pulse(&self) -> &'static str { + LIGHTNING_PULSE[self.slow_frame] + } + + /// Get wave character for background effects + pub fn wave_char(&self, offset: usize) -> char { + WAVE_CHARS[(self.frame + offset) % 8] + } + + /// Generate an animated loading bar + pub fn loading_bar(&self, width: usize) -> String { + let mut bar = String::with_capacity(width); + let pos = self.frame % width; + + for i in 0..width { + let dist = (i as i32 - pos as i32).unsigned_abs() as usize; + if dist == 0 { + bar.push('█'); + } else if dist == 1 { + bar.push('▓'); + } else if dist == 2 { + bar.push('▒'); + } else if dist == 3 { + bar.push('░'); + } else { + bar.push(' '); + } + } + + bar + } + + /// Generate bouncing loading indicator + pub fn bouncing_loader(&self, width: usize) -> String { + let mut bar = String::with_capacity(width); + // Bounce effect using sine-like pattern + let bounce_frames = width * 2 - 2; + let pos = self.frame % bounce_frames.max(1); + let actual_pos = if pos < width { + pos + } else { + bounce_frames - pos + }; + + for i in 0..width { + if i == actual_pos { + bar.push('⚡'); + } else { + bar.push('·'); + } + } + + bar + } + + /// Get animated connection status indicator + pub fn connection_indicator(&self, connected: bool) -> (&'static str, &'static str) { + if connected { + match self.slow_frame % 3 { + 0 => ("◉", "Connected"), + 1 => ("●", "Connected"), + _ => ("◉", "Connected"), + } + } else { + match self.slow_frame % 3 { + 0 => ("○", "Disconnected"), + 1 => ("◌", "Disconnected"), + _ => ("○", "Disconnected"), + } + } + } + + /// Generate animated network mesh for logo + pub fn network_logo(&self) -> Vec { + let node = self.node_char(); + let pulse = if self.frame % 4 < 2 { '─' } else { '━' }; + + vec![ + format!(" {}{pulse}{}{pulse}{} ", node, node, node), + " ╱ ╲ ╱ ╲ ╱ ╲ ".to_string(), + format!( + " {}{pulse}{}{pulse}{}{pulse}{} ", + node, node, node, node + ), + " ╱ ╲ ╱ ╲ ╱ ╲ ╱ ╲ ".to_string(), + format!( + " {}{pulse}{}{pulse}{}{pulse}{}{pulse}{} ", + node, node, node, node, node + ), + " ╲ ╱ ╲ ╱ ╲ ╱ ╲ ╱ ".to_string(), + format!( + " {}{pulse}{}{pulse}{}{pulse}{} ", + node, node, node, node + ), + " ╲ ╱ ╲ ╱ ╲ ╱ ".to_string(), + format!(" {}{pulse}{}{pulse}{} ", node, node, node), + ] + } +} + +/// Sparkline widget helper +pub struct Sparkline { + values: Vec, + min: f64, + max: f64, +} + +impl Sparkline { + const CHARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + pub fn new(values: Vec) -> Self { + let min = values.iter().cloned().fold(f64::INFINITY, f64::min); + let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + Self { values, min, max } + } + + pub fn with_range(values: Vec, min: f64, max: f64) -> Self { + Self { values, min, max } + } + + pub fn render(&self) -> String { + if self.values.is_empty() { + return String::new(); + } + + let range = self.max - self.min; + if range == 0.0 { + return Self::CHARS[4].to_string().repeat(self.values.len()); + } + + self.values + .iter() + .map(|&v| { + let normalized = ((v - self.min) / range).clamp(0.0, 1.0); + let idx = (normalized * 7.0).round() as usize; + Self::CHARS[idx.min(7)] + }) + .collect() + } + + /// Render with color zones (returns tuples of (char, zone)) + /// zone: 0 = low, 1 = mid, 2 = high + pub fn render_with_zones(&self, low_threshold: f64, high_threshold: f64) -> Vec<(char, u8)> { + if self.values.is_empty() { + return vec![]; + } + + let range = self.max - self.min; + if range == 0.0 { + return self.values.iter().map(|_| (Self::CHARS[4], 1)).collect(); + } + + self.values + .iter() + .map(|&v| { + let normalized = ((v - self.min) / range).clamp(0.0, 1.0); + let idx = (normalized * 7.0).round() as usize; + let zone = if v >= high_threshold { + 2 + } else if v >= low_threshold { + 1 + } else { + 0 + }; + (Self::CHARS[idx.min(7)], zone) + }) + .collect() + } +} + +/// Progress bar with gradient effect +pub struct GradientProgress { + percent: f64, + width: usize, +} + +impl GradientProgress { + pub fn new(percent: f64, width: usize) -> Self { + Self { + percent: percent.clamp(0.0, 100.0), + width, + } + } + + pub fn render(&self) -> String { + let filled = (self.percent * self.width as f64 / 100.0).round() as usize; + let filled = filled.min(self.width); + + let mut bar = String::with_capacity(self.width); + + for i in 0..self.width { + if i < filled.saturating_sub(2) { + bar.push('█'); + } else if i == filled.saturating_sub(2) && filled > 1 { + bar.push('▓'); + } else if i == filled.saturating_sub(1) && filled > 0 { + bar.push('▒'); + } else if i == filled { + bar.push('░'); + } else { + bar.push('·'); + } + } + + bar + } + + /// Render with brackets + pub fn render_bracketed(&self) -> String { + format!("[{}]", self.render()) + } +} diff --git a/lightning-tensor/src/tui/components/input.rs b/lightning-tensor/src/tui/components/input.rs new file mode 100644 index 0000000..f395d6f --- /dev/null +++ b/lightning-tensor/src/tui/components/input.rs @@ -0,0 +1,111 @@ +//! # Input Field Component +//! +//! Text input field with cyberpunk styling and optional password masking. + +use crate::tui::theme::{colors, symbols}; +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +/// Input field component with distinctive styling +pub struct InputField<'a> { + pub prompt: &'a str, + pub value: &'a str, + pub is_password: bool, + pub is_focused: bool, + pub placeholder: Option<&'a str>, +} + +impl<'a> InputField<'a> { + pub fn new(prompt: &'a str, value: &'a str) -> Self { + Self { + prompt, + value, + is_password: false, + is_focused: false, + placeholder: None, + } + } + + pub fn password(mut self, is_password: bool) -> Self { + self.is_password = is_password; + self + } + + pub fn focused(mut self, is_focused: bool) -> Self { + self.is_focused = is_focused; + self + } + + pub fn placeholder(mut self, text: &'a str) -> Self { + self.placeholder = Some(text); + self + } + + pub fn render(&self, f: &mut Frame, area: Rect) { + let display_value = if self.is_password { + symbols::BULLET.repeat(self.value.len()) + } else if self.value.is_empty() && self.placeholder.is_some() { + self.placeholder.unwrap().to_string() + } else { + self.value.to_string() + }; + + let is_placeholder = self.value.is_empty() && self.placeholder.is_some(); + + // Blinking cursor effect + let cursor = if self.is_focused { + Span::styled("█", Style::default().fg(colors::LIGHTNING)) + } else { + Span::raw("") + }; + + let value_style = if is_placeholder { + Style::default().fg(colors::TEXT_TERTIARY) + } else if self.is_password { + Style::default().fg(colors::WARNING) + } else { + Style::default().fg(colors::TEXT_PRIMARY) + }; + + let content = Line::from(vec![ + Span::styled( + self.prompt, + Style::default() + .fg(colors::LIGHTNING) + .add_modifier(Modifier::BOLD), + ), + Span::styled(display_value, value_style), + cursor, + ]); + + let (border_color, border_style) = if self.is_focused { + (colors::LIGHTNING, Modifier::BOLD) + } else { + (colors::TEXT_TERTIARY, Modifier::empty()) + }; + + // Title with icon + let title = format!("{} Input", symbols::CHEVRON_RIGHT); + + let input = Paragraph::new(content).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color).add_modifier(border_style)) + .border_type(ratatui::widgets::BorderType::Rounded) + .title(Span::styled( + title, + Style::default() + .fg(colors::VOLT) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(colors::BG_PANEL)), + ); + + f.render_widget(input, area); + } +} diff --git a/lightning-tensor/src/tui/components/mod.rs b/lightning-tensor/src/tui/components/mod.rs new file mode 100644 index 0000000..1c0cc70 --- /dev/null +++ b/lightning-tensor/src/tui/components/mod.rs @@ -0,0 +1,16 @@ +//! # TUI Components +//! +//! Reusable UI components for the TUI. +//! Features distinctive visual elements with cyberpunk aesthetic. + +mod animation; +mod input; +mod popup; +mod spinner; +mod table; + +pub use animation::{AnimationState, GradientProgress, Sparkline}; +pub use input::InputField; +pub use popup::{Popup, PopupType}; +pub use spinner::{LoadingIndicator, Spinner, SpinnerStyle}; +pub use table::{DataTable, StyledCell}; diff --git a/lightning-tensor/src/tui/components/popup.rs b/lightning-tensor/src/tui/components/popup.rs new file mode 100644 index 0000000..e127b01 --- /dev/null +++ b/lightning-tensor/src/tui/components/popup.rs @@ -0,0 +1,198 @@ +//! # Popup Component +//! +//! Modal popup for confirmations and messages with cyberpunk styling. + +use crate::tui::theme::{colors, symbols}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +/// Popup type determines styling +#[derive(Clone, Copy)] +pub enum PopupType { + Info, + Warning, + Error, + Confirm, + Loading, +} + +/// Popup component with distinctive styling +pub struct Popup<'a> { + pub title: &'a str, + pub message: &'a str, + pub popup_type: PopupType, + pub buttons: Option>, + pub selected_button: usize, +} + +impl<'a> Popup<'a> { + pub fn info(title: &'a str, message: &'a str) -> Self { + Self { + title, + message, + popup_type: PopupType::Info, + buttons: None, + selected_button: 0, + } + } + + pub fn warning(title: &'a str, message: &'a str) -> Self { + Self { + title, + message, + popup_type: PopupType::Warning, + buttons: None, + selected_button: 0, + } + } + + pub fn error(title: &'a str, message: &'a str) -> Self { + Self { + title, + message, + popup_type: PopupType::Error, + buttons: None, + selected_button: 0, + } + } + + pub fn confirm(title: &'a str, message: &'a str) -> Self { + Self { + title, + message, + popup_type: PopupType::Confirm, + buttons: Some(vec!["Cancel", "Confirm"]), + selected_button: 1, + } + } + + pub fn loading(title: &'a str, message: &'a str) -> Self { + Self { + title, + message, + popup_type: PopupType::Loading, + buttons: None, + selected_button: 0, + } + } + + pub fn with_buttons(mut self, buttons: Vec<&'a str>) -> Self { + self.buttons = Some(buttons); + self + } + + pub fn select_button(mut self, idx: usize) -> Self { + self.selected_button = idx; + self + } + + pub fn render(&self, f: &mut Frame, area: Rect) { + // Center the popup + let popup_area = centered_rect(60, 40, area); + + // Get type-specific styling + let (border_color, icon, bg_color) = match self.popup_type { + PopupType::Info => (colors::INFO, symbols::INFO, colors::BG_PANEL), + PopupType::Warning => (colors::WARNING, symbols::WARNING, colors::BG_PANEL), + PopupType::Error => (colors::ERROR, symbols::ERROR, colors::BG_PANEL), + PopupType::Confirm => (colors::SUCCESS, symbols::DIAMOND, colors::BG_PANEL), + PopupType::Loading => (colors::LIGHTNING, symbols::LOADING, colors::BG_PANEL), + }; + + // Clear the background + f.render_widget(Clear, popup_area); + + // Title with icon + let title = format!(" {} {} ", icon, self.title); + + let block = Block::default() + .title(Span::styled( + title, + Style::default() + .fg(border_color) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(bg_color)); + + let inner_area = block.inner(popup_area); + f.render_widget(block, popup_area); + + // Layout for content and buttons + let has_buttons = self.buttons.is_some() && !self.buttons.as_ref().unwrap().is_empty(); + let chunks = if has_buttons { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(inner_area) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0)]) + .split(inner_area) + }; + + // Message content + let content = Paragraph::new(self.message) + .style(Style::default().fg(colors::TEXT_PRIMARY)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + f.render_widget(content, chunks[0]); + + // Render buttons if present + if has_buttons { + let buttons = self.buttons.as_ref().unwrap(); + let button_spans: Vec = buttons + .iter() + .enumerate() + .flat_map(|(i, btn)| { + let is_selected = i == self.selected_button; + let style = if is_selected { + Style::default() + .fg(colors::BG_DEEP) + .bg(border_color) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(colors::TEXT_SECONDARY) + }; + + vec![Span::styled(format!(" {} ", btn), style), Span::raw(" ")] + }) + .collect(); + + let buttons_line = Line::from(button_spans); + let buttons_widget = Paragraph::new(buttons_line).alignment(Alignment::Center); + + f.render_widget(buttons_widget, chunks[1]); + } + } +} + +/// Create a centered rect +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} diff --git a/lightning-tensor/src/tui/components/spinner.rs b/lightning-tensor/src/tui/components/spinner.rs new file mode 100644 index 0000000..01d5bec --- /dev/null +++ b/lightning-tensor/src/tui/components/spinner.rs @@ -0,0 +1,123 @@ +//! # Spinner Component +//! +//! Loading spinner animations with cyberpunk styling. + +use super::AnimationState; +use crate::tui::theme::colors; +use ratatui::{ + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +/// Spinner component with multiple styles +pub struct Spinner<'a> { + pub message: &'a str, + pub animation: &'a AnimationState, + pub style: SpinnerStyle, +} + +/// Spinner visual style +#[derive(Clone, Copy, Default)] +pub enum SpinnerStyle { + #[default] + Braille, + Dots, + Lightning, + Bouncing, +} + +impl<'a> Spinner<'a> { + pub fn new(message: &'a str, animation: &'a AnimationState) -> Self { + Self { + message, + animation, + style: SpinnerStyle::default(), + } + } + + pub fn style(mut self, style: SpinnerStyle) -> Self { + self.style = style; + self + } + + pub fn render(&self, f: &mut Frame, area: Rect) { + let (spinner_text, color) = match self.style { + SpinnerStyle::Braille => (self.animation.spinner_char().to_string(), colors::LIGHTNING), + SpinnerStyle::Dots => (self.animation.spinner_dots().to_string(), colors::PLASMA), + SpinnerStyle::Lightning => (self.animation.lightning_pulse().to_string(), colors::VOLT), + SpinnerStyle::Bouncing => (self.animation.bouncing_loader(8), colors::LIGHTNING), + }; + + let line = Line::from(vec![ + Span::styled(spinner_text, Style::default().fg(color)), + Span::raw(" "), + Span::styled(self.message, Style::default().fg(colors::TEXT_SECONDARY)), + ]); + + let widget = Paragraph::new(line); + f.render_widget(widget, area); + } + + /// Render with a gradient loading bar + pub fn render_with_bar(&self, f: &mut Frame, area: Rect, width: usize) { + let bar = self.animation.loading_bar(width); + + let line = Line::from(vec![ + Span::styled("[", Style::default().fg(colors::TEXT_TERTIARY)), + Span::styled(bar, Style::default().fg(colors::LIGHTNING)), + Span::styled("] ", Style::default().fg(colors::TEXT_TERTIARY)), + Span::styled(self.message, Style::default().fg(colors::TEXT_SECONDARY)), + ]); + + let widget = Paragraph::new(line); + f.render_widget(widget, area); + } +} + +/// Inline loading indicator for status bars +pub struct LoadingIndicator<'a> { + pub animation: &'a AnimationState, +} + +impl<'a> LoadingIndicator<'a> { + pub fn new(animation: &'a AnimationState) -> Self { + Self { animation } + } + + /// Get a simple spinning indicator + pub fn spinner(&self) -> Span<'static> { + Span::styled( + self.animation.spinner_char().to_string(), + Style::default().fg(colors::LIGHTNING), + ) + } + + /// Get a pulsing lightning indicator + pub fn pulse(&self) -> Span<'static> { + Span::styled( + self.animation.lightning_pulse().to_string(), + Style::default().fg(colors::VOLT), + ) + } + + /// Get connection status indicator + pub fn connection(&self, connected: bool) -> (Span<'static>, Span<'static>) { + let (icon, text) = self.animation.connection_indicator(connected); + let color = if connected { + colors::SUCCESS + } else { + colors::ERROR + }; + + ( + Span::styled(icon.to_string(), Style::default().fg(color)), + Span::styled( + format!(" {}", text), + Style::default().fg(colors::TEXT_SECONDARY), + ), + ) + } +} diff --git a/lightning-tensor/src/tui/components/table.rs b/lightning-tensor/src/tui/components/table.rs new file mode 100644 index 0000000..9e5324b --- /dev/null +++ b/lightning-tensor/src/tui/components/table.rs @@ -0,0 +1,181 @@ +//! # Data Table Component +//! +//! Reusable data table with cyberpunk styling, headers, and scrolling. + +use crate::tui::theme::{colors, symbols}; +use ratatui::{ + layout::{Constraint, Rect}, + style::{Modifier, Style}, + text::Span, + widgets::{Block, Borders, Cell, Row, Table, TableState}, + Frame, +}; + +/// Data table component with distinctive styling +pub struct DataTable<'a> { + pub title: &'a str, + pub headers: Vec<&'a str>, + pub rows: Vec>, + pub widths: Vec, + pub state: &'a mut TableState, + pub row_styles: Option>, +} + +impl<'a> DataTable<'a> { + pub fn new( + title: &'a str, + headers: Vec<&'a str>, + rows: Vec>, + widths: Vec, + state: &'a mut TableState, + ) -> Self { + Self { + title, + headers, + rows, + widths, + state, + row_styles: None, + } + } + + /// Set custom styles for each row + pub fn with_row_styles(mut self, styles: Vec