From 837624bd6781880d67df3cfb6d3fda5cc6a2a118 Mon Sep 17 00:00:00 2001 From: distributedstatemachine Date: Sat, 27 Dec 2025 14:19:12 -0800 Subject: [PATCH 1/6] feat: start --- Cargo.lock | 243 ++++++--- lightning-tensor/Cargo.toml | 51 +- lightning-tensor/src/cli/crowd.rs | 226 +++++++++ lightning-tensor/src/cli/mod.rs | 145 ++++++ lightning-tensor/src/cli/root.rs | 134 +++++ lightning-tensor/src/cli/stake.rs | 302 ++++++++++++ lightning-tensor/src/cli/subnet.rs | 185 +++++++ lightning-tensor/src/cli/transfer.rs | 51 ++ lightning-tensor/src/cli/wallet.rs | 412 ++++++++++++++++ lightning-tensor/src/cli/weights.rs | 177 +++++++ lightning-tensor/src/config/mod.rs | 34 ++ lightning-tensor/src/config/settings.rs | 270 ++++++++++ lightning-tensor/src/context.rs | 288 +++++++++++ lightning-tensor/src/errors.rs | 195 +++++++- lightning-tensor/src/lib.rs | 30 ++ lightning-tensor/src/main.rs | 72 +-- lightning-tensor/src/models/display.rs | 79 +++ lightning-tensor/src/models/mod.rs | 8 + lightning-tensor/src/services/crowd.rs | 19 + lightning-tensor/src/services/mod.rs | 21 + lightning-tensor/src/services/root.rs | 19 + lightning-tensor/src/services/stake.rs | 19 + lightning-tensor/src/services/subnet.rs | 19 + lightning-tensor/src/services/transfer.rs | 19 + lightning-tensor/src/services/wallet.rs | 171 +++++++ lightning-tensor/src/services/weights.rs | 19 + lightning-tensor/src/tui/app.rs | 466 ++++++++++++++++++ .../src/tui/components/animation.rs | 50 ++ lightning-tensor/src/tui/components/input.rs | 68 +++ lightning-tensor/src/tui/components/mod.rs | 16 + lightning-tensor/src/tui/components/popup.rs | 112 +++++ .../src/tui/components/spinner.rs | 34 ++ lightning-tensor/src/tui/components/table.rs | 74 +++ lightning-tensor/src/tui/mod.rs | 50 ++ lightning-tensor/src/tui/views/home.rs | 131 +++++ lightning-tensor/src/tui/views/metagraph.rs | 44 ++ lightning-tensor/src/tui/views/mod.rs | 164 ++++++ lightning-tensor/src/tui/views/stake.rs | 84 ++++ lightning-tensor/src/tui/views/subnet.rs | 94 ++++ lightning-tensor/src/tui/views/wallet.rs | 145 ++++++ 40 files changed, 4601 insertions(+), 139 deletions(-) create mode 100644 lightning-tensor/src/cli/crowd.rs create mode 100644 lightning-tensor/src/cli/mod.rs create mode 100644 lightning-tensor/src/cli/root.rs create mode 100644 lightning-tensor/src/cli/stake.rs create mode 100644 lightning-tensor/src/cli/subnet.rs create mode 100644 lightning-tensor/src/cli/transfer.rs create mode 100644 lightning-tensor/src/cli/wallet.rs create mode 100644 lightning-tensor/src/cli/weights.rs create mode 100644 lightning-tensor/src/config/mod.rs create mode 100644 lightning-tensor/src/config/settings.rs create mode 100644 lightning-tensor/src/context.rs create mode 100644 lightning-tensor/src/lib.rs create mode 100644 lightning-tensor/src/models/display.rs create mode 100644 lightning-tensor/src/models/mod.rs create mode 100644 lightning-tensor/src/services/crowd.rs create mode 100644 lightning-tensor/src/services/mod.rs create mode 100644 lightning-tensor/src/services/root.rs create mode 100644 lightning-tensor/src/services/stake.rs create mode 100644 lightning-tensor/src/services/subnet.rs create mode 100644 lightning-tensor/src/services/transfer.rs create mode 100644 lightning-tensor/src/services/wallet.rs create mode 100644 lightning-tensor/src/services/weights.rs create mode 100644 lightning-tensor/src/tui/app.rs create mode 100644 lightning-tensor/src/tui/components/animation.rs create mode 100644 lightning-tensor/src/tui/components/input.rs create mode 100644 lightning-tensor/src/tui/components/mod.rs create mode 100644 lightning-tensor/src/tui/components/popup.rs create mode 100644 lightning-tensor/src/tui/components/spinner.rs create mode 100644 lightning-tensor/src/tui/components/table.rs create mode 100644 lightning-tensor/src/tui/mod.rs create mode 100644 lightning-tensor/src/tui/views/home.rs create mode 100644 lightning-tensor/src/tui/views/metagraph.rs create mode 100644 lightning-tensor/src/tui/views/mod.rs create mode 100644 lightning-tensor/src/tui/views/stake.rs create mode 100644 lightning-tensor/src/tui/views/subnet.rs create mode 100644 lightning-tensor/src/tui/views/wallet.rs diff --git a/Cargo.lock b/Cargo.lock index 11c0d08..73a4b50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -570,7 +570,7 @@ 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 +626,7 @@ dependencies = [ "anyhow", "async-trait", "chrono", - "futures 0.3.31", + "futures", "hex", "home", "parity-scale-codec", @@ -780,20 +780,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 +923,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 +955,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" @@ -1316,6 +1325,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 +1520,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 +1700,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 +1715,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 +1746,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" @@ -1889,7 +1890,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 +2035,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 +2046,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 +2056,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 +2087,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 +2125,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", @@ -2234,6 +2235,19 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.2", + "web-time", +] + [[package]] name = "indoc" version = "2.0.5" @@ -2258,15 +2272,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 +2395,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 +2575,34 @@ 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]] @@ -2808,6 +2824,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 +2869,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 +2902,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", @@ -3143,6 +3176,30 @@ dependencies = [ "toml_edit 0.21.1", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -3198,7 +3255,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea6b68e93db3622f3bb3bf363246cf948ed5375afe7abff98ccbdd50b184995" dependencies = [ - "futures 0.3.31", + "futures", "once_cell", "pin-project-lite", "pyo3", @@ -3313,7 +3370,7 @@ dependencies = [ "strum_macros", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -3992,6 +4049,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 +4273,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", @@ -4274,7 +4337,7 @@ dependencies = [ "bs58", "dyn-clonable", "ed25519-zebra", - "futures 0.3.31", + "futures", "hash-db", "hash256-std-hasher", "impl-serde 0.4.0", @@ -4350,7 +4413,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", @@ -4377,7 +4440,7 @@ version = "38.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61e20e9d9fe236466c1e38add64b591237c58540a07408407869d52d0e79fd18" dependencies = [ - "bytes 1.6.1", + "bytes", "docify", "ed25519-dalek", "libsecp256k1", @@ -4481,7 +4544,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", @@ -4761,7 +4824,7 @@ dependencies = [ "derive-where", "either", "frame-metadata", - "futures 0.3.31", + "futures", "hex", "jsonrpsee", "parity-scale-codec", @@ -4841,7 +4904,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 +4955,7 @@ checksum = "ab68a9c20ecedb0cb7d62d64f884e6add91bb70485783bf40aa8eac5c389c6e0" dependencies = [ "derive-where", "frame-metadata", - "futures 0.3.31", + "futures", "hex", "impl-serde 0.5.0", "jsonrpsee", @@ -4973,6 +5036,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tabled" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c998b0c8b921495196a48aabaf1901ff28be0760136e31604f7967b0792050e" +dependencies = [ + "papergrid", + "tabled_derive", + "unicode-width 0.1.13", +] + +[[package]] +name = "tabled_derive" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c138f99377e5d653a371cdad263615634cfc8467685dfe8e73e2b8e98f44b17" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "tap" version = "1.0.1" @@ -5119,7 +5206,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 +5257,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 +5269,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 +5523,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools 0.13.0", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -5445,6 +5532,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.4" @@ -6068,7 +6161,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "deadpool", - "futures 0.3.31", + "futures", "http", "http-body-util", "hyper", diff --git a/lightning-tensor/Cargo.toml b/lightning-tensor/Cargo.toml index 33167df..eaed8f5 100644 --- a/lightning-tensor/Cargo.toml +++ b/lightning-tensor/Cargo.toml @@ -2,23 +2,60 @@ 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 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/cli/crowd.rs b/lightning-tensor/src/cli/crowd.rs new file mode 100644 index 0000000..12dc855 --- /dev/null +++ b/lightning-tensor/src/cli/crowd.rs @@ -0,0 +1,226 @@ +//! # Crowdfunding CLI Commands +//! +//! Command-line interface for crowdfunding operations. + +use clap::Subcommand; +use crate::context::AppContext; +use crate::errors::Result; +use super::OutputFormat; + +/// 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..a044706 --- /dev/null +++ b/lightning-tensor/src/cli/mod.rs @@ -0,0 +1,145 @@ +//! # CLI Module +//! +//! Command-line interface implementation using clap. + +pub mod wallet; +pub mod stake; +pub mod transfer; +pub mod subnet; +pub mod weights; +pub mod root; +pub mod crowd; + +use clap::{Parser, Subcommand}; +use crate::errors::Result; + +/// 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..5c85f0f --- /dev/null +++ b/lightning-tensor/src/cli/root.rs @@ -0,0 +1,134 @@ +//! # Root Network CLI Commands +//! +//! Command-line interface for root network operations. + +use clap::Subcommand; +use crate::context::AppContext; +use crate::errors::Result; +use super::OutputFormat; + +/// 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<()> { + 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..0d89dd8 --- /dev/null +++ b/lightning-tensor/src/cli/stake.rs @@ -0,0 +1,302 @@ +//! # Stake CLI Commands +//! +//! Command-line interface for staking operations. + +use clap::Subcommand; +use crate::context::AppContext; +use crate::errors::Result; +use super::OutputFormat; + +/// 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..4f10477 --- /dev/null +++ b/lightning-tensor/src/cli/subnet.rs @@ -0,0 +1,185 @@ +//! # Subnet CLI Commands +//! +//! Command-line interface for subnet operations. + +use clap::Subcommand; +use crate::context::AppContext; +use crate::errors::Result; +use super::OutputFormat; + +/// 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..c19ac78 --- /dev/null +++ b/lightning-tensor/src/cli/transfer.rs @@ -0,0 +1,51 @@ +//! # Transfer CLI Commands +//! +//! Command-line interface for TAO transfers. + +use clap::Args; +use crate::context::AppContext; +use crate::errors::Result; +use super::OutputFormat; + +/// 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..7d10888 --- /dev/null +++ b/lightning-tensor/src/cli/wallet.rs @@ -0,0 +1,412 @@ +//! # Wallet CLI Commands +//! +//! Command-line interface for wallet operations. + +use clap::Subcommand; +use crate::context::AppContext; +use crate::errors::Result; +use crate::services::wallet::WalletService; +use super::OutputFormat; + +/// 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.get_coldkey_ss58().unwrap_or_else(|_| "N/A".to_string()); + + 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 = wallet.get_coldkey_ss58().unwrap_or_else(|_| "N/A".to_string()); + + // 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 = wallet.get_coldkey_ss58().unwrap_or_else(|_| "N/A".to_string()); + 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 = wallet.get_coldkey_ss58().unwrap_or_else(|_| "N/A".to_string()); + + 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..7bce8f6 --- /dev/null +++ b/lightning-tensor/src/cli/weights.rs @@ -0,0 +1,177 @@ +//! # Weights CLI Commands +//! +//! Command-line interface for weight operations. + +use clap::Subcommand; +use crate::context::AppContext; +use crate::errors::Result; +use super::OutputFormat; + +/// 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..b7a2d1b --- /dev/null +++ b/lightning-tensor/src/config/mod.rs @@ -0,0 +1,34 @@ +//! # 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..81be73a --- /dev/null +++ b/lightning-tensor/src/config/settings.rs @@ -0,0 +1,270 @@ +//! # 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)] +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 Default for Config { + fn default() -> Self { + Self { + network: NetworkConfig::default(), + wallet: WalletConfig::default(), + ui: UiConfig::default(), + } + } +} + +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 => bittensor_rs::BittensorConfig::custom( + vec![custom.to_string()], + wallet_name, + hotkey_name, + netuid, + ), + } + } +} + +/// 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..d89798b --- /dev/null +++ b/lightning-tensor/src/context.rs @@ -0,0 +1,288 @@ +//! # 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::Service; +use bittensor_wallet::Wallet; +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> { + let wallet_name = self + .config + .wallet + .default_wallet + .as_ref() + .ok_or_else(|| Error::config("No default wallet configured"))? + .clone(); + + let hotkey_name = self + .config + .wallet + .default_hotkey + .as_ref() + .ok_or_else(|| Error::config("No default hotkey configured"))? + .clone(); + + 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 + pub async fn load_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(), + }); + } + + let wallet = Wallet::new(name, wallet_path); + *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..306c346 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_wallet::WalletError), +} + +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/lib.rs b/lightning-tensor/src/lib.rs new file mode 100644 index 0000000..8960886 --- /dev/null +++ b/lightning-tensor/src/lib.rs @@ -0,0 +1,30 @@ +//! # 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..28338b0 --- /dev/null +++ b/lightning-tensor/src/models/display.rs @@ -0,0 +1,79 @@ +//! # 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..78be349 --- /dev/null +++ b/lightning-tensor/src/models/mod.rs @@ -0,0 +1,8 @@ +//! # 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..31ec767 --- /dev/null +++ b/lightning-tensor/src/services/crowd.rs @@ -0,0 +1,19 @@ +//! # 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..d45dc7b --- /dev/null +++ b/lightning-tensor/src/services/mod.rs @@ -0,0 +1,21 @@ +//! # Services Module +//! +//! Business logic layer providing clean interfaces for wallet, staking, +//! transfer, subnet, and other operations. + +pub mod wallet; +pub mod stake; +pub mod transfer; +pub mod subnet; +pub mod weights; +pub mod root; +pub mod crowd; + +pub use wallet::WalletService; +pub use stake::StakeService; +pub use transfer::TransferService; +pub use subnet::SubnetService; +pub use weights::WeightsService; +pub use root::RootService; +pub use crowd::CrowdService; + diff --git a/lightning-tensor/src/services/root.rs b/lightning-tensor/src/services/root.rs new file mode 100644 index 0000000..b11e443 --- /dev/null +++ b/lightning-tensor/src/services/root.rs @@ -0,0 +1,19 @@ +//! # 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..39fac49 --- /dev/null +++ b/lightning-tensor/src/services/stake.rs @@ -0,0 +1,19 @@ +//! # 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..bd27528 --- /dev/null +++ b/lightning-tensor/src/services/subnet.rs @@ -0,0 +1,19 @@ +//! # 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..5c8fa09 --- /dev/null +++ b/lightning-tensor/src/services/transfer.rs @@ -0,0 +1,19 @@ +//! # 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..0d69ea6 --- /dev/null +++ b/lightning-tensor/src/services/wallet.rs @@ -0,0 +1,171 @@ +//! # Wallet Service +//! +//! Business logic for wallet operations. + +use crate::errors::{Error, Result}; +use bittensor_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 } + } + + /// Create a new wallet + 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 + std::fs::create_dir_all(&wallet_path)?; + + let mut wallet = Wallet::new(name, wallet_path); + wallet.create_new_wallet(words as u32, password)?; + + Ok(wallet) + } + + /// Load an existing wallet + pub fn load_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() }); + } + + Ok(Wallet::new(name, wallet_path)) + } + + /// 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 { + let mut wallet = self.load_wallet(wallet_name)?; + wallet.create_new_hotkey(hotkey_name, password)?; + + let address = wallet.get_hotkey_ss58(hotkey_name)?; + Ok(address) + } + + /// Sign a message with the coldkey + pub fn sign_message(&self, wallet_name: &str, message: &str, password: &str) -> Result { + let wallet = self.load_wallet(wallet_name)?; + let public = wallet.get_coldkey(password)?; + + // For now, just return a placeholder - actual signing requires the private key + // The Wallet struct stores encrypted mnemonic, so we'd need to derive the keypair + Ok(format!("0x{}", hex::encode(public.0))) + } + + /// 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)?; + + let mut wallet = Wallet::new(name, wallet_path); + wallet.regenerate_wallet(mnemonic, password)?; + + 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..7411901 --- /dev/null +++ b/lightning-tensor/src/services/weights.rs @@ -0,0 +1,19 @@ +//! # 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..28c6a85 --- /dev/null +++ b/lightning-tensor/src/tui/app.rs @@ -0,0 +1,466 @@ +//! # 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::SubnetInfo; +use bittensor_wallet::Wallet; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use ratatui::backend::Backend; +use ratatui::widgets::ListState; +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, +} + +/// Async operation result +#[derive(Debug)] +pub enum AsyncResult { + WalletLoaded(Result), + BalanceLoaded(Result), + SubnetsLoaded(Result>), + Message(String), + Error(String), +} + +/// Main TUI application +pub struct App { + /// Application context + pub ctx: AppContext, + + /// 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 list state + pub wallet_list_state: ListState, + + /// Selected wallet index + pub selected_wallet: Option, + + /// Loaded subnets + pub subnets: Vec, + + /// Selected subnet index + pub selected_subnet: Option, + + /// Animation state + pub animation_state: AnimationState, + + /// Async result channel + pub async_tx: Sender, + pub async_rx: Receiver, + + /// Loading indicator + pub is_loading: bool, + + /// Current netuid for operations + pub current_netuid: u16, +} + +impl App { + /// Create a new TUI application + pub fn new(ctx: AppContext) -> Result { + let (async_tx, async_rx) = channel(100); + + let wallet_dir = ctx.wallet_dir().clone(); + + // Load wallets + let wallets = load_wallets(&wallet_dir); + + let mut wallet_list_state = ListState::default(); + if !wallets.is_empty() { + wallet_list_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_list_state, + selected_wallet: None, + subnets: Vec::new(), + selected_subnet: None, + animation_state: AnimationState::new(), + async_tx, + async_rx, + is_loading: false, + current_netuid: 1, + }) + } + + /// Get selected wallet + pub fn selected_wallet(&self) -> Option<&Wallet> { + self.selected_wallet.and_then(|i| self.wallets.get(i)) + } + + /// 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<()> { + 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, + KeyCode::Char('t') => self.state = AppState::Transfer, + KeyCode::Char('g') => self.state = AppState::Weights, + KeyCode::Char('r') => self.state = AppState::Root, + _ => {} + } + } + + /// 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 self.selected_wallet.is_some() { + self.add_message("Wallet selected".to_string()).await; + } + } + KeyCode::Char('c') => { + self.input_mode = true; + self.input_prompt = "Enter wallet name: ".to_string(); + } + KeyCode::Char('b') => { + // Fetch balance for selected wallet + if let Some(wallet) = self.selected_wallet() { + self.add_message(format!("Fetching balance for {}...", wallet.name)).await; + } + } + _ => {} + } + } + + /// 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') => { + if let Some(selected) = self.selected_subnet { + if selected > 0 { + self.selected_subnet = Some(selected - 1); + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(selected) = self.selected_subnet { + if selected < self.subnets.len().saturating_sub(1) { + self.selected_subnet = Some(selected + 1); + } + } else if !self.subnets.is_empty() { + self.selected_subnet = Some(0); + } + } + KeyCode::Enter => { + if let Some(idx) = self.selected_subnet { + if let Some(subnet) = self.subnets.get(idx) { + self.current_netuid = subnet.netuid; + self.state = AppState::Metagraph; + } + } + } + KeyCode::Char('r') => { + self.add_message("Refreshing subnets...".to_string()).await; + // TODO: Trigger subnet refresh + } + _ => {} + } + } + + /// Handle metagraph view input + async fn handle_metagraph_input(&mut self, key: KeyCode) { + match key { + KeyCode::Char('r') => { + self.add_message("Refreshing metagraph...".to_string()).await; + } + _ => {} + } + } + + /// Handle transfer view input + async fn handle_transfer_input(&mut self, key: KeyCode) { + match key { + KeyCode::Char('t') => { + self.input_mode = true; + self.input_prompt = "Destination address: ".to_string(); + } + _ => {} + } + } + + /// Handle weights view input + async fn handle_weights_input(&mut self, key: KeyCode) { + match key { + KeyCode::Char('s') => { + 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) { + match key { + KeyCode::Char('r') => { + 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::Message(msg) => { + self.add_message(msg).await; + } + AsyncResult::Error(err) => { + self.add_message(format!("Error: {}", 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(res) => { + match res { + Ok(balance) => { + self.add_message(format!("Balance: {:.4} TAO", balance)).await; + } + Err(e) => { + self.add_message(format!("Failed to get balance: {}", e)).await; + } + } + } + AsyncResult::SubnetsLoaded(res) => { + match res { + Ok(subnets) => { + self.subnets = subnets; + self.add_message(format!("Loaded {} subnets", self.subnets.len())).await; + } + Err(e) => { + self.add_message(format!("Failed to load subnets: {}", e)).await; + } + } + } + } + self.is_loading = false; + } +} + +/// 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(); + let wallet = Wallet::new(&wallet_name, wallet_path); + wallets.push(wallet); + } + } + } + } + + wallets.sort_by(|a, b| a.name.cmp(&b.name)); + wallets +} + diff --git a/lightning-tensor/src/tui/components/animation.rs b/lightning-tensor/src/tui/components/animation.rs new file mode 100644 index 0000000..c929e8e --- /dev/null +++ b/lightning-tensor/src/tui/components/animation.rs @@ -0,0 +1,50 @@ +//! # Animation State +//! +//! Animation state management for TUI elements. + +use std::time::{Duration, Instant}; + +/// Duration of each animation frame +const ANIMATION_FRAME_DURATION: Duration = Duration::from_millis(100); + +/// Animation state for UI elements +pub struct AnimationState { + pub frame: usize, + pub last_update: Instant, +} + +impl Default for AnimationState { + fn default() -> Self { + Self::new() + } +} + +impl AnimationState { + pub fn new() -> Self { + Self { + frame: 0, + last_update: Instant::now(), + } + } + + pub fn update(&mut self) { + let now = Instant::now(); + if now.duration_since(self.last_update) >= ANIMATION_FRAME_DURATION { + self.frame = (self.frame + 1) % 8; + self.last_update = now; + } + } + + /// Get spinner character for current frame + pub fn spinner_char(&self) -> char { + const SPINNER_CHARS: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; + SPINNER_CHARS[self.frame] + } + + /// Get rotating node character + pub fn node_char(&self) -> char { + const NODE_CHARS: [char; 8] = ['○', '◔', '◑', '◕', '●', '◕', '◑', '◔']; + NODE_CHARS[self.frame] + } +} + diff --git a/lightning-tensor/src/tui/components/input.rs b/lightning-tensor/src/tui/components/input.rs new file mode 100644 index 0000000..15a4ff6 --- /dev/null +++ b/lightning-tensor/src/tui/components/input.rs @@ -0,0 +1,68 @@ +//! # Input Field Component +//! +//! Text input field with optional password masking. + +use ratatui::{ + layout::Rect, + style::{Color, Style}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +/// Input field component +pub struct InputField<'a> { + pub prompt: &'a str, + pub value: &'a str, + pub is_password: bool, + pub is_focused: bool, +} + +impl<'a> InputField<'a> { + pub fn new(prompt: &'a str, value: &'a str) -> Self { + Self { + prompt, + value, + is_password: false, + is_focused: false, + } + } + + 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 render(&self, f: &mut Frame, area: Rect) { + let display_value = if self.is_password { + "*".repeat(self.value.len()) + } else { + self.value.to_string() + }; + + let cursor = if self.is_focused { "█" } else { "" }; + let text = format!("{}{}{}", self.prompt, display_value, cursor); + + let border_color = if self.is_focused { + Color::Yellow + } else { + Color::Gray + }; + + let input = Paragraph::new(text) + .style(Style::default().fg(Color::White)) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .title("Input") + ); + + 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..781b467 --- /dev/null +++ b/lightning-tensor/src/tui/components/mod.rs @@ -0,0 +1,16 @@ +//! # TUI Components +//! +//! Reusable UI components for the TUI. + +mod animation; +mod input; +mod popup; +mod spinner; +mod table; + +pub use animation::AnimationState; +pub use input::InputField; +pub use popup::Popup; +pub use spinner::Spinner; +pub use table::DataTable; + diff --git a/lightning-tensor/src/tui/components/popup.rs b/lightning-tensor/src/tui/components/popup.rs new file mode 100644 index 0000000..c6f0aa8 --- /dev/null +++ b/lightning-tensor/src/tui/components/popup.rs @@ -0,0 +1,112 @@ +//! # Popup Component +//! +//! Modal popup for confirmations and messages. + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +/// Popup type +pub enum PopupType { + Info, + Warning, + Error, + Confirm, +} + +/// Popup component +pub struct Popup<'a> { + pub title: &'a str, + pub message: &'a str, + pub popup_type: PopupType, +} + +impl<'a> Popup<'a> { + pub fn info(title: &'a str, message: &'a str) -> Self { + Self { + title, + message, + popup_type: PopupType::Info, + } + } + + pub fn warning(title: &'a str, message: &'a str) -> Self { + Self { + title, + message, + popup_type: PopupType::Warning, + } + } + + pub fn error(title: &'a str, message: &'a str) -> Self { + Self { + title, + message, + popup_type: PopupType::Error, + } + } + + pub fn confirm(title: &'a str, message: &'a str) -> Self { + Self { + title, + message, + popup_type: PopupType::Confirm, + } + } + + pub fn render(&self, f: &mut Frame, area: Rect) { + // Center the popup + let popup_area = centered_rect(60, 40, area); + + let border_color = match self.popup_type { + PopupType::Info => Color::Cyan, + PopupType::Warning => Color::Yellow, + PopupType::Error => Color::Red, + PopupType::Confirm => Color::Green, + }; + + // Clear the background + f.render_widget(Clear, popup_area); + + let block = Block::default() + .title(self.title) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(Color::Black)); + + let inner_area = block.inner(popup_area); + f.render_widget(block, popup_area); + + let content = Paragraph::new(self.message) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + f.render_widget(content, inner_area); + } +} + +/// 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..b59da66 --- /dev/null +++ b/lightning-tensor/src/tui/components/spinner.rs @@ -0,0 +1,34 @@ +//! # Spinner Component +//! +//! Loading spinner animation. + +use ratatui::{ + layout::Rect, + style::{Color, Style}, + widgets::Paragraph, + Frame, +}; +use super::AnimationState; + +/// Spinner component +pub struct Spinner<'a> { + pub message: &'a str, + pub animation: &'a AnimationState, +} + +impl<'a> Spinner<'a> { + pub fn new(message: &'a str, animation: &'a AnimationState) -> Self { + Self { message, animation } + } + + pub fn render(&self, f: &mut Frame, area: Rect) { + let spinner_char = self.animation.spinner_char(); + let text = format!("{} {}", spinner_char, self.message); + + let widget = Paragraph::new(text) + .style(Style::default().fg(Color::Yellow)); + + f.render_widget(widget, area); + } +} + diff --git a/lightning-tensor/src/tui/components/table.rs b/lightning-tensor/src/tui/components/table.rs new file mode 100644 index 0000000..47c12fe --- /dev/null +++ b/lightning-tensor/src/tui/components/table.rs @@ -0,0 +1,74 @@ +//! # Data Table Component +//! +//! Reusable data table with headers and scrolling. + +use ratatui::{ + layout::Constraint, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Cell, Row, Table, TableState}, + Frame, + layout::Rect, +}; + +/// Data table component +pub struct DataTable<'a> { + pub title: &'a str, + pub headers: Vec<&'a str>, + pub rows: Vec>, + pub widths: Vec, + pub state: &'a mut TableState, +} + +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, + } + } + + pub fn render(&mut self, f: &mut Frame, area: Rect) { + let header_cells = self.headers.iter().map(|h| { + Cell::from(*h).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + }); + let header = Row::new(header_cells) + .style(Style::default()) + .height(1) + .bottom_margin(1); + + let rows = self.rows.iter().map(|row| { + let cells = row.iter().map(|c| Cell::from(c.as_str())); + Row::new(cells).height(1) + }); + + let table = Table::new(rows, &self.widths) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(self.title), + ) + .highlight_style( + Style::default() + .add_modifier(Modifier::REVERSED) + .fg(Color::Cyan), + ) + .highlight_symbol("▸ "); + + f.render_stateful_widget(table, area, self.state); + } +} + diff --git a/lightning-tensor/src/tui/mod.rs b/lightning-tensor/src/tui/mod.rs new file mode 100644 index 0000000..6a157c6 --- /dev/null +++ b/lightning-tensor/src/tui/mod.rs @@ -0,0 +1,50 @@ +//! # TUI Module +//! +//! Terminal User Interface implementation using ratatui. + +pub mod app; +pub mod components; +pub mod views; + +use crate::context::AppContext; +use crate::errors::Result; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::io; + +pub use app::{App, AppState}; + +/// Run the TUI application +pub async fn run(ctx: AppContext) -> Result<()> { + // Setup terminal + 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(ctx)?; + 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 { + eprintln!("Error: {:?}", err); + return Err(err); + } + + Ok(()) +} + diff --git a/lightning-tensor/src/tui/views/home.rs b/lightning-tensor/src/tui/views/home.rs new file mode 100644 index 0000000..2e8cfe4 --- /dev/null +++ b/lightning-tensor/src/tui/views/home.rs @@ -0,0 +1,131 @@ +//! # Home View +//! +//! Main landing page for the TUI. + +use crate::tui::app::App; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +/// Draw the home view +pub fn draw(f: &mut Frame, app: &mut App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(50), + Constraint::Percentage(30), + Constraint::Percentage(20), + ]) + .split(area); + + // Draw ASCII art logo + draw_logo(f, app, chunks[0]); + + // Draw menu + draw_menu(f, chunks[1]); + + // Draw messages + draw_messages(f, app, chunks[2]); +} + +fn draw_logo(f: &mut Frame, app: &mut App, area: Rect) { + app.animation_state.update(); + let node_char = app.animation_state.node_char(); + + let logo = vec![ + format!(" {}───{}───{} ", node_char, node_char, node_char), + " ╱ ╲ ╱ ╲ ╱ ╲ ".to_string(), + format!(" {}───{}───{}───{} ", node_char, node_char, node_char, node_char), + " ╱ ╲ ╱ ╲ ╱ ╲ ╱ ╲ ".to_string(), + format!(" {}───{}───{}───{}───{} ", node_char, node_char, node_char, node_char, node_char), + " ╲ ╱ ╲ ╱ ╲ ╱ ╲ ╱ ".to_string(), + format!(" {}───{}───{}───{} ", node_char, node_char, node_char, node_char), + " ╲ ╱ ╲ ╱ ╲ ╱ ".to_string(), + format!(" {}───{}───{} ", node_char, node_char, node_char), + ]; + + let logo_text = logo.join("\n"); + + let logo_widget = Paragraph::new(logo_text) + .style(Style::default().fg(Color::Cyan)) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::NONE)); + + f.render_widget(logo_widget, area); +} + +fn draw_menu(f: &mut Frame, area: Rect) { + let menu_items = vec![ + Line::from(vec![ + Span::styled( + "Collective Intelligence at the speed of ⚡", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("[ w ] ", Style::default().fg(Color::Yellow)), + Span::raw("Wallet"), + Span::styled(" [ s ] ", Style::default().fg(Color::Yellow)), + Span::raw("Stake"), + Span::styled(" [ n ] ", Style::default().fg(Color::Yellow)), + Span::raw("Subnets"), + ]), + Line::from(vec![ + Span::styled("[ t ] ", Style::default().fg(Color::Yellow)), + Span::raw("Transfer"), + Span::styled(" [ g ] ", Style::default().fg(Color::Yellow)), + Span::raw("Weights"), + Span::styled(" [ r ] ", Style::default().fg(Color::Yellow)), + Span::raw("Root"), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("[ q ] ", Style::default().fg(Color::Red)), + Span::raw("Quit"), + ]), + ]; + + let menu = Paragraph::new(menu_items) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + .block( + Block::default() + .borders(Borders::ALL) + .title("Menu"), + ); + + f.render_widget(menu, area); +} + +fn draw_messages(f: &mut Frame, app: &App, area: Rect) { + // We need to get messages synchronously for rendering + // Using try_lock to avoid blocking + let messages_text = if let Ok(messages) = app.messages.try_lock() { + if messages.is_empty() { + "No messages".to_string() + } else { + messages.iter().rev().take(5).cloned().collect::>().join("\n") + } + } else { + "Loading...".to_string() + }; + + let messages_widget = Paragraph::new(messages_text) + .style(Style::default().fg(Color::Gray)) + .block( + Block::default() + .borders(Borders::ALL) + .title("Messages"), + ); + + f.render_widget(messages_widget, area); +} + diff --git a/lightning-tensor/src/tui/views/metagraph.rs b/lightning-tensor/src/tui/views/metagraph.rs new file mode 100644 index 0000000..e396644 --- /dev/null +++ b/lightning-tensor/src/tui/views/metagraph.rs @@ -0,0 +1,44 @@ +//! # Metagraph View +//! +//! Subnet metagraph visualization for the TUI. + +use crate::tui::app::App; +use ratatui::{ + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +/// Draw the metagraph view +pub fn draw(f: &mut Frame, app: &mut App, area: Rect) { + let netuid = app.current_netuid; + + let content = vec![ + Line::from(vec![ + Span::styled("Subnet: ", Style::default().fg(Color::Gray)), + Span::styled( + netuid.to_string(), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(""), + Line::from(Span::styled( + "Metagraph data not loaded", + Style::default().fg(Color::Gray), + )), + Line::from(""), + Line::from("Press [r] to refresh metagraph"), + ]; + + let paragraph = Paragraph::new(content) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Metagraph - Subnet {}", netuid)), + ); + + f.render_widget(paragraph, area); +} + diff --git a/lightning-tensor/src/tui/views/mod.rs b/lightning-tensor/src/tui/views/mod.rs new file mode 100644 index 0000000..dcbb0d0 --- /dev/null +++ b/lightning-tensor/src/tui/views/mod.rs @@ -0,0 +1,164 @@ +//! # TUI Views +//! +//! Feature-specific views for the TUI. + +mod home; +mod wallet; +mod stake; +mod subnet; +mod metagraph; + +use crate::tui::app::{App, AppState}; +use crate::tui::components::InputField; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +/// Main draw function - dispatches to appropriate view +pub fn draw(f: &mut Frame, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(0), // Main content + Constraint::Length(3), // Status bar + ]) + .split(f.size()); + + // Draw header + draw_header(f, app, chunks[0]); + + // Draw main content based on state + match app.state { + AppState::Home => home::draw(f, app, chunks[1]), + AppState::Wallet => wallet::draw(f, app, chunks[1]), + AppState::Stake => stake::draw(f, app, chunks[1]), + AppState::Subnet => subnet::draw(f, app, chunks[1]), + AppState::Metagraph => metagraph::draw(f, app, chunks[1]), + AppState::Transfer => draw_placeholder(f, "Transfer", chunks[1]), + AppState::Weights => draw_placeholder(f, "Weights", chunks[1]), + AppState::Root => draw_placeholder(f, "Root Network", chunks[1]), + } + + // Draw status bar + draw_status_bar(f, app, chunks[2]); + + // Draw input overlay if in input mode + if app.input_mode { + draw_input_overlay(f, app, f.size()); + } +} + +/// Draw the header +fn draw_header(f: &mut Frame, app: &App, area: Rect) { + let network = app.ctx.network_name(); + let title = format!("⚡ Lightning Tensor │ {}", network); + + let header = Paragraph::new(title) + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .alignment(ratatui::layout::Alignment::Center) + .block(Block::default().borders(Borders::ALL)); + + f.render_widget(header, area); +} + +/// Draw the status bar +fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) { + let state_name = match app.state { + AppState::Home => "Home", + AppState::Wallet => "Wallet", + AppState::Stake => "Stake", + AppState::Subnet => "Subnets", + AppState::Metagraph => "Metagraph", + AppState::Transfer => "Transfer", + AppState::Weights => "Weights", + AppState::Root => "Root", + }; + + let help_text = match app.state { + AppState::Home => "w:Wallet s:Stake n:Subnets t:Transfer g:Weights r:Root q:Quit", + AppState::Wallet => "↑/↓:Navigate Enter:Select c:Create b:Balance Esc:Back", + AppState::Stake => "a:Add r:Remove l:List Esc:Back", + AppState::Subnet => "↑/↓:Navigate Enter:Metagraph r:Refresh Esc:Back", + AppState::Metagraph => "r:Refresh Esc:Back", + _ => "Esc:Back q:Quit", + }; + + let loading = if app.is_loading { " ⟳" } else { "" }; + + let status = Line::from(vec![ + Span::styled( + format!(" {} ", state_name), + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + Span::raw(" │ "), + Span::styled(help_text, Style::default().fg(Color::Gray)), + Span::styled(loading, Style::default().fg(Color::Yellow)), + ]); + + let status_bar = Paragraph::new(status) + .block(Block::default().borders(Borders::ALL)); + + f.render_widget(status_bar, area); +} + +/// Draw input overlay +fn draw_input_overlay(f: &mut Frame, app: &App, area: Rect) { + // Create a centered input box + let input_area = centered_rect(60, 15, area); + + // Clear background + f.render_widget(ratatui::widgets::Clear, input_area); + + let input = InputField::new(&app.input_prompt, &app.input_buffer) + .password(app.is_password_input) + .focused(true); + + input.render(f, input_area); +} + +/// Draw placeholder for unimplemented views +fn draw_placeholder(f: &mut Frame, title: &str, area: Rect) { + let text = format!("{} view - Coming soon!", title); + let placeholder = Paragraph::new(text) + .style(Style::default().fg(Color::Gray)) + .alignment(ratatui::layout::Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .title(title), + ); + + f.render_widget(placeholder, area); +} + +/// Create a centered rectangle +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/views/stake.rs b/lightning-tensor/src/tui/views/stake.rs new file mode 100644 index 0000000..c9a30b9 --- /dev/null +++ b/lightning-tensor/src/tui/views/stake.rs @@ -0,0 +1,84 @@ +//! # Stake View +//! +//! Staking management view for the TUI. + +use crate::tui::app::App; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +/// Draw the stake view +pub fn draw(f: &mut Frame, app: &mut App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(70), + ]) + .split(area); + + // Draw stake summary + draw_stake_summary(f, app, chunks[0]); + + // Draw stake positions + draw_stake_positions(f, app, chunks[1]); +} + +fn draw_stake_summary(f: &mut Frame, app: &App, area: Rect) { + let wallet_name = app.selected_wallet() + .map(|w| w.name.as_str()) + .unwrap_or("No wallet selected"); + + let content = vec![ + Line::from(vec![ + Span::styled("Wallet: ", Style::default().fg(Color::Gray)), + Span::styled(wallet_name, Style::default().fg(Color::Yellow)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Actions:", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::styled(" [a] ", Style::default().fg(Color::Green)), + Span::raw("Add stake"), + Span::styled(" [r] ", Style::default().fg(Color::Red)), + Span::raw("Remove stake"), + Span::styled(" [l] ", Style::default().fg(Color::Cyan)), + Span::raw("List positions"), + ]), + ]; + + let summary = Paragraph::new(content) + .block( + Block::default() + .borders(Borders::ALL) + .title("Stake Summary"), + ); + + f.render_widget(summary, area); +} + +fn draw_stake_positions(f: &mut Frame, _app: &App, area: Rect) { + let content = vec![ + Line::from(Span::styled( + "No stake positions loaded", + Style::default().fg(Color::Gray), + )), + Line::from(""), + Line::from("Press [l] to load stake positions"), + ]; + + let positions = Paragraph::new(content) + .block( + Block::default() + .borders(Borders::ALL) + .title("Stake Positions"), + ); + + f.render_widget(positions, area); +} + diff --git a/lightning-tensor/src/tui/views/subnet.rs b/lightning-tensor/src/tui/views/subnet.rs new file mode 100644 index 0000000..4235fba --- /dev/null +++ b/lightning-tensor/src/tui/views/subnet.rs @@ -0,0 +1,94 @@ +//! # Subnet View +//! +//! Subnet explorer view for the TUI. + +use crate::tui::app::App; +use ratatui::{ + layout::{Constraint, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Cell, Row, Table}, + Frame, +}; + +/// Draw the subnet view +pub fn draw(f: &mut Frame, app: &mut App, area: Rect) { + if app.subnets.is_empty() { + draw_empty_state(f, area); + } else { + draw_subnet_table(f, app, area); + } +} + +fn draw_empty_state(f: &mut Frame, area: Rect) { + let text = "Press [r] to load subnets"; + let paragraph = ratatui::widgets::Paragraph::new(text) + .style(Style::default().fg(Color::Gray)) + .alignment(ratatui::layout::Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .title("Subnets"), + ); + + f.render_widget(paragraph, area); +} + +fn draw_subnet_table(f: &mut Frame, app: &mut App, area: Rect) { + let header_cells = ["NetUID", "Neurons", "Max", "Emission", "Tempo"] + .iter() + .map(|h| { + Cell::from(*h).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + }); + let header = Row::new(header_cells) + .style(Style::default()) + .height(1) + .bottom_margin(1); + + let rows = app.subnets.iter().enumerate().map(|(i, subnet)| { + let selected = app.selected_subnet == Some(i); + let style = if selected { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + + let cells = vec![ + Cell::from(subnet.netuid.to_string()), + Cell::from(subnet.subnetwork_n.to_string()), + Cell::from(subnet.max_allowed_uids.to_string()), + Cell::from(format!("{:.2}%", (subnet.emission_values as f64 / 65535.0) * 100.0)), + Cell::from(subnet.tempo.to_string()), + ]; + Row::new(cells).style(style) + }); + + let widths = [ + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(8), + Constraint::Length(12), + Constraint::Length(8), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Subnets ({} total)", app.subnets.len())), + ) + .highlight_style( + Style::default() + .add_modifier(Modifier::REVERSED) + .fg(Color::Cyan), + ) + .highlight_symbol("▸ "); + + // We don't have a proper TableState here, just render the table + f.render_widget(table, area); +} + diff --git a/lightning-tensor/src/tui/views/wallet.rs b/lightning-tensor/src/tui/views/wallet.rs new file mode 100644 index 0000000..bd57bd4 --- /dev/null +++ b/lightning-tensor/src/tui/views/wallet.rs @@ -0,0 +1,145 @@ +//! # Wallet View +//! +//! Wallet management view for the TUI. + +use crate::tui::app::App; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph}, + Frame, +}; + +/// Draw the wallet view +pub fn draw(f: &mut Frame, app: &mut App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(60), + ]) + .split(area); + + // Draw wallet list + draw_wallet_list(f, app, chunks[0]); + + // Draw wallet details + draw_wallet_details(f, app, chunks[1]); +} + +fn draw_wallet_list(f: &mut Frame, app: &mut App, area: Rect) { + let items: Vec = app + .wallets + .iter() + .enumerate() + .map(|(i, wallet)| { + let selected = app.selected_wallet == Some(i); + let style = if selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + let marker = if selected { "◉ " } else { "○ " }; + ListItem::new(Line::from(vec![ + Span::styled(marker, style), + Span::styled(&wallet.name, style), + ])) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Wallets"), + ) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▸ "); + + f.render_stateful_widget(list, area, &mut app.wallet_list_state); +} + +fn draw_wallet_details(f: &mut Frame, app: &App, area: Rect) { + let content = if let Some(idx) = app.selected_wallet { + if let Some(wallet) = app.wallets.get(idx) { + let coldkey = wallet.get_coldkey_ss58().unwrap_or_else(|_| "N/A".to_string()); + + vec![ + Line::from(vec![ + Span::styled("Name: ", Style::default().fg(Color::Gray)), + Span::styled(&wallet.name, Style::default().fg(Color::White)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Coldkey: ", Style::default().fg(Color::Gray)), + ]), + Line::from(vec![ + Span::styled( + truncate_address(&coldkey), + Style::default().fg(Color::Cyan), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Path: ", Style::default().fg(Color::Gray)), + ]), + Line::from(vec![ + Span::styled( + wallet.path.display().to_string(), + Style::default().fg(Color::DarkGray), + ), + ]), + Line::from(""), + Line::from(""), + Line::from(vec![ + Span::styled("Actions:", Style::default().fg(Color::Yellow)), + ]), + Line::from(vec![ + Span::styled(" [b] ", Style::default().fg(Color::Green)), + Span::raw("Check balance"), + ]), + Line::from(vec![ + Span::styled(" [h] ", Style::default().fg(Color::Green)), + Span::raw("List hotkeys"), + ]), + ] + } else { + vec![Line::from("Wallet not found")] + } + } else { + vec![ + Line::from(Span::styled( + "No wallet selected", + Style::default().fg(Color::Gray), + )), + Line::from(""), + Line::from("Use ↑/↓ to navigate and Enter to select"), + ] + }; + + let details = Paragraph::new(content) + .block( + Block::default() + .borders(Borders::ALL) + .title("Wallet Details"), + ); + + f.render_widget(details, area); +} + +fn truncate_address(addr: &str) -> String { + if addr.len() > 20 { + format!("{}...{}", &addr[..10], &addr[addr.len()-8..]) + } else { + addr.to_string() + } +} + From 0d51e58dce90bbe94c12deba4e04e18e1653ff1c Mon Sep 17 00:00:00 2001 From: distributedstatemachine Date: Sat, 27 Dec 2025 17:11:28 -0800 Subject: [PATCH 2/6] fix: lighting tesnor --- Cargo.toml | 4 +++- lightning-tensor/src/config/settings.rs | 13 +++++++------ lightning-tensor/src/services/wallet.rs | 3 ++- lightning-tensor/src/tui/views/subnet.rs | 6 +++--- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c146751..7217717 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,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/lightning-tensor/src/config/settings.rs b/lightning-tensor/src/config/settings.rs index 81be73a..ad64978 100644 --- a/lightning-tensor/src/config/settings.rs +++ b/lightning-tensor/src/config/settings.rs @@ -76,12 +76,14 @@ impl Config { "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 => bittensor_rs::BittensorConfig::custom( - vec![custom.to_string()], - wallet_name, - hotkey_name, + 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() + }, } } } @@ -267,4 +269,3 @@ mod tests { assert_eq!(parsed.network.network, config.network.network); } } - diff --git a/lightning-tensor/src/services/wallet.rs b/lightning-tensor/src/services/wallet.rs index 0d69ea6..ca626f6 100644 --- a/lightning-tensor/src/services/wallet.rs +++ b/lightning-tensor/src/services/wallet.rs @@ -99,12 +99,13 @@ impl WalletService { } /// Sign a message with the coldkey - pub fn sign_message(&self, wallet_name: &str, message: &str, password: &str) -> Result { + pub fn sign_message(&self, wallet_name: &str, _message: &str, password: &str) -> Result { let wallet = self.load_wallet(wallet_name)?; let public = wallet.get_coldkey(password)?; // For now, just return a placeholder - actual signing requires the private key // The Wallet struct stores encrypted mnemonic, so we'd need to derive the keypair + // TODO: Implement actual message signing when private key derivation is available Ok(format!("0x{}", hex::encode(public.0))) } diff --git a/lightning-tensor/src/tui/views/subnet.rs b/lightning-tensor/src/tui/views/subnet.rs index 4235fba..e0f23a7 100644 --- a/lightning-tensor/src/tui/views/subnet.rs +++ b/lightning-tensor/src/tui/views/subnet.rs @@ -58,9 +58,9 @@ fn draw_subnet_table(f: &mut Frame, app: &mut App, area: Rect) { let cells = vec![ Cell::from(subnet.netuid.to_string()), - Cell::from(subnet.subnetwork_n.to_string()), - Cell::from(subnet.max_allowed_uids.to_string()), - Cell::from(format!("{:.2}%", (subnet.emission_values as f64 / 65535.0) * 100.0)), + Cell::from(subnet.n.to_string()), + Cell::from(subnet.max_n.to_string()), + Cell::from("N/A".to_string()), // emission not available in SubnetInfo Cell::from(subnet.tempo.to_string()), ]; Row::new(cells).style(style) From 63c9f71cf3778066790735ed1efa9798fbf44657 Mon Sep 17 00:00:00 2001 From: distributedstatemachine Date: Sat, 27 Dec 2025 19:45:55 -0800 Subject: [PATCH 3/6] feat: wip tui --- Cargo.lock | 332 +--------- Cargo.toml | 3 +- bittensor-rs/src/lib.rs | 9 +- bittensor-rs/src/queries/mod.rs | 4 +- bittensor-rs/src/queries/subnet.rs | 119 +++- bittensor-rs/src/service.rs | 34 ++ lightning-tensor/Cargo.toml | 3 +- lightning-tensor/src/app.rs | 2 +- lightning-tensor/src/cli/wallet.rs | 31 +- lightning-tensor/src/context.rs | 25 +- lightning-tensor/src/errors.rs | 2 +- lightning-tensor/src/handlers/wallet.rs | 2 +- lightning-tensor/src/services/wallet.rs | 72 ++- lightning-tensor/src/tui/app.rs | 635 ++++++++++++++++++-- lightning-tensor/src/tui/mod.rs | 5 +- lightning-tensor/src/tui/views/home.rs | 33 +- lightning-tensor/src/tui/views/metagraph.rs | 187 +++++- lightning-tensor/src/tui/views/mod.rs | 14 +- lightning-tensor/src/tui/views/stake.rs | 2 +- lightning-tensor/src/tui/views/subnet.rs | 233 ++++++- lightning-tensor/src/tui/views/wallet.rs | 194 ++++-- lightning-tensor/src/ui/wallet.rs | 5 +- 22 files changed, 1404 insertions(+), 542 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73a4b50..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,15 +518,6 @@ 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" @@ -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" @@ -1141,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" @@ -1856,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" @@ -2248,12 +2158,6 @@ dependencies = [ "web-time", ] -[[package]] -name = "indoc" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" - [[package]] name = "inout" version = "0.1.3" @@ -2578,7 +2482,6 @@ dependencies = [ "anyhow", "async-trait", "bittensor-rs", - "bittensor-wallet", "chrono", "clap", "console", @@ -2651,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" @@ -3101,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" @@ -3231,82 +3113,6 @@ 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", - "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", -] - -[[package]] -name = "pyo3-ffi" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b42531d03e08d4ef1f6e85a2ed422eb678b8cd62b762e53891c05faf0d4afa" -dependencies = [ - "libc", - "pyo3-build-config", -] - -[[package]] -name = "pyo3-macros" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7305c720fa01b8055ec95e484a6eca7a83c841267f0dd5280f0c8b8551d2c158" -dependencies = [ - "proc-macro2", - "pyo3-macros-backend", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "pyo3-macros-backend" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c7e9b68bb9c3149c5b0cade5d07f953d6d125eb4337723c4ccdb665f1f96185" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "pyo3-build-config", - "quote", - "syn 2.0.111", -] - [[package]] name = "quote" version = "1.0.42" @@ -4291,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" @@ -4426,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", - "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", ] @@ -4502,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", @@ -4587,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", @@ -4669,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" @@ -5066,12 +4760,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "target-lexicon" -version = "0.12.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4873307b7c257eddcb50c9bedf158eb669578359fb28428bef438fec8e6ba7c2" - [[package]] name = "tempfile" version = "3.10.1" @@ -5544,12 +5232,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" -[[package]] -name = "unindent" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" - [[package]] name = "universal-hash" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 7217717..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] diff --git a/bittensor-rs/src/lib.rs b/bittensor-rs/src/lib.rs index 408e18c..583473d 100644 --- a/bittensor-rs/src/lib.rs +++ b/bittensor-rs/src/lib.rs @@ -111,10 +111,11 @@ pub use extrinsics::{ // Re-export queries pub use queries::{ - fields as metagraph_fields, get_balance, get_metagraph, get_neuron, get_neuron_lite, get_stake, - get_stake_info_for_coldkey, get_subnet_hyperparameters, get_subnet_info, - get_total_network_stake, get_total_subnets, get_uid_for_hotkey, subnet_exists, Metagraph, - NeuronInfo, NeuronInfoLite, SelectiveMetagraph, StakeInfo, SubnetHyperparameters, SubnetInfo, + fields as metagraph_fields, get_all_dynamic_info, get_balance, get_metagraph, get_neuron, + get_neuron_lite, get_stake, get_stake_info_for_coldkey, get_subnet_hyperparameters, + get_subnet_info, get_total_network_stake, get_total_subnets, get_uid_for_hotkey, subnet_exists, + DynamicSubnetInfo, Metagraph, NeuronInfo, NeuronInfoLite, SelectiveMetagraph, StakeInfo, + SubnetHyperparameters, SubnetInfo, }; // Re-export key types from our generated API diff --git a/bittensor-rs/src/queries/mod.rs b/bittensor-rs/src/queries/mod.rs index 97eddf8..a79540d 100644 --- a/bittensor-rs/src/queries/mod.rs +++ b/bittensor-rs/src/queries/mod.rs @@ -17,6 +17,6 @@ pub use account::{ pub use metagraph::{fields, get_metagraph, Metagraph, SelectiveMetagraph}; pub use neuron::{get_neuron, get_neuron_lite, get_uid_for_hotkey, NeuronInfo, NeuronInfoLite}; pub use subnet::{ - get_subnet_hyperparameters, get_subnet_info, get_total_subnets, subnet_exists, - SubnetHyperparameters, SubnetInfo, + get_all_dynamic_info, get_subnet_hyperparameters, get_subnet_info, get_total_subnets, + subnet_exists, DynamicSubnetInfo, SubnetHyperparameters, SubnetInfo, }; diff --git a/bittensor-rs/src/queries/subnet.rs b/bittensor-rs/src/queries/subnet.rs index 0dfd137..e54c2d5 100644 --- a/bittensor-rs/src/queries/subnet.rs +++ b/bittensor-rs/src/queries/subnet.rs @@ -4,10 +4,11 @@ use crate::api::api; use crate::error::BittensorError; +use subxt::ext::codec; use subxt::OnlineClient; use subxt::PolkadotConfig; -/// Subnet information +/// Subnet information (basic) #[derive(Debug, Clone)] pub struct SubnetInfo { /// Subnet netuid @@ -24,6 +25,38 @@ pub struct SubnetInfo { pub registration_allowed: bool, } +/// Dynamic subnet info with DTAO data (single RPC call for all subnets) +#[derive(Debug, Clone)] +pub struct DynamicSubnetInfo { + /// Subnet netuid + pub netuid: u16, + /// Subnet name (from identity or token symbol) + pub name: String, + /// Token symbol (e.g., "α", "τ") + pub symbol: String, + /// TAO emission per block (RAO) + pub tao_in_emission: u64, + /// Moving price (EMA) - used for emission weight calculation + /// Emission % = moving_price / Σ all_moving_prices × 100 + pub moving_price: f64, + /// Price in TAO (tao_in / alpha_in) + pub price_tao: f64, + /// Alpha in pool (RAO) + pub alpha_in: u64, + /// Alpha out pool (RAO) + pub alpha_out: u64, + /// TAO in pool (RAO) + pub tao_in: u64, + /// Owner hotkey SS58 + pub owner_hotkey: String, + /// Owner coldkey SS58 + pub owner_coldkey: String, + /// Tempo (blocks per epoch) + pub tempo: u16, + /// Block number when subnet was registered + pub registered_at: u64, +} + /// Subnet hyperparameters #[derive(Debug, Clone)] pub struct SubnetHyperparameters { @@ -311,6 +344,90 @@ pub async fn subnet_exists( Ok(exists) } +/// Get all subnet dynamic info in a single RPC call +/// +/// This is MUCH faster than calling get_subnet_info for each subnet. +/// Returns DTAO pricing, emission, identity, and pool info for all subnets. +pub async fn get_all_dynamic_info( + client: &OnlineClient, +) -> 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() + .filter_map(|opt| opt) + .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..78321d9 100644 --- a/bittensor-rs/src/service.rs +++ b/bittensor-rs/src/service.rs @@ -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/lightning-tensor/Cargo.toml b/lightning-tensor/Cargo.toml index eaed8f5..fb1da8a 100644 --- a/lightning-tensor/Cargo.toml +++ b/lightning-tensor/Cargo.toml @@ -10,9 +10,8 @@ name = "lt" path = "src/main.rs" [dependencies] -# Core Bittensor 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 } 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/wallet.rs b/lightning-tensor/src/cli/wallet.rs index 7d10888..692a199 100644 --- a/lightning-tensor/src/cli/wallet.rs +++ b/lightning-tensor/src/cli/wallet.rs @@ -7,6 +7,29 @@ use crate::context::AppContext; use crate::errors::Result; use crate::services::wallet::WalletService; use super::OutputFormat; +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)] @@ -138,7 +161,7 @@ async fn create_wallet( }; let wallet = service.create_wallet(name, words, &password)?; - let address = wallet.get_coldkey_ss58().unwrap_or_else(|_| "N/A".to_string()); + let address = wallet.hotkey().to_string(); // Hotkey address (coldkey needs unlock) match format { OutputFormat::Text => { @@ -195,7 +218,7 @@ async fn show_balance( .ok_or_else(|| crate::errors::Error::wallet("No wallet specified"))?; let wallet = service.load_wallet(&wallet_name)?; - let address = wallet.get_coldkey_ss58().unwrap_or_else(|_| "N/A".to_string()); + let address = read_coldkey_address(&wallet.path); // For balance, we need to connect to the network // For now, just show the address @@ -222,7 +245,7 @@ async fn show_balance( async fn show_wallet_info(service: &WalletService, name: &str, format: OutputFormat) -> Result<()> { let wallet = service.load_wallet(name)?; - let coldkey = wallet.get_coldkey_ss58().unwrap_or_else(|_| "N/A".to_string()); + let coldkey = read_coldkey_address(&wallet.path); let hotkeys = service.list_hotkeys(name)?; match format { @@ -321,7 +344,7 @@ async fn regen_wallet( let password = prompt_password("Enter new wallet password: ")?; let wallet = service.regen_wallet(name, &mnemonic, &password)?; - let address = wallet.get_coldkey_ss58().unwrap_or_else(|_| "N/A".to_string()); + let address = read_coldkey_address(&wallet.path); match format { OutputFormat::Text => { diff --git a/lightning-tensor/src/context.rs b/lightning-tensor/src/context.rs index d89798b..a4879c1 100644 --- a/lightning-tensor/src/context.rs +++ b/lightning-tensor/src/context.rs @@ -6,7 +6,7 @@ use crate::config::Config; use crate::errors::{Error, Result}; use bittensor_rs::Service; -use bittensor_wallet::Wallet; +use bittensor_rs::wallet::Wallet; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; @@ -104,25 +104,26 @@ impl AppContext { /// 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_ref() - .ok_or_else(|| Error::config("No default wallet configured"))? - .clone(); + .map(|s| s.as_str()) + .unwrap_or("default"); let hotkey_name = self .config .wallet .default_hotkey .as_ref() - .ok_or_else(|| Error::config("No default hotkey configured"))? - .clone(); + .map(|s| s.as_str()) + .unwrap_or("default"); let netuid = self.config.wallet.default_netuid; - self.connect(&wallet_name, &hotkey_name, netuid).await + self.connect(wallet_name, hotkey_name, netuid).await } /// Disconnect from the network @@ -136,7 +137,7 @@ impl AppContext { } /// Get the current wallet or return error - pub async fn require_wallet(&self) -> Result { + pub async fn require_wallet(&self) -> Result { self.wallet .read() .await @@ -144,8 +145,13 @@ impl AppContext { .ok_or_else(|| Error::wallet("No wallet loaded. Use 'lt wallet load' first.")) } - /// Load a wallet by name + /// 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() { @@ -154,7 +160,8 @@ impl AppContext { }); } - let wallet = Wallet::new(name, wallet_path); + 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) diff --git a/lightning-tensor/src/errors.rs b/lightning-tensor/src/errors.rs index 306c346..cd85e33 100644 --- a/lightning-tensor/src/errors.rs +++ b/lightning-tensor/src/errors.rs @@ -113,7 +113,7 @@ pub enum Error { BittensorSdk(#[from] bittensor_rs::BittensorError), #[error("Wallet library error: {0}")] - WalletLib(#[from] bittensor_wallet::WalletError), + WalletLib(#[from] bittensor_rs::wallet::KeyfileError), } impl Error { 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/services/wallet.rs b/lightning-tensor/src/services/wallet.rs index ca626f6..48abe70 100644 --- a/lightning-tensor/src/services/wallet.rs +++ b/lightning-tensor/src/services/wallet.rs @@ -1,9 +1,9 @@ //! # Wallet Service //! -//! Business logic for wallet operations. +//! Business logic for wallet operations using bittensor_rs::wallet. use crate::errors::{Error, Result}; -use bittensor_wallet::Wallet; +use bittensor_rs::wallet::Wallet; use std::path::PathBuf; /// Service for wallet operations @@ -16,33 +16,53 @@ impl WalletService { 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 - pub fn create_wallet(&self, name: &str, words: u8, password: &str) -> Result { + /// 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 + // 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)))?; - let mut wallet = Wallet::new(name, wallet_path); - wallet.create_new_wallet(words as u32, password)?; + // 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 + /// 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() }); } - Ok(Wallet::new(name, wallet_path)) + Wallet::load_from_path(name, hotkey, &self.wallet_dir) + .map_err(|e| Error::wallet(&format!("Failed to load wallet: {}", e))) } /// List all wallets @@ -90,23 +110,19 @@ impl WalletService { } /// Create a new hotkey for a wallet - pub fn create_hotkey(&self, wallet_name: &str, hotkey_name: &str, password: &str) -> Result { - let mut wallet = self.load_wallet(wallet_name)?; - wallet.create_new_hotkey(hotkey_name, password)?; + 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)))?; - let address = wallet.get_hotkey_ss58(hotkey_name)?; - Ok(address) + Ok(wallet.hotkey().to_string()) } - /// Sign a message with the coldkey - pub fn sign_message(&self, wallet_name: &str, _message: &str, password: &str) -> Result { + /// 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 public = wallet.get_coldkey(password)?; - - // For now, just return a placeholder - actual signing requires the private key - // The Wallet struct stores encrypted mnemonic, so we'd need to derive the keypair - // TODO: Implement actual message signing when private key derivation is available - Ok(format!("0x{}", hex::encode(public.0))) + let signature = wallet.sign(message.as_bytes()); + Ok(format!("0x{}", hex::encode(signature))) } /// Verify a signature @@ -146,14 +162,20 @@ impl WalletService { } /// Regenerate wallet from mnemonic - pub fn regen_wallet(&self, name: &str, mnemonic: &str, password: &str) -> Result { + 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)))?; - let mut wallet = Wallet::new(name, wallet_path); - wallet.regenerate_wallet(mnemonic, password)?; + // 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) } diff --git a/lightning-tensor/src/tui/app.rs b/lightning-tensor/src/tui/app.rs index 28c6a85..a0f348f 100644 --- a/lightning-tensor/src/tui/app.rs +++ b/lightning-tensor/src/tui/app.rs @@ -6,11 +6,10 @@ use crate::context::AppContext; use crate::errors::Result; use crate::tui::components::AnimationState; use crate::tui::views; -use bittensor_rs::SubnetInfo; -use bittensor_wallet::Wallet; +use bittensor_rs::{DynamicSubnetInfo, NeuronDiscovery}; use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use ratatui::backend::Backend; -use ratatui::widgets::ListState; +use ratatui::widgets::{ListState, TableState}; use ratatui::Terminal; use std::sync::Arc; use std::time::Duration; @@ -30,20 +29,58 @@ pub enum AppState { 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 { - WalletLoaded(Result), - BalanceLoaded(Result), - SubnetsLoaded(Result>), + 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 - pub ctx: AppContext, + /// Application context (Arc wrapped for async sharing) + pub ctx: Arc, /// Current view state pub state: AppState, @@ -67,7 +104,13 @@ pub struct App { pub messages: Arc>>, /// Loaded wallets - pub wallets: Vec, + 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, @@ -75,12 +118,21 @@ pub struct App { /// Selected wallet index pub selected_wallet: Option, - /// Loaded subnets - pub subnets: Vec, + /// 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, @@ -91,25 +143,45 @@ pub struct App { /// 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: AppContext) -> Result { + 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, @@ -120,23 +192,37 @@ impl App { 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 - pub fn selected_wallet(&self) -> Option<&Wallet> { + /// 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; @@ -149,6 +235,11 @@ impl App { /// 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| { @@ -251,14 +342,74 @@ impl App { match key { KeyCode::Char('w') => self.state = AppState::Wallet, KeyCode::Char('s') => self.state = AppState::Stake, - KeyCode::Char('n') => self.state = AppState::Subnet, + 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 { @@ -282,8 +433,10 @@ impl App { } KeyCode::Enter => { self.selected_wallet = self.wallet_list_state.selected(); - if self.selected_wallet.is_some() { - self.add_message("Wallet selected".to_string()).await; + 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') => { @@ -291,9 +444,19 @@ impl App { self.input_prompt = "Enter wallet name: ".to_string(); } KeyCode::Char('b') => { - // Fetch balance for selected wallet - if let Some(wallet) = self.selected_wallet() { - self.add_message(format!("Fetching balance for {}...", wallet.name)).await; + // 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); + } } } _ => {} @@ -319,32 +482,45 @@ impl App { async fn handle_subnet_input(&mut self, key: KeyCode) { match key { KeyCode::Up | KeyCode::Char('k') => { - if let Some(selected) = self.selected_subnet { - if selected > 0 { - self.selected_subnet = Some(selected - 1); + 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') => { - if let Some(selected) = self.selected_subnet { - if selected < self.subnets.len().saturating_sub(1) { - self.selected_subnet = Some(selected + 1); + let i = match self.subnet_list_state.selected() { + Some(i) => { + if i >= self.subnets.len().saturating_sub(1) { + 0 + } else { + i + 1 + } } - } else if !self.subnets.is_empty() { - self.selected_subnet = Some(0); - } + None => 0, + }; + self.subnet_list_state.select(Some(i)); } KeyCode::Enter => { - if let Some(idx) = self.selected_subnet { + 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') => { - self.add_message("Refreshing subnets...".to_string()).await; - // TODO: Trigger subnet refresh + KeyCode::Char('r') | KeyCode::F(5) => { + if !self.is_loading { + self.fetch_subnets(); + } } _ => {} } @@ -353,8 +529,58 @@ impl App { /// Handle metagraph view input async fn handle_metagraph_input(&mut self, key: KeyCode) { match key { - KeyCode::Char('r') => { - self.add_message("Refreshing metagraph...".to_string()).await; + 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; } _ => {} } @@ -401,50 +627,225 @@ impl App { /// 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!("Error: {}", err)).await; + self.add_message(format!("✗ {}", err)).await; } AsyncResult::WalletLoaded(res) => { match res { Ok(wallet) => { - self.add_message(format!("Loaded wallet: {}", wallet.name)).await; + self.add_message(format!("✓ Loaded wallet: {}", wallet.name)).await; } Err(e) => { - self.add_message(format!("Failed to load wallet: {}", e)).await; + self.add_message(format!("✗ Failed to load wallet: {}", e)).await; } } } - AsyncResult::BalanceLoaded(res) => { - match res { - Ok(balance) => { - self.add_message(format!("Balance: {:.4} TAO", balance)).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) => { - self.add_message(format!("Failed to get balance: {}", e)).await; + 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; - self.add_message(format!("Loaded {} subnets", self.subnets.len())).await; + 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 subnets: {}", e)).await; + 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 { +fn load_wallets(wallet_dir: &std::path::PathBuf) -> Vec { let mut wallets = Vec::new(); if let Ok(entries) = std::fs::read_dir(wallet_dir) { @@ -453,8 +854,33 @@ fn load_wallets(wallet_dir: &std::path::PathBuf) -> Vec { if file_type.is_dir() { let wallet_name = entry.file_name().to_string_lossy().into_owned(); let wallet_path = entry.path(); - let wallet = Wallet::new(&wallet_name, wallet_path); - wallets.push(wallet); + + // 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, + }); } } } @@ -464,3 +890,112 @@ fn load_wallets(wallet_dir: &std::path::PathBuf) -> Vec { 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/mod.rs b/lightning-tensor/src/tui/mod.rs index 6a157c6..4a6feb0 100644 --- a/lightning-tensor/src/tui/mod.rs +++ b/lightning-tensor/src/tui/mod.rs @@ -15,6 +15,7 @@ use crossterm::{ }; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; +use std::sync::Arc; pub use app::{App, AppState}; @@ -27,8 +28,8 @@ pub async fn run(ctx: AppContext) -> Result<()> { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Create app and run it - let mut app = App::new(ctx)?; + // Create app and run it (wrap context in Arc for async sharing) + let mut app = App::new(Arc::new(ctx))?; let res = app.run(&mut terminal).await; // Restore terminal diff --git a/lightning-tensor/src/tui/views/home.rs b/lightning-tensor/src/tui/views/home.rs index 2e8cfe4..b455242 100644 --- a/lightning-tensor/src/tui/views/home.rs +++ b/lightning-tensor/src/tui/views/home.rs @@ -16,8 +16,8 @@ pub fn draw(f: &mut Frame, app: &mut App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Percentage(50), - Constraint::Percentage(30), + Constraint::Percentage(45), + Constraint::Percentage(35), Constraint::Percentage(20), ]) .split(area); @@ -26,7 +26,7 @@ pub fn draw(f: &mut Frame, app: &mut App, area: Rect) { draw_logo(f, app, chunks[0]); // Draw menu - draw_menu(f, chunks[1]); + draw_menu(f, app, chunks[1]); // Draw messages draw_messages(f, app, chunks[2]); @@ -58,7 +58,13 @@ fn draw_logo(f: &mut Frame, app: &mut App, area: Rect) { f.render_widget(logo_widget, area); } -fn draw_menu(f: &mut Frame, area: Rect) { +fn draw_menu(f: &mut Frame, app: &App, area: Rect) { + let connection_status = if app.is_connected { + Span::styled("● Connected", Style::default().fg(Color::Green)) + } else { + Span::styled("○ Disconnected", Style::default().fg(Color::Red)) + }; + let menu_items = vec![ Line::from(vec![ Span::styled( @@ -69,6 +75,13 @@ fn draw_menu(f: &mut Frame, area: Rect) { ), ]), Line::from(""), + Line::from(vec![ + Span::styled("Network: ", Style::default().fg(Color::Gray)), + Span::styled(app.ctx.network_name(), Style::default().fg(Color::Cyan)), + Span::raw(" "), + connection_status, + ]), + Line::from(""), Line::from(vec![ Span::styled("[ w ] ", Style::default().fg(Color::Yellow)), Span::raw("Wallet"), @@ -78,16 +91,20 @@ fn draw_menu(f: &mut Frame, area: Rect) { Span::raw("Subnets"), ]), Line::from(vec![ - Span::styled("[ t ] ", Style::default().fg(Color::Yellow)), + Span::styled("[ m ] ", Style::default().fg(Color::Yellow)), + Span::raw("Metagraph"), + Span::styled(" [ t ] ", Style::default().fg(Color::Yellow)), Span::raw("Transfer"), Span::styled(" [ g ] ", Style::default().fg(Color::Yellow)), Span::raw("Weights"), - Span::styled(" [ r ] ", Style::default().fg(Color::Yellow)), - Span::raw("Root"), ]), Line::from(""), Line::from(vec![ - Span::styled("[ q ] ", Style::default().fg(Color::Red)), + Span::styled("[ c ] ", Style::default().fg(Color::Cyan)), + Span::raw("Connect"), + Span::styled(" [ r ] ", Style::default().fg(Color::Yellow)), + Span::raw("Root"), + Span::styled(" [ q ] ", Style::default().fg(Color::Red)), Span::raw("Quit"), ]), ]; diff --git a/lightning-tensor/src/tui/views/metagraph.rs b/lightning-tensor/src/tui/views/metagraph.rs index e396644..8438bca 100644 --- a/lightning-tensor/src/tui/views/metagraph.rs +++ b/lightning-tensor/src/tui/views/metagraph.rs @@ -1,44 +1,193 @@ //! # Metagraph View //! -//! Subnet metagraph visualization for the TUI. +//! Subnet metagraph visualization for the TUI with a rich table display. use crate::tui::app::App; use ratatui::{ - layout::Rect, - style::{Color, Style}, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table, Scrollbar, ScrollbarOrientation, ScrollbarState}, Frame, }; /// Draw the metagraph view pub fn draw(f: &mut Frame, app: &mut App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header info + Constraint::Min(0), // Table + Constraint::Length(2), // Footer with scroll info + ]) + .split(area); + + draw_header(f, app, chunks[0]); + draw_table(f, app, chunks[1]); + draw_footer(f, app, chunks[2]); +} + +fn draw_header(f: &mut Frame, app: &App, area: Rect) { let netuid = app.current_netuid; + let neuron_count = app.metagraph_neurons.len(); + + let validator_count = app.metagraph_neurons.iter().filter(|n| n.is_validator).count(); + let miner_count = neuron_count - validator_count; - let content = vec![ + let header_text = vec![ Line::from(vec![ Span::styled("Subnet: ", Style::default().fg(Color::Gray)), Span::styled( - netuid.to_string(), - Style::default().fg(Color::Yellow), + format!("{}", netuid), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + ), + Span::raw(" │ "), + Span::styled("Neurons: ", Style::default().fg(Color::Gray)), + Span::styled( + format!("{}", neuron_count), + Style::default().fg(Color::Cyan), + ), + Span::raw(" │ "), + Span::styled("Validators: ", Style::default().fg(Color::Gray)), + Span::styled( + format!("{}", validator_count), + Style::default().fg(Color::Green), + ), + Span::raw(" │ "), + Span::styled("Miners: ", Style::default().fg(Color::Gray)), + Span::styled( + format!("{}", miner_count), + Style::default().fg(Color::Magenta), ), ]), - Line::from(""), - Line::from(Span::styled( - "Metagraph data not loaded", - Style::default().fg(Color::Gray), - )), - Line::from(""), - Line::from("Press [r] to refresh metagraph"), ]; - - let paragraph = Paragraph::new(content) + + let header = Paragraph::new(header_text) + .block(Block::default().borders(Borders::BOTTOM)); + + f.render_widget(header, area); +} + +fn draw_table(f: &mut Frame, app: &mut App, area: Rect) { + if app.metagraph_neurons.is_empty() { + let loading_msg = if app.is_loading { + format!("⟳ {}", app.loading_message) + } else { + "No neurons loaded. Press [r] to refresh.".to_string() + }; + + let empty = Paragraph::new(loading_msg) + .style(Style::default().fg(Color::Gray)) + .alignment(ratatui::layout::Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .title("Metagraph"), + ); + f.render_widget(empty, area); + return; + } + + // Table header + let header_cells = ["UID", "Type", "Hotkey", "Coldkey", "Stake (τ)", "IP", "Port"] + .iter() + .map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))); + let header = Row::new(header_cells) + .style(Style::default()) + .height(1); + + // Table rows + let rows = app.metagraph_neurons.iter().enumerate().map(|(idx, neuron)| { + let selected = app.metagraph_table_state.selected() == Some(idx); + let style = if selected { + Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) + } else if neuron.is_validator { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::White) + }; + + let type_icon = if neuron.is_validator { "V" } else { "M" }; + let type_color = if neuron.is_validator { Color::Green } else { Color::Magenta }; + + let cells = vec![ + Cell::from(format!("{:>4}", neuron.uid)), + Cell::from(type_icon).style(Style::default().fg(type_color)), + Cell::from(neuron.hotkey.clone()), + Cell::from(neuron.coldkey.clone()), + Cell::from(format!("{:>10.4}", neuron.stake)), + Cell::from(if neuron.ip.is_empty() { "—".to_string() } else { neuron.ip.clone() }), + Cell::from(if neuron.port == 0 { "—".to_string() } else { neuron.port.to_string() }), + ]; + + Row::new(cells).style(style).height(1) + }); + + let widths = [ + Constraint::Length(5), // UID + Constraint::Length(4), // Type + Constraint::Length(18), // Hotkey + Constraint::Length(18), // Coldkey + Constraint::Length(12), // Stake + Constraint::Length(15), // IP + Constraint::Length(6), // Port + ]; + + let table = Table::new(rows, widths) + .header(header) .block( Block::default() .borders(Borders::ALL) - .title(format!("Metagraph - Subnet {}", netuid)), + .title(format!("⚡ Metagraph - Subnet {}", app.current_netuid)) + .title_style(Style::default().fg(Color::Yellow)), + ) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("▶ "); + + // Render table with scroll state + f.render_stateful_widget(table, area, &mut app.metagraph_table_state); + + // Add scrollbar if there are many neurons + if app.metagraph_neurons.len() > 10 { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + let mut scrollbar_state = ScrollbarState::new(app.metagraph_neurons.len()) + .position(app.metagraph_table_state.selected().unwrap_or(0)); + + f.render_stateful_widget( + scrollbar, + area.inner(ratatui::layout::Margin { horizontal: 0, vertical: 1 }), + &mut scrollbar_state, ); - - f.render_widget(paragraph, area); + } } +fn draw_footer(f: &mut Frame, app: &App, area: Rect) { + let selected = app.metagraph_table_state.selected().unwrap_or(0); + let total = app.metagraph_neurons.len(); + + let footer_text = Line::from(vec![ + Span::styled("↑/↓ ", Style::default().fg(Color::Yellow)), + Span::raw("Navigate "), + Span::styled("PgUp/PgDn ", Style::default().fg(Color::Yellow)), + Span::raw("Page "), + Span::styled("Home/End ", Style::default().fg(Color::Yellow)), + Span::raw("Jump "), + Span::styled("r ", Style::default().fg(Color::Yellow)), + Span::raw("Refresh "), + Span::styled("Esc ", Style::default().fg(Color::Yellow)), + Span::raw("Back "), + Span::raw("│ "), + Span::styled( + format!("{}/{}", selected + 1, total), + Style::default().fg(Color::Cyan), + ), + ]); + + let footer = Paragraph::new(footer_text) + .style(Style::default().fg(Color::Gray)); + + f.render_widget(footer, area); +} diff --git a/lightning-tensor/src/tui/views/mod.rs b/lightning-tensor/src/tui/views/mod.rs index dcbb0d0..291abb3 100644 --- a/lightning-tensor/src/tui/views/mod.rs +++ b/lightning-tensor/src/tui/views/mod.rs @@ -85,12 +85,14 @@ fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) { }; let help_text = match app.state { - AppState::Home => "w:Wallet s:Stake n:Subnets t:Transfer g:Weights r:Root q:Quit", - AppState::Wallet => "↑/↓:Navigate Enter:Select c:Create b:Balance Esc:Back", - AppState::Stake => "a:Add r:Remove l:List Esc:Back", - AppState::Subnet => "↑/↓:Navigate Enter:Metagraph r:Refresh Esc:Back", - AppState::Metagraph => "r:Refresh Esc:Back", - _ => "Esc:Back q:Quit", + AppState::Home => "w:Wallet s:Stake n:Subnets m:Metagraph t:Transfer c:Connect q:Quit", + AppState::Wallet => "↑↓:Navigate Enter:Select b:Balance B:All Esc:Back", + AppState::Stake => "a:Add r:Remove Esc:Back", + AppState::Subnet => "↑↓:Navigate Enter:Metagraph r:Refresh Esc:Back", + AppState::Metagraph => "↑↓:Navigate PgUp/Dn:Page r:Refresh Esc:Back", + AppState::Transfer => "t:Transfer Esc:Back", + AppState::Weights => "s:Set Esc:Back", + AppState::Root => "r:Register Esc:Back", }; let loading = if app.is_loading { " ⟳" } else { "" }; diff --git a/lightning-tensor/src/tui/views/stake.rs b/lightning-tensor/src/tui/views/stake.rs index c9a30b9..78e70e5 100644 --- a/lightning-tensor/src/tui/views/stake.rs +++ b/lightning-tensor/src/tui/views/stake.rs @@ -29,7 +29,7 @@ pub fn draw(f: &mut Frame, app: &mut App, area: Rect) { } fn draw_stake_summary(f: &mut Frame, app: &App, area: Rect) { - let wallet_name = app.selected_wallet() + let wallet_name = app.selected_wallet_info() .map(|w| w.name.as_str()) .unwrap_or("No wallet selected"); diff --git a/lightning-tensor/src/tui/views/subnet.rs b/lightning-tensor/src/tui/views/subnet.rs index e0f23a7..6826612 100644 --- a/lightning-tensor/src/tui/views/subnet.rs +++ b/lightning-tensor/src/tui/views/subnet.rs @@ -1,40 +1,112 @@ //! # Subnet View //! //! Subnet explorer view for the TUI. +//! Shows DTAO pricing, emission, name, and symbol for all subnets. use crate::tui::app::App; use ratatui::{ - layout::{Constraint, Rect}, + layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, - widgets::{Block, Borders, Cell, Row, Table}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, Frame, }; /// Draw the subnet view pub fn draw(f: &mut Frame, app: &mut App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(0), // Table + Constraint::Length(2), // Footer + ]) + .split(area); + + draw_header(f, app, chunks[0]); + if app.subnets.is_empty() { - draw_empty_state(f, area); + draw_empty_state(f, app, chunks[1]); } else { - draw_subnet_table(f, app, area); + draw_subnet_table(f, app, chunks[1]); } + + draw_footer(f, app, chunks[2]); +} + +fn draw_header(f: &mut Frame, app: &App, area: Rect) { + // Total TAO emission per block (excludes root) + let total_tao_emission: u64 = app.subnets.iter() + .filter(|s| s.netuid != 0) + .map(|s| s.tao_in_emission) + .sum(); + let total_tao = total_tao_emission as f64 / 1_000_000_000.0; + + // Sum of moving prices (for root sell flag: >1.0 means sell, ≤1.0 means recycle) + let total_moving_price: f64 = app.subnets.iter() + .map(|s| s.moving_price) + .sum(); + let root_sell = total_moving_price > 1.0; + + let header_text = vec![ + Line::from(vec![ + Span::styled("Subnets: ", Style::default().fg(Color::Gray)), + Span::styled( + format!("{}", app.subnets.len()), + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::raw(" │ "), + Span::styled("Emission: ", Style::default().fg(Color::Gray)), + Span::styled( + format!("{:.4} τ/blk", total_tao), + Style::default().fg(Color::Green), + ), + Span::raw(" │ "), + Span::styled("Root: ", Style::default().fg(Color::Gray)), + Span::styled( + if root_sell { "Sell" } else { "Recycle" }, + Style::default().fg(if root_sell { Color::Yellow } else { Color::Magenta }), + ), + Span::styled( + format!(" (Σ={:.2})", total_moving_price), + Style::default().fg(Color::DarkGray), + ), + if app.is_loading { + Span::styled(" ⟳ Loading...", Style::default().fg(Color::Yellow)) + } else { + Span::raw("") + }, + ]), + ]; + + let header = Paragraph::new(header_text) + .block(Block::default().borders(Borders::BOTTOM)); + + f.render_widget(header, area); } -fn draw_empty_state(f: &mut Frame, area: Rect) { - let text = "Press [r] to load subnets"; - let paragraph = ratatui::widgets::Paragraph::new(text) +fn draw_empty_state(f: &mut Frame, app: &App, area: Rect) { + let msg = if app.is_loading { + format!("⟳ {}", app.loading_message) + } else { + "No subnets loaded. Press [r] to fetch subnets.".to_string() + }; + + let paragraph = Paragraph::new(msg) .style(Style::default().fg(Color::Gray)) .alignment(ratatui::layout::Alignment::Center) .block( Block::default() .borders(Borders::ALL) - .title("Subnets"), + .title("⚡ DTAO Subnets") + .title_style(Style::default().fg(Color::Yellow)), ); f.render_widget(paragraph, area); } fn draw_subnet_table(f: &mut Frame, app: &mut App, area: Rect) { - let header_cells = ["NetUID", "Neurons", "Max", "Emission", "Tempo"] + let header_cells = ["#", "Name", "Price (τ)", "Emission (%)", "Alpha Pool", "TAO Pool"] .iter() .map(|h| { Cell::from(*h).style( @@ -45,33 +117,80 @@ fn draw_subnet_table(f: &mut Frame, app: &mut App, area: Rect) { }); let header = Row::new(header_cells) .style(Style::default()) - .height(1) - .bottom_margin(1); + .height(1); + let selected_idx = app.subnet_list_state.selected(); + + // Emission % is based on actual tao_in_emission (result of TAO flow calculation) + // This reflects the actual TAO distributed to each subnet + // Note: Underlying calculation uses EMA of TAO flow (stake - unstake) + let total_tao_emission: u64 = app.subnets.iter() + .filter(|s| s.netuid != 0) + .map(|s| s.tao_in_emission) + .sum(); + let rows = app.subnets.iter().enumerate().map(|(i, subnet)| { - let selected = app.selected_subnet == Some(i); + let selected = selected_idx == Some(i); let style = if selected { - Style::default().fg(Color::Cyan) + Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) } else { Style::default() }; + // Emission % based on actual TAO emission proportion + let emission_pct = if total_tao_emission > 0 && subnet.netuid != 0 { + (subnet.tao_in_emission as f64 / total_tao_emission as f64) * 100.0 + } else { + 0.0 + }; + + // Format pools with symbol appended + let alpha_in_tao = subnet.alpha_in as f64 / 1_000_000_000.0; + let tao_in_tao = subnet.tao_in as f64 / 1_000_000_000.0; + + // Color price based on value + let price_style = if subnet.price_tao > 1.0 { + Style::default().fg(Color::Green) + } else if subnet.price_tao > 0.1 { + Style::default().fg(Color::Yellow) + } else if subnet.price_tao > 0.0 { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::DarkGray) + }; + + // Color emission based on percentage + let emission_style = if emission_pct > 5.0 { + Style::default().fg(Color::Green) + } else if emission_pct > 1.0 { + Style::default().fg(Color::Yellow) + } else if emission_pct > 0.0 { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::DarkGray) + }; + let cells = vec![ - Cell::from(subnet.netuid.to_string()), - Cell::from(subnet.n.to_string()), - Cell::from(subnet.max_n.to_string()), - Cell::from("N/A".to_string()), // emission not available in SubnetInfo - Cell::from(subnet.tempo.to_string()), + Cell::from(format!("{:>2}", subnet.netuid)).style(Style::default().fg(Color::Cyan)), + Cell::from(truncate_name(&subnet.name, 24)), + Cell::from(format!("{:.4}", subnet.price_tao)).style(price_style), + Cell::from(format!("{:.2}%", emission_pct)).style(emission_style), + // Alpha pool with symbol + Cell::from(format!("{} {}", format_compact(alpha_in_tao), subnet.symbol)) + .style(Style::default().fg(Color::Magenta)), + Cell::from(format!("{} τ", format_compact(tao_in_tao))) + .style(Style::default().fg(Color::Yellow)), ]; - Row::new(cells).style(style) + Row::new(cells).style(style).height(1) }); let widths = [ - Constraint::Length(8), - Constraint::Length(10), - Constraint::Length(8), - Constraint::Length(12), - Constraint::Length(8), + Constraint::Length(4), // NetUID + Constraint::Length(26), // Name + Constraint::Length(10), // Price + Constraint::Length(12), // Emission + Constraint::Length(14), // Alpha Pool + Constraint::Length(12), // TAO Pool ]; let table = Table::new(rows, widths) @@ -79,16 +198,64 @@ fn draw_subnet_table(f: &mut Frame, app: &mut App, area: Rect) { .block( Block::default() .borders(Borders::ALL) - .title(format!("Subnets ({} total)", app.subnets.len())), - ) - .highlight_style( - Style::default() - .add_modifier(Modifier::REVERSED) - .fg(Color::Cyan), + .title(format!("⚡ DTAO Subnets ({} active)", app.subnets.len())) + .title_style(Style::default().fg(Color::Yellow)), ) - .highlight_symbol("▸ "); + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("▶ "); + + f.render_stateful_widget(table, area, &mut app.subnet_list_state); +} + +fn draw_footer(f: &mut Frame, app: &App, area: Rect) { + let selected = app.subnet_list_state.selected().unwrap_or(0); + let total = app.subnets.len(); + + let footer_text = Line::from(vec![ + Span::styled("↑/↓ ", Style::default().fg(Color::Yellow)), + Span::raw("Navigate "), + Span::styled("Enter ", Style::default().fg(Color::Yellow)), + Span::raw("Metagraph "), + Span::styled("r ", Style::default().fg(Color::Yellow)), + Span::raw("Refresh "), + Span::styled("Esc ", Style::default().fg(Color::Yellow)), + Span::raw("Back "), + if total > 0 { + Span::styled( + format!("│ {}/{}", selected + 1, total), + Style::default().fg(Color::Cyan), + ) + } else { + Span::raw("") + }, + ]); + + let footer = Paragraph::new(footer_text) + .style(Style::default().fg(Color::Gray)); + + f.render_widget(footer, area); +} - // We don't have a proper TableState here, just render the table - f.render_widget(table, area); +/// Truncate name for display +fn truncate_name(name: &str, max_len: usize) -> String { + if name.len() <= max_len { + name.to_string() + } else { + format!("{}…", &name[..max_len - 1]) + } } +/// Format large numbers compactly (K, M, etc) +fn format_compact(value: f64) -> String { + if value >= 1_000_000.0 { + format!("{:.2}M", value / 1_000_000.0) + } else if value >= 1_000.0 { + format!("{:.2}K", value / 1_000.0) + } else if value >= 1.0 { + format!("{:.2}", value) + } else if value > 0.0 { + format!("{:.4}", value) + } else { + "0".to_string() + } +} diff --git a/lightning-tensor/src/tui/views/wallet.rs b/lightning-tensor/src/tui/views/wallet.rs index bd57bd4..73691f4 100644 --- a/lightning-tensor/src/tui/views/wallet.rs +++ b/lightning-tensor/src/tui/views/wallet.rs @@ -7,7 +7,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph}, + widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table}, Frame, }; @@ -34,19 +34,31 @@ fn draw_wallet_list(f: &mut Frame, app: &mut App, area: Rect) { .iter() .enumerate() .map(|(i, wallet)| { - let selected = app.selected_wallet == Some(i); - let style = if selected { + let selected = app.wallet_list_state.selected() == Some(i); + let is_active = app.selected_wallet == Some(i); + + let style = if is_active { Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD) + } else if selected { + Style::default().fg(Color::Cyan) } else { Style::default().fg(Color::White) }; - let marker = if selected { "◉ " } else { "○ " }; + let marker = if is_active { "◉ " } else { "○ " }; + + // Show balance if available + let balance_str = app.wallet_balances.get(i) + .and_then(|b| *b) + .map(|b| format!(" [{:.2}τ]", b)) + .unwrap_or_default(); + ListItem::new(Line::from(vec![ Span::styled(marker, style), Span::styled(&wallet.name, style), + Span::styled(balance_str, Style::default().fg(Color::Green)), ])) }) .collect(); @@ -55,73 +67,79 @@ fn draw_wallet_list(f: &mut Frame, app: &mut App, area: Rect) { .block( Block::default() .borders(Borders::ALL) - .title("Wallets"), + .title(format!("⚡ Wallets ({})", app.wallets.len())) + .title_style(Style::default().fg(Color::Yellow)), ) .highlight_style( Style::default() .bg(Color::DarkGray) .add_modifier(Modifier::BOLD), ) - .highlight_symbol("▸ "); + .highlight_symbol("▶ "); f.render_stateful_widget(list, area, &mut app.wallet_list_state); } fn draw_wallet_details(f: &mut Frame, app: &App, area: Rect) { - let content = if let Some(idx) = app.selected_wallet { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(10), // Basic info + Constraint::Min(0), // Stakes table + Constraint::Length(2), // Footer + ]) + .split(area); + + // Basic wallet info + let content = if let Some(idx) = app.wallet_list_state.selected() { if let Some(wallet) = app.wallets.get(idx) { - let coldkey = wallet.get_coldkey_ss58().unwrap_or_else(|_| "N/A".to_string()); + let balance = app.wallet_balances.get(idx) + .and_then(|b| *b) + .map(|b| format!("{:.4} τ", b)) + .unwrap_or_else(|| "Loading...".to_string()); + + let addr = wallet.coldkey_address.as_deref().unwrap_or("N/A"); + let short_addr = if addr.len() > 16 { + format!("{}...{}", &addr[..8], &addr[addr.len()-8..]) + } else { + addr.to_string() + }; vec![ Line::from(vec![ Span::styled("Name: ", Style::default().fg(Color::Gray)), - Span::styled(&wallet.name, Style::default().fg(Color::White)), + Span::styled(&wallet.name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), ]), - Line::from(""), Line::from(vec![ - Span::styled("Coldkey: ", Style::default().fg(Color::Gray)), + Span::styled("Address: ", Style::default().fg(Color::Gray)), + Span::styled(short_addr, Style::default().fg(Color::Cyan)), ]), Line::from(vec![ + Span::styled("Balance: ", Style::default().fg(Color::Gray)), Span::styled( - truncate_address(&coldkey), - Style::default().fg(Color::Cyan), + balance, + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), ), ]), - Line::from(""), - Line::from(vec![ - Span::styled("Path: ", Style::default().fg(Color::Gray)), - ]), Line::from(vec![ + Span::styled("Hotkeys: ", Style::default().fg(Color::Gray)), + Span::styled( + format!("{}", wallet.hotkeys.len()), + Style::default().fg(Color::Cyan), + ), Span::styled( - wallet.path.display().to_string(), + format!(" ({})", wallet.hotkeys.join(", ")), Style::default().fg(Color::DarkGray), ), ]), - Line::from(""), - Line::from(""), - Line::from(vec![ - Span::styled("Actions:", Style::default().fg(Color::Yellow)), - ]), - Line::from(vec![ - Span::styled(" [b] ", Style::default().fg(Color::Green)), - Span::raw("Check balance"), - ]), - Line::from(vec![ - Span::styled(" [h] ", Style::default().fg(Color::Green)), - Span::raw("List hotkeys"), - ]), ] } else { vec![Line::from("Wallet not found")] } } else { vec![ - Line::from(Span::styled( - "No wallet selected", - Style::default().fg(Color::Gray), - )), - Line::from(""), - Line::from("Use ↑/↓ to navigate and Enter to select"), + Line::from(Span::styled("No wallet selected", Style::default().fg(Color::Gray))), + Line::from("Use ↑/↓ to navigate"), ] }; @@ -129,17 +147,105 @@ fn draw_wallet_details(f: &mut Frame, app: &App, area: Rect) { .block( Block::default() .borders(Borders::ALL) - .title("Wallet Details"), + .title("Wallet Info") + .title_style(Style::default().fg(Color::Yellow)), ); - f.render_widget(details, area); + f.render_widget(details, chunks[0]); + + // Stakes table + draw_stakes_table(f, app, chunks[1]); + + // Footer + let footer = Line::from(vec![ + Span::styled("↑/↓ ", Style::default().fg(Color::Yellow)), + Span::raw("Navigate "), + Span::styled("Enter ", Style::default().fg(Color::Yellow)), + Span::raw("Select "), + Span::styled("b ", Style::default().fg(Color::Yellow)), + Span::raw("Balance "), + Span::styled("Esc ", Style::default().fg(Color::Yellow)), + Span::raw("Back"), + ]); + + let footer_widget = Paragraph::new(footer) + .style(Style::default().fg(Color::Gray)); + + f.render_widget(footer_widget, chunks[2]); } -fn truncate_address(addr: &str) -> String { - if addr.len() > 20 { - format!("{}...{}", &addr[..10], &addr[addr.len()-8..]) - } else { - addr.to_string() +fn draw_stakes_table(f: &mut Frame, app: &App, area: Rect) { + let stakes = app.wallet_list_state.selected() + .and_then(|idx| app.wallet_stakes.get(idx)) + .cloned() + .unwrap_or_default(); + + if stakes.is_empty() { + let msg = if app.is_connected { + "No stakes found (or loading...)" + } else { + "Connect to view stakes" + }; + let paragraph = Paragraph::new(msg) + .style(Style::default().fg(Color::Gray)) + .alignment(ratatui::layout::Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .title("Alpha Stakes") + .title_style(Style::default().fg(Color::Yellow)), + ); + f.render_widget(paragraph, area); + return; } + + // Calculate total stake + let total_stake: f64 = stakes.iter().map(|s| s.stake_tao).sum(); + + let header_cells = ["Subnet", "Hotkey", "Stake (α)", "Emission"] + .iter() + .map(|h| { + Cell::from(*h).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + }); + let header = Row::new(header_cells).height(1); + + let rows = stakes.iter().map(|stake| { + let cells = vec![ + Cell::from(format!("{}", stake.netuid)).style(Style::default().fg(Color::Cyan)), + Cell::from(truncate(&stake.hotkey, 12)), + Cell::from(format!("{:.4}", stake.stake_tao)).style(Style::default().fg(Color::Magenta)), + Cell::from(format!("{:.6}", stake.emission_tao)).style(Style::default().fg(Color::Green)), + ]; + Row::new(cells).height(1) + }); + + let widths = [ + Constraint::Length(8), + Constraint::Length(14), + Constraint::Length(12), + Constraint::Length(12), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Alpha Stakes (Total: {:.4} α)", total_stake)) + .title_style(Style::default().fg(Color::Yellow)), + ); + + f.render_widget(table, area); } +fn truncate(s: &str, len: usize) -> String { + if s.len() <= len { + s.to_string() + } else { + format!("{}…", &s[..len-1]) + } +} diff --git a/lightning-tensor/src/ui/wallet.rs b/lightning-tensor/src/ui/wallet.rs index 15f6c39..845bd8f 100644 --- a/lightning-tensor/src/ui/wallet.rs +++ b/lightning-tensor/src/ui/wallet.rs @@ -7,9 +7,8 @@ use ratatui::{ }; use crate::App; -// use bittensor_wallet::keypair::Keypair; -use bittensor_wallet::Wallet; -use bittensor_wallet::WalletError; +use bittensor_rs::wallet::Wallet; +use bittensor_rs::BittensorError; /// Renders the wallet interface /// From 760fcce962abae9f4114dc07f609d2c18624f1ce Mon Sep 17 00:00:00 2001 From: distributedstatemachine <112424909+distributedstatemachine@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:47:02 -0800 Subject: [PATCH 4/6] Update lightning-tensor/src/cli/root.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- lightning-tensor/src/cli/root.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lightning-tensor/src/cli/root.rs b/lightning-tensor/src/cli/root.rs index 5c85f0f..342ff96 100644 --- a/lightning-tensor/src/cli/root.rs +++ b/lightning-tensor/src/cli/root.rs @@ -80,6 +80,29 @@ async fn set_root_weights( 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"); From 1a2ca84bd5cfbe7968f5965891b82159a6798150 Mon Sep 17 00:00:00 2001 From: distributedstatemachine Date: Mon, 29 Dec 2025 08:25:09 -0800 Subject: [PATCH 5/6] feat: improve ui --- .../src/tui/components/animation.rs | 258 ++++++++++++- lightning-tensor/src/tui/components/input.rs | 91 +++-- lightning-tensor/src/tui/components/mod.rs | 9 +- lightning-tensor/src/tui/components/popup.rs | 137 +++++-- .../src/tui/components/spinner.rs | 116 +++++- lightning-tensor/src/tui/components/table.rs | 131 ++++++- lightning-tensor/src/tui/mod.rs | 2 + lightning-tensor/src/tui/theme.rs | 356 ++++++++++++++++++ lightning-tensor/src/tui/views/home.rs | 255 +++++++++---- lightning-tensor/src/tui/views/metagraph.rs | 282 +++++++++----- lightning-tensor/src/tui/views/mod.rs | 182 +++++---- lightning-tensor/src/tui/views/root.rs | 297 +++++++++++++++ lightning-tensor/src/tui/views/stake.rs | 228 +++++++++-- lightning-tensor/src/tui/views/subnet.rs | 265 ++++++++----- lightning-tensor/src/tui/views/transfer.rs | 276 ++++++++++++++ lightning-tensor/src/tui/views/wallet.rs | 253 ++++++++----- lightning-tensor/src/tui/views/weights.rs | 243 ++++++++++++ 17 files changed, 2843 insertions(+), 538 deletions(-) create mode 100644 lightning-tensor/src/tui/theme.rs create mode 100644 lightning-tensor/src/tui/views/root.rs create mode 100644 lightning-tensor/src/tui/views/transfer.rs create mode 100644 lightning-tensor/src/tui/views/weights.rs diff --git a/lightning-tensor/src/tui/components/animation.rs b/lightning-tensor/src/tui/components/animation.rs index c929e8e..824ef0b 100644 --- a/lightning-tensor/src/tui/components/animation.rs +++ b/lightning-tensor/src/tui/components/animation.rs @@ -1,16 +1,42 @@ //! # 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(100); +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 { @@ -24,27 +50,243 @@ impl AnimationState { 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 spinner character for current frame + + /// Get braille spinner character for current frame pub fn spinner_char(&self) -> char { - const SPINNER_CHARS: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; - SPINNER_CHARS[self.frame] + 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 { - const NODE_CHARS: [char; 8] = ['○', '◔', '◑', '◕', '●', '◕', '◑', '◔']; - NODE_CHARS[self.frame] + 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).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_str("⚡"); + } else { + bar.push_str("·"); + } + } + + 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 index 15a4ff6..025e33d 100644 --- a/lightning-tensor/src/tui/components/input.rs +++ b/lightning-tensor/src/tui/components/input.rs @@ -1,20 +1,23 @@ //! # Input Field Component //! -//! Text input field with optional password masking. +//! Text input field with cyberpunk styling and optional password masking. +use crate::tui::theme::{colors, symbols}; use ratatui::{ layout::Rect, - style::{Color, Style}, + style::{Modifier, Style}, + text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Frame, }; -/// Input field component +/// 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> { @@ -24,45 +27,83 @@ impl<'a> InputField<'a> { 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 { - "*".repeat(self.value.len()) + 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 cursor = if self.is_focused { "█" } else { "" }; - let text = format!("{}{}{}", self.prompt, display_value, cursor); - - let border_color = if self.is_focused { - Color::Yellow + + 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 { - Color::Gray + Span::raw("") }; - - let input = Paragraph::new(text) - .style(Style::default().fg(Color::White)) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)) - .title("Input") - ); - + + 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 index 781b467..f2406c9 100644 --- a/lightning-tensor/src/tui/components/mod.rs +++ b/lightning-tensor/src/tui/components/mod.rs @@ -1,6 +1,7 @@ //! # TUI Components //! //! Reusable UI components for the TUI. +//! Features distinctive visual elements with cyberpunk aesthetic. mod animation; mod input; @@ -8,9 +9,9 @@ mod popup; mod spinner; mod table; -pub use animation::AnimationState; +pub use animation::{AnimationState, GradientProgress, Sparkline}; pub use input::InputField; -pub use popup::Popup; -pub use spinner::Spinner; -pub use table::DataTable; +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 index c6f0aa8..ab33949 100644 --- a/lightning-tensor/src/tui/components/popup.rs +++ b/lightning-tensor/src/tui/components/popup.rs @@ -1,27 +1,33 @@ //! # Popup Component //! -//! Modal popup for confirmations and messages. +//! Modal popup for confirmations and messages with cyberpunk styling. +use crate::tui::theme::{colors, symbols}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Style}, + style::{Modifier, Style}, + text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph, Wrap}, Frame, }; -/// Popup type +/// Popup type determines styling +#[derive(Clone, Copy)] pub enum PopupType { Info, Warning, Error, Confirm, + Loading, } -/// Popup component +/// 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> { @@ -30,62 +36,146 @@ impl<'a> Popup<'a> { 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); - - let border_color = match self.popup_type { - PopupType::Info => Color::Cyan, - PopupType::Warning => Color::Yellow, - PopupType::Error => Color::Red, - PopupType::Confirm => Color::Green, + + // 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(self.title) + .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(Color::Black)); - + .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(Color::White)) + .style(Style::default().fg(colors::TEXT_PRIMARY)) .alignment(Alignment::Center) .wrap(Wrap { trim: true }); - - f.render_widget(content, inner_area); + + 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]); + } } } @@ -109,4 +199,3 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { ]) .split(popup_layout[1])[1] } - diff --git a/lightning-tensor/src/tui/components/spinner.rs b/lightning-tensor/src/tui/components/spinner.rs index b59da66..a8522e3 100644 --- a/lightning-tensor/src/tui/components/spinner.rs +++ b/lightning-tensor/src/tui/components/spinner.rs @@ -1,34 +1,126 @@ //! # Spinner Component //! -//! Loading spinner animation. +//! Loading spinner animations with cyberpunk styling. +use super::AnimationState; +use crate::tui::theme::colors; use ratatui::{ layout::Rect, - style::{Color, Style}, + style::Style, + text::{Line, Span}, widgets::Paragraph, Frame, }; -use super::AnimationState; -/// Spinner component +/// 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 } + 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_char = self.animation.spinner_char(); - let text = format!("{} {}", spinner_char, self.message); - - let widget = Paragraph::new(text) - .style(Style::default().fg(Color::Yellow)); - + 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 index 47c12fe..b3afff8 100644 --- a/lightning-tensor/src/tui/components/table.rs +++ b/lightning-tensor/src/tui/components/table.rs @@ -1,22 +1,24 @@ //! # Data Table Component //! -//! Reusable data table with headers and scrolling. +//! Reusable data table with cyberpunk styling, headers, and scrolling. +use crate::tui::theme::{colors, symbols}; use ratatui::{ - layout::Constraint, - style::{Color, Modifier, Style}, + layout::{Constraint, Rect}, + style::{Modifier, Style}, + text::Span, widgets::{Block, Borders, Cell, Row, Table, TableState}, Frame, - layout::Rect, }; -/// Data table component +/// 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> { @@ -33,38 +35,74 @@ impl<'a> DataTable<'a> { rows, widths, state, + row_styles: None, } } - + + /// Set custom styles for each row + pub fn with_row_styles(mut self, styles: Vec