diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6107f4e..2cdaf37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,17 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-target-cache + workspaces: | + . -> target + python/agcli-py -> target - name: Check formatting run: cargo fmt --all -- --check - name: Clippy @@ -35,13 +42,58 @@ jobs: --test cli_test --test helpers_test -- -D warnings + python-clippy: + name: Python clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-target-cache + workspaces: | + . -> target + python/agcli-py -> target + - name: Clippy agcli-py + run: cargo clippy -p agcli-py --no-deps --features pyo3/extension-module -- -D warnings + + sdk-only: + name: SDK-only check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-target-cache + workspaces: | + . -> target + python/agcli-py -> target + - name: Clippy sdk-only feature + run: cargo check --no-default-features --features sdk-only + test: name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-target-cache + workspaces: | + . -> target + python/agcli-py -> target - uses: taiki-e/install-action@nextest - name: Run tests run: >- @@ -60,10 +112,17 @@ jobs: id-token: write steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: dtolnay/rust-toolchain@stable with: components: llvm-tools-preview - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-target-cache + workspaces: | + . -> target + python/agcli-py -> target - uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@nextest - name: Generate coverage @@ -88,6 +147,11 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-target-cache + workspaces: | + . -> target + python/agcli-py -> target - name: Build release binary run: cargo build --release - name: Upload binary @@ -103,6 +167,11 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-target-cache + workspaces: | + . -> target + python/agcli-py -> target - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -119,6 +188,39 @@ jobs: METADATA_CHAIN_ENDPOINT: wss://entrypoint-finney.opentensor.ai:443 - name: Run Python tests working-directory: python - run: pytest -m "not network" + run: pytest -m "not network and not localnet" + env: + METADATA_CHAIN_ENDPOINT: wss://entrypoint-finney.opentensor.ai:443 + + wheels: + name: Wheels (${{ matrix.os }} / py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-target-cache + workspaces: | + . -> target + python/agcli-py -> target + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install maturin + run: pip install "maturin>=1.7,<2.0" + - name: Build wheels + working-directory: python + run: maturin build --release --strip --manifest-path agcli-py/Cargo.toml --out dist env: METADATA_CHAIN_ENDPOINT: wss://entrypoint-finney.opentensor.ai:443 + - name: Upload wheel artifacts + uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.os }}-py${{ matrix.python-version }} + path: python/dist/*.whl diff --git a/Cargo.toml b/Cargo.toml index cfa69c1..6cb381a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,11 @@ sdk-only = [] # Enables Docker / live-chain integration test targets (not run by default `cargo test`). e2e = [] +[[bin]] +name = "agcli" +path = "src/main.rs" +required-features = ["cli"] + [[test]] name = "e2e_test" path = "tests/e2e_test.rs" diff --git a/docs/tutorials/python-sdk.md b/docs/tutorials/python-sdk.md new file mode 100644 index 0000000..796424d --- /dev/null +++ b/docs/tutorials/python-sdk.md @@ -0,0 +1,217 @@ +# Python SDK tutorial + +The Python SDK exposes async and sync clients, wallet lifecycle helpers, typed errors, event subscriptions, localnet helpers, and admin/extrinsic APIs through `agcli`. + +## Install + +```bash +python -m pip install agcli +``` + +## Connect + reads (`AsyncClient.connect_network` and `SyncClient`) + +Source: `examples/python/connect_and_balance.py` + +```python +import asyncio +import os + +from agcli import AsyncClient, Config, Network, NetworkError, SyncClient, ValidationError + + +DEFAULT_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + + +async def main() -> None: + config = Config.load() + address = os.environ.get("AGCLI_ADDRESS", DEFAULT_ADDRESS) + network = Network.parse(os.environ.get("AGCLI_NETWORK", config.network or "finney")) + endpoint = os.environ.get("AGCLI_WS", config.endpoint or "") + + try: + client = await ( + AsyncClient.connect(endpoint) + if endpoint + else AsyncClient.connect_network(network) + ) + balance = await client.get_balance(address) + metagraph = await client.get_metagraph(1) + portfolio = await client.fetch_portfolio(address) + print(f"async endpoint={client.endpoint}") + print(f"async balance[{address}]={balance.tao:.6f} TAO") + print(f"metagraph netuid={metagraph['netuid']} n={metagraph['n']}") + print(f"portfolio keys={sorted(portfolio.keys())}") + except (NetworkError, ValidationError) as err: + print(f"{err.__class__.__name__}: {err}") + return + + sync_client = SyncClient.connect_network(network) + sync_balance = sync_client.get_balance(address) + print(f"sync endpoint={sync_client.endpoint}") + print(f"sync balance[{address}]={sync_balance.tao:.6f} TAO") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Wallet lifecycle (`Wallet.create`, `Wallet.create_from_uri`, `session(...)`) + +Source: `examples/python/wallet_lifecycle.py` + +```python +from __future__ import annotations + +import tempfile +from pathlib import Path + +from agcli import Wallet + + +def main() -> None: + password = "password123" + with tempfile.TemporaryDirectory(prefix="agcli-wallet-") as wallet_dir: + created = Wallet.create(str(Path(wallet_dir)), "example", password) + print(f"created wallet={created.wallet.name}") + print(f"coldkey mnemonic words={len(created.coldkey_mnemonic.split())}") + + uri_wallet = Wallet.create_from_uri(str(Path(wallet_dir)), "//Alice", password) + with uri_wallet.session(password=password) as session_wallet: + print(f"session coldkey unlocked={session_wallet.is_coldkey_unlocked}") + print(f"session hotkey loaded={session_wallet.is_hotkey_loaded}") + + +if __name__ == "__main__": + main() +``` + +## Extrinsics (`transfer`, `add_stake`, `set_weights`, `dry_run`, `mev`) + +Source: `examples/python/extrinsics_mvp.py` + +```python +import asyncio +import os +import tempfile +from pathlib import Path + +from agcli import AsyncClient, Wallet + + +DEFAULT_DEST = "5FHneW46xGXgs5mUiveU4sbTyGBzmst7hQfYQfR6N2P2Vf5Q" + + +async def main() -> None: + endpoint = os.environ.get("AGCLI_WS", "ws://127.0.0.1:9944") + password = os.environ.get("AGCLI_PASSWORD", "password123") + dest_ss58 = os.environ.get("AGCLI_DEST", DEFAULT_DEST) + + with tempfile.TemporaryDirectory(prefix="agcli-py-") as wallet_dir: + wallet = Wallet.create_from_uri(str(Path(wallet_dir)), "//Alice", password) + wallet.load_hotkey("default") + wallet.unlock_coldkey(password) + + client = await AsyncClient.connect(endpoint) + transfer_hash = await client.transfer( + wallet, + dest_ss58, + 10_000_000, + dry_run=True, + mev=False, + ) + stake_hash = await client.add_stake( + wallet, + 1, + 10_000_000, + dry_run=True, + mev=False, + ) + weights_hash = await client.set_weights( + wallet, + 1, + [0, 1], + [32_767, 32_768], + 0, + dry_run=True, + mev=False, + ) + print(f"transfer={transfer_hash}") + print(f"add_stake={stake_hash}") + print(f"set_weights={weights_hash}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +For a minimal transfer-only dry-run script, see `examples/python/transfer_dry_run.py`. + +## Events (`subscribe_events`) + +Source: `examples/python/subscribe_events.py` + +```python +import asyncio +import os + +from agcli import AsyncClient +from agcli.events import EventFilter + + +async def main() -> None: + endpoint = os.environ.get("AGCLI_WS", "ws://127.0.0.1:9944") + limit = int(os.environ.get("AGCLI_EVENT_LIMIT", "5")) + category = os.environ.get("AGCLI_EVENT_CATEGORY", "all") + + client = await AsyncClient.connect(endpoint) + stream = client.subscribe_events(EventFilter(category=category)) + try: + for _ in range(limit): + event = await stream.__anext__() + print( + f"#{event['block_number']} {event['pallet']}.{event['variant']} " + f"extrinsic_index={event['extrinsic_index']}" + ) + finally: + await stream.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Localnet (`start`, `dev_accounts`) + +Source: `examples/python/localnet_smoke.py` + +```python +import asyncio + +from agcli import AsyncClient, localnet + + +async def main() -> None: + info = await localnet.start() + try: + print(f"localnet endpoint={info.endpoint}") + accounts = localnet.dev_accounts() + for account in accounts: + print(f"{account.name}: {account.ss58}") + + client = await AsyncClient.connect(info.endpoint) + alice = accounts[0] + balance = await client.get_balance(alice.ss58) + print(f"alice balance={balance.tao:.6f} TAO") + finally: + localnet.stop(info.container_name) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Typed errors and config + +- Typed exceptions map to the Rust SDK exit code classes (`NetworkError`, `ValidationError`, `AuthError`, `ChainError`, `TimeoutError`, `IOError`). +- `Config.load()` / `Config.save()` let you persist endpoint and wallet defaults in the same shape as the CLI config. +- `examples/python/connect_and_balance.py` shows a typed `try/except` around connection/read calls and config-driven endpoint/network selection. diff --git a/examples/python/connect_and_balance.py b/examples/python/connect_and_balance.py new file mode 100644 index 0000000..a0b894c --- /dev/null +++ b/examples/python/connect_and_balance.py @@ -0,0 +1,40 @@ +import asyncio +import os + +from agcli import AsyncClient, Config, Network, NetworkError, SyncClient, ValidationError + + +DEFAULT_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + + +async def main() -> None: + config = Config.load() + address = os.environ.get("AGCLI_ADDRESS", DEFAULT_ADDRESS) + network = Network.parse(os.environ.get("AGCLI_NETWORK", config.network or "finney")) + endpoint = os.environ.get("AGCLI_WS", config.endpoint or "") + + try: + client = await ( + AsyncClient.connect(endpoint) + if endpoint + else AsyncClient.connect_network(network) + ) + balance = await client.get_balance(address) + metagraph = await client.get_metagraph(1) + portfolio = await client.fetch_portfolio(address) + print(f"async endpoint={client.endpoint}") + print(f"async balance[{address}]={balance.tao:.6f} TAO") + print(f"metagraph netuid={metagraph['netuid']} n={metagraph['n']}") + print(f"portfolio keys={sorted(portfolio.keys())}") + except (NetworkError, ValidationError) as err: + print(f"{err.__class__.__name__}: {err}") + return + + sync_client = SyncClient.connect_network(network) + sync_balance = sync_client.get_balance(address) + print(f"sync endpoint={sync_client.endpoint}") + print(f"sync balance[{address}]={sync_balance.tao:.6f} TAO") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/python/extrinsics_mvp.py b/examples/python/extrinsics_mvp.py new file mode 100644 index 0000000..aafdb54 --- /dev/null +++ b/examples/python/extrinsics_mvp.py @@ -0,0 +1,52 @@ +import asyncio +import os +import tempfile +from pathlib import Path + +from agcli import AsyncClient, Wallet + + +DEFAULT_DEST = "5FHneW46xGXgs5mUiveU4sbTyGBzmst7hQfYQfR6N2P2Vf5Q" + + +async def main() -> None: + endpoint = os.environ.get("AGCLI_WS", "ws://127.0.0.1:9944") + password = os.environ.get("AGCLI_PASSWORD", "password123") + dest_ss58 = os.environ.get("AGCLI_DEST", DEFAULT_DEST) + + with tempfile.TemporaryDirectory(prefix="agcli-py-") as wallet_dir: + wallet = Wallet.create_from_uri(str(Path(wallet_dir)), "//Alice", password) + wallet.load_hotkey("default") + wallet.unlock_coldkey(password) + + client = await AsyncClient.connect(endpoint) + transfer_hash = await client.transfer( + wallet, + dest_ss58, + 10_000_000, + dry_run=True, + mev=False, + ) + stake_hash = await client.add_stake( + wallet, + 1, + 10_000_000, + dry_run=True, + mev=False, + ) + weights_hash = await client.set_weights( + wallet, + 1, + [0, 1], + [32_767, 32_768], + 0, + dry_run=True, + mev=False, + ) + print(f"transfer={transfer_hash}") + print(f"add_stake={stake_hash}") + print(f"set_weights={weights_hash}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/python/localnet_smoke.py b/examples/python/localnet_smoke.py new file mode 100644 index 0000000..02a9e09 --- /dev/null +++ b/examples/python/localnet_smoke.py @@ -0,0 +1,23 @@ +import asyncio + +from agcli import AsyncClient, localnet + + +async def main() -> None: + info = await localnet.start() + try: + print(f"localnet endpoint={info.endpoint}") + accounts = localnet.dev_accounts() + for account in accounts: + print(f"{account.name}: {account.ss58}") + + client = await AsyncClient.connect(info.endpoint) + alice = accounts[0] + balance = await client.get_balance(alice.ss58) + print(f"alice balance={balance.tao:.6f} TAO") + finally: + localnet.stop(info.container_name) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/python/subscribe_events.py b/examples/python/subscribe_events.py new file mode 100644 index 0000000..49c2dd8 --- /dev/null +++ b/examples/python/subscribe_events.py @@ -0,0 +1,27 @@ +import asyncio +import os + +from agcli import AsyncClient +from agcli.events import EventFilter + + +async def main() -> None: + endpoint = os.environ.get("AGCLI_WS", "ws://127.0.0.1:9944") + limit = int(os.environ.get("AGCLI_EVENT_LIMIT", "5")) + category = os.environ.get("AGCLI_EVENT_CATEGORY", "all") + + client = await AsyncClient.connect(endpoint) + stream = client.subscribe_events(EventFilter(category=category)) + try: + for _ in range(limit): + event = await stream.__anext__() + print( + f"#{event['block_number']} {event['pallet']}.{event['variant']} " + f"extrinsic_index={event['extrinsic_index']}" + ) + finally: + await stream.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/python/transfer_dry_run.py b/examples/python/transfer_dry_run.py new file mode 100644 index 0000000..d1394cc --- /dev/null +++ b/examples/python/transfer_dry_run.py @@ -0,0 +1,35 @@ +import asyncio +import os +import tempfile +from pathlib import Path + +from agcli import AsyncClient, Wallet + + +DEFAULT_DEST = "5FHneW46xGXgs5mUiveU4sbTyGBzmst7hQfYQfR6N2P2Vf5Q" + + +async def main() -> None: + endpoint = os.environ.get("AGCLI_WS", "ws://127.0.0.1:9944") + dest_ss58 = os.environ.get("AGCLI_DEST", DEFAULT_DEST) + uri = os.environ.get("AGCLI_URI", "//Alice") + password = os.environ.get("AGCLI_PASSWORD", "password123") + + with tempfile.TemporaryDirectory(prefix="agcli-py-") as wallet_dir: + wallet = Wallet.create_from_uri(str(Path(wallet_dir)), uri, password) + wallet.load_hotkey("default") + wallet.unlock_coldkey(password) + + client = await AsyncClient.connect(endpoint) + extrinsic_hash = await client.transfer( + wallet, + dest_ss58, + 10_000_000, + dry_run=True, + mev=False, + ) + print(f"dry-run transfer hash={extrinsic_hash}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/python/wallet_lifecycle.py b/examples/python/wallet_lifecycle.py new file mode 100644 index 0000000..e9c9af9 --- /dev/null +++ b/examples/python/wallet_lifecycle.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +from agcli import Wallet + + +def main() -> None: + password = "password123" + with tempfile.TemporaryDirectory(prefix="agcli-wallet-") as wallet_dir: + created = Wallet.create(str(Path(wallet_dir)), "example", password) + print(f"created wallet={created.wallet.name}") + print(f"coldkey mnemonic words={len(created.coldkey_mnemonic.split())}") + + uri_wallet = Wallet.create_from_uri(str(Path(wallet_dir)), "//Alice", password) + with uri_wallet.session(password=password) as session_wallet: + print(f"session coldkey unlocked={session_wallet.is_coldkey_unlocked}") + print(f"session hotkey loaded={session_wallet.is_hotkey_loaded}") + + +if __name__ == "__main__": + main() diff --git a/python/MANIFEST.in b/python/MANIFEST.in new file mode 100644 index 0000000..a3f72e1 --- /dev/null +++ b/python/MANIFEST.in @@ -0,0 +1,2 @@ +include agcli/py.typed +include agcli/_native.pyi diff --git a/python/SECURITY.md b/python/SECURITY.md new file mode 100644 index 0000000..892421f --- /dev/null +++ b/python/SECURITY.md @@ -0,0 +1,21 @@ +# agcli Python SDK security notes + +## Security model + +- **Pickle disabled at the binding boundary.** All PyO3 classes exported from `agcli._agcli` implement `__reduce__` and `__getstate__` to raise `TypeError`, preventing accidental serialization of wallets, signers, clients, and runtime state. +- **Coldkey unlock is password gated.** Spending keys remain encrypted at rest and are only loaded into memory after explicit `unlock_coldkey(password)` / `unlock_coldkey_with_password(password)` calls. +- **Signing uses sr25519.** `Wallet.sign_message(...)` signs with the same sr25519 key material used by the Rust SDK wallet implementation. +- **No implicit key export over FFI.** Secret key material is not returned across the Python binding boundary; only explicit public getters (for example `coldkey_ss58`, `hotkey_ss58`) are exposed. +- **Mnemonic handling is explicit and one-time.** Mnemonics are only returned at wallet creation time (`Wallet.create(...) -> (wallet, coldkey_mnemonic, hotkey_mnemonic)`) so callers can persist backups intentionally. + +## Dependency audit + +`cargo audit -q` was run from the repository root on this branch. + +- Result: `17 vulnerabilities found`, `12 allowed warnings found` +- Notable advisories affecting the current dependency graph: + - `RUSTSEC-2025-0020` (`pyo3`): upgrade recommended to `>=0.24.1` + - Multiple `wasmtime` advisories in transitive dependencies pulled via the substrate/polkadot stack + - `RUSTSEC-2026-0002` (`lru`) transitive advisory + +These findings are currently upstream/transitive to the SDK dependency tree and should be tracked for coordinated dependency upgrades. diff --git a/python/agcli-py/Cargo.toml b/python/agcli-py/Cargo.toml index 29cdf92..c47ef1b 100644 --- a/python/agcli-py/Cargo.toml +++ b/python/agcli-py/Cargo.toml @@ -11,9 +11,10 @@ crate-type = ["cdylib", "rlib"] [dependencies] agcli = { path = "../..", default-features = false, features = ["sdk-only"] } anyhow = "1" -pyo3 = { version = "0.23", features = [] } +pyo3 = { version = "0.23", features = ["multiple-pymethods"] } pyo3-async-runtimes = { version = "0.23", features = ["tokio-runtime"] } pythonize = "0.23" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread"] } +hex = "0.4" diff --git a/python/agcli-py/src/admin.rs b/python/agcli-py/src/admin.rs new file mode 100644 index 0000000..7b57432 --- /dev/null +++ b/python/agcli-py/src/admin.rs @@ -0,0 +1,286 @@ +use agcli::admin; +use agcli::chain::subxt::dynamic::Value; +use agcli::chain::subxt::ext::sp_core::sr25519; +use pyo3::prelude::*; +use pyo3::types::PyAny; +use pyo3_async_runtimes::tokio::future_into_py; + +use crate::client::PyClient; +use crate::errors::{map_error, ValidationError}; +use crate::types::netuid_from_py; +use crate::wallet::PyWallet; + +fn validation_error(message: impl Into) -> PyErr { + let message = message.into(); + Python::with_gil(|py| { + let py_err = ValidationError::new_err(message); + let value = py_err.value(py); + let _ = value.setattr("code", agcli::error::exit_code::VALIDATION); + py_err + }) +} + +fn wallet_coldkey_pair(wallet: &agcli::Wallet) -> PyResult { + wallet.coldkey().map(|p| p.clone()).map_err(map_error) +} + +fn parse_netuid_u16(value: &Bound<'_, PyAny>, field: &str) -> PyResult { + let netuid = netuid_from_py(value) + .map_err(|_| validation_error(format!("{field} must be an integer or NetUid instance")))?; + Ok(netuid.as_u16()) +} + +macro_rules! admin_call_subnet { + ($fn_name:ident, $rust_fn:ident, $value_ty:ty) => { + #[pyfunction] + #[allow(clippy::too_many_arguments)] + fn $fn_name<'py>( + py: Python<'py>, + client: PyRef<'_, PyClient>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + value: $value_ty, + ) -> PyResult> { + let netuid = parse_netuid_u16(&netuid, "netuid")?; + let client = client.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + admin::$rust_fn(&client, &pair, netuid, value) + .await + .map_err(map_error) + }) + } + }; +} + +macro_rules! admin_call_global { + ($fn_name:ident, $rust_fn:ident, $value_ty:ty) => { + #[pyfunction] + fn $fn_name<'py>( + py: Python<'py>, + client: PyRef<'_, PyClient>, + wallet: PyRef<'_, PyWallet>, + value: $value_ty, + ) -> PyResult> { + let client = client.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + admin::$rust_fn(&client, &pair, value) + .await + .map_err(map_error) + }) + } + }; +} + +admin_call_subnet!(set_tempo, set_tempo, u16); +admin_call_subnet!(set_max_allowed_validators, set_max_allowed_validators, u16); +admin_call_subnet!(set_max_allowed_uids, set_max_allowed_uids, u16); +admin_call_subnet!(set_immunity_period, set_immunity_period, u16); +admin_call_subnet!(set_min_allowed_weights, set_min_allowed_weights, u16); +admin_call_subnet!(set_max_weight_limit, set_max_weight_limit, u16); +admin_call_subnet!(set_weights_set_rate_limit, set_weights_set_rate_limit, u64); +admin_call_subnet!( + set_commit_reveal_weights_enabled, + set_commit_reveal_weights_enabled, + bool +); +admin_call_subnet!(set_difficulty, set_difficulty, u64); +admin_call_subnet!(set_bonds_moving_average, set_bonds_moving_average, u64); +admin_call_subnet!( + set_target_registrations_per_interval, + set_target_registrations_per_interval, + u16 +); +admin_call_subnet!(set_activity_cutoff, set_activity_cutoff, u16); +admin_call_subnet!(set_serving_rate_limit, set_serving_rate_limit, u64); +admin_call_subnet!(set_min_difficulty, set_min_difficulty, u64); +admin_call_subnet!(set_max_difficulty, set_max_difficulty, u64); +admin_call_subnet!(set_adjustment_interval, set_adjustment_interval, u16); +admin_call_subnet!(set_adjustment_alpha, set_adjustment_alpha, u64); +admin_call_subnet!(set_kappa, set_kappa, u16); +admin_call_subnet!(set_rho, set_rho, u16); +admin_call_subnet!(set_min_burn, set_min_burn, u64); +admin_call_subnet!(set_max_burn, set_max_burn, u64); +admin_call_subnet!(set_liquid_alpha_enabled, set_liquid_alpha_enabled, bool); +admin_call_subnet!(set_yuma3_enabled, set_yuma3_enabled, bool); +admin_call_subnet!(set_bonds_penalty, set_bonds_penalty, u16); +admin_call_subnet!(set_mechanism_count, set_mechanism_count, u16); +admin_call_subnet!( + set_network_registration_allowed, + set_network_registration_allowed, + bool +); +admin_call_subnet!( + set_network_pow_registration_allowed, + set_network_pow_registration_allowed, + bool +); + +admin_call_global!(set_default_take, set_default_take, u16); +admin_call_global!(set_tx_rate_limit, set_tx_rate_limit, u64); +admin_call_global!(set_subnet_moving_alpha, set_subnet_moving_alpha, u64); +admin_call_global!(set_stake_threshold, set_stake_threshold, u64); +admin_call_global!( + set_nominator_min_required_stake, + set_nominator_min_required_stake, + u64 +); + +#[pyfunction] +fn set_alpha_values<'py>( + py: Python<'py>, + client: PyRef<'_, PyClient>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + alpha_low: u16, + alpha_high: u16, +) -> PyResult> { + let netuid = parse_netuid_u16(&netuid, "netuid")?; + let client = client.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + admin::set_alpha_values(&client, &pair, netuid, alpha_low, alpha_high) + .await + .map_err(map_error) + }) +} + +#[pyfunction] +fn set_mechanism_emission_split<'py>( + py: Python<'py>, + client: PyRef<'_, PyClient>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + split: Vec, +) -> PyResult> { + let netuid = parse_netuid_u16(&netuid, "netuid")?; + let client = client.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + admin::set_mechanism_emission_split(&client, &pair, netuid, split) + .await + .map_err(map_error) + }) +} + +fn parse_admin_args(values: Bound<'_, PyAny>) -> PyResult> { + let list: Vec> = values.extract().map_err(|_| { + validation_error("args must be a sequence of integers, booleans, or strings") + })?; + let mut out = Vec::with_capacity(list.len()); + for (idx, item) in list.into_iter().enumerate() { + if let Ok(b) = item.extract::() { + out.push(Value::bool(b)); + continue; + } + if let Ok(n) = item.extract::() { + if n < 0 { + return Err(validation_error(format!( + "args[{idx}]={n} is negative; only non-negative values are supported" + ))); + } + out.push(Value::u128(n as u128)); + continue; + } + if let Ok(n) = item.extract::() { + out.push(Value::u128(n)); + continue; + } + if let Ok(s) = item.extract::() { + out.push(Value::string(s)); + continue; + } + return Err(validation_error(format!( + "args[{idx}] has unsupported type; pass int, bool, or str" + ))); + } + Ok(out) +} + +#[pyfunction] +fn raw_admin_call<'py>( + py: Python<'py>, + client: PyRef<'_, PyClient>, + wallet: PyRef<'_, PyWallet>, + call_name: String, + args: Bound<'_, PyAny>, +) -> PyResult> { + let parsed = parse_admin_args(args)?; + let client = client.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + admin::raw_admin_call(&client, &pair, &call_name, parsed) + .await + .map_err(map_error) + }) +} + +#[pyfunction] +fn known_params() -> Vec<(String, String, Vec)> { + admin::known_params() + .into_iter() + .map(|(name, desc, args)| { + ( + name.to_string(), + desc.to_string(), + args.iter().map(|s| s.to_string()).collect(), + ) + }) + .collect() +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(set_tempo, m)?)?; + m.add_function(wrap_pyfunction!(set_max_allowed_validators, m)?)?; + m.add_function(wrap_pyfunction!(set_max_allowed_uids, m)?)?; + m.add_function(wrap_pyfunction!(set_immunity_period, m)?)?; + m.add_function(wrap_pyfunction!(set_min_allowed_weights, m)?)?; + m.add_function(wrap_pyfunction!(set_max_weight_limit, m)?)?; + m.add_function(wrap_pyfunction!(set_weights_set_rate_limit, m)?)?; + m.add_function(wrap_pyfunction!(set_commit_reveal_weights_enabled, m)?)?; + m.add_function(wrap_pyfunction!(set_difficulty, m)?)?; + m.add_function(wrap_pyfunction!(set_bonds_moving_average, m)?)?; + m.add_function(wrap_pyfunction!(set_target_registrations_per_interval, m)?)?; + m.add_function(wrap_pyfunction!(set_activity_cutoff, m)?)?; + m.add_function(wrap_pyfunction!(set_serving_rate_limit, m)?)?; + m.add_function(wrap_pyfunction!(set_default_take, m)?)?; + m.add_function(wrap_pyfunction!(set_tx_rate_limit, m)?)?; + m.add_function(wrap_pyfunction!(set_min_difficulty, m)?)?; + m.add_function(wrap_pyfunction!(set_max_difficulty, m)?)?; + m.add_function(wrap_pyfunction!(set_adjustment_interval, m)?)?; + m.add_function(wrap_pyfunction!(set_adjustment_alpha, m)?)?; + m.add_function(wrap_pyfunction!(set_kappa, m)?)?; + m.add_function(wrap_pyfunction!(set_rho, m)?)?; + m.add_function(wrap_pyfunction!(set_min_burn, m)?)?; + m.add_function(wrap_pyfunction!(set_max_burn, m)?)?; + m.add_function(wrap_pyfunction!(set_liquid_alpha_enabled, m)?)?; + m.add_function(wrap_pyfunction!(set_alpha_values, m)?)?; + m.add_function(wrap_pyfunction!(set_yuma3_enabled, m)?)?; + m.add_function(wrap_pyfunction!(set_bonds_penalty, m)?)?; + m.add_function(wrap_pyfunction!(set_subnet_moving_alpha, m)?)?; + m.add_function(wrap_pyfunction!(set_mechanism_count, m)?)?; + m.add_function(wrap_pyfunction!(set_mechanism_emission_split, m)?)?; + m.add_function(wrap_pyfunction!(set_stake_threshold, m)?)?; + m.add_function(wrap_pyfunction!(set_nominator_min_required_stake, m)?)?; + m.add_function(wrap_pyfunction!(set_network_registration_allowed, m)?)?; + m.add_function(wrap_pyfunction!(set_network_pow_registration_allowed, m)?)?; + m.add_function(wrap_pyfunction!(raw_admin_call, m)?)?; + m.add_function(wrap_pyfunction!(known_params, m)?)?; + Ok(()) +} diff --git a/python/agcli-py/src/chain_data.rs b/python/agcli-py/src/chain_data.rs new file mode 100644 index 0000000..b49e88a --- /dev/null +++ b/python/agcli-py/src/chain_data.rs @@ -0,0 +1,164 @@ +use pyo3::exceptions::PyAttributeError; +use pyo3::prelude::*; +use pyo3::types::PyAny; +use serde::Serialize; + +use crate::errors::map_error; +use crate::types::to_pyobject_unbound; + +fn json_field_to_pyobject(value: &T, field: &str) -> PyResult { + let json = serde_json::to_value(value).map_err(|e| map_error(anyhow::anyhow!("{e}")))?; + if let serde_json::Value::Object(map) = json { + if let Some(field_value) = map.get(field) { + return Python::with_gil(|py| { + pythonize::pythonize(py, field_value) + .map(|obj| obj.unbind()) + .map_err(|e| map_error(anyhow::anyhow!("{e}"))) + }); + } + } + Err(PyAttributeError::new_err(format!( + "attribute '{field}' not found" + ))) +} + +macro_rules! serde_pyclass { + ($py_name:ident, $inner:path, $class_name:literal) => { + #[pyclass(name = $class_name, module = "agcli._agcli")] + #[derive(Clone)] + pub struct $py_name { + inner: $inner, + } + + #[pymethods] + impl $py_name { + #[new] + fn new(value: &Bound<'_, PyAny>) -> PyResult { + let inner: $inner = + pythonize::depythonize(value).map_err(|e| map_error(anyhow::anyhow!("{e}")))?; + Ok(Self { inner }) + } + + fn to_dict(&self) -> PyResult { + to_pyobject_unbound(&self.inner) + } + + fn __getattr__(&self, field: &str) -> PyResult { + json_field_to_pyobject(&self.inner, field) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked($class_name)) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked($class_name)) + } + } + }; +} + +serde_pyclass!( + PySubnetInfo, + agcli::types::chain_data::SubnetInfo, + "SubnetInfo" +); +serde_pyclass!( + PyNeuronInfo, + agcli::types::chain_data::NeuronInfo, + "NeuronInfo" +); +serde_pyclass!( + PyNeuronInfoLite, + agcli::types::chain_data::NeuronInfoLite, + "NeuronInfoLite" +); +serde_pyclass!( + PyMetagraph, + agcli::types::chain_data::Metagraph, + "Metagraph" +); +serde_pyclass!( + PyStakeInfo, + agcli::types::chain_data::StakeInfo, + "StakeInfo" +); +serde_pyclass!( + PyDelegateInfo, + agcli::types::chain_data::DelegateInfo, + "DelegateInfo" +); +serde_pyclass!( + PyDynamicInfo, + agcli::types::chain_data::DynamicInfo, + "DynamicInfo" +); +serde_pyclass!( + PySubnetHyperparameters, + agcli::types::chain_data::SubnetHyperparameters, + "SubnetHyperparameters" +); +serde_pyclass!( + PyChainIdentity, + agcli::types::chain_data::ChainIdentity, + "ChainIdentity" +); +serde_pyclass!( + PySubnetIdentity, + agcli::types::chain_data::SubnetIdentity, + "SubnetIdentity" +); + +impl PySubnetIdentity { + pub(crate) fn inner_clone(&self) -> agcli::types::chain_data::SubnetIdentity { + self.inner.clone() + } +} + +#[pyclass(name = "AlphaBalance", module = "agcli._agcli")] +#[derive(Clone, Copy)] +pub struct PyAlphaBalance { + inner: agcli::types::balance::AlphaBalance, +} + +#[pymethods] +impl PyAlphaBalance { + #[new] + fn new(value: &Bound<'_, PyAny>) -> PyResult { + if let Ok(raw) = value.extract::() { + return Ok(Self { + inner: agcli::types::balance::AlphaBalance::from_raw(raw), + }); + } + let inner: agcli::types::balance::AlphaBalance = + pythonize::depythonize(value).map_err(|e| map_error(anyhow::anyhow!("{e}")))?; + Ok(Self { inner }) + } + + #[getter] + fn raw(&self) -> u64 { + self.inner.raw() + } + + #[getter] + fn rao(&self) -> u64 { + self.inner.raw() + } + + #[getter] + fn tao(&self) -> f64 { + self.inner.raw() as f64 / 1_000_000_000_f64 + } + + fn to_dict(&self) -> PyResult { + to_pyobject_unbound(&self.inner) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("AlphaBalance")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("AlphaBalance")) + } +} diff --git a/python/agcli-py/src/client.rs b/python/agcli-py/src/client.rs index e5e8e3e..ed73e8b 100644 --- a/python/agcli-py/src/client.rs +++ b/python/agcli-py/src/client.rs @@ -4,25 +4,69 @@ use agcli::{Client, Config}; use pyo3::prelude::*; use pyo3::types::PyAny; use pyo3_async_runtimes::tokio::future_into_py; +use tokio::sync::Mutex; use crate::errors::map_error; -use crate::types::{netuid_from_py, parse_network, to_pyobject_unbound, PyBalance, PyNetwork}; +use crate::runtime::runtime; +use crate::types::{ + hash_to_hex, netuid_from_py, parse_hash, parse_network, to_pyobject_unbound, u64_to_u32, + PyBalance, PyNetwork, +}; + +pub(crate) type SharedClient = Arc>; #[pyclass(name = "Client", module = "agcli._agcli")] pub struct PyClient { - inner: Arc, + inner: SharedClient, +} + +#[pyclass(name = "ClientSync", module = "agcli._agcli")] +pub struct PyClientSync { + inner: SharedClient, } fn wrap_client(client: Client) -> PyClient { PyClient { - inner: Arc::new(client), + inner: Arc::new(Mutex::new(client)), + } +} + +impl PyClient { + pub(crate) fn shared_client(&self) -> SharedClient { + Arc::clone(&self.inner) + } +} + +fn wrap_client_sync(client: Client) -> PyClientSync { + PyClientSync { + inner: Arc::new(Mutex::new(client)), } } +async fn block_hash_from_number( + client: &mut Client, + block_number: u64, +) -> anyhow::Result { + let block_number = u32::try_from(block_number) + .map_err(|_| anyhow::anyhow!("block number {block_number} exceeds u32 range"))?; + client.get_block_hash(block_number).await +} + +fn serialize_weight_commits( + commits: Option>, +) -> Option> { + commits.map(|rows| { + rows.into_iter() + .map(|(hash, commit_block, first_reveal, last_reveal)| { + (hash_to_hex(hash), commit_block, first_reveal, last_reveal) + }) + .collect() + }) +} + #[pymethods] impl PyClient { #[staticmethod] - #[pyo3(signature = (url))] fn connect<'py>(py: Python<'py>, url: String) -> PyResult> { future_into_py(py, async move { let client = Client::connect(&url).await.map_err(map_error)?; @@ -31,13 +75,19 @@ impl PyClient { } #[staticmethod] - #[pyo3(signature = (urls))] fn connect_with_retry<'py>(py: Python<'py>, urls: Vec) -> PyResult> { future_into_py(py, async move { let refs: Vec<&str> = urls.iter().map(String::as_str).collect(); - let client = Client::connect_with_retry(&refs) - .await - .map_err(map_error)?; + let client = Client::connect_with_retry(&refs).await.map_err(map_error)?; + Ok(wrap_client(client)) + }) + } + + #[staticmethod] + fn best_connection<'py>(py: Python<'py>, urls: Vec) -> PyResult> { + future_into_py(py, async move { + let refs: Vec<&str> = urls.iter().map(String::as_str).collect(); + let client = Client::best_connection(&refs).await.map_err(map_error)?; Ok(wrap_client(client)) }) } @@ -52,9 +102,7 @@ impl PyClient { .map(|n| n.inner.clone()) .unwrap_or(agcli::types::Network::Finney); future_into_py(py, async move { - let client = Client::connect_network(&network) - .await - .map_err(map_error)?; + let client = Client::connect_network(&network).await.map_err(map_error)?; Ok(wrap_client(client)) }) } @@ -69,21 +117,441 @@ impl PyClient { } #[getter] - fn endpoint(&self) -> String { - self.inner.url().to_string() + fn endpoint(&self) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + Ok(client.url().to_string()) + }) + .map_err(map_error) + } + + #[getter] + fn network(&self) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + Ok(PyNetwork { + inner: client.network().clone(), + }) + }) + .map_err(map_error) + } + + fn set_dry_run(&self, enabled: bool) -> PyResult<()> { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let mut client = client.lock().await; + client.set_dry_run(enabled); + Ok(()) + }) + .map_err(map_error) + } + + fn is_dry_run(&self) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + Ok(client.is_dry_run()) + }) + .map_err(map_error) + } + + fn set_finalization_timeout(&self, timeout: u64) -> PyResult<()> { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let mut client = client.lock().await; + client.set_finalization_timeout(timeout); + Ok(()) + }) + .map_err(map_error) + } + + fn finalization_timeout(&self) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + Ok(client.finalization_timeout()) + }) + .map_err(map_error) + } + + fn set_mortality_blocks(&self, blocks: u64) -> PyResult<()> { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let mut client = client.lock().await; + client.set_mortality_blocks(blocks); + Ok(()) + }) + .map_err(map_error) + } + + fn mortality_blocks(&self) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + Ok(client.mortality_blocks()) + }) + .map_err(map_error) + } + + fn reconnect<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let mut client = client.lock().await; + client.reconnect().await.map_err(map_error)?; + Ok(()) + }) + } + + fn invalidate_cache<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + client.invalidate_cache().await; + Ok(()) + }) + } + + fn is_alive<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + Ok(client.is_alive().await) + }) } fn get_balance<'py>(&self, py: Python<'py>, address: String) -> PyResult> { let client = Arc::clone(&self.inner); future_into_py(py, async move { + let client = client.lock().await; let balance = client.get_balance_ss58(&address).await.map_err(map_error)?; Ok(PyBalance::new_inner(balance)) }) } + fn get_balance_at_hash<'py>( + &self, + py: Python<'py>, + address: String, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let balance = client + .get_balance_at_hash(&address, block_hash) + .await + .map_err(map_error)?; + Ok(PyBalance::new_inner(balance)) + }) + } + + fn get_balance_at_block<'py>( + &self, + py: Python<'py>, + address: String, + block_number: u64, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let mut client = client.lock().await; + let block_hash = block_hash_from_number(&mut client, block_number) + .await + .map_err(map_error)?; + let balance = client + .get_balance_at_block(&address, block_hash) + .await + .map_err(map_error)?; + Ok(PyBalance::new_inner(balance)) + }) + } + + fn get_balances_multi<'py>( + &self, + py: Python<'py>, + addresses: Vec, + ) -> PyResult> { + let refs: Vec = addresses; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let refs_borrowed: Vec<&str> = refs.iter().map(String::as_str).collect(); + let client = client.lock().await; + let balances = client + .get_balances_multi(&refs_borrowed) + .await + .map_err(map_error)?; + to_pyobject_unbound(&balances) + }) + } + + fn get_block_hash<'py>( + &self, + py: Python<'py>, + block_number: u64, + ) -> PyResult> { + let block_number = u64_to_u32(block_number)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let hash = client + .get_block_hash(block_number) + .await + .map_err(map_error)?; + Ok(hash_to_hex(hash)) + }) + } + + fn get_block_number<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + client.get_block_number().await.map_err(map_error) + }) + } + + fn get_finalized_block_number<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + client.get_finalized_block_number().await.map_err(map_error) + }) + } + + fn pin_latest_block<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let hash = client.pin_latest_block().await.map_err(map_error)?; + Ok(hash_to_hex(hash)) + }) + } + + fn get_total_stake_at_block<'py>( + &self, + py: Python<'py>, + block_number: u64, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let mut client = client.lock().await; + let block_hash = block_hash_from_number(&mut client, block_number) + .await + .map_err(map_error)?; + let balance = client + .get_total_stake_at_block(block_hash) + .await + .map_err(map_error)?; + Ok(PyBalance::new_inner(balance)) + }) + } + + fn get_block_header<'py>( + &self, + py: Python<'py>, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let (number, hash, parent_hash, state_root) = client + .get_block_header(block_hash) + .await + .map_err(map_error)?; + to_pyobject_unbound(&( + number, + hash_to_hex(hash), + hash_to_hex(parent_hash), + hash_to_hex(state_root), + )) + }) + } + + fn get_block_extrinsic_count<'py>( + &self, + py: Python<'py>, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + client + .get_block_extrinsic_count(block_hash) + .await + .map_err(map_error) + }) + } + + fn get_block_timestamp<'py>( + &self, + py: Python<'py>, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + client + .get_block_timestamp(block_hash) + .await + .map_err(map_error) + }) + } + + fn get_total_issuance<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client.get_total_issuance().await.map_err(map_error)?; + Ok(PyBalance::new_inner(value)) + }) + } + + fn get_total_stake<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client.get_total_stake().await.map_err(map_error)?; + Ok(PyBalance::new_inner(value)) + }) + } + + fn get_total_networks<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + client.get_total_networks().await.map_err(map_error) + }) + } + + fn get_block_emission<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client.get_block_emission().await.map_err(map_error)?; + Ok(PyBalance::new_inner(value)) + }) + } + + fn get_subnet_registration_cost<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client + .get_subnet_registration_cost() + .await + .map_err(map_error)?; + Ok(PyBalance::new_inner(value)) + }) + } + + fn get_network_overview<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client.get_network_overview().await.map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn get_total_issuance_at<'py>( + &self, + py: Python<'py>, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client + .get_total_issuance_at(block_hash) + .await + .map_err(map_error)?; + Ok(PyBalance::new_inner(value)) + }) + } + + fn get_total_stake_at<'py>( + &self, + py: Python<'py>, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client + .get_total_stake_at(block_hash) + .await + .map_err(map_error)?; + Ok(PyBalance::new_inner(value)) + }) + } + + fn get_total_networks_at<'py>( + &self, + py: Python<'py>, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + client + .get_total_networks_at(block_hash) + .await + .map_err(map_error) + }) + } + + fn get_block_emission_at<'py>( + &self, + py: Python<'py>, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client + .get_block_emission_at(block_hash) + .await + .map_err(map_error)?; + Ok(PyBalance::new_inner(value)) + }) + } + + fn get_block_number_at<'py>( + &self, + py: Python<'py>, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + client + .get_block_number_at(block_hash) + .await + .map_err(map_error) + }) + } + fn get_all_subnets<'py>(&self, py: Python<'py>) -> PyResult> { let client = Arc::clone(&self.inner); future_into_py(py, async move { + let client = client.lock().await; let subnets = client.get_all_subnets().await.map_err(map_error)?; to_pyobject_unbound(subnets.as_ref()) }) @@ -97,6 +565,7 @@ impl PyClient { let netuid = netuid_from_py(&netuid)?; let client = Arc::clone(&self.inner); future_into_py(py, async move { + let client = client.lock().await; let info = client.get_subnet_info(netuid).await.map_err(map_error)?; match info { Some(value) => to_pyobject_unbound(&value), @@ -113,11 +582,12 @@ impl PyClient { let netuid = netuid_from_py(&netuid)?; let client = Arc::clone(&self.inner); future_into_py(py, async move { - let params = client + let client = client.lock().await; + let info = client .get_subnet_hyperparams(netuid) .await .map_err(map_error)?; - match params { + match info { Some(value) => to_pyobject_unbound(&value), None => Python::with_gil(|py| Ok(py.None())), } @@ -132,6 +602,7 @@ impl PyClient { let netuid = netuid_from_py(&netuid)?; let client = Arc::clone(&self.inner); future_into_py(py, async move { + let client = client.lock().await; let info = client.get_dynamic_info(netuid).await.map_err(map_error)?; match info { Some(value) => to_pyobject_unbound(&value), @@ -140,6 +611,15 @@ impl PyClient { }) } + fn get_all_dynamic_info<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let info = client.get_all_dynamic_info().await.map_err(map_error)?; + to_pyobject_unbound(info.as_ref()) + }) + } + fn get_metagraph<'py>( &self, py: Python<'py>, @@ -148,6 +628,7 @@ impl PyClient { let netuid = netuid_from_py(&netuid)?; let client = Arc::clone(&self.inner); future_into_py(py, async move { + let client = client.lock().await; let metagraph = client.get_metagraph(netuid).await.map_err(map_error)?; to_pyobject_unbound(&metagraph) }) @@ -162,6 +643,7 @@ impl PyClient { let netuid = netuid_from_py(&netuid)?; let client = Arc::clone(&self.inner); future_into_py(py, async move { + let client = client.lock().await; let neuron = client.get_neuron(netuid, uid).await.map_err(map_error)?; match neuron { Some(value) => to_pyobject_unbound(&value), @@ -178,6 +660,7 @@ impl PyClient { let netuid = netuid_from_py(&netuid)?; let client = Arc::clone(&self.inner); future_into_py(py, async move { + let client = client.lock().await; let neurons = client.get_neurons_lite(netuid).await.map_err(map_error)?; to_pyobject_unbound(neurons.as_ref()) }) @@ -190,6 +673,7 @@ impl PyClient { ) -> PyResult> { let client = Arc::clone(&self.inner); future_into_py(py, async move { + let client = client.lock().await; let stakes = client .get_stake_for_coldkey(&coldkey) .await @@ -198,37 +682,135 @@ impl PyClient { }) } - fn get_delegates<'py>(&self, py: Python<'py>) -> PyResult> { + fn get_stake_for_coldkey_pinned<'py>( + &self, + py: Python<'py>, + coldkey: String, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; let client = Arc::clone(&self.inner); future_into_py(py, async move { + let client = client.lock().await; + let stakes = client + .get_stake_for_coldkey_pinned(&coldkey, block_hash) + .await + .map_err(map_error)?; + to_pyobject_unbound(&stakes) + }) + } + + fn get_delegates<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; let delegates = client.get_delegates().await.map_err(map_error)?; to_pyobject_unbound(&delegates) }) } - fn get_delegate<'py>( + fn get_delegate<'py>(&self, py: Python<'py>, hotkey: String) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let delegate = client.get_delegate(&hotkey).await.map_err(map_error)?; + match delegate { + Some(value) => to_pyobject_unbound(&value), + None => Python::with_gil(|py| Ok(py.None())), + } + }) + } + + fn get_identity<'py>(&self, py: Python<'py>, ss58: String) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let identity = client.get_identity(&ss58).await.map_err(map_error)?; + match identity { + Some(value) => to_pyobject_unbound(&value), + None => Python::with_gil(|py| Ok(py.None())), + } + }) + } + + fn get_subnet_identity<'py>( &self, py: Python<'py>, - hotkey: String, + netuid: Bound<'_, PyAny>, ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; let client = Arc::clone(&self.inner); future_into_py(py, async move { - let delegate = client.get_delegate(&hotkey).await.map_err(map_error)?; - match delegate { + let client = client.lock().await; + let identity = client + .get_subnet_identity(netuid) + .await + .map_err(map_error)?; + match identity { + Some(value) => to_pyobject_unbound(&value), + None => Python::with_gil(|py| Ok(py.None())), + } + }) + } + + fn get_subnet_info_pinned<'py>( + &self, + py: Python<'py>, + netuid: Bound<'_, PyAny>, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let info = client + .get_subnet_info_pinned(netuid, block_hash) + .await + .map_err(map_error)?; + match info { + Some(value) => to_pyobject_unbound(&value), + None => Python::with_gil(|py| Ok(py.None())), + } + }) + } + + fn get_subnet_hyperparams_pinned<'py>( + &self, + py: Python<'py>, + netuid: Bound<'_, PyAny>, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let params = client + .get_subnet_hyperparams_pinned(netuid, block_hash) + .await + .map_err(map_error)?; + match params { Some(value) => to_pyobject_unbound(&value), None => Python::with_gil(|py| Ok(py.None())), } }) } - fn get_identity<'py>( + fn get_identity_pinned<'py>( &self, py: Python<'py>, ss58: String, + block_hash: Bound<'_, PyAny>, ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; let client = Arc::clone(&self.inner); future_into_py(py, async move { - let identity = client.get_identity(&ss58).await.map_err(map_error)?; + let client = client.lock().await; + let identity = client + .get_identity_pinned(&ss58, block_hash) + .await + .map_err(map_error)?; match identity { Some(value) => to_pyobject_unbound(&value), None => Python::with_gil(|py| Ok(py.None())), @@ -236,16 +818,19 @@ impl PyClient { }) } - fn get_subnet_identity<'py>( + fn get_subnet_identity_pinned<'py>( &self, py: Python<'py>, netuid: Bound<'_, PyAny>, + block_hash: Bound<'_, PyAny>, ) -> PyResult> { let netuid = netuid_from_py(&netuid)?; + let block_hash = parse_hash(&block_hash)?; let client = Arc::clone(&self.inner); future_into_py(py, async move { + let client = client.lock().await; let identity = client - .get_subnet_identity(netuid) + .get_subnet_identity_pinned(netuid, block_hash) .await .map_err(map_error)?; match identity { @@ -254,6 +839,818 @@ impl PyClient { } }) } + + fn get_delegate_pinned<'py>( + &self, + py: Python<'py>, + hotkey: String, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let delegate = client + .get_delegate_pinned(&hotkey, block_hash) + .await + .map_err(map_error)?; + match delegate { + Some(value) => to_pyobject_unbound(&value), + None => Python::with_gil(|py| Ok(py.None())), + } + }) + } + + fn list_proxies_pinned<'py>( + &self, + py: Python<'py>, + ss58: String, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let proxies = client + .list_proxies_pinned(&ss58, block_hash) + .await + .map_err(map_error)?; + to_pyobject_unbound(&proxies) + }) + } + + fn get_coldkey_swap_scheduled_pinned<'py>( + &self, + py: Python<'py>, + ss58: String, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client + .get_coldkey_swap_scheduled_pinned(&ss58, block_hash) + .await + .map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn get_child_keys_pinned<'py>( + &self, + py: Python<'py>, + hotkey_ss58: String, + netuid: Bound<'_, PyAny>, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let keys = client + .get_child_keys_pinned(&hotkey_ss58, netuid, block_hash) + .await + .map_err(map_error)?; + to_pyobject_unbound(&keys) + }) + } + + fn get_pending_child_keys_pinned<'py>( + &self, + py: Python<'py>, + hotkey_ss58: String, + netuid: Bound<'_, PyAny>, + block_hash: Bound<'_, PyAny>, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let block_hash = parse_hash(&block_hash)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let keys = client + .get_pending_child_keys_pinned(&hotkey_ss58, netuid, block_hash) + .await + .map_err(map_error)?; + to_pyobject_unbound(&keys) + }) + } + + fn get_stake_for_coldkey_at_block<'py>( + &self, + py: Python<'py>, + coldkey: String, + block_number: u64, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let mut client = client.lock().await; + let block_hash = block_hash_from_number(&mut client, block_number) + .await + .map_err(map_error)?; + let stakes = client + .get_stake_for_coldkey_at_block(&coldkey, block_hash) + .await + .map_err(map_error)?; + to_pyobject_unbound(&stakes) + }) + } + + fn get_identity_at_block<'py>( + &self, + py: Python<'py>, + ss58: String, + block_number: u64, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let mut client = client.lock().await; + let block_hash = block_hash_from_number(&mut client, block_number) + .await + .map_err(map_error)?; + let value = client + .get_identity_at_block(&ss58, block_hash) + .await + .map_err(map_error)?; + match value { + Some(identity) => to_pyobject_unbound(&identity), + None => Python::with_gil(|py| Ok(py.None())), + } + }) + } + + fn get_all_subnets_at_block<'py>( + &self, + py: Python<'py>, + block_number: u64, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let mut client = client.lock().await; + let block_hash = block_hash_from_number(&mut client, block_number) + .await + .map_err(map_error)?; + let value = client + .get_all_subnets_at_block(block_hash) + .await + .map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn get_all_dynamic_info_at_block<'py>( + &self, + py: Python<'py>, + block_number: u64, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let mut client = client.lock().await; + let block_hash = block_hash_from_number(&mut client, block_number) + .await + .map_err(map_error)?; + let value = client + .get_all_dynamic_info_at_block(block_hash) + .await + .map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn get_dynamic_info_at_block<'py>( + &self, + py: Python<'py>, + netuid: Bound<'_, PyAny>, + block_number: u64, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let mut client = client.lock().await; + let block_hash = block_hash_from_number(&mut client, block_number) + .await + .map_err(map_error)?; + let value = client + .get_dynamic_info_at_block(netuid, block_hash) + .await + .map_err(map_error)?; + match value { + Some(info) => to_pyobject_unbound(&info), + None => Python::with_gil(|py| Ok(py.None())), + } + }) + } + + fn get_neurons_lite_at_block<'py>( + &self, + py: Python<'py>, + netuid: Bound<'_, PyAny>, + block_number: u64, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let mut client = client.lock().await; + let block_hash = block_hash_from_number(&mut client, block_number) + .await + .map_err(map_error)?; + let value = client + .get_neurons_lite_at_block(netuid, block_hash) + .await + .map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn get_neuron_at_block<'py>( + &self, + py: Python<'py>, + netuid: Bound<'_, PyAny>, + uid: u16, + block_number: u64, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let mut client = client.lock().await; + let block_hash = block_hash_from_number(&mut client, block_number) + .await + .map_err(map_error)?; + let value = client + .get_neuron_at_block(netuid, uid, block_hash) + .await + .map_err(map_error)?; + match value { + Some(info) => to_pyobject_unbound(&info), + None => Python::with_gil(|py| Ok(py.None())), + } + }) + } + + fn get_delegates_at_block<'py>( + &self, + py: Python<'py>, + block_number: u64, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let mut client = client.lock().await; + let block_hash = block_hash_from_number(&mut client, block_number) + .await + .map_err(map_error)?; + let value = client + .get_delegates_at_block(block_hash) + .await + .map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn get_total_issuance_at_block<'py>( + &self, + py: Python<'py>, + block_number: u64, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let mut client = client.lock().await; + let block_hash = block_hash_from_number(&mut client, block_number) + .await + .map_err(map_error)?; + let value = client + .get_total_issuance_at_block(block_hash) + .await + .map_err(map_error)?; + Ok(PyBalance::new_inner(value)) + }) + } + + fn list_proxies<'py>(&self, py: Python<'py>, ss58: String) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let proxies = client.list_proxies(&ss58).await.map_err(map_error)?; + to_pyobject_unbound(&proxies) + }) + } + + fn list_multisig_pending<'py>( + &self, + py: Python<'py>, + multisig_ss58: String, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client + .list_multisig_pending(&multisig_ss58) + .await + .map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn list_proxy_announcements<'py>( + &self, + py: Python<'py>, + ss58: String, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client + .list_proxy_announcements(&ss58) + .await + .map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn get_coldkey_swap_scheduled<'py>( + &self, + py: Python<'py>, + ss58: String, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client + .get_coldkey_swap_scheduled(&ss58) + .await + .map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn get_child_keys<'py>( + &self, + py: Python<'py>, + hotkey_ss58: String, + netuid: Bound<'_, PyAny>, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let keys = client + .get_child_keys(&hotkey_ss58, netuid) + .await + .map_err(map_error)?; + to_pyobject_unbound(&keys) + }) + } + + fn get_parent_keys<'py>( + &self, + py: Python<'py>, + hotkey_ss58: String, + netuid: Bound<'_, PyAny>, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let keys = client + .get_parent_keys(&hotkey_ss58, netuid) + .await + .map_err(map_error)?; + to_pyobject_unbound(&keys) + }) + } + + fn get_pending_child_keys<'py>( + &self, + py: Python<'py>, + hotkey_ss58: String, + netuid: Bound<'_, PyAny>, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let keys = client + .get_pending_child_keys(&hotkey_ss58, netuid) + .await + .map_err(map_error)?; + to_pyobject_unbound(&keys) + }) + } + + fn get_delegated<'py>( + &self, + py: Python<'py>, + hotkey_ss58: String, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let delegated = client + .get_delegated(&hotkey_ss58) + .await + .map_err(map_error)?; + to_pyobject_unbound(&delegated) + }) + } + + fn get_weight_commits<'py>( + &self, + py: Python<'py>, + netuid: Bound<'_, PyAny>, + hotkey_ss58: String, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let commits = client + .get_weight_commits(netuid, &hotkey_ss58) + .await + .map_err(map_error)?; + to_pyobject_unbound(&serialize_weight_commits(commits)) + }) + } + + fn get_all_weight_commits<'py>( + &self, + py: Python<'py>, + netuid: Bound<'_, PyAny>, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let commits = client + .get_all_weight_commits(netuid) + .await + .map_err(map_error)?; + let value: Vec<(String, Vec<(String, u64, u64, u64)>)> = commits + .into_iter() + .map(|(account, rows)| { + ( + account.to_string(), + rows.into_iter() + .map(|(hash, commit_block, first_reveal, last_reveal)| { + (hash_to_hex(hash), commit_block, first_reveal, last_reveal) + }) + .collect(), + ) + }) + .collect(); + to_pyobject_unbound(&value) + }) + } + + fn get_reveal_period_epochs<'py>( + &self, + py: Python<'py>, + netuid: Bound<'_, PyAny>, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + client + .get_reveal_period_epochs(netuid) + .await + .map_err(map_error) + }) + } + + fn get_weights_for_uid<'py>( + &self, + py: Python<'py>, + netuid: Bound<'_, PyAny>, + uid: u16, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client + .get_weights_for_uid(netuid, uid) + .await + .map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn get_all_weights<'py>( + &self, + py: Python<'py>, + netuid: Bound<'_, PyAny>, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client.get_all_weights(netuid).await.map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn get_commit_reveal_weights_version<'py>( + &self, + py: Python<'py>, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + client + .get_commit_reveal_weights_version() + .await + .map_err(map_error) + }) + } + + fn get_commitment<'py>( + &self, + py: Python<'py>, + netuid: u16, + hotkey_ss58: String, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client + .get_commitment(netuid, &hotkey_ss58) + .await + .map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn get_all_commitments<'py>( + &self, + py: Python<'py>, + netuid: u16, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let value = client + .get_all_commitments(netuid) + .await + .map_err(map_error)?; + to_pyobject_unbound(&value) + }) + } + + fn get_block_info_for_pow<'py>(&self, py: Python<'py>) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let (block_number, hash_bytes) = + client.get_block_info_for_pow().await.map_err(map_error)?; + to_pyobject_unbound(&(block_number, format!("0x{}", hex::encode(hash_bytes)))) + }) + } + + fn get_difficulty<'py>( + &self, + py: Python<'py>, + netuid: Bound<'_, PyAny>, + ) -> PyResult> { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + client.get_difficulty(netuid).await.map_err(map_error) + }) + } + + fn fetch_portfolio<'py>( + &self, + py: Python<'py>, + coldkey_ss58: String, + ) -> PyResult> { + let client = Arc::clone(&self.inner); + future_into_py(py, async move { + let client = client.lock().await; + let portfolio = agcli::queries::portfolio::fetch_portfolio(&client, &coldkey_ss58) + .await + .map_err(map_error)?; + to_pyobject_unbound(&portfolio) + }) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("Client")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("Client")) + } +} + +#[pymethods] +impl PyClientSync { + #[staticmethod] + fn connect(url: String) -> PyResult { + let client = runtime() + .block_on(Client::connect(&url)) + .map_err(map_error)?; + Ok(wrap_client_sync(client)) + } + + #[staticmethod] + fn connect_with_retry(urls: Vec) -> PyResult { + let refs: Vec<&str> = urls.iter().map(String::as_str).collect(); + let client = runtime() + .block_on(Client::connect_with_retry(&refs)) + .map_err(map_error)?; + Ok(wrap_client_sync(client)) + } + + #[staticmethod] + fn best_connection(urls: Vec) -> PyResult { + let refs: Vec<&str> = urls.iter().map(String::as_str).collect(); + let client = runtime() + .block_on(Client::best_connection(&refs)) + .map_err(map_error)?; + Ok(wrap_client_sync(client)) + } + + #[staticmethod] + #[pyo3(signature = (network=None))] + fn connect_network(network: Option>) -> PyResult { + let network = network + .map(|n| n.inner.clone()) + .unwrap_or(agcli::types::Network::Finney); + let client = runtime() + .block_on(Client::connect_network(&network)) + .map_err(map_error)?; + Ok(wrap_client_sync(client)) + } + + #[staticmethod] + fn from_config() -> PyResult { + let config = Config::load(); + let client = runtime() + .block_on(client_from_config(&config)) + .map_err(map_error)?; + Ok(wrap_client_sync(client)) + } + + #[getter] + fn endpoint(&self) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + Ok(client.url().to_string()) + }) + .map_err(map_error) + } + + #[getter] + fn network(&self) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + Ok(PyNetwork { + inner: client.network().clone(), + }) + }) + .map_err(map_error) + } + + fn set_dry_run(&self, enabled: bool) -> PyResult<()> { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let mut client = client.lock().await; + client.set_dry_run(enabled); + Ok(()) + }) + .map_err(map_error) + } + + fn is_dry_run(&self) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + Ok(client.is_dry_run()) + }) + .map_err(map_error) + } + + fn set_finalization_timeout(&self, timeout: u64) -> PyResult<()> { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let mut client = client.lock().await; + client.set_finalization_timeout(timeout); + Ok(()) + }) + .map_err(map_error) + } + + fn finalization_timeout(&self) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + Ok(client.finalization_timeout()) + }) + .map_err(map_error) + } + + fn set_mortality_blocks(&self, blocks: u64) -> PyResult<()> { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let mut client = client.lock().await; + client.set_mortality_blocks(blocks); + Ok(()) + }) + .map_err(map_error) + } + + fn mortality_blocks(&self) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + Ok(client.mortality_blocks()) + }) + .map_err(map_error) + } + + fn reconnect(&self) -> PyResult<()> { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let mut client = client.lock().await; + client.reconnect().await + }) + .map_err(map_error) + } + + fn invalidate_cache(&self) -> PyResult<()> { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + client.invalidate_cache().await; + Ok(()) + }) + .map_err(map_error) + } + + fn is_alive(&self) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + Ok(client.is_alive().await) + }) + .map_err(map_error) + } + + fn get_balance(&self, address: String) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + let value = client.get_balance_ss58(&address).await?; + Ok(PyBalance::new_inner(value)) + }) + .map_err(map_error) + } + + fn get_total_issuance(&self) -> PyResult { + let client = Arc::clone(&self.inner); + runtime() + .block_on(async move { + let client = client.lock().await; + let value = client.get_total_issuance().await?; + Ok(PyBalance::new_inner(value)) + }) + .map_err(map_error) + } + + fn get_metagraph(&self, netuid: Bound<'_, PyAny>) -> PyResult { + let netuid = netuid_from_py(&netuid)?; + let client = Arc::clone(&self.inner); + let value = runtime() + .block_on(async move { + let client = client.lock().await; + client.get_metagraph(netuid).await + }) + .map_err(map_error)?; + to_pyobject_unbound(&value) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("ClientSync")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("ClientSync")) + } } async fn client_from_config(config: &Config) -> anyhow::Result { diff --git a/python/agcli-py/src/config.rs b/python/agcli-py/src/config.rs new file mode 100644 index 0000000..8735592 --- /dev/null +++ b/python/agcli-py/src/config.rs @@ -0,0 +1,154 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use agcli::Config; +use pyo3::prelude::*; + +use crate::errors::map_error; + +#[pyclass(name = "Config", module = "agcli._agcli")] +#[derive(Clone, Default)] +pub struct PyConfig { + #[pyo3(get, set)] + pub network: Option, + #[pyo3(get, set)] + pub endpoint: Option, + #[pyo3(get, set)] + pub wallet_dir: Option, + #[pyo3(get, set)] + pub wallet: Option, + #[pyo3(get, set)] + pub hotkey: Option, + #[pyo3(get, set)] + pub output: Option, + #[pyo3(get, set)] + pub proxy: Option, + #[pyo3(get, set)] + pub live_interval: Option, + #[pyo3(get, set)] + pub batch: Option, + #[pyo3(get, set)] + pub spending_limits: Option>, + #[pyo3(get, set)] + pub finalization_timeout: Option, + #[pyo3(get, set)] + pub mortality_blocks: Option, +} + +impl PyConfig { + pub fn from_inner(inner: Config) -> Self { + Self { + network: inner.network, + endpoint: inner.endpoint, + wallet_dir: inner.wallet_dir, + wallet: inner.wallet, + hotkey: inner.hotkey, + output: inner.output, + proxy: inner.proxy, + live_interval: inner.live_interval, + batch: inner.batch, + spending_limits: inner.spending_limits, + finalization_timeout: inner.finalization_timeout, + mortality_blocks: inner.mortality_blocks, + } + } + + pub fn to_inner(&self) -> Config { + Config { + network: self.network.clone(), + endpoint: self.endpoint.clone(), + wallet_dir: self.wallet_dir.clone(), + wallet: self.wallet.clone(), + hotkey: self.hotkey.clone(), + output: self.output.clone(), + proxy: self.proxy.clone(), + live_interval: self.live_interval, + batch: self.batch, + spending_limits: self.spending_limits.clone(), + finalization_timeout: self.finalization_timeout, + mortality_blocks: self.mortality_blocks, + } + } +} + +#[pymethods] +impl PyConfig { + #[new] + #[pyo3(signature = ( + network=None, + endpoint=None, + wallet_dir=None, + wallet=None, + hotkey=None, + output=None, + proxy=None, + live_interval=None, + batch=None, + spending_limits=None, + finalization_timeout=None, + mortality_blocks=None + ))] + fn new( + network: Option, + endpoint: Option, + wallet_dir: Option, + wallet: Option, + hotkey: Option, + output: Option, + proxy: Option, + live_interval: Option, + batch: Option, + spending_limits: Option>, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> Self { + Self { + network, + endpoint, + wallet_dir, + wallet, + hotkey, + output, + proxy, + live_interval, + batch, + spending_limits, + finalization_timeout, + mortality_blocks, + } + } + + #[staticmethod] + fn load() -> Self { + Self::from_inner(Config::load()) + } + + fn save(&self) -> PyResult<()> { + self.to_inner().save().map_err(map_error) + } + + #[staticmethod] + fn load_from(path: String) -> PyResult { + let cfg = Config::load_from(PathBuf::from(path).as_path()).map_err(map_error)?; + Ok(Self::from_inner(cfg)) + } + + fn save_to(&self, path: String) -> PyResult<()> { + self.to_inner() + .save_to(PathBuf::from(path).as_path()) + .map_err(map_error) + } + + #[staticmethod] + fn default_path() -> String { + Config::default_path().display().to_string() + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("Config")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("Config")) + } +} diff --git a/python/agcli-py/src/errors.rs b/python/agcli-py/src/errors.rs index dd9512b..ff6aba4 100644 --- a/python/agcli-py/src/errors.rs +++ b/python/agcli-py/src/errors.rs @@ -3,13 +3,27 @@ use pyo3::exceptions::PyException; use pyo3::prelude::*; create_exception!(_agcli, AgcliError, PyException); +create_exception!(_agcli, NetworkError, AgcliError); +create_exception!(_agcli, AuthError, AgcliError); +create_exception!(_agcli, ValidationError, AgcliError); +create_exception!(_agcli, ChainError, AgcliError); +create_exception!(_agcli, TimeoutError, AgcliError); +create_exception!(_agcli, IOError, AgcliError); pub fn map_error(err: anyhow::Error) -> PyErr { let message = format!("{:#}", err); let code = agcli::error::classify(&err); let hint = agcli::error::hint(code, &message).map(str::to_string); Python::with_gil(|py| { - let py_err = AgcliError::new_err(message); + let py_err = match code { + agcli::error::exit_code::NETWORK => NetworkError::new_err(message), + agcli::error::exit_code::AUTH => AuthError::new_err(message), + agcli::error::exit_code::VALIDATION => ValidationError::new_err(message), + agcli::error::exit_code::CHAIN => ChainError::new_err(message), + agcli::error::exit_code::TIMEOUT => TimeoutError::new_err(message), + agcli::error::exit_code::IO => IOError::new_err(message), + _ => AgcliError::new_err(message), + }; let value = py_err.value(py); let _ = value.setattr("code", code); if let Some(h) = hint { @@ -18,3 +32,12 @@ pub fn map_error(err: anyhow::Error) -> PyErr { py_err }) } + +pub fn pickle_blocked(type_name: &str) -> PyErr { + pyo3::exceptions::PyTypeError::new_err(format!("{type_name} objects cannot be pickled")) +} + +#[pyfunction] +pub fn raise_test_error(message: String) -> PyResult<()> { + Err(map_error(anyhow::anyhow!(message))) +} diff --git a/python/agcli-py/src/events.rs b/python/agcli-py/src/events.rs new file mode 100644 index 0000000..e538589 --- /dev/null +++ b/python/agcli-py/src/events.rs @@ -0,0 +1,672 @@ +use std::str::FromStr; +use std::sync::Arc; + +use agcli::chain::subxt::events::Phase; +use agcli::chain::subxt::ext::scale_value::{Composite, Primitive, Value, ValueDef}; +use agcli::chain::subxt::ext::sp_core::crypto::Ss58Codec; +use agcli::chain::subxt::ext::sp_core::sr25519; +use agcli::chain::subxt::OnlineClient; +use agcli::events::EventFilter as RustEventFilter; +use agcli::SubtensorConfig; +use pyo3::exceptions::PyStopAsyncIteration; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyDict}; +use pyo3_async_runtimes::tokio::{future_into_py, get_runtime}; +use tokio::sync::{mpsc, Mutex}; +use tokio::task::JoinHandle; + +use crate::client::PyClient; +use crate::errors::{map_error, ValidationError}; + +#[derive(Clone, Debug)] +struct ParsedFilter { + category: RustEventFilter, + netuid: Option, + account: Option, +} + +impl ParsedFilter { + fn matches(&self, pallet: &str, variant: &str) -> bool { + filter_matches(&self.category, pallet, variant) + } +} + +fn filter_matches(filter: &RustEventFilter, pallet: &str, variant: &str) -> bool { + use RustEventFilter::*; + match filter { + All => true, + Staking => pallet == "SubtensorModule" && STAKING_VARIANTS.iter().any(|v| *v == variant), + Registration => { + pallet == "SubtensorModule" && REGISTRATION_VARIANTS.iter().any(|v| *v == variant) + } + Transfer => pallet == "Balances", + Weights => pallet == "SubtensorModule" && WEIGHT_VARIANTS.iter().any(|v| *v == variant), + Subnet => pallet == "SubtensorModule" && SUBNET_VARIANTS.iter().any(|v| *v == variant), + Delegation => { + pallet == "SubtensorModule" && DELEGATION_VARIANTS.iter().any(|v| *v == variant) + } + Keys => pallet == "SubtensorModule" && KEY_VARIANTS.iter().any(|v| *v == variant), + Swap => pallet == "Swap" && SWAP_VARIANTS.iter().any(|v| *v == variant), + Governance => GOVERNANCE_VARIANTS + .iter() + .any(|(p, v)| *p == pallet && *v == variant), + Crowdloan => pallet == "Crowdloan" && CROWDLOAN_VARIANTS.iter().any(|v| *v == variant), + } +} + +// Kept in sync with `agcli::events` (the upstream lists are private). The +// binding boundary is the right layer to mirror them so we don't import +// internal modules. +const STAKING_VARIANTS: &[&str] = &[ + "StakeAdded", + "StakeRemoved", + "StakeMoved", + "StakeSwapped", + "AllStakeRemoved", + "StakeTransferred", + "AlphaRecycled", + "AlphaBurned", + "RootClaimed", + "AutoStakeAdded", + "AddStakeBurn", + "AutoStakeDestinationSet", +]; + +const REGISTRATION_VARIANTS: &[&str] = &[ + "NeuronRegistered", + "BurnedRegister", + "SubnetRegistered", + "PowRegistered", + "BulkNeuronsRegistered", +]; + +const WEIGHT_VARIANTS: &[&str] = &[ + "WeightsSet", + "WeightsCommitted", + "WeightsRevealed", + "WeightsBatchRevealed", + "CRV3WeightsCommitted", + "CRV3WeightsRevealed", + "TimelockedWeightsCommitted", + "TimelockedWeightsRevealed", + "BatchWeightsCompleted", + "BatchCompletedWithErrors", + "BatchWeightItemFailed", + "CommitRevealEnabled", + "CommitRevealPeriodsSet", +]; + +const SUBNET_VARIANTS: &[&str] = &[ + "SubnetHyperparamsSet", + "SubnetIdentitySet", + "SubnetIdentityRemoved", + "NetworkAdded", + "NetworkRemoved", + "TempoSet", + "DissolveNetworkScheduled", + "SubnetLeaseCreated", + "SubnetLeaseTerminated", + "SubnetLeaseDividendsDistributed", + "SymbolUpdated", + "FirstEmissionBlockNumberSet", + "TransferToggle", + "SubnetOwnerHotkeySet", +]; + +const DELEGATION_VARIANTS: &[&str] = &[ + "DelegateAdded", + "TakeDecreased", + "TakeIncreased", + "ChildKeyTakeSet", + "SetChildren", + "SetChildrenScheduled", + "AutoParentDelegationEnabledSet", +]; + +const KEY_VARIANTS: &[&str] = &[ + "HotkeySwapped", + "HotkeySwappedOnSubnet", + "ColdkeySwapAnnounced", + "ColdkeySwapReset", + "ColdkeySwapped", + "ColdkeySwapDisputed", + "ColdkeySwapCleared", + "AllBalanceUnstakedAndTransferredToNewColdkey", + "ArbitrationPeriodExtended", + "EvmKeyAssociated", + "ChainIdentitySet", +]; + +const SWAP_VARIANTS: &[&str] = &[ + "FeeRateSet", + "UserLiquidityToggled", + "LiquidityAdded", + "LiquidityRemoved", + "LiquidityModified", +]; + +const GOVERNANCE_VARIANTS: &[(&str, &str)] = &[ + ("SafeMode", "Entered"), + ("SafeMode", "Exited"), + ("SafeMode", "DepositPlaced"), + ("SafeMode", "DepositReleased"), + ("Sudo", "Sudid"), + ("Sudo", "KeyChanged"), + ("Sudo", "KeyRotated"), + ("Sudo", "SudoAsDone"), + ("Scheduler", "Scheduled"), + ("Scheduler", "Canceled"), + ("Scheduler", "Dispatched"), + ("Proxy", "ProxyExecuted"), + ("Proxy", "PureCreated"), + ("Proxy", "Announced"), + ("Proxy", "ProxyAdded"), + ("Proxy", "ProxyRemoved"), + ("Multisig", "NewMultisig"), + ("Multisig", "MultisigApproval"), + ("Multisig", "MultisigExecuted"), + ("Multisig", "MultisigCancelled"), +]; + +const CROWDLOAN_VARIANTS: &[&str] = &[ + "Created", + "Contributed", + "Withdrew", + "PartiallyRefunded", + "AllRefunded", + "Finalized", + "Dissolved", + "MinContributionUpdated", + "EndUpdated", + "CapUpdated", +]; + +fn validation_error(message: impl Into) -> PyErr { + let message = message.into(); + Python::with_gil(|py| { + let py_err = ValidationError::new_err(message); + let value = py_err.value(py); + let _ = value.setattr("code", agcli::error::exit_code::VALIDATION); + py_err + }) +} + +fn category_from_str(s: &str) -> RustEventFilter { + RustEventFilter::from_str(s).unwrap_or(RustEventFilter::All) +} + +fn extract_str_attr(value: &Bound<'_, PyAny>, name: &str) -> PyResult> { + if value.hasattr(name)? { + let attr = value.getattr(name)?; + if attr.is_none() { + return Ok(None); + } + return Ok(Some(attr.extract::().map_err(|_| { + validation_error(format!("filter.{name} must be a string")) + })?)); + } + if let Ok(item) = value.get_item(name) { + if item.is_none() { + return Ok(None); + } + return Ok(Some(item.extract::().map_err(|_| { + validation_error(format!("filter['{name}'] must be a string")) + })?)); + } + Ok(None) +} + +fn extract_u16_attr(value: &Bound<'_, PyAny>, name: &str) -> PyResult> { + if value.hasattr(name)? { + let attr = value.getattr(name)?; + if attr.is_none() { + return Ok(None); + } + return Ok(Some(attr.extract::().map_err(|_| { + validation_error(format!("filter.{name} must fit in u16")) + })?)); + } + if let Ok(item) = value.get_item(name) { + if item.is_none() { + return Ok(None); + } + return Ok(Some(item.extract::().map_err(|_| { + validation_error(format!("filter['{name}'] must fit in u16")) + })?)); + } + Ok(None) +} + +fn parse_filter(value: Option<&Bound<'_, PyAny>>) -> PyResult { + let Some(value) = value else { + return Ok(ParsedFilter { + category: RustEventFilter::All, + netuid: None, + account: None, + }); + }; + if value.is_none() { + return Ok(ParsedFilter { + category: RustEventFilter::All, + netuid: None, + account: None, + }); + } + if let Ok(s) = value.extract::() { + return Ok(ParsedFilter { + category: category_from_str(&s), + netuid: None, + account: None, + }); + } + let category_name = extract_str_attr(value, "category")?.unwrap_or_else(|| "all".to_string()); + let netuid = extract_u16_attr(value, "netuid")?; + let account = extract_str_attr(value, "account")?; + if let Some(acc) = account.as_deref() { + if agcli::Client::ss58_to_account_id_pub(acc).is_err() { + return Err(validation_error(format!( + "filter.account is not a valid SS58 address: {acc}" + ))); + } + } + Ok(ParsedFilter { + category: category_from_str(&category_name), + netuid, + account, + }) +} + +fn extract_netuid(composite: &Composite) -> Option { + if let Composite::Named(fields) = composite { + for (name, val) in fields { + if name == "netuid" { + if let ValueDef::Primitive(Primitive::U128(n)) = &val.value { + if *n <= u16::MAX as u128 { + return Some(*n as u16); + } + return None; + } + } + } + } + None +} + +fn try_composite_as_ss58(composite: &Composite) -> Option { + let fields = match composite { + Composite::Unnamed(fields) if fields.len() == 32 => fields, + _ => return None, + }; + let mut bytes = [0u8; 32]; + for (i, field) in fields.iter().enumerate() { + match &field.value { + ValueDef::Primitive(Primitive::U128(n)) if *n <= 255 => { + bytes[i] = *n as u8; + } + _ => return None, + } + } + let public = sr25519::Public::from_raw(bytes); + Some(public.to_ss58check()) +} + +fn extract_accounts_from_value(val: &ValueDef, out: &mut Vec) { + match val { + ValueDef::Composite(inner) => { + if let Some(ss58) = try_composite_as_ss58(inner) { + out.push(ss58); + } else { + match inner { + Composite::Named(fields) => { + for (_, v) in fields { + extract_accounts_from_value(&v.value, out); + } + } + Composite::Unnamed(fields) => { + for v in fields { + extract_accounts_from_value(&v.value, out); + } + } + } + } + } + ValueDef::Variant(variant) => match &variant.values { + Composite::Named(fields) => { + for (_, v) in fields { + extract_accounts_from_value(&v.value, out); + } + } + Composite::Unnamed(fields) => { + for v in fields { + extract_accounts_from_value(&v.value, out); + } + } + }, + _ => {} + } +} + +fn extract_accounts(composite: &Composite) -> Vec { + let mut accounts = Vec::new(); + match composite { + Composite::Named(fields) => { + for (_, val) in fields { + extract_accounts_from_value(&val.value, &mut accounts); + } + } + Composite::Unnamed(fields) => { + for val in fields { + extract_accounts_from_value(&val.value, &mut accounts); + } + } + } + accounts +} + +fn value_to_json(val: &Value) -> serde_json::Value { + match &val.value { + ValueDef::Primitive(p) => match p { + Primitive::Bool(b) => serde_json::Value::Bool(*b), + Primitive::Char(c) => serde_json::Value::String(c.to_string()), + Primitive::U128(n) => { + if *n <= u64::MAX as u128 { + serde_json::json!(*n as u64) + } else { + serde_json::Value::String(n.to_string()) + } + } + Primitive::I128(n) => { + if *n >= i64::MIN as i128 && *n <= i64::MAX as i128 { + serde_json::json!(*n as i64) + } else { + serde_json::Value::String(n.to_string()) + } + } + Primitive::U256(n) => serde_json::Value::String(format!("{:?}", n)), + Primitive::I256(n) => serde_json::Value::String(format!("{:?}", n)), + Primitive::String(s) => serde_json::Value::String(s.clone()), + }, + ValueDef::Composite(composite) => composite_to_json(composite), + ValueDef::Variant(variant) => { + let inner = composite_to_json(&variant.values); + serde_json::json!({ &variant.name: inner }) + } + ValueDef::BitSequence(bits) => serde_json::Value::String(format!("bits({})", bits.len())), + } +} + +fn composite_to_json(composite: &Composite) -> serde_json::Value { + match composite { + Composite::Named(fields) => { + let map: serde_json::Map = fields + .iter() + .map(|(k, v)| (k.clone(), value_to_json(v))) + .collect(); + serde_json::Value::Object(map) + } + Composite::Unnamed(fields) => { + if fields.len() == 32 || fields.len() == 64 { + let bytes: Vec = fields + .iter() + .filter_map(|v| match &v.value { + ValueDef::Primitive(Primitive::U128(n)) if *n <= 255 => Some(*n as u8), + _ => None, + }) + .collect(); + if bytes.len() == fields.len() { + return serde_json::Value::String(format!("0x{}", hex::encode(&bytes))); + } + } + let arr: Vec = fields.iter().map(value_to_json).collect(); + serde_json::Value::Array(arr) + } + } +} + +fn json_to_py(py: Python<'_>, value: &serde_json::Value) -> PyResult { + pythonize::pythonize(py, value) + .map(|b| b.unbind()) + .map_err(|e| map_error(anyhow::anyhow!("{e}"))) +} + +fn build_event_dict( + py: Python<'_>, + block_number: u64, + pallet: &str, + variant: &str, + extrinsic_index: Option, + fields: &serde_json::Value, +) -> PyResult { + let dict = PyDict::new(py); + dict.set_item("block_number", block_number)?; + dict.set_item("pallet", pallet)?; + dict.set_item("variant", variant)?; + dict.set_item("extrinsic_index", extrinsic_index)?; + dict.set_item("fields", json_to_py(py, fields)?)?; + Ok(dict.unbind().into()) +} + +fn build_block_dict( + py: Python<'_>, + block_number: u64, + block_hash: &str, + extrinsic_count: usize, +) -> PyResult { + let dict = PyDict::new(py); + dict.set_item("block_number", block_number)?; + dict.set_item("hash", block_hash)?; + dict.set_item("extrinsics", extrinsic_count)?; + Ok(dict.unbind().into()) +} + +const STREAM_CHANNEL_CAPACITY: usize = 64; + +#[pyclass(name = "EventStream", module = "agcli._agcli")] +pub struct PyEventStream { + receiver: Arc>>>, + handle: Arc>>>, +} + +impl Drop for PyEventStream { + fn drop(&mut self) { + let handle = Arc::clone(&self.handle); + get_runtime().spawn(async move { + let mut guard = handle.lock().await; + if let Some(h) = guard.take() { + h.abort(); + } + }); + } +} + +#[pymethods] +impl PyEventStream { + fn __aiter__<'py>(slf: PyRef<'py, Self>) -> PyRef<'py, Self> { + slf + } + + fn __anext__<'py>(&self, py: Python<'py>) -> PyResult> { + let rx = Arc::clone(&self.receiver); + future_into_py(py, async move { + let mut guard = rx.lock().await; + let Some(receiver) = guard.as_mut() else { + return Err(PyStopAsyncIteration::new_err("stream is closed")); + }; + match receiver.recv().await { + Some(obj) => Ok(obj), + None => Err(PyStopAsyncIteration::new_err("stream closed")), + } + }) + } + + fn close<'py>(&self, py: Python<'py>) -> PyResult> { + let rx = Arc::clone(&self.receiver); + let handle = Arc::clone(&self.handle); + future_into_py(py, async move { + let mut rx = rx.lock().await; + *rx = None; + let mut handle = handle.lock().await; + if let Some(h) = handle.take() { + h.abort(); + } + Ok(()) + }) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("EventStream")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("EventStream")) + } +} + +fn spawn_event_subscription( + subxt_client: OnlineClient, + filter: ParsedFilter, +) -> (mpsc::Receiver, JoinHandle<()>) { + let (tx, rx) = mpsc::channel::(STREAM_CHANNEL_CAPACITY); + let handle = get_runtime().spawn(async move { + let mut sub = match subxt_client.blocks().subscribe_finalized().await { + Ok(s) => s, + Err(_) => return, + }; + while let Some(block_result) = sub.next().await { + let block = match block_result { + Ok(b) => b, + Err(_) => break, + }; + let block_number = block.number() as u64; + let events = match block.events().await { + Ok(ev) => ev, + Err(_) => continue, + }; + for event in events.iter() { + let event = match event { + Ok(e) => e, + Err(_) => continue, + }; + let pallet = event.pallet_name().to_string(); + let variant = event.variant_name().to_string(); + if !filter.matches(&pallet, &variant) { + continue; + } + let field_values = match event.field_values() { + Ok(fv) => fv, + Err(_) => continue, + }; + if let Some(target_netuid) = filter.netuid { + match extract_netuid(&field_values) { + Some(found) if found == target_netuid => {} + _ => continue, + } + } + if let Some(target_account) = filter.account.as_deref() { + let accounts = extract_accounts(&field_values); + if !accounts.iter().any(|a| a == target_account) { + continue; + } + } + let extrinsic_index = match event.phase() { + Phase::ApplyExtrinsic(i) => Some(i), + _ => None, + }; + let json_fields = composite_to_json(&field_values); + let dict = match Python::with_gil(|py| { + build_event_dict( + py, + block_number, + &pallet, + &variant, + extrinsic_index, + &json_fields, + ) + }) { + Ok(d) => d, + Err(_) => continue, + }; + if tx.send(dict).await.is_err() { + return; + } + } + } + }); + (rx, handle) +} + +fn spawn_block_subscription( + subxt_client: OnlineClient, +) -> (mpsc::Receiver, JoinHandle<()>) { + let (tx, rx) = mpsc::channel::(STREAM_CHANNEL_CAPACITY); + let handle = get_runtime().spawn(async move { + let mut sub = match subxt_client.blocks().subscribe_finalized().await { + Ok(s) => s, + Err(_) => return, + }; + while let Some(block_result) = sub.next().await { + let block = match block_result { + Ok(b) => b, + Err(_) => break, + }; + let block_number = block.number() as u64; + let block_hash = format!("{:?}", block.hash()); + let extrinsic_count = block.extrinsics().await.map(|e| e.len()).unwrap_or(0); + let dict = match Python::with_gil(|py| { + build_block_dict(py, block_number, &block_hash, extrinsic_count) + }) { + Ok(d) => d, + Err(_) => continue, + }; + if tx.send(dict).await.is_err() { + return; + } + } + }); + (rx, handle) +} + +#[pymethods] +impl PyClient { + #[pyo3(signature = (filter=None))] + fn subscribe_events(&self, filter: Option>) -> PyResult { + let parsed = parse_filter(filter.as_ref())?; + let shared = self.shared_client(); + let subxt_client = get_runtime().block_on(async move { + let client = shared.lock().await; + client.subxt().clone() + }); + let (rx, handle) = spawn_event_subscription(subxt_client, parsed); + Ok(PyEventStream { + receiver: Arc::new(Mutex::new(Some(rx))), + handle: Arc::new(Mutex::new(Some(handle))), + }) + } + + fn subscribe_events_filtered(&self, filter: Bound<'_, PyAny>) -> PyResult { + let parsed = parse_filter(Some(&filter))?; + let shared = self.shared_client(); + let subxt_client = get_runtime().block_on(async move { + let client = shared.lock().await; + client.subxt().clone() + }); + let (rx, handle) = spawn_event_subscription(subxt_client, parsed); + Ok(PyEventStream { + receiver: Arc::new(Mutex::new(Some(rx))), + handle: Arc::new(Mutex::new(Some(handle))), + }) + } + + fn subscribe_blocks(&self) -> PyResult { + let shared = self.shared_client(); + let subxt_client = get_runtime().block_on(async move { + let client = shared.lock().await; + client.subxt().clone() + }); + let (rx, handle) = spawn_block_subscription(subxt_client); + Ok(PyEventStream { + receiver: Arc::new(Mutex::new(Some(rx))), + handle: Arc::new(Mutex::new(Some(handle))), + }) + } +} diff --git a/python/agcli-py/src/extrinsics.rs b/python/agcli-py/src/extrinsics.rs new file mode 100644 index 0000000..e97bcda --- /dev/null +++ b/python/agcli-py/src/extrinsics.rs @@ -0,0 +1,1311 @@ +use agcli::chain::subxt::ext::sp_core::sr25519; +use agcli::types::network::NetUid; +use pyo3::prelude::*; +use pyo3::types::PyAny; +use pyo3_async_runtimes::tokio::future_into_py; + +use crate::chain_data::PySubnetIdentity; +use crate::client::PyClient; +use crate::errors::{map_error, ValidationError}; +use crate::types::{parse_hash, PyBalance, PyNetUid}; +use crate::wallet::PyWallet; + +fn validation_error(message: impl Into) -> PyErr { + let message = message.into(); + Python::with_gil(|py| { + let py_err = ValidationError::new_err(message); + let value = py_err.value(py); + let _ = value.setattr("code", agcli::error::exit_code::VALIDATION); + py_err + }) +} + +fn ensure_ss58(label: &str, ss58: &str) -> PyResult<()> { + agcli::Client::ss58_to_account_id_pub(ss58) + .map(|_| ()) + .map_err(|e| validation_error(format!("invalid {label} SS58 address: {e}"))) +} + +fn parse_netuid(value: &Bound<'_, PyAny>, field: &str) -> PyResult { + if let Ok(netuid) = value.extract::>() { + return Ok(netuid.inner); + } + let raw = value + .extract::() + .map_err(|_| validation_error(format!("{field} must be an integer or NetUid instance")))?; + if raw < 0 { + return Err(validation_error(format!("{field} cannot be negative"))); + } + if raw > u16::MAX as i128 { + return Err(validation_error(format!( + "{field} {raw} exceeds maximum value {}", + u16::MAX + ))); + } + Ok(NetUid(raw as u16)) +} + +fn parse_balance(value: &Bound<'_, PyAny>, field: &str) -> PyResult { + if let Ok(balance) = value.extract::>() { + return Ok(balance.inner()); + } + let raw = value.extract::().map_err(|_| { + validation_error(format!( + "{field} must be a Balance or non-negative integer amount in rao" + )) + })?; + if raw < 0 { + return Err(validation_error(format!("{field} cannot be negative"))); + } + if raw > u64::MAX as i128 { + return Err(validation_error(format!( + "{field} {raw} exceeds maximum value {}", + u64::MAX + ))); + } + Ok(agcli::Balance::from_rao(raw as u64)) +} + +fn parse_rao_amount(value: &Bound<'_, PyAny>, field: &str) -> PyResult { + Ok(parse_balance(value, field)?.rao()) +} + +fn parse_hash_32(value: &Bound<'_, PyAny>, field: &str) -> PyResult<[u8; 32]> { + let hash = parse_hash(value).map_err(|_| { + validation_error(format!( + "{field} must be 32-byte bytes or a 0x-prefixed 32-byte hex string" + )) + })?; + let mut out = [0u8; 32]; + out.copy_from_slice(hash.as_ref()); + Ok(out) +} + +fn parse_u16_list(value: &Bound<'_, PyAny>, field: &str) -> PyResult> { + let raw = value + .extract::>() + .map_err(|_| validation_error(format!("{field} must be a list of integers")))?; + let mut parsed = Vec::with_capacity(raw.len()); + for (index, item) in raw.into_iter().enumerate() { + if !(0..=u16::MAX as i128).contains(&item) { + return Err(validation_error(format!( + "{field}[{index}]={item} is outside the u16 range" + ))); + } + parsed.push(item as u16); + } + Ok(parsed) +} + +fn wallet_hotkey_ss58(wallet: &agcli::Wallet) -> PyResult { + wallet.hotkey_ss58().map(str::to_string).ok_or_else(|| { + validation_error("wallet hotkey SS58 is unavailable; call wallet.load_hotkey(...) first") + }) +} + +fn wallet_coldkey_pair(wallet: &agcli::Wallet) -> PyResult { + wallet.coldkey().map(|pair| pair.clone()).map_err(map_error) +} + +fn wallet_hotkey_pair(wallet: &agcli::Wallet) -> PyResult { + wallet.hotkey().map(|pair| pair.clone()).map_err(map_error) +} + +fn apply_call_overrides( + client: &mut agcli::Client, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, +) -> (bool, u64, u64) { + let previous = ( + client.is_dry_run(), + client.finalization_timeout(), + client.mortality_blocks(), + ); + if dry_run { + client.set_dry_run(true); + } + if let Some(timeout) = finalization_timeout { + client.set_finalization_timeout(timeout); + } + if let Some(blocks) = mortality_blocks { + client.set_mortality_blocks(blocks); + } + previous +} + +fn restore_call_overrides(client: &mut agcli::Client, previous: (bool, u64, u64)) { + client.set_dry_run(previous.0); + client.set_finalization_timeout(previous.1); + client.set_mortality_blocks(previous.2); +} + +#[pymethods] +impl PyClient { + #[pyo3(signature = (wallet, dest_ss58, amount, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn transfer<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + dest_ss58: String, + amount: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + ensure_ss58("destination", &dest_ss58)?; + let amount = parse_balance(&amount, "amount")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.transfer(&pair, &dest_ss58, amount).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, dest_ss58, keep_alive=false, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn transfer_all<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + dest_ss58: String, + keep_alive: bool, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + ensure_ss58("destination", &dest_ss58)?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.transfer_all(&pair, &dest_ss58, keep_alive).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, amount, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn add_stake<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + amount: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let netuid = parse_netuid(&netuid, "netuid")?; + let amount = parse_balance(&amount, "amount")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = if mev { + client + .add_stake_mev(&pair, &hotkey_ss58, netuid, amount, true) + .await + } else { + client.add_stake(&pair, &hotkey_ss58, netuid, amount).await + }; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, amount, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn remove_stake<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + amount: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let netuid = parse_netuid(&netuid, "netuid")?; + let amount = parse_balance(&amount, "amount")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = if mev { + client + .remove_stake_mev(&pair, &hotkey_ss58, netuid, amount, true) + .await + } else { + client + .remove_stake(&pair, &hotkey_ss58, netuid, amount) + .await + }; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, amount, limit_price, allow_partial=true, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn add_stake_limit<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + amount: Bound<'_, PyAny>, + limit_price: u64, + allow_partial: bool, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let netuid = parse_netuid(&netuid, "netuid")?; + let amount = parse_balance(&amount, "amount")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = if mev { + client + .add_stake_limit_mev( + &pair, + &hotkey_ss58, + netuid, + amount, + limit_price, + allow_partial, + true, + ) + .await + } else { + client + .add_stake_limit( + &pair, + &hotkey_ss58, + netuid, + amount, + limit_price, + allow_partial, + ) + .await + }; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, amount, limit_price, allow_partial=true, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn remove_stake_limit<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + amount: Bound<'_, PyAny>, + limit_price: u64, + allow_partial: bool, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let netuid = parse_netuid(&netuid, "netuid")?; + let amount = parse_rao_amount(&amount, "amount")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = if mev { + client + .remove_stake_limit_mev( + &pair, + &hotkey_ss58, + netuid, + amount, + limit_price, + allow_partial, + true, + ) + .await + } else { + client + .remove_stake_limit( + &pair, + &hotkey_ss58, + netuid, + amount, + limit_price, + allow_partial, + ) + .await + }; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, from_netuid, to_netuid, amount, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn move_stake<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + from_netuid: Bound<'_, PyAny>, + to_netuid: Bound<'_, PyAny>, + amount: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let from_netuid = parse_netuid(&from_netuid, "from_netuid")?; + let to_netuid = parse_netuid(&to_netuid, "to_netuid")?; + let amount = parse_balance(&amount, "amount")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = if mev { + client + .move_stake_mev(&pair, &hotkey_ss58, from_netuid, to_netuid, amount, true) + .await + } else { + client + .move_stake(&pair, &hotkey_ss58, from_netuid, to_netuid, amount) + .await + }; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, from_netuid, to_netuid, amount, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn swap_stake<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + from_netuid: Bound<'_, PyAny>, + to_netuid: Bound<'_, PyAny>, + amount: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let from_netuid = parse_netuid(&from_netuid, "from_netuid")?; + let to_netuid = parse_netuid(&to_netuid, "to_netuid")?; + let amount = parse_balance(&amount, "amount")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = if mev { + client + .swap_stake_mev(&pair, &hotkey_ss58, from_netuid, to_netuid, amount, true) + .await + } else { + client + .swap_stake(&pair, &hotkey_ss58, from_netuid, to_netuid, amount) + .await + }; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, from_netuid, to_netuid, amount, limit_price, allow_partial=true, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn swap_stake_limit<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + from_netuid: Bound<'_, PyAny>, + to_netuid: Bound<'_, PyAny>, + amount: Bound<'_, PyAny>, + limit_price: u64, + allow_partial: bool, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let from_netuid = parse_netuid(&from_netuid, "from_netuid")?; + let to_netuid = parse_netuid(&to_netuid, "to_netuid")?; + let amount = parse_rao_amount(&amount, "amount")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = if mev { + client + .swap_stake_limit_mev( + &pair, + &hotkey_ss58, + from_netuid, + to_netuid, + amount, + limit_price, + allow_partial, + true, + ) + .await + } else { + client + .swap_stake_limit( + &pair, + &hotkey_ss58, + from_netuid, + to_netuid, + amount, + limit_price, + allow_partial, + ) + .await + }; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, dest_ss58, from_netuid, to_netuid, amount, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn transfer_stake<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + dest_ss58: String, + from_netuid: Bound<'_, PyAny>, + to_netuid: Bound<'_, PyAny>, + amount: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + ensure_ss58("destination", &dest_ss58)?; + let from_netuid = parse_netuid(&from_netuid, "from_netuid")?; + let to_netuid = parse_netuid(&to_netuid, "to_netuid")?; + let amount = parse_balance(&amount, "amount")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = if mev { + client + .transfer_stake_mev( + &pair, + &dest_ss58, + &hotkey_ss58, + from_netuid, + to_netuid, + amount, + true, + ) + .await + } else { + client + .transfer_stake( + &pair, + &dest_ss58, + &hotkey_ss58, + from_netuid, + to_netuid, + amount, + ) + .await + }; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn unstake_all<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.unstake_all(&pair, &hotkey_ss58).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn unstake_all_alpha<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.unstake_all_alpha(&pair, &hotkey_ss58).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, amount, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn recycle_alpha<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + amount: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let netuid = parse_netuid(&netuid, "netuid")?; + let amount = parse_rao_amount(&amount, "amount")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = if mev { + client + .recycle_alpha_mev(&pair, &hotkey_ss58, netuid, amount, true) + .await + } else { + client + .recycle_alpha(&pair, &hotkey_ss58, netuid, amount) + .await + }; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, amount, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn burn_alpha<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + amount: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let netuid = parse_netuid(&netuid, "netuid")?; + let amount = parse_rao_amount(&amount, "amount")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = if mev { + client + .burn_alpha_mev(&pair, &hotkey_ss58, amount, netuid, true) + .await + } else { + client.burn_alpha(&pair, &hotkey_ss58, amount, netuid).await + }; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, subnets, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn claim_root<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + subnets: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let subnets = parse_u16_list(&subnets, "subnets")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.claim_root(&pair, &subnets).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, uids, values, version_key, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn set_weights<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + uids: Bound<'_, PyAny>, + values: Bound<'_, PyAny>, + version_key: u64, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let netuid = parse_netuid(&netuid, "netuid")?; + let uids = parse_u16_list(&uids, "uids")?; + let values = parse_u16_list(&values, "values")?; + if uids.len() != values.len() { + return Err(validation_error(format!( + "uids and values length mismatch: {} != {}", + uids.len(), + values.len() + ))); + } + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_hotkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client + .set_weights(&pair, netuid, &uids, &values, version_key) + .await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, commit_hash, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn commit_weights<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + commit_hash: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let netuid = parse_netuid(&netuid, "netuid")?; + let commit_hash = parse_hash_32(&commit_hash, "commit_hash")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_hotkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.commit_weights(&pair, netuid, commit_hash).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, uids, values, salt, version_key, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn reveal_weights<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + uids: Bound<'_, PyAny>, + values: Bound<'_, PyAny>, + salt: Bound<'_, PyAny>, + version_key: u64, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let netuid = parse_netuid(&netuid, "netuid")?; + let uids = parse_u16_list(&uids, "uids")?; + let values = parse_u16_list(&values, "values")?; + let salt = parse_u16_list(&salt, "salt")?; + if uids.len() != values.len() { + return Err(validation_error(format!( + "uids and values length mismatch: {} != {}", + uids.len(), + values.len() + ))); + } + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_hotkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client + .reveal_weights(&pair, netuid, &uids, &values, &salt, version_key) + .await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn register_network<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.register_network(&pair, &hotkey_ss58).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn burned_register<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let netuid = parse_netuid(&netuid, "netuid")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.burned_register(&pair, netuid, &hotkey_ss58).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, block_number, nonce, work, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn pow_register<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + block_number: u64, + nonce: u64, + work: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let netuid = parse_netuid(&netuid, "netuid")?; + let work = parse_hash_32(&work, "work")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client + .pow_register(&pair, netuid, &hotkey_ss58, block_number, nonce, work) + .await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn root_register<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey_ss58 = wallet_hotkey_ss58(&wallet)?; + ensure_ss58("wallet hotkey", &hotkey_ss58)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.root_register(&pair, &hotkey_ss58).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn dissolve_network<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let netuid = parse_netuid(&netuid, "netuid")?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.dissolve_network(&pair, netuid).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, delegate_ss58, proxy_type, delay=0, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn add_proxy<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + delegate_ss58: String, + proxy_type: String, + delay: u32, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + ensure_ss58("delegate", &delegate_ss58)?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client + .add_proxy(&pair, &delegate_ss58, &proxy_type, delay) + .await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, delegate_ss58, proxy_type, delay=0, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn remove_proxy<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + delegate_ss58: String, + proxy_type: String, + delay: u32, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + ensure_ss58("delegate", &delegate_ss58)?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client + .remove_proxy(&pair, &delegate_ss58, &proxy_type, delay) + .await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, proxy_type, delay=0, index=0, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn create_pure_proxy<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + proxy_type: String, + delay: u32, + index: u16, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client + .create_pure_proxy(&pair, &proxy_type, delay, index) + .await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, spawner_ss58, proxy_type, index, height, ext_index, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn kill_pure_proxy<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + spawner_ss58: String, + proxy_type: String, + index: u16, + height: u32, + ext_index: u32, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + ensure_ss58("spawner", &spawner_ss58)?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client + .kill_pure_proxy(&pair, &spawner_ss58, &proxy_type, index, height, ext_index) + .await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, hotkey_ss58=None, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn try_associate_hotkey<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + hotkey_ss58: Option, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let hotkey = hotkey_ss58.unwrap_or_else(|| { + wallet + .hotkey_ss58() + .map(str::to_string) + .unwrap_or_else(String::new) + }); + if hotkey.is_empty() { + return Err(validation_error( + "hotkey_ss58 is required when wallet has no loaded hotkey address", + )); + } + ensure_ss58("hotkey", &hotkey)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.try_associate_hotkey(&pair, &hotkey).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, new_coldkey_ss58, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn schedule_swap_coldkey<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + new_coldkey_ss58: String, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + ensure_ss58("new_coldkey", &new_coldkey_ss58)?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.schedule_swap_coldkey(&pair, &new_coldkey_ss58).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, old_hotkey_ss58, new_hotkey_ss58, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn swap_hotkey<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + old_hotkey_ss58: String, + new_hotkey_ss58: String, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + ensure_ss58("old_hotkey", &old_hotkey_ss58)?; + ensure_ss58("new_hotkey", &new_hotkey_ss58)?; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client + .swap_hotkey(&pair, &old_hotkey_ss58, &new_hotkey_ss58) + .await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } + + #[pyo3(signature = (wallet, netuid, identity, *, wait=true, mev=true, dry_run=false, finalization_timeout=None, mortality_blocks=None))] + fn set_subnet_identity<'py>( + &self, + py: Python<'py>, + wallet: PyRef<'_, PyWallet>, + netuid: Bound<'_, PyAny>, + identity: Bound<'_, PyAny>, + wait: bool, + mev: bool, + dry_run: bool, + finalization_timeout: Option, + mortality_blocks: Option, + ) -> PyResult> { + let _ = wait; + let _ = mev; + let netuid = parse_netuid(&netuid, "netuid")?; + let identity = if let Ok(identity) = identity.extract::>() { + identity.inner_clone() + } else { + pythonize::depythonize::(&identity) + .map_err(|e| validation_error(format!("invalid subnet identity payload: {e}")))? + }; + let client = self.shared_client(); + let wallet = wallet.shared_wallet(); + future_into_py(py, async move { + let mut client = client.lock().await; + let wallet = wallet.lock().await; + let pair = wallet_coldkey_pair(&wallet)?; + let previous = + apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); + let result = client.set_subnet_identity(&pair, netuid, &identity).await; + restore_call_overrides(&mut client, previous); + result.map_err(map_error) + }) + } +} diff --git a/python/agcli-py/src/lib.rs b/python/agcli-py/src/lib.rs index e43eec9..2d2500f 100644 --- a/python/agcli-py/src/lib.rs +++ b/python/agcli-py/src/lib.rs @@ -1,26 +1,103 @@ +#![allow( + clippy::manual_contains, + clippy::map_clone, + clippy::too_many_arguments, + clippy::type_complexity, + clippy::unwrap_or_default, + clippy::wrong_self_convention +)] + +mod admin; +mod chain_data; mod client; +mod config; mod errors; +mod events; +mod extrinsics; +mod live; +mod localnet; mod runtime; +mod scaffold; mod types; mod wallet; use pyo3::prelude::*; -use client::PyClient; -use errors::AgcliError; +use chain_data::{ + PyAlphaBalance, PyChainIdentity, PyDelegateInfo, PyDynamicInfo, PyMetagraph, PyNeuronInfo, + PyNeuronInfoLite, PyStakeInfo, PySubnetHyperparameters, PySubnetIdentity, PySubnetInfo, +}; +use client::{PyClient, PyClientSync}; +use config::PyConfig; +use errors::{ + AgcliError, AuthError, ChainError, IOError, NetworkError, TimeoutError, ValidationError, +}; +use events::PyEventStream; +use localnet::{PyDevAccount, PyLocalnetConfig, PyLocalnetInfo, PyLocalnetStatus}; +use scaffold::{ + PyChainConfig, PyNeuronConfig, PyNeuronResult, PyScaffoldConfig, PyScaffoldResult, + PySubnetConfig, PySubnetResult, +}; use types::{PyBalance, PyNetUid, PyNetwork}; use wallet::PyWallet; +fn register_submodule( + parent: &Bound<'_, PyModule>, + name: &str, + register_fn: impl FnOnce(&Bound<'_, PyModule>) -> PyResult<()>, +) -> PyResult<()> { + let submod = PyModule::new(parent.py(), name)?; + register_fn(&submod)?; + parent.add(name, &submod)?; + Ok(()) +} + /// Python bindings for the agcli Bittensor SDK. #[pymodule] fn _agcli(m: &Bound<'_, PyModule>) -> PyResult<()> { let py = m.py(); runtime::runtime(); m.add("AgcliError", py.get_type::())?; + m.add("NetworkError", py.get_type::())?; + m.add("AuthError", py.get_type::())?; + m.add("ValidationError", py.get_type::())?; + m.add("ChainError", py.get_type::())?; + m.add("TimeoutError", py.get_type::())?; + m.add("IOError", py.get_type::())?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(errors::raise_test_error, m)?)?; + register_submodule(m, "live", live::register)?; + register_submodule(m, "localnet", localnet::register)?; + register_submodule(m, "scaffold", scaffold::register)?; + register_submodule(m, "admin", admin::register)?; Ok(()) } diff --git a/python/agcli-py/src/live.rs b/python/agcli-py/src/live.rs new file mode 100644 index 0000000..e8faa1e --- /dev/null +++ b/python/agcli-py/src/live.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use agcli::types::network::NetUid; +use pyo3::prelude::*; +use pyo3::types::PyAny; +use pyo3_async_runtimes::tokio::future_into_py; + +use crate::client::PyClient; +use crate::errors::map_error; +use crate::types::netuid_from_py; + +#[pyfunction] +#[pyo3(signature = (client, interval_secs=5))] +pub fn live_dynamic<'py>( + py: Python<'py>, + client: PyRef<'_, PyClient>, + interval_secs: u64, +) -> PyResult> { + let shared = client.shared_client(); + future_into_py(py, async move { + let guard = shared.lock().await; + agcli::live::live_dynamic(&guard, interval_secs) + .await + .map_err(map_error) + }) +} + +#[pyfunction] +#[pyo3(signature = (client, netuid, interval_secs=5))] +pub fn live_metagraph<'py>( + py: Python<'py>, + client: PyRef<'_, PyClient>, + netuid: Bound<'_, PyAny>, + interval_secs: u64, +) -> PyResult> { + let netuid: NetUid = netuid_from_py(&netuid)?; + let shared = client.shared_client(); + future_into_py(py, async move { + let guard = shared.lock().await; + agcli::live::live_metagraph(&guard, netuid, interval_secs) + .await + .map_err(map_error) + }) +} + +#[pyfunction] +#[pyo3(signature = (client, coldkey_ss58, interval_secs=5))] +pub fn live_portfolio<'py>( + py: Python<'py>, + client: PyRef<'_, PyClient>, + coldkey_ss58: String, + interval_secs: u64, +) -> PyResult> { + let shared = Arc::clone(&client.shared_client()); + future_into_py(py, async move { + let guard = shared.lock().await; + agcli::live::live_portfolio(&guard, &coldkey_ss58, interval_secs) + .await + .map_err(map_error) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(live_dynamic, m)?)?; + m.add_function(wrap_pyfunction!(live_metagraph, m)?)?; + m.add_function(wrap_pyfunction!(live_portfolio, m)?)?; + Ok(()) +} diff --git a/python/agcli-py/src/localnet.rs b/python/agcli-py/src/localnet.rs new file mode 100644 index 0000000..f54a1be --- /dev/null +++ b/python/agcli-py/src/localnet.rs @@ -0,0 +1,398 @@ +use agcli::localnet; +use pyo3::prelude::*; +use pyo3::types::PyAny; +use pyo3_async_runtimes::tokio::future_into_py; + +use crate::errors::map_error; +use crate::types::to_pyobject_unbound; + +#[pyclass(name = "LocalnetConfig", module = "agcli._agcli")] +#[derive(Clone)] +pub struct PyLocalnetConfig { + pub(crate) inner: localnet::LocalnetConfig, +} + +#[pymethods] +impl PyLocalnetConfig { + #[new] + #[pyo3(signature = ( + image=None, + container_name=None, + port=None, + wait=None, + wait_timeout=None, + ))] + fn new( + image: Option, + container_name: Option, + port: Option, + wait: Option, + wait_timeout: Option, + ) -> Self { + let mut cfg = localnet::LocalnetConfig::default(); + if let Some(v) = image { + cfg.image = v; + } + if let Some(v) = container_name { + cfg.container_name = v; + } + if let Some(v) = port { + cfg.port = v; + } + if let Some(v) = wait { + cfg.wait = v; + } + if let Some(v) = wait_timeout { + cfg.wait_timeout = v; + } + Self { inner: cfg } + } + + #[getter] + fn image(&self) -> String { + self.inner.image.clone() + } + + #[setter] + fn set_image(&mut self, value: String) { + self.inner.image = value; + } + + #[getter] + fn container_name(&self) -> String { + self.inner.container_name.clone() + } + + #[setter] + fn set_container_name(&mut self, value: String) { + self.inner.container_name = value; + } + + #[getter] + fn port(&self) -> u16 { + self.inner.port + } + + #[setter] + fn set_port(&mut self, value: u16) { + self.inner.port = value; + } + + #[getter] + fn wait(&self) -> bool { + self.inner.wait + } + + #[setter] + fn set_wait(&mut self, value: bool) { + self.inner.wait = value; + } + + #[getter] + fn wait_timeout(&self) -> u64 { + self.inner.wait_timeout + } + + #[setter] + fn set_wait_timeout(&mut self, value: u64) { + self.inner.wait_timeout = value; + } + + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("image", &self.inner.image)?; + dict.set_item("container_name", &self.inner.container_name)?; + dict.set_item("port", self.inner.port)?; + dict.set_item("wait", self.inner.wait)?; + dict.set_item("wait_timeout", self.inner.wait_timeout)?; + Ok(dict.into_any()) + } + + fn __repr__(&self) -> String { + format!( + "LocalnetConfig(image={:?}, container_name={:?}, port={}, wait={}, wait_timeout={})", + self.inner.image, + self.inner.container_name, + self.inner.port, + self.inner.wait, + self.inner.wait_timeout + ) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("LocalnetConfig")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("LocalnetConfig")) + } +} + +#[pyclass(name = "DevAccount", module = "agcli._agcli")] +#[derive(Clone)] +pub struct PyDevAccount { + pub(crate) inner: localnet::DevAccount, +} + +#[pymethods] +impl PyDevAccount { + #[getter] + fn name(&self) -> String { + self.inner.name.clone() + } + + #[getter] + fn uri(&self) -> String { + self.inner.uri.clone() + } + + #[getter] + fn ss58(&self) -> String { + self.inner.ss58.clone() + } + + #[getter] + fn balance(&self) -> String { + self.inner.balance.clone() + } + + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("name", &self.inner.name)?; + dict.set_item("uri", &self.inner.uri)?; + dict.set_item("ss58", &self.inner.ss58)?; + dict.set_item("balance", &self.inner.balance)?; + Ok(dict.into_any()) + } + + fn __repr__(&self) -> String { + format!( + "DevAccount(name={:?}, uri={:?}, ss58={:?})", + self.inner.name, self.inner.uri, self.inner.ss58 + ) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("DevAccount")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("DevAccount")) + } +} + +#[pyclass(name = "LocalnetInfo", module = "agcli._agcli")] +#[derive(Clone)] +pub struct PyLocalnetInfo { + pub(crate) inner: localnet::LocalnetInfo, +} + +#[pymethods] +impl PyLocalnetInfo { + #[getter] + fn container_name(&self) -> String { + self.inner.container_name.clone() + } + + #[getter] + fn container_id(&self) -> String { + self.inner.container_id.clone() + } + + #[getter] + fn image(&self) -> String { + self.inner.image.clone() + } + + #[getter] + fn endpoint(&self) -> String { + self.inner.endpoint.clone() + } + + #[getter] + fn port(&self) -> u16 { + self.inner.port + } + + #[getter] + fn block_height(&self) -> u64 { + self.inner.block_height + } + + #[getter] + fn dev_accounts(&self) -> Vec { + self.inner + .dev_accounts + .iter() + .cloned() + .map(|a| PyDevAccount { inner: a }) + .collect() + } + + fn to_dict(&self) -> PyResult { + to_pyobject_unbound(&self.inner) + } + + fn __repr__(&self) -> String { + format!( + "LocalnetInfo(container_name={:?}, endpoint={:?}, block_height={})", + self.inner.container_name, self.inner.endpoint, self.inner.block_height + ) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("LocalnetInfo")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("LocalnetInfo")) + } +} + +#[pyclass(name = "LocalnetStatus", module = "agcli._agcli")] +#[derive(Clone)] +pub struct PyLocalnetStatus { + pub(crate) inner: localnet::LocalnetStatus, +} + +#[pymethods] +impl PyLocalnetStatus { + #[getter] + fn running(&self) -> bool { + self.inner.running + } + + #[getter] + fn container_name(&self) -> String { + self.inner.container_name.clone() + } + + #[getter] + fn container_id(&self) -> Option { + self.inner.container_id.clone() + } + + #[getter] + fn image(&self) -> Option { + self.inner.image.clone() + } + + #[getter] + fn endpoint(&self) -> Option { + self.inner.endpoint.clone() + } + + #[getter] + fn block_height(&self) -> Option { + self.inner.block_height + } + + #[getter] + fn started_at(&self) -> Option { + self.inner.started_at.clone() + } + + #[getter] + fn uptime(&self) -> Option { + self.inner.started_at.clone() + } + + fn to_dict(&self) -> PyResult { + to_pyobject_unbound(&self.inner) + } + + fn __repr__(&self) -> String { + format!( + "LocalnetStatus(running={}, container_name={:?}, endpoint={:?})", + self.inner.running, self.inner.container_name, self.inner.endpoint + ) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("LocalnetStatus")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("LocalnetStatus")) + } +} + +#[pyfunction] +pub fn dev_accounts() -> Vec { + localnet::dev_accounts() + .into_iter() + .map(|a| PyDevAccount { inner: a }) + .collect() +} + +#[pyfunction] +#[pyo3(signature = (config=None))] +pub fn start<'py>( + py: Python<'py>, + config: Option>, +) -> PyResult> { + let cfg = config + .map(|c| c.inner.clone()) + .unwrap_or_else(localnet::LocalnetConfig::default); + future_into_py(py, async move { + let info = localnet::start(&cfg).await.map_err(map_error)?; + Ok(PyLocalnetInfo { inner: info }) + }) +} + +#[pyfunction] +pub fn stop(container_name: String) -> PyResult<()> { + localnet::stop(&container_name).map_err(map_error) +} + +#[pyfunction] +#[pyo3(signature = (container_name=None, port=9944))] +pub fn status<'py>( + py: Python<'py>, + container_name: Option, + port: u16, +) -> PyResult> { + let name = container_name.unwrap_or_else(|| localnet::DEFAULT_CONTAINER.to_string()); + future_into_py(py, async move { + let s = localnet::status(&name, port).await.map_err(map_error)?; + Ok(PyLocalnetStatus { inner: s }) + }) +} + +#[pyfunction] +#[pyo3(signature = (config=None))] +pub fn reset<'py>( + py: Python<'py>, + config: Option>, +) -> PyResult> { + let cfg = config + .map(|c| c.inner.clone()) + .unwrap_or_else(localnet::LocalnetConfig::default); + future_into_py(py, async move { + let info = localnet::reset(&cfg).await.map_err(map_error)?; + Ok(PyLocalnetInfo { inner: info }) + }) +} + +#[pyfunction] +#[pyo3(signature = (container_name, tail=None))] +pub fn logs(container_name: String, tail: Option) -> PyResult { + localnet::logs(&container_name, tail).map_err(map_error) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(dev_accounts, m)?)?; + m.add_function(wrap_pyfunction!(start, m)?)?; + m.add_function(wrap_pyfunction!(stop, m)?)?; + m.add_function(wrap_pyfunction!(status, m)?)?; + m.add_function(wrap_pyfunction!(reset, m)?)?; + m.add_function(wrap_pyfunction!(logs, m)?)?; + m.add("DEFAULT_IMAGE", localnet::DEFAULT_IMAGE)?; + m.add("DEFAULT_CONTAINER", localnet::DEFAULT_CONTAINER)?; + m.add("DEFAULT_WS", localnet::DEFAULT_WS)?; + Ok(()) +} diff --git a/python/agcli-py/src/runtime.rs b/python/agcli-py/src/runtime.rs index 3eb1a03..3bb649d 100644 --- a/python/agcli-py/src/runtime.rs +++ b/python/agcli-py/src/runtime.rs @@ -6,7 +6,5 @@ static RUNTIME: OnceLock = OnceLock::new(); /// Process-wide tokio runtime for async SDK calls from Python. pub fn runtime() -> &'static Runtime { - RUNTIME.get_or_init(|| { - Runtime::new().expect("failed to create tokio runtime for agcli-py") - }) + RUNTIME.get_or_init(|| Runtime::new().expect("failed to create tokio runtime for agcli-py")) } diff --git a/python/agcli-py/src/scaffold.rs b/python/agcli-py/src/scaffold.rs new file mode 100644 index 0000000..bcfd646 --- /dev/null +++ b/python/agcli-py/src/scaffold.rs @@ -0,0 +1,577 @@ +use agcli::scaffold; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyDict, PyList}; +use pyo3_async_runtimes::tokio::future_into_py; + +use crate::errors::map_error; +use crate::types::to_pyobject_unbound; + +#[pyclass(name = "NeuronConfig", module = "agcli._agcli")] +#[derive(Clone)] +pub struct PyNeuronConfig { + pub(crate) inner: scaffold::NeuronConfig, +} + +#[pymethods] +impl PyNeuronConfig { + #[new] + #[pyo3(signature = (name, fund_tao=None, register=true))] + fn new(name: String, fund_tao: Option, register: bool) -> Self { + Self { + inner: scaffold::NeuronConfig { + name, + fund_tao, + register, + }, + } + } + + #[getter] + fn name(&self) -> String { + self.inner.name.clone() + } + + #[setter] + fn set_name(&mut self, value: String) { + self.inner.name = value; + } + + #[getter] + fn fund_tao(&self) -> Option { + self.inner.fund_tao + } + + #[setter] + fn set_fund_tao(&mut self, value: Option) { + self.inner.fund_tao = value; + } + + #[getter] + fn register(&self) -> bool { + self.inner.register + } + + #[setter] + fn set_register(&mut self, value: bool) { + self.inner.register = value; + } + + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = PyDict::new(py); + dict.set_item("name", &self.inner.name)?; + dict.set_item("fund_tao", self.inner.fund_tao)?; + dict.set_item("register", self.inner.register)?; + Ok(dict.into_any()) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("NeuronConfig")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("NeuronConfig")) + } +} + +#[pyclass(name = "SubnetConfig", module = "agcli._agcli")] +#[derive(Clone)] +pub struct PySubnetConfig { + pub(crate) inner: scaffold::SubnetConfig, +} + +#[pymethods] +impl PySubnetConfig { + #[new] + #[pyo3(signature = ( + tempo=None, + max_allowed_validators=None, + max_allowed_uids=None, + min_allowed_weights=None, + max_weight_limit=None, + immunity_period=None, + weights_rate_limit=None, + commit_reveal=None, + activity_cutoff=None, + neurons=None, + ))] + fn new( + tempo: Option, + max_allowed_validators: Option, + max_allowed_uids: Option, + min_allowed_weights: Option, + max_weight_limit: Option, + immunity_period: Option, + weights_rate_limit: Option, + commit_reveal: Option, + activity_cutoff: Option, + neurons: Option>, + ) -> Self { + let inner = scaffold::SubnetConfig { + tempo, + max_allowed_validators, + max_allowed_uids, + min_allowed_weights, + max_weight_limit, + immunity_period, + weights_rate_limit, + commit_reveal, + activity_cutoff, + neuron: neurons + .map(|v| v.into_iter().map(|n| n.inner).collect()) + .unwrap_or_default(), + }; + Self { inner } + } + + #[getter] + fn tempo(&self) -> Option { + self.inner.tempo + } + #[setter] + fn set_tempo(&mut self, v: Option) { + self.inner.tempo = v; + } + #[getter] + fn max_allowed_validators(&self) -> Option { + self.inner.max_allowed_validators + } + #[setter] + fn set_max_allowed_validators(&mut self, v: Option) { + self.inner.max_allowed_validators = v; + } + #[getter] + fn max_allowed_uids(&self) -> Option { + self.inner.max_allowed_uids + } + #[setter] + fn set_max_allowed_uids(&mut self, v: Option) { + self.inner.max_allowed_uids = v; + } + #[getter] + fn min_allowed_weights(&self) -> Option { + self.inner.min_allowed_weights + } + #[setter] + fn set_min_allowed_weights(&mut self, v: Option) { + self.inner.min_allowed_weights = v; + } + #[getter] + fn max_weight_limit(&self) -> Option { + self.inner.max_weight_limit + } + #[setter] + fn set_max_weight_limit(&mut self, v: Option) { + self.inner.max_weight_limit = v; + } + #[getter] + fn immunity_period(&self) -> Option { + self.inner.immunity_period + } + #[setter] + fn set_immunity_period(&mut self, v: Option) { + self.inner.immunity_period = v; + } + #[getter] + fn weights_rate_limit(&self) -> Option { + self.inner.weights_rate_limit + } + #[setter] + fn set_weights_rate_limit(&mut self, v: Option) { + self.inner.weights_rate_limit = v; + } + #[getter] + fn commit_reveal(&self) -> Option { + self.inner.commit_reveal + } + #[setter] + fn set_commit_reveal(&mut self, v: Option) { + self.inner.commit_reveal = v; + } + #[getter] + fn activity_cutoff(&self) -> Option { + self.inner.activity_cutoff + } + #[setter] + fn set_activity_cutoff(&mut self, v: Option) { + self.inner.activity_cutoff = v; + } + + #[getter] + fn neurons(&self) -> Vec { + self.inner + .neuron + .iter() + .cloned() + .map(|inner| PyNeuronConfig { inner }) + .collect() + } + + #[setter] + fn set_neurons(&mut self, value: Vec) { + self.inner.neuron = value.into_iter().map(|n| n.inner).collect(); + } + + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = PyDict::new(py); + dict.set_item("tempo", self.inner.tempo)?; + dict.set_item("max_allowed_validators", self.inner.max_allowed_validators)?; + dict.set_item("max_allowed_uids", self.inner.max_allowed_uids)?; + dict.set_item("min_allowed_weights", self.inner.min_allowed_weights)?; + dict.set_item("max_weight_limit", self.inner.max_weight_limit)?; + dict.set_item("immunity_period", self.inner.immunity_period)?; + dict.set_item("weights_rate_limit", self.inner.weights_rate_limit)?; + dict.set_item("commit_reveal", self.inner.commit_reveal)?; + dict.set_item("activity_cutoff", self.inner.activity_cutoff)?; + let neurons = PyList::empty(py); + for n in &self.inner.neuron { + let cfg = PyNeuronConfig { inner: n.clone() }; + neurons.append(cfg.to_dict(py)?)?; + } + dict.set_item("neurons", neurons)?; + Ok(dict.into_any()) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("SubnetConfig")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("SubnetConfig")) + } +} + +#[pyclass(name = "ChainConfig", module = "agcli._agcli")] +#[derive(Clone)] +pub struct PyChainConfig { + pub(crate) inner: scaffold::ChainConfig, +} + +#[pymethods] +impl PyChainConfig { + #[new] + #[pyo3(signature = ( + image=None, + container=None, + port=None, + start=None, + timeout=None, + ))] + fn new( + image: Option, + container: Option, + port: Option, + start: Option, + timeout: Option, + ) -> Self { + let mut inner = scaffold::ChainConfig::default(); + if let Some(v) = image { + inner.image = v; + } + if let Some(v) = container { + inner.container = v; + } + if let Some(v) = port { + inner.port = v; + } + if let Some(v) = start { + inner.start = v; + } + if let Some(v) = timeout { + inner.timeout = v; + } + Self { inner } + } + + #[getter] + fn image(&self) -> String { + self.inner.image.clone() + } + #[setter] + fn set_image(&mut self, v: String) { + self.inner.image = v; + } + #[getter] + fn container(&self) -> String { + self.inner.container.clone() + } + #[setter] + fn set_container(&mut self, v: String) { + self.inner.container = v; + } + #[getter] + fn port(&self) -> u16 { + self.inner.port + } + #[setter] + fn set_port(&mut self, v: u16) { + self.inner.port = v; + } + #[getter] + fn start(&self) -> bool { + self.inner.start + } + #[setter] + fn set_start(&mut self, v: bool) { + self.inner.start = v; + } + #[getter] + fn timeout(&self) -> u64 { + self.inner.timeout + } + #[setter] + fn set_timeout(&mut self, v: u64) { + self.inner.timeout = v; + } + + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = PyDict::new(py); + dict.set_item("image", &self.inner.image)?; + dict.set_item("container", &self.inner.container)?; + dict.set_item("port", self.inner.port)?; + dict.set_item("start", self.inner.start)?; + dict.set_item("timeout", self.inner.timeout)?; + Ok(dict.into_any()) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("ChainConfig")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("ChainConfig")) + } +} + +#[pyclass(name = "ScaffoldConfig", module = "agcli._agcli")] +#[derive(Clone)] +pub struct PyScaffoldConfig { + pub(crate) inner: scaffold::ScaffoldConfig, +} + +#[pymethods] +impl PyScaffoldConfig { + #[new] + #[pyo3(signature = (chain=None, subnets=None))] + fn new(chain: Option, subnets: Option>) -> Self { + let inner = scaffold::ScaffoldConfig { + chain: chain + .map(|c| c.inner) + .unwrap_or_else(scaffold::ChainConfig::default), + subnet: subnets + .map(|v| v.into_iter().map(|s| s.inner).collect()) + .unwrap_or_default(), + }; + Self { inner } + } + + #[getter] + fn chain(&self) -> PyChainConfig { + PyChainConfig { + inner: self.inner.chain.clone(), + } + } + + #[setter] + fn set_chain(&mut self, value: PyChainConfig) { + self.inner.chain = value.inner; + } + + #[getter] + fn subnets(&self) -> Vec { + self.inner + .subnet + .iter() + .cloned() + .map(|inner| PySubnetConfig { inner }) + .collect() + } + + #[setter] + fn set_subnets(&mut self, value: Vec) { + self.inner.subnet = value.into_iter().map(|s| s.inner).collect(); + } + + fn to_dict<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = PyDict::new(py); + let chain = PyChainConfig { + inner: self.inner.chain.clone(), + }; + dict.set_item("chain", chain.to_dict(py)?)?; + let subnets = PyList::empty(py); + for s in &self.inner.subnet { + let sc = PySubnetConfig { inner: s.clone() }; + subnets.append(sc.to_dict(py)?)?; + } + dict.set_item("subnets", subnets)?; + Ok(dict.into_any()) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("ScaffoldConfig")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("ScaffoldConfig")) + } +} + +#[pyclass(name = "NeuronResult", module = "agcli._agcli")] +#[derive(Clone)] +pub struct PyNeuronResult { + pub(crate) inner: scaffold::NeuronResult, +} + +#[pymethods] +impl PyNeuronResult { + #[getter] + fn name(&self) -> String { + self.inner.name.clone() + } + #[getter] + fn ss58(&self) -> String { + self.inner.ss58.clone() + } + #[getter] + fn seed(&self) -> String { + self.inner.seed.clone() + } + #[getter] + fn uid(&self) -> Option { + self.inner.uid + } + #[getter] + fn balance_tao(&self) -> Option { + self.inner.balance_tao + } + + fn to_dict(&self) -> PyResult { + to_pyobject_unbound(&self.inner) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("NeuronResult")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("NeuronResult")) + } +} + +#[pyclass(name = "SubnetResult", module = "agcli._agcli")] +#[derive(Clone)] +pub struct PySubnetResult { + pub(crate) inner: scaffold::SubnetResult, +} + +#[pymethods] +impl PySubnetResult { + #[getter] + fn netuid(&self) -> u16 { + self.inner.netuid + } + + #[getter] + fn hyperparams(&self) -> PyResult { + Python::with_gil(|py| { + pythonize::pythonize(py, &self.inner.hyperparams) + .map(|b| b.unbind()) + .map_err(|e| map_error(anyhow::anyhow!("{e}"))) + }) + } + + #[getter] + fn neurons(&self) -> Vec { + self.inner + .neurons + .iter() + .cloned() + .map(|inner| PyNeuronResult { inner }) + .collect() + } + + fn to_dict(&self) -> PyResult { + to_pyobject_unbound(&self.inner) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("SubnetResult")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("SubnetResult")) + } +} + +#[pyclass(name = "ScaffoldResult", module = "agcli._agcli")] +#[derive(Clone)] +pub struct PyScaffoldResult { + pub(crate) inner: scaffold::ScaffoldResult, +} + +#[pymethods] +impl PyScaffoldResult { + #[getter] + fn endpoint(&self) -> String { + self.inner.endpoint.clone() + } + #[getter] + fn container(&self) -> Option { + self.inner.container.clone() + } + #[getter] + fn block_height(&self) -> u64 { + self.inner.block_height + } + #[getter] + fn subnets(&self) -> Vec { + self.inner + .subnets + .iter() + .cloned() + .map(|inner| PySubnetResult { inner }) + .collect() + } + + fn to_dict(&self) -> PyResult { + to_pyobject_unbound(&self.inner) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("ScaffoldResult")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("ScaffoldResult")) + } +} + +#[pyfunction] +pub fn load_config(path: String) -> PyResult { + let inner = scaffold::load_config(&path).map_err(map_error)?; + Ok(PyScaffoldConfig { inner }) +} + +#[pyfunction] +pub fn run<'py>( + py: Python<'py>, + config: PyRef<'_, PyScaffoldConfig>, +) -> PyResult> { + let cfg = config.inner.clone(); + future_into_py(py, async move { + let result = scaffold::run(&cfg).await.map_err(map_error)?; + Ok(PyScaffoldResult { inner: result }) + }) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(load_config, m)?)?; + m.add_function(wrap_pyfunction!(run, m)?)?; + Ok(()) +} diff --git a/python/agcli-py/src/types.rs b/python/agcli-py/src/types.rs index 8459565..0b80ce0 100644 --- a/python/agcli-py/src/types.rs +++ b/python/agcli-py/src/types.rs @@ -14,6 +14,53 @@ pub fn to_pyobject_unbound(value: &T) -> PyResult Python::with_gil(|py| to_pyobject(py, value).map(|obj| obj.unbind())) } +pub fn hash_to_hex(hash: agcli::Hash) -> String { + format!("0x{}", hex::encode(hash.as_ref())) +} + +pub fn parse_hash(value: &Bound<'_, PyAny>) -> PyResult { + if let Ok(bytes) = value.extract::>() { + if bytes.len() != 32 { + return Err(map_error(anyhow::anyhow!( + "expected 32-byte hash, got {} bytes", + bytes.len() + ))); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + return Ok(arr.into()); + } + + if let Ok(hex_str) = value.extract::() { + let trimmed = hex_str.trim(); + let raw = trimmed.strip_prefix("0x").unwrap_or(trimmed); + let bytes = hex::decode(raw).map_err(|e| map_error(anyhow::anyhow!("{e}")))?; + if bytes.len() != 32 { + return Err(map_error(anyhow::anyhow!( + "expected 32-byte hash hex string, got {} bytes", + bytes.len() + ))); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + return Ok(arr.into()); + } + + Err(map_error(anyhow::anyhow!( + "expected block hash as bytes or hex string, got {}", + value.get_type().name()? + ))) +} + +pub fn u64_to_u32(block_number: u64) -> PyResult { + if block_number > u32::MAX as u64 { + return Err(map_error(anyhow::anyhow!( + "block number {block_number} exceeds max u32 value" + ))); + } + Ok(block_number as u32) +} + pub fn netuid_from_py(value: &Bound<'_, PyAny>) -> PyResult { if let Ok(uid) = value.extract::() { return Ok(agcli::types::network::NetUid(uid)); @@ -94,18 +141,34 @@ impl PyBalance { } fn __repr__(&self) -> String { - format!("Balance(rao={}, tao={})", self.inner.rao(), self.inner.tao()) + format!( + "Balance(rao={}, tao={})", + self.inner.rao(), + self.inner.tao() + ) } fn __str__(&self) -> String { self.inner.display_tao() } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("Balance")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("Balance")) + } } impl PyBalance { pub fn new_inner(inner: agcli::Balance) -> Self { Self { inner } } + + pub(crate) fn inner(&self) -> agcli::Balance { + self.inner + } } #[pyclass(name = "NetUid", module = "agcli._agcli")] @@ -135,6 +198,14 @@ impl PyNetUid { fn __int__(&self) -> u16 { self.inner.as_u16() } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("NetUid")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("NetUid")) + } } impl PyNetUid { @@ -201,6 +272,14 @@ impl PyNetwork { fn __repr__(&self) -> String { self.inner.to_string() } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("Network")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("Network")) + } } pub fn parse_network(name: &str) -> anyhow::Result { diff --git a/python/agcli-py/src/wallet.rs b/python/agcli-py/src/wallet.rs index a324df4..29d9976 100644 --- a/python/agcli-py/src/wallet.rs +++ b/python/agcli-py/src/wallet.rs @@ -1,11 +1,30 @@ +use std::sync::Arc; + +use agcli::chain::subxt::ext::sp_core::{crypto::Ss58Codec, sr25519, Pair as _}; use agcli::Wallet; use pyo3::prelude::*; +use tokio::sync::Mutex; use crate::errors::map_error; +use crate::runtime::runtime; + +pub(crate) type SharedWallet = Arc>; #[pyclass(name = "Wallet", module = "agcli._agcli")] pub struct PyWallet { - inner: Wallet, + inner: SharedWallet, +} + +impl PyWallet { + pub(crate) fn shared_wallet(&self) -> SharedWallet { + Arc::clone(&self.inner) + } +} + +fn wrap_wallet(wallet: Wallet) -> PyWallet { + PyWallet { + inner: Arc::new(Mutex::new(wallet)), + } } #[pymethods] @@ -13,7 +32,7 @@ impl PyWallet { #[staticmethod] fn open(path: String) -> PyResult { let wallet = Wallet::open(path).map_err(map_error)?; - Ok(Self { inner: wallet }) + Ok(wrap_wallet(wallet)) } #[staticmethod] @@ -27,11 +46,7 @@ impl PyWallet { let hotkey_name = hotkey_name.unwrap_or_else(|| "default".to_string()); let (wallet, coldkey_mnemonic, hotkey_mnemonic) = Wallet::create(wallet_dir, &name, &password, &hotkey_name).map_err(map_error)?; - Ok(( - Self { inner: wallet }, - coldkey_mnemonic, - hotkey_mnemonic, - )) + Ok((wrap_wallet(wallet), coldkey_mnemonic, hotkey_mnemonic)) } #[staticmethod] @@ -42,9 +57,15 @@ impl PyWallet { mnemonic: String, password: String, ) -> PyResult { - let wallet = - Wallet::import_from_mnemonic(wallet_dir, &name, &mnemonic, &password).map_err(map_error)?; - Ok(Self { inner: wallet }) + let wallet = Wallet::import_from_mnemonic(wallet_dir, &name, &mnemonic, &password) + .map_err(map_error)?; + Ok(wrap_wallet(wallet)) + } + + #[staticmethod] + fn create_from_uri(wallet_dir: String, uri: String, password: String) -> PyResult { + let wallet = Wallet::create_from_uri(wallet_dir, &uri, &password).map_err(map_error)?; + Ok(wrap_wallet(wallet)) } #[staticmethod] @@ -52,43 +73,181 @@ impl PyWallet { Wallet::list_wallets(wallet_dir).map_err(map_error) } - fn unlock_coldkey(&mut self, password: String) -> PyResult<()> { - self.inner.unlock_coldkey(&password).map_err(map_error) + fn unlock_coldkey(&self, password: String) -> PyResult<()> { + let wallet = self.shared_wallet(); + runtime() + .block_on(async move { + let mut wallet = wallet.lock().await; + wallet.unlock_coldkey(&password) + }) + .map_err(map_error) + } + + fn unlock_coldkey_with_password(&self, password: String) -> PyResult<()> { + self.unlock_coldkey(password) + } + + fn load_hotkey(&self, hotkey_name: String) -> PyResult<()> { + let wallet = self.shared_wallet(); + runtime() + .block_on(async move { + let mut wallet = wallet.lock().await; + wallet.load_hotkey(&hotkey_name) + }) + .map_err(map_error) + } + + fn lock(&self) -> PyResult<()> { + let wallet = self.shared_wallet(); + runtime() + .block_on(async move { + let mut wallet = wallet.lock().await; + let reopened = Wallet::open(&wallet.path)?; + *wallet = reopened; + Ok(()) + }) + .map_err(map_error) } - fn load_hotkey(&mut self, hotkey_name: String) -> PyResult<()> { - self.inner.load_hotkey(&hotkey_name).map_err(map_error) + #[getter] + fn is_coldkey_unlocked(&self) -> PyResult { + let wallet = self.shared_wallet(); + runtime() + .block_on(async move { + let wallet = wallet.lock().await; + Ok(wallet.coldkey().is_ok()) + }) + .map_err(map_error) + } + + #[getter] + fn is_hotkey_loaded(&self) -> PyResult { + let wallet = self.shared_wallet(); + runtime() + .block_on(async move { + let wallet = wallet.lock().await; + Ok(wallet.hotkey().is_ok()) + }) + .map_err(map_error) } #[getter] - fn name(&self) -> String { - self.inner.name.clone() + fn name(&self) -> PyResult { + let wallet = self.shared_wallet(); + runtime() + .block_on(async move { + let wallet = wallet.lock().await; + Ok(wallet.name.clone()) + }) + .map_err(map_error) } #[getter] - fn path(&self) -> String { - self.inner.path.display().to_string() + fn path(&self) -> PyResult { + let wallet = self.shared_wallet(); + runtime() + .block_on(async move { + let wallet = wallet.lock().await; + Ok(wallet.path.display().to_string()) + }) + .map_err(map_error) } #[getter] - fn coldkey_ss58(&self) -> Option { - self.inner.coldkey_ss58().map(str::to_string) + fn coldkey_ss58(&self) -> PyResult> { + let wallet = self.shared_wallet(); + runtime() + .block_on(async move { + let wallet = wallet.lock().await; + Ok(wallet.coldkey_ss58().map(str::to_string)) + }) + .map_err(map_error) } #[getter] - fn hotkey_ss58(&self) -> Option { - self.inner.hotkey_ss58().map(str::to_string) + fn coldkey_public_ss58(&self) -> PyResult> { + let wallet = self.shared_wallet(); + runtime() + .block_on(async move { + let wallet = wallet.lock().await; + Ok(wallet.coldkey_ss58().map(str::to_string)) + }) + .map_err(map_error) + } + + #[getter] + fn hotkey_ss58(&self) -> PyResult> { + let wallet = self.shared_wallet(); + runtime() + .block_on(async move { + let wallet = wallet.lock().await; + Ok(wallet.hotkey_ss58().map(str::to_string)) + }) + .map_err(map_error) } fn list_hotkeys(&self) -> PyResult> { - self.inner.list_hotkeys().map_err(map_error) + let wallet = self.shared_wallet(); + runtime() + .block_on(async move { + let wallet = wallet.lock().await; + wallet.list_hotkeys() + }) + .map_err(map_error) + } + + fn sign_message(&self, role: &str, message: Vec) -> PyResult> { + let wallet = self.shared_wallet(); + runtime() + .block_on(async move { + let wallet = wallet.lock().await; + let signature = match role { + "coldkey" => wallet.coldkey()?.sign(&message), + "hotkey" => wallet.hotkey()?.sign(&message), + other => { + anyhow::bail!("invalid role '{other}', expected 'coldkey' or 'hotkey'") + } + }; + Ok(signature.0.to_vec()) + }) + .map_err(map_error) + } + + #[staticmethod] + fn verify_message(ss58: String, message: Vec, signature: Vec) -> PyResult { + if signature.len() != 64 { + return Err(map_error(anyhow::anyhow!( + "invalid signature length {}, expected 64 bytes", + signature.len() + ))); + } + let public = sr25519::Public::from_ss58check(&ss58) + .map_err(|e| map_error(anyhow::anyhow!("{e}")))?; + let mut signature_bytes = [0u8; 64]; + signature_bytes.copy_from_slice(&signature); + let sig = sr25519::Signature::from_raw(signature_bytes); + Ok(sr25519::Pair::verify(&sig, &message, &public)) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("Wallet")) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(crate::errors::pickle_blocked("Wallet")) } fn __repr__(&self) -> String { - format!( - "Wallet(name={:?}, path={:?})", - self.inner.name, - self.inner.path - ) + let wallet = self.shared_wallet(); + match runtime().block_on(async move { + let wallet = wallet.lock().await; + Ok::(format!( + "Wallet(name={:?}, path={:?})", + wallet.name, wallet.path + )) + }) { + Ok(value) => value, + Err(_) => "Wallet()".to_string(), + } } } diff --git a/python/agcli/__about__.py b/python/agcli/__about__.py new file mode 100644 index 0000000..8c4e56d --- /dev/null +++ b/python/agcli/__about__.py @@ -0,0 +1,5 @@ +"""Project version metadata.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/python/agcli/__init__.py b/python/agcli/__init__.py index 1f9f52e..8c208e3 100644 --- a/python/agcli/__init__.py +++ b/python/agcli/__init__.py @@ -1,30 +1,72 @@ """agcli — Python bindings for the Bittensor agcli SDK.""" +from agcli import admin, events, live, localnet, scaffold +from agcli.__about__ import __version__ from agcli.client import AsyncClient +from agcli.config import Config from agcli.errors import ( AgcliError, AuthError, ChainError, + IOError, NetworkError, TimeoutError, ValidationError, ) -from agcli.types import Balance, NetUid, Network +from agcli.events import EventFilter, EventStream +from agcli.sync import SyncClient +from agcli.types import ( + AlphaBalance, + Balance, + ChainIdentity, + DelegateInfo, + DynamicInfo, + Metagraph, + NetUid, + Network, + NeuronInfo, + NeuronInfoLite, + StakeInfo, + SubnetHyperparameters, + SubnetIdentity, + SubnetInfo, +) from agcli.wallet import Wallet, WalletCreateResult __all__ = [ + "admin", "AgcliError", + "AlphaBalance", "AsyncClient", "AuthError", "Balance", + "ChainIdentity", "ChainError", + "Config", + "DelegateInfo", + "DynamicInfo", + "events", + "EventFilter", + "EventStream", + "IOError", + "live", + "localnet", + "Metagraph", "NetUid", "Network", "NetworkError", + "NeuronInfo", + "NeuronInfoLite", + "scaffold", + "StakeInfo", + "SubnetHyperparameters", + "SubnetIdentity", + "SubnetInfo", + "SyncClient", "TimeoutError", "ValidationError", "Wallet", "WalletCreateResult", + "__version__", ] -__version__ = "0.1.0" diff --git a/python/agcli/_native.pyi b/python/agcli/_native.pyi index 591159f..e68b186 100644 --- a/python/agcli/_native.pyi +++ b/python/agcli/_native.pyi @@ -2,10 +2,19 @@ from typing import Any +HashInput = bytes | str + class AgcliError(Exception): code: int hint: str | None +class NetworkError(AgcliError): ... +class AuthError(AgcliError): ... +class ValidationError(AgcliError): ... +class ChainError(AgcliError): ... +class TimeoutError(AgcliError): ... +class IOError(AgcliError): ... + class Balance: def __init__(self, *, rao: int | None = None, tao: float | None = None) -> None: ... @staticmethod @@ -17,6 +26,16 @@ class Balance: @property def tao(self) -> float: ... +class AlphaBalance: + def __init__(self, value: Any) -> None: ... + @property + def raw(self) -> int: ... + @property + def rao(self) -> int: ... + @property + def tao(self) -> float: ... + def to_dict(self) -> dict[str, Any]: ... + class NetUid: def __init__(self, value: int) -> None: ... @property @@ -38,22 +57,110 @@ class Network: @property def ws_url(self) -> str: ... +class _TypedSerde: + def __init__(self, value: Any) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class SubnetInfo(_TypedSerde): ... +class NeuronInfo(_TypedSerde): ... +class NeuronInfoLite(_TypedSerde): ... +class Metagraph(_TypedSerde): ... +class StakeInfo(_TypedSerde): ... +class DelegateInfo(_TypedSerde): ... +class DynamicInfo(_TypedSerde): ... +class SubnetHyperparameters(_TypedSerde): ... +class ChainIdentity(_TypedSerde): ... +class SubnetIdentity(_TypedSerde): ... + +class Config: + network: str | None + endpoint: str | None + wallet_dir: str | None + wallet: str | None + hotkey: str | None + output: str | None + proxy: str | None + live_interval: int | None + batch: bool | None + spending_limits: dict[str, float] | None + finalization_timeout: int | None + mortality_blocks: int | None + def __init__( + self, + network: str | None = None, + endpoint: str | None = None, + wallet_dir: str | None = None, + wallet: str | None = None, + hotkey: str | None = None, + output: str | None = None, + proxy: str | None = None, + live_interval: int | None = None, + batch: bool | None = None, + spending_limits: dict[str, float] | None = None, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> None: ... + @staticmethod + def load() -> Config: ... + @staticmethod + def load_from(path: str) -> Config: ... + @staticmethod + def default_path() -> str: ... + def save(self) -> None: ... + def save_to(self, path: str) -> None: ... + class Client: @staticmethod async def connect(url: str) -> Client: ... @staticmethod async def connect_with_retry(urls: list[str]) -> Client: ... @staticmethod + async def best_connection(urls: list[str]) -> Client: ... + @staticmethod async def connect_network(network: Network | None = None) -> Client: ... @staticmethod async def from_config() -> Client: ... @property def endpoint(self) -> str: ... + @property + def network(self) -> Network: ... + def set_dry_run(self, enabled: bool) -> None: ... + def is_dry_run(self) -> bool: ... + def set_finalization_timeout(self, timeout: int) -> None: ... + def finalization_timeout(self) -> int: ... + def set_mortality_blocks(self, blocks: int) -> None: ... + def mortality_blocks(self) -> int: ... + async def reconnect(self) -> None: ... + async def is_alive(self) -> bool: ... + async def invalidate_cache(self) -> None: ... async def get_balance(self, address: str) -> Balance: ... + async def get_balance_at_hash(self, address: str, block_hash: HashInput) -> Balance: ... + async def get_balance_at_block(self, address: str, block_number: int) -> Balance: ... + async def get_balances_multi(self, addresses: list[str]) -> list[tuple[str, dict[str, Any]]]: ... + async def get_block_hash(self, block_number: int) -> str: ... + async def get_block_number(self) -> int: ... + async def get_finalized_block_number(self) -> int: ... + async def pin_latest_block(self) -> str: ... + async def get_total_stake_at_block(self, block_number: int) -> Balance: ... + async def get_block_header(self, block_hash: HashInput) -> tuple[int, str, str, str]: ... + async def get_block_extrinsic_count(self, block_hash: HashInput) -> int: ... + async def get_block_timestamp(self, block_hash: HashInput) -> int | None: ... + async def get_total_issuance(self) -> Balance: ... + async def get_total_stake(self) -> Balance: ... + async def get_total_networks(self) -> int: ... + async def get_block_emission(self) -> Balance: ... + async def get_subnet_registration_cost(self) -> Balance: ... + async def get_network_overview(self) -> tuple[int, dict[str, Any], int, dict[str, Any], dict[str, Any]]: ... + async def get_total_issuance_at(self, block_hash: HashInput) -> Balance: ... + async def get_total_stake_at(self, block_hash: HashInput) -> Balance: ... + async def get_total_networks_at(self, block_hash: HashInput) -> int: ... + async def get_block_emission_at(self, block_hash: HashInput) -> Balance: ... + async def get_block_number_at(self, block_hash: HashInput) -> int: ... async def get_all_subnets(self) -> list[dict[str, Any]]: ... async def get_subnet_info(self, netuid: NetUid | int) -> dict[str, Any] | None: ... async def get_subnet_hyperparams(self, netuid: NetUid | int) -> dict[str, Any] | None: ... async def get_dynamic_info(self, netuid: NetUid | int) -> dict[str, Any] | None: ... + async def get_all_dynamic_info(self) -> list[dict[str, Any]]: ... async def get_metagraph(self, netuid: NetUid | int) -> dict[str, Any]: ... async def get_neuron(self, netuid: NetUid | int, uid: int) -> dict[str, Any] | None: ... async def get_neurons_lite(self, netuid: NetUid | int) -> list[dict[str, Any]]: ... @@ -62,6 +169,107 @@ class Client: async def get_delegate(self, hotkey: str) -> dict[str, Any] | None: ... async def get_identity(self, ss58: str) -> dict[str, Any] | None: ... async def get_subnet_identity(self, netuid: NetUid | int) -> dict[str, Any] | None: ... + async def get_subnet_info_pinned(self, netuid: NetUid | int, block_hash: HashInput) -> dict[str, Any] | None: ... + async def get_subnet_hyperparams_pinned(self, netuid: NetUid | int, block_hash: HashInput) -> dict[str, Any] | None: ... + async def get_identity_pinned(self, ss58: str, block_hash: HashInput) -> dict[str, Any] | None: ... + async def get_subnet_identity_pinned(self, netuid: NetUid | int, block_hash: HashInput) -> dict[str, Any] | None: ... + async def get_delegate_pinned(self, hotkey: str, block_hash: HashInput) -> dict[str, Any] | None: ... + async def list_proxies_pinned(self, ss58: str, block_hash: HashInput) -> list[tuple[str, str, int]]: ... + async def get_coldkey_swap_scheduled_pinned(self, ss58: str, block_hash: HashInput) -> tuple[int, str] | None: ... + async def get_child_keys_pinned(self, hotkey_ss58: str, netuid: NetUid | int, block_hash: HashInput) -> list[tuple[int, str]]: ... + async def get_pending_child_keys_pinned(self, hotkey_ss58: str, netuid: NetUid | int, block_hash: HashInput) -> tuple[list[tuple[int, str]], int] | None: ... + async def get_stake_for_coldkey_pinned(self, coldkey: str, block_hash: HashInput) -> list[dict[str, Any]]: ... + async def get_stake_for_coldkey_at_block(self, coldkey: str, block_number: int) -> list[dict[str, Any]]: ... + async def get_identity_at_block(self, ss58: str, block_number: int) -> dict[str, Any] | None: ... + async def get_all_subnets_at_block(self, block_number: int) -> list[dict[str, Any]]: ... + async def get_all_dynamic_info_at_block(self, block_number: int) -> list[dict[str, Any]]: ... + async def get_dynamic_info_at_block(self, netuid: NetUid | int, block_number: int) -> dict[str, Any] | None: ... + async def get_neurons_lite_at_block(self, netuid: NetUid | int, block_number: int) -> list[dict[str, Any]]: ... + async def get_neuron_at_block(self, netuid: NetUid | int, uid: int, block_number: int) -> dict[str, Any] | None: ... + async def get_delegates_at_block(self, block_number: int) -> list[dict[str, Any]]: ... + async def get_total_issuance_at_block(self, block_number: int) -> Balance: ... + async def list_proxies(self, ss58: str) -> list[tuple[str, str, int]]: ... + async def list_multisig_pending(self, multisig_ss58: str) -> list[tuple[str, int, int, int, int]]: ... + async def list_proxy_announcements(self, ss58: str) -> list[tuple[str, str, int]]: ... + async def get_coldkey_swap_scheduled(self, ss58: str) -> tuple[int, str] | None: ... + async def get_child_keys(self, hotkey_ss58: str, netuid: NetUid | int) -> list[tuple[int, str]]: ... + async def get_parent_keys(self, hotkey_ss58: str, netuid: NetUid | int) -> list[tuple[int, str]]: ... + async def get_pending_child_keys(self, hotkey_ss58: str, netuid: NetUid | int) -> tuple[list[tuple[int, str]], int] | None: ... + async def get_delegated(self, hotkey_ss58: str) -> list[dict[str, Any]]: ... + async def get_weight_commits(self, netuid: NetUid | int, hotkey_ss58: str) -> list[tuple[str, int, int, int]] | None: ... + async def get_all_weight_commits(self, netuid: NetUid | int) -> list[tuple[str, list[tuple[str, int, int, int]]]]: ... + async def get_reveal_period_epochs(self, netuid: NetUid | int) -> int: ... + async def get_weights_for_uid(self, netuid: NetUid | int, uid: int) -> list[tuple[int, int]]: ... + async def get_all_weights(self, netuid: NetUid | int) -> list[tuple[int, list[tuple[int, int]]]]: ... + async def get_commit_reveal_weights_version(self) -> int: ... + async def get_commitment(self, netuid: int, hotkey_ss58: str) -> tuple[int, list[str]] | None: ... + async def get_all_commitments(self, netuid: int) -> list[tuple[str, int, list[str]]]: ... + async def get_block_info_for_pow(self) -> tuple[int, str]: ... + async def get_difficulty(self, netuid: NetUid | int) -> int: ... + async def fetch_portfolio(self, coldkey_ss58: str) -> dict[str, Any]: ... + async def transfer(self, wallet: Wallet, dest_ss58: str, amount: Balance | int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def transfer_all(self, wallet: Wallet, dest_ss58: str, keep_alive: bool = False, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def add_stake(self, wallet: Wallet, netuid: NetUid | int, amount: Balance | int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def remove_stake(self, wallet: Wallet, netuid: NetUid | int, amount: Balance | int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def add_stake_limit(self, wallet: Wallet, netuid: NetUid | int, amount: Balance | int, limit_price: int, allow_partial: bool = True, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def remove_stake_limit(self, wallet: Wallet, netuid: NetUid | int, amount: Balance | int, limit_price: int, allow_partial: bool = True, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def move_stake(self, wallet: Wallet, from_netuid: NetUid | int, to_netuid: NetUid | int, amount: Balance | int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def swap_stake(self, wallet: Wallet, from_netuid: NetUid | int, to_netuid: NetUid | int, amount: Balance | int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def swap_stake_limit(self, wallet: Wallet, from_netuid: NetUid | int, to_netuid: NetUid | int, amount: Balance | int, limit_price: int, allow_partial: bool = True, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def transfer_stake(self, wallet: Wallet, dest_ss58: str, from_netuid: NetUid | int, to_netuid: NetUid | int, amount: Balance | int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def unstake_all(self, wallet: Wallet, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def unstake_all_alpha(self, wallet: Wallet, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def recycle_alpha(self, wallet: Wallet, netuid: NetUid | int, amount: int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def burn_alpha(self, wallet: Wallet, netuid: NetUid | int, amount: int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def claim_root(self, wallet: Wallet, subnets: list[int], *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def set_weights(self, wallet: Wallet, netuid: NetUid | int, uids: list[int], values: list[int], version_key: int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def commit_weights(self, wallet: Wallet, netuid: NetUid | int, commit_hash: HashInput, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def reveal_weights(self, wallet: Wallet, netuid: NetUid | int, uids: list[int], values: list[int], salt: list[int], version_key: int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def register_network(self, wallet: Wallet, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def burned_register(self, wallet: Wallet, netuid: NetUid | int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def pow_register(self, wallet: Wallet, netuid: NetUid | int, block_number: int, nonce: int, work: HashInput, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def root_register(self, wallet: Wallet, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def dissolve_network(self, wallet: Wallet, netuid: NetUid | int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def add_proxy(self, wallet: Wallet, delegate_ss58: str, proxy_type: str, delay: int = 0, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def remove_proxy(self, wallet: Wallet, delegate_ss58: str, proxy_type: str, delay: int = 0, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def create_pure_proxy(self, wallet: Wallet, proxy_type: str, delay: int = 0, index: int = 0, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def kill_pure_proxy(self, wallet: Wallet, spawner_ss58: str, proxy_type: str, index: int, height: int, ext_index: int, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def try_associate_hotkey(self, wallet: Wallet, hotkey_ss58: str | None = None, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def schedule_swap_coldkey(self, wallet: Wallet, new_coldkey_ss58: str, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def swap_hotkey(self, wallet: Wallet, old_hotkey_ss58: str, new_hotkey_ss58: str, *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + async def set_subnet_identity(self, wallet: Wallet, netuid: NetUid | int, identity: SubnetIdentity | dict[str, Any], *, wait: bool = True, mev: bool = True, dry_run: bool = False, finalization_timeout: int | None = None, mortality_blocks: int | None = None) -> str: ... + def subscribe_events(self, filter: Any = None) -> EventStream: ... + def subscribe_events_filtered(self, filter: Any) -> EventStream: ... + def subscribe_blocks(self) -> EventStream: ... + +class ClientSync: + @staticmethod + def connect(url: str) -> ClientSync: ... + @staticmethod + def connect_with_retry(urls: list[str]) -> ClientSync: ... + @staticmethod + def best_connection(urls: list[str]) -> ClientSync: ... + @staticmethod + def connect_network(network: Network | None = None) -> ClientSync: ... + @staticmethod + def from_config() -> ClientSync: ... + @property + def endpoint(self) -> str: ... + @property + def network(self) -> Network: ... + def set_dry_run(self, enabled: bool) -> None: ... + def is_dry_run(self) -> bool: ... + def set_finalization_timeout(self, timeout: int) -> None: ... + def finalization_timeout(self) -> int: ... + def set_mortality_blocks(self, blocks: int) -> None: ... + def mortality_blocks(self) -> int: ... + def reconnect(self) -> None: ... + def is_alive(self) -> bool: ... + def invalidate_cache(self) -> None: ... + def get_balance(self, address: str) -> Balance: ... + def get_total_issuance(self) -> Balance: ... + def get_metagraph(self, netuid: NetUid | int) -> dict[str, Any]: ... + def __getattr__(self, name: str) -> Any: ... class Wallet: @staticmethod @@ -81,9 +289,17 @@ class Wallet: password: str, ) -> Wallet: ... @staticmethod + def create_from_uri(wallet_dir: str, uri: str, password: str) -> Wallet: ... + @staticmethod def list_wallets(wallet_dir: str) -> list[str]: ... def unlock_coldkey(self, password: str) -> None: ... + def unlock_coldkey_with_password(self, password: str) -> None: ... def load_hotkey(self, hotkey_name: str) -> None: ... + def lock(self) -> None: ... + @property + def is_coldkey_unlocked(self) -> bool: ... + @property + def is_hotkey_loaded(self) -> bool: ... @property def name(self) -> str: ... @property @@ -91,5 +307,259 @@ class Wallet: @property def coldkey_ss58(self) -> str | None: ... @property + def coldkey_public_ss58(self) -> str | None: ... + @property def hotkey_ss58(self) -> str | None: ... def list_hotkeys(self) -> list[str]: ... + def sign_message(self, role: str, message: bytes) -> bytes: ... + @staticmethod + def verify_message(ss58: str, message: bytes, signature: bytes) -> bool: ... + +def raise_test_error(message: str) -> None: ... + +class EventStream: + def __aiter__(self) -> EventStream: ... + async def __anext__(self) -> dict[str, Any]: ... + async def close(self) -> None: ... + +class LocalnetConfig: + image: str + container_name: str + port: int + wait: bool + wait_timeout: int + def __init__( + self, + image: str | None = None, + container_name: str | None = None, + port: int | None = None, + wait: bool | None = None, + wait_timeout: int | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class DevAccount: + name: str + uri: str + ss58: str + balance: str + def to_dict(self) -> dict[str, Any]: ... + +class LocalnetInfo: + container_name: str + container_id: str + image: str + endpoint: str + port: int + block_height: int + dev_accounts: list[DevAccount] + def to_dict(self) -> dict[str, Any]: ... + +class LocalnetStatus: + running: bool + container_name: str + container_id: str | None + image: str | None + endpoint: str | None + block_height: int | None + started_at: str | None + uptime: str | None + def to_dict(self) -> dict[str, Any]: ... + +class NeuronConfig: + name: str + fund_tao: float | None + register: bool + def __init__( + self, name: str, fund_tao: float | None = None, register: bool = True + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class SubnetConfig: + tempo: int | None + max_allowed_validators: int | None + max_allowed_uids: int | None + min_allowed_weights: int | None + max_weight_limit: int | None + immunity_period: int | None + weights_rate_limit: int | None + commit_reveal: bool | None + activity_cutoff: int | None + neurons: list[NeuronConfig] + def __init__( + self, + tempo: int | None = None, + max_allowed_validators: int | None = None, + max_allowed_uids: int | None = None, + min_allowed_weights: int | None = None, + max_weight_limit: int | None = None, + immunity_period: int | None = None, + weights_rate_limit: int | None = None, + commit_reveal: bool | None = None, + activity_cutoff: int | None = None, + neurons: list[NeuronConfig] | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class ChainConfig: + image: str + container: str + port: int + start: bool + timeout: int + def __init__( + self, + image: str | None = None, + container: str | None = None, + port: int | None = None, + start: bool | None = None, + timeout: int | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class ScaffoldConfig: + chain: ChainConfig + subnets: list[SubnetConfig] + def __init__( + self, + chain: ChainConfig | None = None, + subnets: list[SubnetConfig] | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class NeuronResult: + name: str + ss58: str + seed: str + uid: int | None + balance_tao: float | None + def to_dict(self) -> dict[str, Any]: ... + +class SubnetResult: + netuid: int + hyperparams: dict[str, Any] + neurons: list[NeuronResult] + def to_dict(self) -> dict[str, Any]: ... + +class ScaffoldResult: + endpoint: str + container: str | None + block_height: int + subnets: list[SubnetResult] + def to_dict(self) -> dict[str, Any]: ... + +class _LiveModule: + @staticmethod + async def live_dynamic(client: Client, interval_secs: int = 5) -> None: ... + @staticmethod + async def live_metagraph( + client: Client, netuid: NetUid | int, interval_secs: int = 5 + ) -> None: ... + @staticmethod + async def live_portfolio( + client: Client, coldkey_ss58: str, interval_secs: int = 5 + ) -> None: ... + +class _LocalnetModule: + DEFAULT_IMAGE: str + DEFAULT_CONTAINER: str + DEFAULT_WS: str + @staticmethod + def dev_accounts() -> list[DevAccount]: ... + @staticmethod + async def start(config: LocalnetConfig | None = None) -> LocalnetInfo: ... + @staticmethod + def stop(container_name: str) -> None: ... + @staticmethod + async def status( + container_name: str | None = None, port: int = 9944 + ) -> LocalnetStatus: ... + @staticmethod + async def reset(config: LocalnetConfig | None = None) -> LocalnetInfo: ... + @staticmethod + def logs(container_name: str, tail: int | None = None) -> str: ... + +class _ScaffoldModule: + @staticmethod + def load_config(path: str) -> ScaffoldConfig: ... + @staticmethod + async def run(config: ScaffoldConfig) -> ScaffoldResult: ... + +class _AdminModule: + @staticmethod + def known_params() -> list[tuple[str, str, list[str]]]: ... + @staticmethod + async def raw_admin_call(client: Client, wallet: Wallet, call_name: str, args: list[Any]) -> str: ... + @staticmethod + async def set_tempo(client: Client, wallet: Wallet, netuid: NetUid | int, tempo: int) -> str: ... + @staticmethod + async def set_max_allowed_validators(client: Client, wallet: Wallet, netuid: NetUid | int, max: int) -> str: ... + @staticmethod + async def set_max_allowed_uids(client: Client, wallet: Wallet, netuid: NetUid | int, max: int) -> str: ... + @staticmethod + async def set_immunity_period(client: Client, wallet: Wallet, netuid: NetUid | int, period: int) -> str: ... + @staticmethod + async def set_min_allowed_weights(client: Client, wallet: Wallet, netuid: NetUid | int, min: int) -> str: ... + @staticmethod + async def set_max_weight_limit(client: Client, wallet: Wallet, netuid: NetUid | int, limit: int) -> str: ... + @staticmethod + async def set_weights_set_rate_limit(client: Client, wallet: Wallet, netuid: NetUid | int, limit: int) -> str: ... + @staticmethod + async def set_commit_reveal_weights_enabled(client: Client, wallet: Wallet, netuid: NetUid | int, enabled: bool) -> str: ... + @staticmethod + async def set_difficulty(client: Client, wallet: Wallet, netuid: NetUid | int, difficulty: int) -> str: ... + @staticmethod + async def set_bonds_moving_average(client: Client, wallet: Wallet, netuid: NetUid | int, avg: int) -> str: ... + @staticmethod + async def set_target_registrations_per_interval(client: Client, wallet: Wallet, netuid: NetUid | int, target: int) -> str: ... + @staticmethod + async def set_activity_cutoff(client: Client, wallet: Wallet, netuid: NetUid | int, cutoff: int) -> str: ... + @staticmethod + async def set_serving_rate_limit(client: Client, wallet: Wallet, netuid: NetUid | int, limit: int) -> str: ... + @staticmethod + async def set_default_take(client: Client, wallet: Wallet, take: int) -> str: ... + @staticmethod + async def set_tx_rate_limit(client: Client, wallet: Wallet, limit: int) -> str: ... + @staticmethod + async def set_min_difficulty(client: Client, wallet: Wallet, netuid: NetUid | int, min: int) -> str: ... + @staticmethod + async def set_max_difficulty(client: Client, wallet: Wallet, netuid: NetUid | int, max: int) -> str: ... + @staticmethod + async def set_adjustment_interval(client: Client, wallet: Wallet, netuid: NetUid | int, interval: int) -> str: ... + @staticmethod + async def set_adjustment_alpha(client: Client, wallet: Wallet, netuid: NetUid | int, alpha: int) -> str: ... + @staticmethod + async def set_kappa(client: Client, wallet: Wallet, netuid: NetUid | int, kappa: int) -> str: ... + @staticmethod + async def set_rho(client: Client, wallet: Wallet, netuid: NetUid | int, rho: int) -> str: ... + @staticmethod + async def set_min_burn(client: Client, wallet: Wallet, netuid: NetUid | int, min_burn: int) -> str: ... + @staticmethod + async def set_max_burn(client: Client, wallet: Wallet, netuid: NetUid | int, max_burn: int) -> str: ... + @staticmethod + async def set_liquid_alpha_enabled(client: Client, wallet: Wallet, netuid: NetUid | int, enabled: bool) -> str: ... + @staticmethod + async def set_alpha_values(client: Client, wallet: Wallet, netuid: NetUid | int, alpha_low: int, alpha_high: int) -> str: ... + @staticmethod + async def set_yuma3_enabled(client: Client, wallet: Wallet, netuid: NetUid | int, enabled: bool) -> str: ... + @staticmethod + async def set_bonds_penalty(client: Client, wallet: Wallet, netuid: NetUid | int, penalty: int) -> str: ... + @staticmethod + async def set_subnet_moving_alpha(client: Client, wallet: Wallet, alpha: int) -> str: ... + @staticmethod + async def set_mechanism_count(client: Client, wallet: Wallet, netuid: NetUid | int, count: int) -> str: ... + @staticmethod + async def set_mechanism_emission_split(client: Client, wallet: Wallet, netuid: NetUid | int, split: list[int]) -> str: ... + @staticmethod + async def set_stake_threshold(client: Client, wallet: Wallet, threshold: int) -> str: ... + @staticmethod + async def set_nominator_min_required_stake(client: Client, wallet: Wallet, min_stake: int) -> str: ... + @staticmethod + async def set_network_registration_allowed(client: Client, wallet: Wallet, netuid: NetUid | int, allowed: bool) -> str: ... + @staticmethod + async def set_network_pow_registration_allowed(client: Client, wallet: Wallet, netuid: NetUid | int, allowed: bool) -> str: ... + +live: _LiveModule +localnet: _LocalnetModule +scaffold: _ScaffoldModule +admin: _AdminModule diff --git a/python/agcli/admin.py b/python/agcli/admin.py new file mode 100644 index 0000000..78de386 --- /dev/null +++ b/python/agcli/admin.py @@ -0,0 +1,289 @@ +"""AdminUtils sudo helpers — re-exports `agcli::admin`. + +Each `set_*` function takes `(client, wallet, netuid, value)` (or +`(client, wallet, value)` for global params) and returns the extrinsic hash as +a hex string. `raw_admin_call` lets you invoke any AdminUtils extrinsic by name +with a positional list of arguments (ints, bools, or strings). +""" + +from __future__ import annotations + +from typing import Any + +from agcli import _agcli +from agcli.client import AsyncClient +from agcli.types import NetUid +from agcli.wallet import Wallet + + +def known_params() -> list[tuple[str, str, list[str]]]: + """Return `(name, description, arg_types)` for every known admin param.""" + return _agcli.admin.known_params() + + +async def raw_admin_call( + client: AsyncClient, + wallet: Wallet, + call_name: str, + args: list[Any], +) -> str: + return await _agcli.admin.raw_admin_call( + client._inner, wallet._inner, call_name, args + ) + + +async def set_tempo(client: AsyncClient, wallet: Wallet, netuid: NetUid | int, tempo: int) -> str: + return await _agcli.admin.set_tempo(client._inner, wallet._inner, netuid, tempo) + + +async def set_max_allowed_validators( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, max: int +) -> str: + return await _agcli.admin.set_max_allowed_validators(client._inner, wallet._inner, netuid, max) + + +async def set_max_allowed_uids( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, max: int +) -> str: + return await _agcli.admin.set_max_allowed_uids(client._inner, wallet._inner, netuid, max) + + +async def set_immunity_period( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, period: int +) -> str: + return await _agcli.admin.set_immunity_period(client._inner, wallet._inner, netuid, period) + + +async def set_min_allowed_weights( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, min: int +) -> str: + return await _agcli.admin.set_min_allowed_weights(client._inner, wallet._inner, netuid, min) + + +async def set_max_weight_limit( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, limit: int +) -> str: + return await _agcli.admin.set_max_weight_limit(client._inner, wallet._inner, netuid, limit) + + +async def set_weights_set_rate_limit( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, limit: int +) -> str: + return await _agcli.admin.set_weights_set_rate_limit(client._inner, wallet._inner, netuid, limit) + + +async def set_commit_reveal_weights_enabled( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, enabled: bool +) -> str: + return await _agcli.admin.set_commit_reveal_weights_enabled( + client._inner, wallet._inner, netuid, enabled + ) + + +async def set_difficulty( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, difficulty: int +) -> str: + return await _agcli.admin.set_difficulty(client._inner, wallet._inner, netuid, difficulty) + + +async def set_bonds_moving_average( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, avg: int +) -> str: + return await _agcli.admin.set_bonds_moving_average(client._inner, wallet._inner, netuid, avg) + + +async def set_target_registrations_per_interval( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, target: int +) -> str: + return await _agcli.admin.set_target_registrations_per_interval( + client._inner, wallet._inner, netuid, target + ) + + +async def set_activity_cutoff( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, cutoff: int +) -> str: + return await _agcli.admin.set_activity_cutoff(client._inner, wallet._inner, netuid, cutoff) + + +async def set_serving_rate_limit( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, limit: int +) -> str: + return await _agcli.admin.set_serving_rate_limit(client._inner, wallet._inner, netuid, limit) + + +async def set_default_take(client: AsyncClient, wallet: Wallet, take: int) -> str: + return await _agcli.admin.set_default_take(client._inner, wallet._inner, take) + + +async def set_tx_rate_limit(client: AsyncClient, wallet: Wallet, limit: int) -> str: + return await _agcli.admin.set_tx_rate_limit(client._inner, wallet._inner, limit) + + +async def set_min_difficulty( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, min: int +) -> str: + return await _agcli.admin.set_min_difficulty(client._inner, wallet._inner, netuid, min) + + +async def set_max_difficulty( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, max: int +) -> str: + return await _agcli.admin.set_max_difficulty(client._inner, wallet._inner, netuid, max) + + +async def set_adjustment_interval( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, interval: int +) -> str: + return await _agcli.admin.set_adjustment_interval( + client._inner, wallet._inner, netuid, interval + ) + + +async def set_adjustment_alpha( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, alpha: int +) -> str: + return await _agcli.admin.set_adjustment_alpha(client._inner, wallet._inner, netuid, alpha) + + +async def set_kappa( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, kappa: int +) -> str: + return await _agcli.admin.set_kappa(client._inner, wallet._inner, netuid, kappa) + + +async def set_rho( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, rho: int +) -> str: + return await _agcli.admin.set_rho(client._inner, wallet._inner, netuid, rho) + + +async def set_min_burn( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, min_burn: int +) -> str: + return await _agcli.admin.set_min_burn(client._inner, wallet._inner, netuid, min_burn) + + +async def set_max_burn( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, max_burn: int +) -> str: + return await _agcli.admin.set_max_burn(client._inner, wallet._inner, netuid, max_burn) + + +async def set_liquid_alpha_enabled( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, enabled: bool +) -> str: + return await _agcli.admin.set_liquid_alpha_enabled(client._inner, wallet._inner, netuid, enabled) + + +async def set_alpha_values( + client: AsyncClient, + wallet: Wallet, + netuid: NetUid | int, + alpha_low: int, + alpha_high: int, +) -> str: + return await _agcli.admin.set_alpha_values( + client._inner, wallet._inner, netuid, alpha_low, alpha_high + ) + + +async def set_yuma3_enabled( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, enabled: bool +) -> str: + return await _agcli.admin.set_yuma3_enabled(client._inner, wallet._inner, netuid, enabled) + + +async def set_bonds_penalty( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, penalty: int +) -> str: + return await _agcli.admin.set_bonds_penalty(client._inner, wallet._inner, netuid, penalty) + + +async def set_subnet_moving_alpha(client: AsyncClient, wallet: Wallet, alpha: int) -> str: + return await _agcli.admin.set_subnet_moving_alpha(client._inner, wallet._inner, alpha) + + +async def set_mechanism_count( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, count: int +) -> str: + return await _agcli.admin.set_mechanism_count(client._inner, wallet._inner, netuid, count) + + +async def set_mechanism_emission_split( + client: AsyncClient, + wallet: Wallet, + netuid: NetUid | int, + split: list[int], +) -> str: + return await _agcli.admin.set_mechanism_emission_split( + client._inner, wallet._inner, netuid, split + ) + + +async def set_stake_threshold(client: AsyncClient, wallet: Wallet, threshold: int) -> str: + return await _agcli.admin.set_stake_threshold(client._inner, wallet._inner, threshold) + + +async def set_nominator_min_required_stake( + client: AsyncClient, wallet: Wallet, min_stake: int +) -> str: + return await _agcli.admin.set_nominator_min_required_stake( + client._inner, wallet._inner, min_stake + ) + + +async def set_network_registration_allowed( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, allowed: bool +) -> str: + return await _agcli.admin.set_network_registration_allowed( + client._inner, wallet._inner, netuid, allowed + ) + + +async def set_network_pow_registration_allowed( + client: AsyncClient, wallet: Wallet, netuid: NetUid | int, allowed: bool +) -> str: + return await _agcli.admin.set_network_pow_registration_allowed( + client._inner, wallet._inner, netuid, allowed + ) + + +__all__ = [ + "known_params", + "raw_admin_call", + "set_activity_cutoff", + "set_adjustment_alpha", + "set_adjustment_interval", + "set_alpha_values", + "set_bonds_moving_average", + "set_bonds_penalty", + "set_commit_reveal_weights_enabled", + "set_default_take", + "set_difficulty", + "set_immunity_period", + "set_kappa", + "set_liquid_alpha_enabled", + "set_max_allowed_uids", + "set_max_allowed_validators", + "set_max_burn", + "set_max_difficulty", + "set_max_weight_limit", + "set_mechanism_count", + "set_mechanism_emission_split", + "set_min_allowed_weights", + "set_min_burn", + "set_min_difficulty", + "set_network_pow_registration_allowed", + "set_network_registration_allowed", + "set_nominator_min_required_stake", + "set_rho", + "set_serving_rate_limit", + "set_stake_threshold", + "set_subnet_moving_alpha", + "set_target_registrations_per_interval", + "set_tempo", + "set_tx_rate_limit", + "set_weights_set_rate_limit", + "set_yuma3_enabled", +] diff --git a/python/agcli/client.py b/python/agcli/client.py index 81b7e82..c34bbe8 100644 --- a/python/agcli/client.py +++ b/python/agcli/client.py @@ -5,7 +5,25 @@ from typing import Any from agcli import _agcli -from agcli.types import Balance, NetUid, Network +from agcli.events import EventFilter, _wrap_stream +from agcli.types import ( + Balance, + ChainIdentity, + DelegateInfo, + DynamicInfo, + Metagraph, + NetUid, + Network, + NeuronInfo, + NeuronInfoLite, + StakeInfo, + SubnetHyperparameters, + SubnetIdentity, + SubnetInfo, +) +from agcli.wallet import Wallet + +HashInput = bytes | str class AsyncClient: @@ -22,6 +40,10 @@ async def connect(cls, url: str) -> AsyncClient: async def connect_with_retry(cls, urls: list[str]) -> AsyncClient: return cls(await _agcli.Client.connect_with_retry(urls)) + @classmethod + async def best_connection(cls, urls: list[str]) -> AsyncClient: + return cls(await _agcli.Client.best_connection(urls)) + @classmethod async def connect_network(cls, network: Network | None = None) -> AsyncClient: return cls(await _agcli.Client.connect_network(network)) @@ -34,44 +56,1104 @@ async def from_config(cls) -> AsyncClient: def endpoint(self) -> str: return self._inner.endpoint + @property + def network(self) -> Network: + return self._inner.network + + def set_dry_run(self, enabled: bool) -> None: + self._inner.set_dry_run(enabled) + + def is_dry_run(self) -> bool: + return self._inner.is_dry_run() + + def set_finalization_timeout(self, timeout: int) -> None: + self._inner.set_finalization_timeout(timeout) + + def finalization_timeout(self) -> int: + return self._inner.finalization_timeout() + + def set_mortality_blocks(self, blocks: int) -> None: + self._inner.set_mortality_blocks(blocks) + + def mortality_blocks(self) -> int: + return self._inner.mortality_blocks() + + async def reconnect(self) -> None: + await self._inner.reconnect() + + async def is_alive(self) -> bool: + return await self._inner.is_alive() + + async def invalidate_cache(self) -> None: + await self._inner.invalidate_cache() + async def get_balance(self, address: str) -> Balance: return await self._inner.get_balance(address) + async def get_balance_at_hash(self, address: str, block_hash: HashInput) -> Balance: + return await self._inner.get_balance_at_hash(address, block_hash) + + async def get_balance_at_block(self, address: str, block_number: int) -> Balance: + return await self._inner.get_balance_at_block(address, block_number) + + async def get_balances_multi(self, addresses: list[str]) -> list[tuple[str, dict[str, Any]]]: + return await self._inner.get_balances_multi(addresses) + + async def get_block_hash(self, block_number: int) -> str: + return await self._inner.get_block_hash(block_number) + + async def get_block_number(self) -> int: + return await self._inner.get_block_number() + + async def get_finalized_block_number(self) -> int: + return await self._inner.get_finalized_block_number() + + async def pin_latest_block(self) -> str: + return await self._inner.pin_latest_block() + + async def get_total_stake_at_block(self, block_number: int) -> Balance: + return await self._inner.get_total_stake_at_block(block_number) + + async def get_block_header( + self, block_hash: HashInput + ) -> tuple[int, str, str, str]: + return await self._inner.get_block_header(block_hash) + + async def get_block_extrinsic_count(self, block_hash: HashInput) -> int: + return await self._inner.get_block_extrinsic_count(block_hash) + + async def get_block_timestamp(self, block_hash: HashInput) -> int | None: + return await self._inner.get_block_timestamp(block_hash) + + async def get_total_issuance(self) -> Balance: + return await self._inner.get_total_issuance() + + async def get_total_stake(self) -> Balance: + return await self._inner.get_total_stake() + + async def get_total_networks(self) -> int: + return await self._inner.get_total_networks() + + async def get_block_emission(self) -> Balance: + return await self._inner.get_block_emission() + + async def get_subnet_registration_cost(self) -> Balance: + return await self._inner.get_subnet_registration_cost() + + async def get_network_overview(self) -> tuple[int, dict[str, Any], int, dict[str, Any], dict[str, Any]]: + return await self._inner.get_network_overview() + + async def get_total_issuance_at(self, block_hash: HashInput) -> Balance: + return await self._inner.get_total_issuance_at(block_hash) + + async def get_total_stake_at(self, block_hash: HashInput) -> Balance: + return await self._inner.get_total_stake_at(block_hash) + + async def get_total_networks_at(self, block_hash: HashInput) -> int: + return await self._inner.get_total_networks_at(block_hash) + + async def get_block_emission_at(self, block_hash: HashInput) -> Balance: + return await self._inner.get_block_emission_at(block_hash) + + async def get_block_number_at(self, block_hash: HashInput) -> int: + return await self._inner.get_block_number_at(block_hash) + async def get_all_subnets(self) -> list[dict[str, Any]]: return await self._inner.get_all_subnets() + async def get_all_subnets_typed(self) -> list[SubnetInfo]: + return [SubnetInfo(v) for v in await self.get_all_subnets()] + async def get_subnet_info(self, netuid: NetUid | int) -> dict[str, Any] | None: return await self._inner.get_subnet_info(netuid) + async def get_subnet_info_typed(self, netuid: NetUid | int) -> SubnetInfo | None: + value = await self.get_subnet_info(netuid) + return None if value is None else SubnetInfo(value) + async def get_subnet_hyperparams(self, netuid: NetUid | int) -> dict[str, Any] | None: return await self._inner.get_subnet_hyperparams(netuid) + async def get_subnet_hyperparams_typed( + self, netuid: NetUid | int + ) -> SubnetHyperparameters | None: + value = await self.get_subnet_hyperparams(netuid) + return None if value is None else SubnetHyperparameters(value) + async def get_dynamic_info(self, netuid: NetUid | int) -> dict[str, Any] | None: return await self._inner.get_dynamic_info(netuid) + async def get_dynamic_info_typed(self, netuid: NetUid | int) -> DynamicInfo | None: + value = await self.get_dynamic_info(netuid) + return None if value is None else DynamicInfo(value) + + async def get_all_dynamic_info(self) -> list[dict[str, Any]]: + return await self._inner.get_all_dynamic_info() + + async def get_all_dynamic_info_typed(self) -> list[DynamicInfo]: + return [DynamicInfo(v) for v in await self.get_all_dynamic_info()] + async def get_metagraph(self, netuid: NetUid | int) -> dict[str, Any]: return await self._inner.get_metagraph(netuid) + async def get_metagraph_typed(self, netuid: NetUid | int) -> Metagraph: + return Metagraph(await self.get_metagraph(netuid)) + async def get_neuron(self, netuid: NetUid | int, uid: int) -> dict[str, Any] | None: return await self._inner.get_neuron(netuid, uid) + async def get_neuron_typed(self, netuid: NetUid | int, uid: int) -> NeuronInfo | None: + value = await self.get_neuron(netuid, uid) + return None if value is None else NeuronInfo(value) + async def get_neurons_lite(self, netuid: NetUid | int) -> list[dict[str, Any]]: return await self._inner.get_neurons_lite(netuid) + async def get_neurons_lite_typed(self, netuid: NetUid | int) -> list[NeuronInfoLite]: + return [NeuronInfoLite(v) for v in await self.get_neurons_lite(netuid)] + async def get_stake_for_coldkey(self, coldkey: str) -> list[dict[str, Any]]: return await self._inner.get_stake_for_coldkey(coldkey) + async def get_stake_for_coldkey_typed(self, coldkey: str) -> list[StakeInfo]: + return [StakeInfo(v) for v in await self.get_stake_for_coldkey(coldkey)] + async def get_delegates(self) -> list[dict[str, Any]]: return await self._inner.get_delegates() + async def get_delegates_typed(self) -> list[DelegateInfo]: + return [DelegateInfo(v) for v in await self.get_delegates()] + async def get_delegate(self, hotkey: str) -> dict[str, Any] | None: return await self._inner.get_delegate(hotkey) + async def get_delegate_typed(self, hotkey: str) -> DelegateInfo | None: + value = await self.get_delegate(hotkey) + return None if value is None else DelegateInfo(value) + async def get_identity(self, ss58: str) -> dict[str, Any] | None: return await self._inner.get_identity(ss58) + async def get_identity_typed(self, ss58: str) -> ChainIdentity | None: + value = await self.get_identity(ss58) + return None if value is None else ChainIdentity(value) + async def get_subnet_identity(self, netuid: NetUid | int) -> dict[str, Any] | None: return await self._inner.get_subnet_identity(netuid) + async def get_subnet_identity_typed( + self, netuid: NetUid | int + ) -> SubnetIdentity | None: + value = await self.get_subnet_identity(netuid) + return None if value is None else SubnetIdentity(value) + + async def get_subnet_info_pinned( + self, netuid: NetUid | int, block_hash: HashInput + ) -> dict[str, Any] | None: + return await self._inner.get_subnet_info_pinned(netuid, block_hash) + + async def get_subnet_hyperparams_pinned( + self, netuid: NetUid | int, block_hash: HashInput + ) -> dict[str, Any] | None: + return await self._inner.get_subnet_hyperparams_pinned(netuid, block_hash) + + async def get_identity_pinned(self, ss58: str, block_hash: HashInput) -> dict[str, Any] | None: + return await self._inner.get_identity_pinned(ss58, block_hash) + + async def get_subnet_identity_pinned( + self, netuid: NetUid | int, block_hash: HashInput + ) -> dict[str, Any] | None: + return await self._inner.get_subnet_identity_pinned(netuid, block_hash) + + async def get_delegate_pinned( + self, hotkey: str, block_hash: HashInput + ) -> dict[str, Any] | None: + return await self._inner.get_delegate_pinned(hotkey, block_hash) + + async def list_proxies_pinned(self, ss58: str, block_hash: HashInput) -> list[tuple[str, str, int]]: + return await self._inner.list_proxies_pinned(ss58, block_hash) + + async def get_coldkey_swap_scheduled_pinned( + self, ss58: str, block_hash: HashInput + ) -> tuple[int, str] | None: + return await self._inner.get_coldkey_swap_scheduled_pinned(ss58, block_hash) + + async def get_child_keys_pinned( + self, hotkey_ss58: str, netuid: NetUid | int, block_hash: HashInput + ) -> list[tuple[int, str]]: + return await self._inner.get_child_keys_pinned(hotkey_ss58, netuid, block_hash) + + async def get_pending_child_keys_pinned( + self, hotkey_ss58: str, netuid: NetUid | int, block_hash: HashInput + ) -> tuple[list[tuple[int, str]], int] | None: + return await self._inner.get_pending_child_keys_pinned(hotkey_ss58, netuid, block_hash) + + async def get_stake_for_coldkey_pinned( + self, coldkey: str, block_hash: HashInput + ) -> list[dict[str, Any]]: + return await self._inner.get_stake_for_coldkey_pinned(coldkey, block_hash) + + async def get_stake_for_coldkey_at_block( + self, coldkey: str, block_number: int + ) -> list[dict[str, Any]]: + return await self._inner.get_stake_for_coldkey_at_block(coldkey, block_number) + + async def get_identity_at_block(self, ss58: str, block_number: int) -> dict[str, Any] | None: + return await self._inner.get_identity_at_block(ss58, block_number) + + async def get_all_subnets_at_block(self, block_number: int) -> list[dict[str, Any]]: + return await self._inner.get_all_subnets_at_block(block_number) + + async def get_all_dynamic_info_at_block(self, block_number: int) -> list[dict[str, Any]]: + return await self._inner.get_all_dynamic_info_at_block(block_number) + + async def get_dynamic_info_at_block( + self, netuid: NetUid | int, block_number: int + ) -> dict[str, Any] | None: + return await self._inner.get_dynamic_info_at_block(netuid, block_number) + + async def get_neurons_lite_at_block( + self, netuid: NetUid | int, block_number: int + ) -> list[dict[str, Any]]: + return await self._inner.get_neurons_lite_at_block(netuid, block_number) + + async def get_neuron_at_block( + self, netuid: NetUid | int, uid: int, block_number: int + ) -> dict[str, Any] | None: + return await self._inner.get_neuron_at_block(netuid, uid, block_number) + + async def get_delegates_at_block(self, block_number: int) -> list[dict[str, Any]]: + return await self._inner.get_delegates_at_block(block_number) + + async def get_total_issuance_at_block(self, block_number: int) -> Balance: + return await self._inner.get_total_issuance_at_block(block_number) + + async def list_proxies(self, ss58: str) -> list[tuple[str, str, int]]: + return await self._inner.list_proxies(ss58) + + async def list_multisig_pending(self, multisig_ss58: str) -> list[tuple[str, int, int, int, int]]: + return await self._inner.list_multisig_pending(multisig_ss58) + + async def list_proxy_announcements(self, ss58: str) -> list[tuple[str, str, int]]: + return await self._inner.list_proxy_announcements(ss58) + + async def get_coldkey_swap_scheduled(self, ss58: str) -> tuple[int, str] | None: + return await self._inner.get_coldkey_swap_scheduled(ss58) + + async def get_child_keys(self, hotkey_ss58: str, netuid: NetUid | int) -> list[tuple[int, str]]: + return await self._inner.get_child_keys(hotkey_ss58, netuid) + + async def get_parent_keys(self, hotkey_ss58: str, netuid: NetUid | int) -> list[tuple[int, str]]: + return await self._inner.get_parent_keys(hotkey_ss58, netuid) + + async def get_pending_child_keys( + self, hotkey_ss58: str, netuid: NetUid | int + ) -> tuple[list[tuple[int, str]], int] | None: + return await self._inner.get_pending_child_keys(hotkey_ss58, netuid) + + async def get_delegated(self, hotkey_ss58: str) -> list[dict[str, Any]]: + return await self._inner.get_delegated(hotkey_ss58) + + async def get_weight_commits( + self, netuid: NetUid | int, hotkey_ss58: str + ) -> list[tuple[str, int, int, int]] | None: + return await self._inner.get_weight_commits(netuid, hotkey_ss58) + + async def get_all_weight_commits( + self, netuid: NetUid | int + ) -> list[tuple[str, list[tuple[str, int, int, int]]]]: + return await self._inner.get_all_weight_commits(netuid) + + async def get_reveal_period_epochs(self, netuid: NetUid | int) -> int: + return await self._inner.get_reveal_period_epochs(netuid) + + async def get_weights_for_uid(self, netuid: NetUid | int, uid: int) -> list[tuple[int, int]]: + return await self._inner.get_weights_for_uid(netuid, uid) + + async def get_all_weights(self, netuid: NetUid | int) -> list[tuple[int, list[tuple[int, int]]]]: + return await self._inner.get_all_weights(netuid) + + async def get_commit_reveal_weights_version(self) -> int: + return await self._inner.get_commit_reveal_weights_version() + + async def get_commitment( + self, netuid: int, hotkey_ss58: str + ) -> tuple[int, list[str]] | None: + return await self._inner.get_commitment(netuid, hotkey_ss58) + + async def get_all_commitments(self, netuid: int) -> list[tuple[str, int, list[str]]]: + return await self._inner.get_all_commitments(netuid) + + async def get_block_info_for_pow(self) -> tuple[int, str]: + return await self._inner.get_block_info_for_pow() + + async def get_difficulty(self, netuid: NetUid | int) -> int: + return await self._inner.get_difficulty(netuid) + + async def fetch_portfolio(self, coldkey_ss58: str) -> dict[str, Any]: + return await self._inner.fetch_portfolio(coldkey_ss58) + + async def transfer( + self, + wallet: Wallet, + dest_ss58: str, + amount: Balance | int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.transfer( + wallet._inner, + dest_ss58, + amount, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def transfer_all( + self, + wallet: Wallet, + dest_ss58: str, + *, + keep_alive: bool = False, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.transfer_all( + wallet._inner, + dest_ss58, + keep_alive=keep_alive, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def add_stake( + self, + wallet: Wallet, + netuid: NetUid | int, + amount: Balance | int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.add_stake( + wallet._inner, + netuid, + amount, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def remove_stake( + self, + wallet: Wallet, + netuid: NetUid | int, + amount: Balance | int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.remove_stake( + wallet._inner, + netuid, + amount, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def add_stake_limit( + self, + wallet: Wallet, + netuid: NetUid | int, + amount: Balance | int, + limit_price: int, + *, + allow_partial: bool = True, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.add_stake_limit( + wallet._inner, + netuid, + amount, + limit_price, + allow_partial=allow_partial, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def remove_stake_limit( + self, + wallet: Wallet, + netuid: NetUid | int, + amount: Balance | int, + limit_price: int, + *, + allow_partial: bool = True, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.remove_stake_limit( + wallet._inner, + netuid, + amount, + limit_price, + allow_partial=allow_partial, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def move_stake( + self, + wallet: Wallet, + from_netuid: NetUid | int, + to_netuid: NetUid | int, + amount: Balance | int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.move_stake( + wallet._inner, + from_netuid, + to_netuid, + amount, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def swap_stake( + self, + wallet: Wallet, + from_netuid: NetUid | int, + to_netuid: NetUid | int, + amount: Balance | int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.swap_stake( + wallet._inner, + from_netuid, + to_netuid, + amount, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def swap_stake_limit( + self, + wallet: Wallet, + from_netuid: NetUid | int, + to_netuid: NetUid | int, + amount: Balance | int, + limit_price: int, + *, + allow_partial: bool = True, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.swap_stake_limit( + wallet._inner, + from_netuid, + to_netuid, + amount, + limit_price, + allow_partial=allow_partial, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def transfer_stake( + self, + wallet: Wallet, + dest_ss58: str, + from_netuid: NetUid | int, + to_netuid: NetUid | int, + amount: Balance | int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.transfer_stake( + wallet._inner, + dest_ss58, + from_netuid, + to_netuid, + amount, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def unstake_all( + self, + wallet: Wallet, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.unstake_all( + wallet._inner, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def unstake_all_alpha( + self, + wallet: Wallet, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.unstake_all_alpha( + wallet._inner, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def recycle_alpha( + self, + wallet: Wallet, + netuid: NetUid | int, + amount: int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.recycle_alpha( + wallet._inner, + netuid, + amount, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def burn_alpha( + self, + wallet: Wallet, + netuid: NetUid | int, + amount: int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.burn_alpha( + wallet._inner, + netuid, + amount, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def claim_root( + self, + wallet: Wallet, + subnets: list[int], + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.claim_root( + wallet._inner, + subnets, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def set_weights( + self, + wallet: Wallet, + netuid: NetUid | int, + uids: list[int], + values: list[int], + version_key: int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.set_weights( + wallet._inner, + netuid, + uids, + values, + version_key, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def commit_weights( + self, + wallet: Wallet, + netuid: NetUid | int, + commit_hash: HashInput, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.commit_weights( + wallet._inner, + netuid, + commit_hash, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def reveal_weights( + self, + wallet: Wallet, + netuid: NetUid | int, + uids: list[int], + values: list[int], + salt: list[int], + version_key: int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.reveal_weights( + wallet._inner, + netuid, + uids, + values, + salt, + version_key, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def register_network( + self, + wallet: Wallet, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.register_network( + wallet._inner, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def burned_register( + self, + wallet: Wallet, + netuid: NetUid | int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.burned_register( + wallet._inner, + netuid, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def pow_register( + self, + wallet: Wallet, + netuid: NetUid | int, + block_number: int, + nonce: int, + work: HashInput, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.pow_register( + wallet._inner, + netuid, + block_number, + nonce, + work, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def root_register( + self, + wallet: Wallet, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.root_register( + wallet._inner, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def dissolve_network( + self, + wallet: Wallet, + netuid: NetUid | int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.dissolve_network( + wallet._inner, + netuid, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def add_proxy( + self, + wallet: Wallet, + delegate_ss58: str, + proxy_type: str, + *, + delay: int = 0, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.add_proxy( + wallet._inner, + delegate_ss58, + proxy_type, + delay=delay, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def remove_proxy( + self, + wallet: Wallet, + delegate_ss58: str, + proxy_type: str, + *, + delay: int = 0, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.remove_proxy( + wallet._inner, + delegate_ss58, + proxy_type, + delay=delay, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def create_pure_proxy( + self, + wallet: Wallet, + proxy_type: str, + *, + delay: int = 0, + index: int = 0, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.create_pure_proxy( + wallet._inner, + proxy_type, + delay=delay, + index=index, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def kill_pure_proxy( + self, + wallet: Wallet, + spawner_ss58: str, + proxy_type: str, + index: int, + height: int, + ext_index: int, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.kill_pure_proxy( + wallet._inner, + spawner_ss58, + proxy_type, + index, + height, + ext_index, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def try_associate_hotkey( + self, + wallet: Wallet, + hotkey_ss58: str | None = None, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.try_associate_hotkey( + wallet._inner, + hotkey_ss58, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def schedule_swap_coldkey( + self, + wallet: Wallet, + new_coldkey_ss58: str, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.schedule_swap_coldkey( + wallet._inner, + new_coldkey_ss58, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def swap_hotkey( + self, + wallet: Wallet, + old_hotkey_ss58: str, + new_hotkey_ss58: str, + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.swap_hotkey( + wallet._inner, + old_hotkey_ss58, + new_hotkey_ss58, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + async def set_subnet_identity( + self, + wallet: Wallet, + netuid: NetUid | int, + identity: SubnetIdentity | dict[str, Any], + *, + wait: bool = True, + mev: bool = True, + dry_run: bool = False, + finalization_timeout: int | None = None, + mortality_blocks: int | None = None, + ) -> str: + return await self._inner.set_subnet_identity( + wallet._inner, + netuid, + identity, + wait=wait, + mev=mev, + dry_run=dry_run, + finalization_timeout=finalization_timeout, + mortality_blocks=mortality_blocks, + ) + + def subscribe_events( + self, filter: EventFilter | dict[str, Any] | str | None = None + ) -> Any: + """Return an async iterator of event dicts from finalized blocks. + + Closing the iterator (drop or `await stream.close()`) cancels the + subscription. Each yielded dict carries `block_number`, `pallet`, + `variant`, `extrinsic_index`, and a `fields` payload. + """ + return _wrap_stream(self._inner.subscribe_events(filter)) + + def subscribe_events_filtered( + self, filter: EventFilter | dict[str, Any] | str + ) -> Any: + """Like `subscribe_events` but requires an explicit filter.""" + return _wrap_stream(self._inner.subscribe_events_filtered(filter)) + + def subscribe_blocks(self) -> Any: + """Return an async iterator of block dicts (block_number, hash, extrinsics).""" + return _wrap_stream(self._inner.subscribe_blocks()) + + def __getstate__(self) -> None: + raise TypeError("AsyncClient objects cannot be pickled") + + def __reduce__(self) -> None: + raise TypeError("AsyncClient objects cannot be pickled") + def __repr__(self) -> str: return f"AsyncClient(endpoint={self.endpoint!r})" diff --git a/python/agcli/config.py b/python/agcli/config.py new file mode 100644 index 0000000..a6ca9f8 --- /dev/null +++ b/python/agcli/config.py @@ -0,0 +1,44 @@ +"""Config facade.""" + +from __future__ import annotations + +from agcli import _agcli + + +class Config: + """Wrapper around the native ``agcli._agcli.Config``.""" + + def __init__(self, inner: _agcli.Config | None = None, **kwargs) -> None: + object.__setattr__(self, "_inner", inner or _agcli.Config()) + for key, value in kwargs.items(): + setattr(self._inner, key, value) + + @classmethod + def load(cls) -> Config: + return cls(_agcli.Config.load()) + + @classmethod + def load_from(cls, path: str) -> Config: + return cls(_agcli.Config.load_from(path)) + + @staticmethod + def default_path() -> str: + return _agcli.Config.default_path() + + def save(self) -> None: + self._inner.save() + + def save_to(self, path: str) -> None: + self._inner.save_to(path) + + def __getattr__(self, name: str): + return getattr(self._inner, name) + + def __setattr__(self, name: str, value) -> None: + if name == "_inner": + object.__setattr__(self, name, value) + return + setattr(self._inner, name, value) + + def __repr__(self) -> str: + return f"Config(network={self.network!r}, endpoint={self.endpoint!r})" diff --git a/python/agcli/errors.py b/python/agcli/errors.py index b84d16b..95c4e47 100644 --- a/python/agcli/errors.py +++ b/python/agcli/errors.py @@ -4,25 +4,20 @@ from agcli import _agcli -# Re-export the native exception; it carries `.code` and `.hint` attributes. AgcliError = _agcli.AgcliError - - -class NetworkError(AgcliError): - """Exit code 10 — connection, DNS, transport failures.""" - - -class AuthError(AgcliError): - """Exit code 11 — wallet/password/key errors.""" - - -class ValidationError(AgcliError): - """Exit code 12 — invalid input or parse failures.""" - - -class ChainError(AgcliError): - """Exit code 13 — on-chain/runtime rejections.""" - - -class TimeoutError(AgcliError): - """Exit code 15 — operation deadline exceeded.""" +NetworkError = _agcli.NetworkError +AuthError = _agcli.AuthError +ValidationError = _agcli.ValidationError +ChainError = _agcli.ChainError +TimeoutError = _agcli.TimeoutError +IOError = _agcli.IOError + +__all__ = [ + "AgcliError", + "AuthError", + "ChainError", + "IOError", + "NetworkError", + "TimeoutError", + "ValidationError", +] diff --git a/python/agcli/events.py b/python/agcli/events.py new file mode 100644 index 0000000..845fc72 --- /dev/null +++ b/python/agcli/events.py @@ -0,0 +1,85 @@ +"""Event subscription helpers. + +Provides an `EventFilter` dataclass mirroring `agcli::events::EventFilter` plus +the async iterators returned from `AsyncClient.subscribe_events`, +`AsyncClient.subscribe_events_filtered`, and `AsyncClient.subscribe_blocks`. + +Closing the iterator (calling `await stream.close()` or letting it be garbage +collected) cancels the underlying chain subscription task. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any, AsyncIterator + +from agcli import _agcli + +EVENT_CATEGORIES: tuple[str, ...] = ( + "all", + "staking", + "registration", + "transfer", + "weights", + "subnet", + "delegation", + "keys", + "swap", + "governance", + "crowdloan", +) + + +@dataclass +class EventFilter: + """Subscription filter mirroring `agcli::events::EventFilter`. + + `category` matches the Rust enum variant (`all`, `staking`, `registration`, + `transfer`, `weights`, `subnet`, `delegation`, `keys`, `swap`, `governance`, + `crowdloan`). `netuid` and `account` apply additional narrowing on top of + the category, mirroring the `subscribe_events_filtered` arguments. + """ + + category: str = "all" + netuid: int | None = None + account: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +EventStream = _agcli.EventStream + + +class _EventStreamWrapper: + """Async iterator wrapper around the native EventStream.""" + + def __init__(self, inner: _agcli.EventStream) -> None: + self._inner = inner + + def __aiter__(self) -> "_EventStreamWrapper": + return self + + async def __anext__(self) -> dict[str, Any]: + return await self._inner.__anext__() + + async def close(self) -> None: + await self._inner.close() + + async def __aenter__(self) -> "_EventStreamWrapper": + return self + + async def __aexit__(self, *exc: Any) -> None: + await self._inner.close() + + +def _wrap_stream(stream: _agcli.EventStream) -> AsyncIterator[dict[str, Any]]: + return _EventStreamWrapper(stream) + + +__all__ = [ + "EVENT_CATEGORIES", + "EventFilter", + "EventStream", + "_wrap_stream", +] diff --git a/python/agcli/live.py b/python/agcli/live.py new file mode 100644 index 0000000..52be518 --- /dev/null +++ b/python/agcli/live.py @@ -0,0 +1,36 @@ +"""Live polling helpers — thin wrappers over `agcli::live::*`. + +These functions print formatted output to stdout (matching the Rust SDK +behaviour) and run until the underlying chain stream ends or the call is +cancelled. They are best-effort interactive helpers; programmatic consumers +should subscribe to events directly via `AsyncClient.subscribe_events` or use +the typed query methods. +""" + +from __future__ import annotations + +from agcli import _agcli +from agcli.client import AsyncClient +from agcli.types import NetUid + + +async def live_dynamic(client: AsyncClient, interval_secs: int = 5) -> None: + """Stream dynamic info deltas every `interval_secs` seconds.""" + await _agcli.live.live_dynamic(client._inner, interval_secs) + + +async def live_metagraph( + client: AsyncClient, netuid: NetUid | int, interval_secs: int = 5 +) -> None: + """Stream per-subnet metagraph snapshots every `interval_secs` seconds.""" + await _agcli.live.live_metagraph(client._inner, netuid, interval_secs) + + +async def live_portfolio( + client: AsyncClient, coldkey_ss58: str, interval_secs: int = 5 +) -> None: + """Stream per-coldkey portfolio snapshots every `interval_secs` seconds.""" + await _agcli.live.live_portfolio(client._inner, coldkey_ss58, interval_secs) + + +__all__ = ["live_dynamic", "live_metagraph", "live_portfolio"] diff --git a/python/agcli/localnet.py b/python/agcli/localnet.py new file mode 100644 index 0000000..af6fa74 --- /dev/null +++ b/python/agcli/localnet.py @@ -0,0 +1,63 @@ +"""Docker-based local chain management — re-exports `agcli::localnet`.""" + +from __future__ import annotations + +from agcli import _agcli + +LocalnetConfig = _agcli.LocalnetConfig +LocalnetInfo = _agcli.LocalnetInfo +LocalnetStatus = _agcli.LocalnetStatus +DevAccount = _agcli.DevAccount + +DEFAULT_IMAGE = _agcli.localnet.DEFAULT_IMAGE +DEFAULT_CONTAINER = _agcli.localnet.DEFAULT_CONTAINER +DEFAULT_WS = _agcli.localnet.DEFAULT_WS + + +def dev_accounts() -> list[DevAccount]: + """Return the pre-funded dev accounts (Alice, Bob) available on localnet.""" + return _agcli.localnet.dev_accounts() + + +async def start(config: LocalnetConfig | None = None) -> LocalnetInfo: + """Start a localnet Docker container, returning `LocalnetInfo` on success.""" + return await _agcli.localnet.start(config) + + +def stop(container_name: str = DEFAULT_CONTAINER) -> None: + """Stop and remove a localnet container.""" + _agcli.localnet.stop(container_name) + + +async def status( + container_name: str | None = None, port: int = 9944 +) -> LocalnetStatus: + """Return current status for a named localnet container (`started_at` + legacy `uptime`).""" + return await _agcli.localnet.status(container_name, port) + + +async def reset(config: LocalnetConfig | None = None) -> LocalnetInfo: + """Stop and re-start a localnet container.""" + return await _agcli.localnet.reset(config) + + +def logs(container_name: str = DEFAULT_CONTAINER, tail: int | None = None) -> str: + """Return container logs.""" + return _agcli.localnet.logs(container_name, tail) + + +__all__ = [ + "DEFAULT_CONTAINER", + "DEFAULT_IMAGE", + "DEFAULT_WS", + "DevAccount", + "LocalnetConfig", + "LocalnetInfo", + "LocalnetStatus", + "dev_accounts", + "logs", + "reset", + "start", + "status", + "stop", +] diff --git a/python/agcli/py.typed b/python/agcli/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/python/agcli/py.typed @@ -0,0 +1 @@ + diff --git a/python/agcli/scaffold.py b/python/agcli/scaffold.py new file mode 100644 index 0000000..71d4a06 --- /dev/null +++ b/python/agcli/scaffold.py @@ -0,0 +1,36 @@ +"""Declarative test environment scaffolding — re-exports `agcli::scaffold`.""" + +from __future__ import annotations + +from agcli import _agcli + +NeuronConfig = _agcli.NeuronConfig +SubnetConfig = _agcli.SubnetConfig +ChainConfig = _agcli.ChainConfig +ScaffoldConfig = _agcli.ScaffoldConfig +NeuronResult = _agcli.NeuronResult +SubnetResult = _agcli.SubnetResult +ScaffoldResult = _agcli.ScaffoldResult + + +def load_config(path: str) -> ScaffoldConfig: + """Load a scaffold config from a TOML file.""" + return _agcli.scaffold.load_config(path) + + +async def run(config: ScaffoldConfig) -> ScaffoldResult: + """Run the full scaffold orchestration. Requires Docker + localnet.""" + return await _agcli.scaffold.run(config) + + +__all__ = [ + "ChainConfig", + "NeuronConfig", + "NeuronResult", + "ScaffoldConfig", + "ScaffoldResult", + "SubnetConfig", + "SubnetResult", + "load_config", + "run", +] diff --git a/python/agcli/sync.py b/python/agcli/sync.py new file mode 100644 index 0000000..fed245f --- /dev/null +++ b/python/agcli/sync.py @@ -0,0 +1,86 @@ +"""Synchronous chain client facade.""" + +from __future__ import annotations + +from agcli import _agcli +from agcli.types import Balance, NetUid, Network + + +class SyncClient: + """Blocking wrapper around the native ``agcli._agcli.ClientSync``.""" + + def __init__(self, inner: _agcli.ClientSync) -> None: + self._inner = inner + + @classmethod + def connect(cls, url: str) -> SyncClient: + return cls(_agcli.ClientSync.connect(url)) + + @classmethod + def connect_with_retry(cls, urls: list[str]) -> SyncClient: + return cls(_agcli.ClientSync.connect_with_retry(urls)) + + @classmethod + def best_connection(cls, urls: list[str]) -> SyncClient: + return cls(_agcli.ClientSync.best_connection(urls)) + + @classmethod + def connect_network(cls, network: Network | None = None) -> SyncClient: + return cls(_agcli.ClientSync.connect_network(network)) + + @classmethod + def from_config(cls) -> SyncClient: + return cls(_agcli.ClientSync.from_config()) + + @property + def endpoint(self) -> str: + return self._inner.endpoint + + @property + def network(self) -> Network: + return self._inner.network + + def set_dry_run(self, enabled: bool) -> None: + self._inner.set_dry_run(enabled) + + def is_dry_run(self) -> bool: + return self._inner.is_dry_run() + + def set_finalization_timeout(self, timeout: int) -> None: + self._inner.set_finalization_timeout(timeout) + + def finalization_timeout(self) -> int: + return self._inner.finalization_timeout() + + def set_mortality_blocks(self, blocks: int) -> None: + self._inner.set_mortality_blocks(blocks) + + def mortality_blocks(self) -> int: + return self._inner.mortality_blocks() + + def reconnect(self) -> None: + self._inner.reconnect() + + def is_alive(self) -> bool: + return self._inner.is_alive() + + def invalidate_cache(self) -> None: + self._inner.invalidate_cache() + + def get_balance(self, address: str) -> Balance: + return self._inner.get_balance(address) + + def get_total_issuance(self) -> Balance: + return self._inner.get_total_issuance() + + def get_metagraph(self, netuid: NetUid | int) -> dict: + return self._inner.get_metagraph(netuid) + + def __getattr__(self, name: str): + return getattr(self._inner, name) + + def __getstate__(self) -> None: + raise TypeError("SyncClient objects cannot be pickled") + + def __reduce__(self) -> None: + raise TypeError("SyncClient objects cannot be pickled") diff --git a/python/agcli/types.py b/python/agcli/types.py index d3ee878..1d0972f 100644 --- a/python/agcli/types.py +++ b/python/agcli/types.py @@ -5,7 +5,33 @@ from agcli import _agcli Balance = _agcli.Balance +AlphaBalance = _agcli.AlphaBalance NetUid = _agcli.NetUid Network = _agcli.Network +SubnetInfo = _agcli.SubnetInfo +NeuronInfo = _agcli.NeuronInfo +NeuronInfoLite = _agcli.NeuronInfoLite +Metagraph = _agcli.Metagraph +StakeInfo = _agcli.StakeInfo +DelegateInfo = _agcli.DelegateInfo +DynamicInfo = _agcli.DynamicInfo +SubnetHyperparameters = _agcli.SubnetHyperparameters +ChainIdentity = _agcli.ChainIdentity +SubnetIdentity = _agcli.SubnetIdentity -__all__ = ["Balance", "NetUid", "Network"] +__all__ = [ + "AlphaBalance", + "Balance", + "ChainIdentity", + "DelegateInfo", + "DynamicInfo", + "Metagraph", + "NetUid", + "Network", + "NeuronInfo", + "NeuronInfoLite", + "StakeInfo", + "SubnetHyperparameters", + "SubnetIdentity", + "SubnetInfo", +] diff --git a/python/agcli/wallet.py b/python/agcli/wallet.py index ac589e9..fc95c7d 100644 --- a/python/agcli/wallet.py +++ b/python/agcli/wallet.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import AbstractContextManager from dataclasses import dataclass from agcli import _agcli @@ -14,6 +15,22 @@ class WalletCreateResult: hotkey_mnemonic: str +class WalletSession(AbstractContextManager["Wallet"]): + def __init__(self, wallet: Wallet, password: str) -> None: + self._wallet = wallet + self._password = password + + def __enter__(self) -> Wallet: + self._wallet.unlock_coldkey_with_password(self._password) + if not self._wallet.is_hotkey_loaded: + self._wallet.load_hotkey("default") + return self._wallet + + def __exit__(self, exc_type, exc, tb) -> bool: + self._wallet.lock() + return False + + class Wallet: """Wrapper around the native ``agcli._agcli.Wallet``.""" @@ -57,6 +74,10 @@ def import_from_mnemonic( _agcli.Wallet.import_from_mnemonic(wallet_dir, name, mnemonic, password) ) + @classmethod + def create_from_uri(cls, wallet_dir: str, uri: str, password: str) -> Wallet: + return cls(_agcli.Wallet.create_from_uri(wallet_dir, uri, password)) + @classmethod def list_wallets(cls, wallet_dir: str) -> list[str]: return _agcli.Wallet.list_wallets(wallet_dir) @@ -64,9 +85,23 @@ def list_wallets(cls, wallet_dir: str) -> list[str]: def unlock_coldkey(self, password: str) -> None: self._inner.unlock_coldkey(password) + def unlock_coldkey_with_password(self, password: str) -> None: + self._inner.unlock_coldkey_with_password(password) + def load_hotkey(self, hotkey_name: str) -> None: self._inner.load_hotkey(hotkey_name) + def lock(self) -> None: + self._inner.lock() + + @property + def is_coldkey_unlocked(self) -> bool: + return self._inner.is_coldkey_unlocked + + @property + def is_hotkey_loaded(self) -> bool: + return self._inner.is_hotkey_loaded + @property def name(self) -> str: return self._inner.name @@ -79,6 +114,10 @@ def path(self) -> str: def coldkey_ss58(self) -> str | None: return self._inner.coldkey_ss58 + @property + def coldkey_public_ss58(self) -> str | None: + return self._inner.coldkey_public_ss58 + @property def hotkey_ss58(self) -> str | None: return self._inner.hotkey_ss58 @@ -86,5 +125,21 @@ def hotkey_ss58(self) -> str | None: def list_hotkeys(self) -> list[str]: return self._inner.list_hotkeys() + def sign_message(self, role: str, message: bytes) -> bytes: + return self._inner.sign_message(role, message) + + def session(self, *, password: str) -> WalletSession: + return WalletSession(self, password) + + @staticmethod + def verify_message(ss58: str, message: bytes, signature: bytes) -> bool: + return _agcli.Wallet.verify_message(ss58, message, signature) + + def __getstate__(self) -> None: + raise TypeError("Wallet objects cannot be pickled") + + def __reduce__(self) -> None: + raise TypeError("Wallet objects cannot be pickled") + def __repr__(self) -> str: return repr(self._inner) diff --git a/python/pyproject.toml b/python/pyproject.toml index a000d11..328aceb 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -6,14 +6,27 @@ build-backend = "maturin" name = "agcli" version = "0.1.0" description = "Python bindings for the agcli Bittensor SDK" +readme = "../README.md" requires-python = ">=3.10" license = { text = "MIT" } classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Rust", "Programming Language :: Python :: 3 :: Only", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Typing :: Typed", ] +[project.urls] +Homepage = "https://github.com/unarbos/agcli" +Documentation = "https://github.com/unarbos/agcli/tree/main/docs" +Repository = "https://github.com/unarbos/agcli" +Issues = "https://github.com/unarbos/agcli/issues" + [project.optional-dependencies] dev = ["pytest>=8.0", "pytest-asyncio>=0.24"] @@ -22,10 +35,12 @@ manifest-path = "agcli-py/Cargo.toml" python-source = "." module-name = "agcli._agcli" features = ["pyo3/extension-module"] +include = ["agcli/py.typed"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] markers = [ "network: tests that require live chain connectivity", + "localnet: tests that require a local subtensor node", ] diff --git a/python/tests/test_admin.py b/python/tests/test_admin.py new file mode 100644 index 0000000..365014f --- /dev/null +++ b/python/tests/test_admin.py @@ -0,0 +1,82 @@ +import pytest + +from agcli import admin + + +EXPECTED_FUNCTION_NAMES = { + "set_tempo", + "set_max_allowed_validators", + "set_max_allowed_uids", + "set_immunity_period", + "set_min_allowed_weights", + "set_max_weight_limit", + "set_weights_set_rate_limit", + "set_commit_reveal_weights_enabled", + "set_difficulty", + "set_bonds_moving_average", + "set_target_registrations_per_interval", + "set_activity_cutoff", + "set_serving_rate_limit", + "set_default_take", + "set_tx_rate_limit", + "set_min_difficulty", + "set_max_difficulty", + "set_adjustment_interval", + "set_adjustment_alpha", + "set_kappa", + "set_rho", + "set_min_burn", + "set_max_burn", + "set_liquid_alpha_enabled", + "set_alpha_values", + "set_yuma3_enabled", + "set_bonds_penalty", + "set_subnet_moving_alpha", + "set_mechanism_count", + "set_mechanism_emission_split", + "set_stake_threshold", + "set_nominator_min_required_stake", + "set_network_registration_allowed", + "set_network_pow_registration_allowed", + "raw_admin_call", +} + + +def test_admin_exports_every_set_fn() -> None: + for name in EXPECTED_FUNCTION_NAMES: + assert hasattr(admin, name), f"admin.{name} is missing" + fn = getattr(admin, name) + assert callable(fn) + + +def test_known_params_non_empty_and_well_formed() -> None: + params = admin.known_params() + assert isinstance(params, list) + assert len(params) > 0 + names = set() + for entry in params: + assert len(entry) == 3 + name, description, args = entry + assert isinstance(name, str) + assert isinstance(description, str) + assert isinstance(args, list) + assert name.startswith("sudo_set_") + assert description + assert args + assert name not in names + names.add(name) + + +def test_known_params_covers_set_tempo() -> None: + params = {name: (desc, args) for name, desc, args in admin.known_params()} + assert "sudo_set_tempo" in params + desc, args = params["sudo_set_tempo"] + assert "epoch" in desc.lower() or "tempo" in desc.lower() + assert args[0] == "netuid: u16" + + +@pytest.mark.network +def test_module_import_is_clean() -> None: + import importlib + + importlib.import_module("agcli.admin") diff --git a/python/tests/test_bindings.py b/python/tests/test_bindings.py index 705592a..17c9a8f 100644 --- a/python/tests/test_bindings.py +++ b/python/tests/test_bindings.py @@ -50,6 +50,27 @@ def test_invalid_wallet_name_raises(tmp_path) -> None: assert exc.value.code != 0 +def test_wallet_sign_and_verify(tmp_path) -> None: + wallet = Wallet.create_from_uri(str(tmp_path), "//Alice", "password123") + message = b"agcli python bindings" + signature = wallet.sign_message("coldkey", message) + assert wallet.coldkey_ss58 + assert Wallet.verify_message(wallet.coldkey_ss58, message, signature) + assert wallet.coldkey_public_ss58 == wallet.coldkey_ss58 + + +def test_pickle_is_blocked_for_wallet_and_async_client(tmp_path) -> None: + import pickle + + wallet = Wallet.create_from_uri(str(tmp_path), "//Alice", "password123") + with pytest.raises(TypeError, match="cannot be pickled"): + pickle.dumps(wallet) + + client = AsyncClient.__new__(AsyncClient) + with pytest.raises(TypeError, match="cannot be pickled"): + pickle.dumps(client) + + @pytest.mark.network @pytest.mark.asyncio async def test_connect_and_list_subnets() -> None: diff --git a/python/tests/test_config.py b/python/tests/test_config.py new file mode 100644 index 0000000..fdc0e05 --- /dev/null +++ b/python/tests/test_config.py @@ -0,0 +1,35 @@ +from agcli import Config + + +def test_config_roundtrip(tmp_path) -> None: + path = tmp_path / "config.toml" + config = Config( + network="finney", + endpoint="wss://example.org:443", + wallet_dir="/tmp/wallets", + wallet="main", + hotkey="default", + output="json", + proxy="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + live_interval=15, + batch=True, + spending_limits={"1": 1.5, "18": 10.0}, + finalization_timeout=45, + mortality_blocks=64, + ) + + config.save_to(str(path)) + loaded = Config.load_from(str(path)) + + assert loaded.network == "finney" + assert loaded.endpoint == "wss://example.org:443" + assert loaded.wallet_dir == "/tmp/wallets" + assert loaded.wallet == "main" + assert loaded.hotkey == "default" + assert loaded.output == "json" + assert loaded.proxy + assert loaded.live_interval == 15 + assert loaded.batch is True + assert loaded.spending_limits == {"1": 1.5, "18": 10.0} + assert loaded.finalization_timeout == 45 + assert loaded.mortality_blocks == 64 diff --git a/python/tests/test_errors.py b/python/tests/test_errors.py new file mode 100644 index 0000000..c0cacd7 --- /dev/null +++ b/python/tests/test_errors.py @@ -0,0 +1,27 @@ +import pytest + +from agcli import ( + AuthError, + ChainError, + IOError, + NetworkError, + TimeoutError, + ValidationError, + _agcli, +) + + +@pytest.mark.parametrize( + ("message", "error_type"), + [ + ("Failed to connect to endpoint", NetworkError), + ("Wrong password for wallet coldkey", AuthError), + ("Invalid SS58 address: bad checksum", ValidationError), + ("Extrinsic failed: insufficient balance", ChainError), + ("Operation timed out after 30s", TimeoutError), + ("Permission denied writing to /tmp/file", IOError), + ], +) +def test_error_subclass_mapping(message: str, error_type: type[Exception]) -> None: + with pytest.raises(error_type): + _agcli.raise_test_error(message) diff --git a/python/tests/test_events.py b/python/tests/test_events.py new file mode 100644 index 0000000..94d3a3a --- /dev/null +++ b/python/tests/test_events.py @@ -0,0 +1,81 @@ +import pytest + +from agcli.events import EVENT_CATEGORIES, EventFilter + + +def test_event_filter_defaults() -> None: + f = EventFilter() + assert f.category == "all" + assert f.netuid is None + assert f.account is None + + +def test_event_filter_roundtrip() -> None: + f = EventFilter(category="staking", netuid=42, account="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY") + payload = f.to_dict() + assert payload == { + "category": "staking", + "netuid": 42, + "account": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + } + restored = EventFilter(**payload) + assert restored == f + + +def test_event_categories_contains_known_values() -> None: + expected = { + "all", + "staking", + "registration", + "transfer", + "weights", + "subnet", + "delegation", + "keys", + "swap", + "governance", + "crowdloan", + } + assert expected.issubset(set(EVENT_CATEGORIES)) + + +@pytest.mark.localnet +@pytest.mark.asyncio +async def test_subscribe_blocks_yields_dicts() -> None: + import os + + from agcli import AsyncClient + + ws = os.environ.get("AGCLI_LOCALNET_WS") + if not ws: + pytest.skip("AGCLI_LOCALNET_WS not set") + client = await AsyncClient.connect(ws) + stream = client.subscribe_blocks() + try: + first = await stream.__anext__() + assert isinstance(first, dict) + assert "block_number" in first + assert "hash" in first + finally: + await stream.close() + + +@pytest.mark.localnet +@pytest.mark.asyncio +async def test_subscribe_events_with_filter() -> None: + import os + + from agcli import AsyncClient + + ws = os.environ.get("AGCLI_LOCALNET_WS") + if not ws: + pytest.skip("AGCLI_LOCALNET_WS not set") + client = await AsyncClient.connect(ws) + stream = client.subscribe_events(EventFilter(category="all")) + try: + event = await stream.__anext__() + assert isinstance(event, dict) + for key in ("block_number", "pallet", "variant", "fields", "extrinsic_index"): + assert key in event + finally: + await stream.close() diff --git a/python/tests/test_extrinsics_localnet.py b/python/tests/test_extrinsics_localnet.py new file mode 100644 index 0000000..a2e26fb --- /dev/null +++ b/python/tests/test_extrinsics_localnet.py @@ -0,0 +1,168 @@ +import hashlib +import os + +import pytest + +from agcli import AsyncClient, ValidationError, Wallet + +pytestmark = [pytest.mark.localnet, pytest.mark.asyncio] + +LOCALNET_PASSWORD = "localnet-pass" + + +def _salt_to_u16_words(value: str) -> list[int]: + encoded = value.encode() + words: list[int] = [] + for idx in range(0, len(encoded), 2): + first = encoded[idx] + second = encoded[idx + 1] if idx + 1 < len(encoded) else 0 + words.append((second << 8) | first) + return words + + +def _weight_commit_hash(uids: list[int], values: list[int], salt: str) -> bytes: + hasher = hashlib.blake2b(digest_size=32) + for uid in uids: + hasher.update(uid.to_bytes(2, byteorder="little", signed=False)) + for value in values: + hasher.update(value.to_bytes(2, byteorder="little", signed=False)) + hasher.update(salt.encode()) + return hasher.digest() + + +@pytest.fixture(scope="module") +def localnet_ws() -> str: + endpoint = os.getenv("AGCLI_LOCALNET_WS") + if not endpoint: + pytest.skip("AGCLI_LOCALNET_WS is not set") + return endpoint + + +@pytest.fixture(scope="module") +async def localnet_client(localnet_ws: str) -> AsyncClient: + try: + client = await AsyncClient.connect(localnet_ws) + if not await client.is_alive(): + pytest.skip(f"Localnet endpoint is unreachable: {localnet_ws}") + return client + except Exception as exc: # pragma: no cover - defensive skip path + pytest.skip(f"Failed to connect to localnet endpoint {localnet_ws}: {exc}") + + +@pytest.fixture(scope="module") +def localnet_wallets(tmp_path_factory: pytest.TempPathFactory) -> tuple[Wallet, Wallet]: + wallet_dir = tmp_path_factory.mktemp("localnet-wallets") + alice = Wallet.create_from_uri(str(wallet_dir), "//Alice", LOCALNET_PASSWORD) + bob = Wallet.create_from_uri(str(wallet_dir), "//Bob", LOCALNET_PASSWORD) + return alice, bob + + +async def test_transfer_alice_to_bob( + localnet_client: AsyncClient, + localnet_wallets: tuple[Wallet, Wallet], +) -> None: + alice, bob = localnet_wallets + assert bob.coldkey_ss58 is not None + before = await localnet_client.get_balance(bob.coldkey_ss58) + with alice.session(password=LOCALNET_PASSWORD): + tx_hash = await localnet_client.transfer(alice, bob.coldkey_ss58, 1_000_000_000) + after = await localnet_client.get_balance(bob.coldkey_ss58) + assert tx_hash + assert after.rao >= before.rao + 1_000_000_000 + + +async def test_add_and_remove_stake( + localnet_client: AsyncClient, + localnet_wallets: tuple[Wallet, Wallet], +) -> None: + alice, _ = localnet_wallets + with alice.session(password=LOCALNET_PASSWORD): + add_hash = await localnet_client.add_stake(alice, 0, 100_000_000) + remove_hash = await localnet_client.remove_stake(alice, 0, 50_000_000) + assert add_hash + assert remove_hash + + +async def test_weights_set_commit_reveal( + localnet_client: AsyncClient, + localnet_wallets: tuple[Wallet, Wallet], +) -> None: + alice, _ = localnet_wallets + uids = [0] + values = [1] + salt_str = "phase2-localnet" + salt_words = _salt_to_u16_words(salt_str) + commit_hash = _weight_commit_hash(uids, values, salt_str) + + with alice.session(password=LOCALNET_PASSWORD): + set_hash = await localnet_client.set_weights(alice, 0, uids, values, 0, dry_run=True) + commit_tx = await localnet_client.commit_weights( + alice, + 0, + commit_hash, + dry_run=True, + ) + reveal_tx = await localnet_client.reveal_weights( + alice, + 0, + uids, + values, + salt_words, + 0, + dry_run=True, + ) + assert set_hash + assert commit_tx + assert reveal_tx + + +async def test_register_and_dissolve_network( + localnet_client: AsyncClient, + localnet_wallets: tuple[Wallet, Wallet], +) -> None: + alice, _ = localnet_wallets + with alice.session(password=LOCALNET_PASSWORD): + register_hash = await localnet_client.register_network(alice, dry_run=True) + dissolve_hash = await localnet_client.dissolve_network(alice, 0, dry_run=True) + assert register_hash + assert dissolve_hash + + +async def test_proxy_add_and_remove( + localnet_client: AsyncClient, + localnet_wallets: tuple[Wallet, Wallet], +) -> None: + alice, bob = localnet_wallets + assert bob.coldkey_ss58 is not None + with alice.session(password=LOCALNET_PASSWORD): + add_hash = await localnet_client.add_proxy( + alice, + bob.coldkey_ss58, + "Any", + dry_run=True, + ) + remove_hash = await localnet_client.remove_proxy( + alice, + bob.coldkey_ss58, + "Any", + dry_run=True, + ) + assert add_hash + assert remove_hash + + +async def test_binding_validation_errors_before_submit( + localnet_client: AsyncClient, + localnet_wallets: tuple[Wallet, Wallet], +) -> None: + alice, bob = localnet_wallets + assert bob.coldkey_ss58 is not None + with alice.session(password=LOCALNET_PASSWORD): + with pytest.raises(ValidationError, match="cannot be negative"): + await localnet_client.transfer(alice, bob.coldkey_ss58, -1, dry_run=True) + with pytest.raises(ValidationError, match="invalid destination SS58"): + await localnet_client.transfer(alice, "invalid-ss58", 1, dry_run=True) + with pytest.raises(ValidationError, match="length mismatch"): + await localnet_client.set_weights(alice, 0, [0, 1], [1], 0, dry_run=True) + with pytest.raises(ValidationError, match="exceeds maximum value"): + await localnet_client.add_stake(alice, 70_000, 1, dry_run=True) diff --git a/python/tests/test_live.py b/python/tests/test_live.py new file mode 100644 index 0000000..d3ed958 --- /dev/null +++ b/python/tests/test_live.py @@ -0,0 +1,28 @@ +import pytest + +from agcli import live + + +def test_live_module_exports() -> None: + assert callable(live.live_dynamic) + assert callable(live.live_metagraph) + assert callable(live.live_portfolio) + + +@pytest.mark.localnet +@pytest.mark.asyncio +async def test_live_module_smoke() -> None: + import asyncio + import os + + from agcli import AsyncClient + + ws = os.environ.get("AGCLI_LOCALNET_WS") + if not ws: + pytest.skip("AGCLI_LOCALNET_WS not set") + client = await AsyncClient.connect(ws) + task = asyncio.create_task(live.live_dynamic(client, interval_secs=5)) + await asyncio.sleep(0.1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task diff --git a/python/tests/test_localnet.py b/python/tests/test_localnet.py new file mode 100644 index 0000000..b67df0f --- /dev/null +++ b/python/tests/test_localnet.py @@ -0,0 +1,65 @@ +import pytest + +from agcli import localnet + + +def test_dev_accounts_returns_alice_bob() -> None: + accounts = localnet.dev_accounts() + names = {a.name for a in accounts} + assert "Alice" in names + assert "Bob" in names + alice = next(a for a in accounts if a.name == "Alice") + assert alice.uri == "//Alice" + assert alice.ss58.startswith("5") + assert "TAO" in alice.balance + + +def test_localnet_config_defaults_and_overrides() -> None: + default = localnet.LocalnetConfig() + assert default.container_name == localnet.DEFAULT_CONTAINER + assert default.image == localnet.DEFAULT_IMAGE + assert default.port == 9944 + assert default.wait is True + + custom = localnet.LocalnetConfig( + container_name="my-localnet", + port=19944, + wait=False, + wait_timeout=60, + ) + assert custom.container_name == "my-localnet" + assert custom.port == 19944 + assert custom.wait is False + assert custom.wait_timeout == 60 + payload = custom.to_dict() + assert payload["container_name"] == "my-localnet" + assert payload["port"] == 19944 + + +def test_dev_account_to_dict_shape() -> None: + alice = next(a for a in localnet.dev_accounts() if a.name == "Alice") + payload = alice.to_dict() + assert payload["name"] == "Alice" + assert payload["uri"] == "//Alice" + assert payload["ss58"].startswith("5") + + +@pytest.mark.asyncio +async def test_status_against_missing_container_returns_inactive() -> None: + status = await localnet.status("agcli_pytest_nonexistent_xyz", port=29944) + assert status.running is False + assert status.endpoint is None + assert status.container_id is None + + +@pytest.mark.localnet +@pytest.mark.asyncio +async def test_status_against_real_container() -> None: + import os + + container = os.environ.get("AGCLI_LOCALNET_CONTAINER") + port = int(os.environ.get("AGCLI_LOCALNET_PORT", "9944")) + if not container: + pytest.skip("AGCLI_LOCALNET_CONTAINER not set") + status = await localnet.status(container, port=port) + assert status.container_name == container diff --git a/python/tests/test_scaffold.py b/python/tests/test_scaffold.py new file mode 100644 index 0000000..311e9e5 --- /dev/null +++ b/python/tests/test_scaffold.py @@ -0,0 +1,78 @@ +import textwrap + +from agcli import scaffold + + +SAMPLE_TOML = textwrap.dedent( + """\ + [chain] + image = "ghcr.io/opentensor/subtensor-localnet:devnet-ready" + container = "agcli_pytest_scaffold" + port = 19944 + start = false + timeout = 90 + + [[subnet]] + tempo = 50 + max_allowed_validators = 4 + min_allowed_weights = 1 + weights_rate_limit = 0 + commit_reveal = true + + [[subnet.neuron]] + name = "validator1" + fund_tao = 500.0 + register = true + + [[subnet.neuron]] + name = "miner1" + fund_tao = 50.0 + register = true + """ +) + + +def test_load_config_roundtrip(tmp_path) -> None: + path = tmp_path / "scaffold.toml" + path.write_text(SAMPLE_TOML) + + cfg = scaffold.load_config(str(path)) + + assert cfg.chain.container == "agcli_pytest_scaffold" + assert cfg.chain.port == 19944 + assert cfg.chain.start is False + assert cfg.chain.timeout == 90 + + subnets = cfg.subnets + assert len(subnets) == 1 + subnet = subnets[0] + assert subnet.tempo == 50 + assert subnet.max_allowed_validators == 4 + assert subnet.min_allowed_weights == 1 + assert subnet.weights_rate_limit == 0 + assert subnet.commit_reveal is True + + neurons = subnet.neurons + assert [n.name for n in neurons] == ["validator1", "miner1"] + assert neurons[0].fund_tao == 500.0 + assert neurons[0].register is True + assert neurons[1].fund_tao == 50.0 + + payload = cfg.to_dict() + assert payload["chain"]["container"] == "agcli_pytest_scaffold" + assert payload["chain"]["port"] == 19944 + assert payload["subnets"][0]["tempo"] == 50 + assert payload["subnets"][0]["neurons"][0]["name"] == "validator1" + + +def test_scaffold_config_constructor_defaults() -> None: + cfg = scaffold.ScaffoldConfig() + assert cfg.chain.port == 9944 + assert cfg.chain.start is True + assert cfg.subnets == [] + + +def test_neuron_config_constructor() -> None: + n = scaffold.NeuronConfig("alpha", fund_tao=100.0, register=False) + payload = n.to_dict() + assert payload == {"name": "alpha", "fund_tao": 100.0, "register": False} diff --git a/python/tests/test_sync.py b/python/tests/test_sync.py new file mode 100644 index 0000000..4fd73f1 --- /dev/null +++ b/python/tests/test_sync.py @@ -0,0 +1,19 @@ +import pytest + +from agcli import Network, SyncClient + + +@pytest.mark.network +def test_sync_client_core_reads() -> None: + client = SyncClient.connect_network(Network.finney()) + assert client.endpoint.startswith("ws") + + issuance = client.get_total_issuance() + assert issuance.rao >= 0 + + metagraph = client.get_metagraph(1) + assert isinstance(metagraph, dict) + assert metagraph["netuid"] == 1 + + balance = client.get_balance("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY") + assert balance.rao >= 0 diff --git a/python/tests/test_types.py b/python/tests/test_types.py new file mode 100644 index 0000000..0e93a6e --- /dev/null +++ b/python/tests/test_types.py @@ -0,0 +1,47 @@ +import pytest + +from agcli.types import AlphaBalance, ChainIdentity, SubnetInfo + + +def test_subnet_info_roundtrip() -> None: + value = { + "netuid": 1, + "name": "alpha", + "symbol": "A", + "n": 128, + "max_n": 4096, + "tempo": 360, + "emission_value": 10, + "burn": {"rao": 1_000_000_000}, + "difficulty": 1_000, + "immunity_period": 100, + "owner": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "registration_allowed": True, + } + typed = SubnetInfo(value) + assert typed.netuid == 1 + assert typed.name == "alpha" + assert typed.to_dict()["symbol"] == "A" + + +def test_alpha_balance_roundtrip() -> None: + alpha = AlphaBalance(2_500_000_000) + assert alpha.raw == 2_500_000_000 + assert alpha.rao == 2_500_000_000 + assert alpha.tao == pytest.approx(2.5) + assert alpha.to_dict()["raw"] == 2_500_000_000 + + +def test_chain_identity_roundtrip() -> None: + value = { + "name": "alice", + "url": "https://example.org", + "github": "alice/repo", + "image": "https://example.org/image.png", + "discord": "alice#1234", + "description": "identity", + "additional": "none", + } + typed = ChainIdentity(value) + assert typed.name == "alice" + assert typed.to_dict()["url"] == "https://example.org" diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 94e9a59..be6aad3 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -215,6 +215,7 @@ pub struct Client { cache: QueryCache, dry_run: bool, url: String, + network: crate::types::Network, /// Timeout for waiting for transaction finalization (seconds). finalization_timeout: u64, /// Extrinsic mortality in blocks (0 = use default). @@ -227,6 +228,11 @@ impl Client { &self.url } + /// Network used for this connection. + pub fn network(&self) -> &crate::types::Network { + &self.network + } + /// Access the runtime metadata from the connected chain. pub fn metadata(&self) -> subxt::Metadata { self.inner.metadata() @@ -256,6 +262,7 @@ impl Client { cache: QueryCache::new_with_network(&net_prefix), dry_run: false, url: url.to_string(), + network: crate::types::Network::Custom(url.to_string()), finalization_timeout: 30, mortality_blocks: 0, }) @@ -264,10 +271,12 @@ impl Client { /// Reconnect to the same endpoint. Creates a fresh RPC connection while preserving settings. /// Useful when the subxt background task dies (e.g. on fast-block devnets). pub async fn reconnect(&mut self) -> Result<()> { + let network = self.network.clone(); let fresh = Self::connect_once(&self.url).await?; self.inner = fresh.inner; self.rpc = fresh.rpc; self.cache = QueryCache::new_with_network(&url_to_cache_prefix(&self.url)); + self.network = network; Ok(()) } @@ -416,7 +425,9 @@ impl Client { /// Connect to a well-known network with automatic fallback endpoints. pub async fn connect_network(network: &crate::types::Network) -> Result { let urls = network.ws_urls(); - Self::connect_with_retry(&urls).await + let mut client = Self::connect_with_retry(&urls).await?; + client.network = network.clone(); + Ok(client) } /// Get a reference to the underlying subxt client. @@ -1002,29 +1013,28 @@ impl Client { /// Format submission errors (before tx reaches chain) with actionable hints. fn format_submit_error(e: subxt::Error) -> anyhow::Error { let msg = e.to_string(); - + // Log the raw error for debugging tracing::debug!("Transaction submission error (raw): {}", msg); - + // Try to decode custom error codes first let (decoded_msg, decoded_desc) = if let Some(decoded) = decode_custom_error(&msg) { tracing::debug!("Decoded custom error: {} - {}", decoded.name, decoded.desc); - ( - format!("{} [{}]", msg, decoded.name), - Some(decoded.desc), - ) + (format!("{} [{}]", msg, decoded.name), Some(decoded.desc)) } else { // Check if this is an "Invalid Transaction" with a custom error code // These often indicate transaction pool validation failures (e.g., insufficient stake) if msg.contains("Invalid Transaction") && msg.contains("Custom error:") { - tracing::warn!("Invalid Transaction with unrecognized custom error code. This may indicate: + tracing::warn!( + "Invalid Transaction with unrecognized custom error code. This may indicate: - Insufficient stake to set weights (need ~1000τ) - Hotkey not registered on the subnet - - Transaction validation failure"); + - Transaction validation failure" + ); } (msg.clone(), None) }; - + if msg.contains("connection") || msg.contains("Connection") || msg.contains("Ws") { anyhow::anyhow!("Connection lost while submitting transaction. Check your network and endpoint.\n Error: {}", decoded_msg) } else if msg.contains("Priority is too low") || msg.contains("priority") { diff --git a/src/chain/queries.rs b/src/chain/queries.rs index 7b4fdb0..1d07a3b 100644 --- a/src/chain/queries.rs +++ b/src/chain/queries.rs @@ -1619,7 +1619,7 @@ impl Client { } } } - results.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by amount descending + results.sort_by_key(|item| std::cmp::Reverse(item.1)); // Sort by amount descending Ok(results) } diff --git a/src/cli/helpers.rs b/src/cli/helpers.rs index e6b9e71..fa094e7 100644 --- a/src/cli/helpers.rs +++ b/src/cli/helpers.rs @@ -1304,10 +1304,10 @@ pub fn validate_multisig_json_args(json_str: &str) -> Result { - if n.as_f64().is_some_and(|f| f.is_nan() || f.is_infinite()) { - anyhow::bail!("NaN/Infinity not allowed in args.\n Tip: use a finite number."); - } + serde_json::Value::Number(n) + if n.as_f64().is_some_and(|f| f.is_nan() || f.is_infinite()) => + { + anyhow::bail!("NaN/Infinity not allowed in args.\n Tip: use a finite number."); } serde_json::Value::Array(inner) => { for item in inner { diff --git a/src/cli/stake_cmds.rs b/src/cli/stake_cmds.rs index 3a0c54e..6d15e51 100644 --- a/src/cli/stake_cmds.rs +++ b/src/cli/stake_cmds.rs @@ -893,7 +893,7 @@ async fn staking_wizard( return Ok(()); } let mut subnets_with_pool: Vec<_> = dynamic.iter().filter(|d| d.tao_in.rao() > 0).collect(); - subnets_with_pool.sort_by(|a, b| b.tao_in.rao().cmp(&a.tao_in.rao())); + subnets_with_pool.sort_by_key(|item| std::cmp::Reverse(item.tao_in.rao())); println!("\nTop subnets by TAO pool:"); let display_count = subnets_with_pool.len().min(15); diff --git a/src/cli/subnet_cmds.rs b/src/cli/subnet_cmds.rs index a0c4159..c3e9a50 100644 --- a/src/cli/subnet_cmds.rs +++ b/src/cli/subnet_cmds.rs @@ -1844,7 +1844,7 @@ async fn handle_subnet_liquidity( } let mut sorted: Vec<_> = dynamic.iter().filter(|d| d.tao_in.rao() > 0).collect(); - sorted.sort_by(|a, b| b.tao_in.rao().cmp(&a.tao_in.rao())); + sorted.sort_by_key(|item| std::cmp::Reverse(item.tao_in.rao())); render_rows( OutputFormat::Table, diff --git a/src/cli/view_cmds.rs b/src/cli/view_cmds.rs index 3470b98..8f8102c 100644 --- a/src/cli/view_cmds.rs +++ b/src/cli/view_cmds.rs @@ -502,7 +502,7 @@ async fn handle_validators( std::sync::Arc::try_unwrap(arc).unwrap_or_else(|a| (*a).clone()) }; let mut validators: Vec<_> = neurons.into_iter().filter(|n| n.validator_permit).collect(); - validators.sort_by(|a, b| b.stake.rao().cmp(&a.stake.rao())); + validators.sort_by_key(|item| std::cmp::Reverse(item.stake.rao())); validators.truncate(limit); render_rows( @@ -547,7 +547,7 @@ async fn handle_validators( client.get_delegates().await? }; let mut sorted = delegates; - sorted.sort_by(|a, b| b.total_stake.rao().cmp(&a.total_stake.rao())); + sorted.sort_by_key(|item| std::cmp::Reverse(item.total_stake.rao())); sorted.truncate(limit); // Add rank index for table display diff --git a/src/error.rs b/src/error.rs index cc3ec28..632028a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,7 +19,7 @@ pub mod exit_code { /// Chain/runtime error (extrinsic rejected, insufficient balance, rate-limited). pub const CHAIN: i32 = 13; /// File I/O error (permission denied, missing file, disk full). - pub const IO: i32 = 14; + pub const IO: i32 = 16; /// Timeout (operation exceeded deadline). pub const TIMEOUT: i32 = 15; } @@ -220,15 +220,14 @@ pub fn classify(err: &anyhow::Error) -> i32 { // DispatchError mapping: // - surfaced pallets -> CHAIN // - unrecognized pallets -> GENERIC (message already contains pallet + variant) - let dispatch_context = - msg.contains("transaction failed") - || msg.contains("dispatch error") - || msg.contains("dispatch") - || msg.contains("pallet error") - || msg.contains("runtime pallet `"); + let dispatch_context = msg.contains("transaction failed") + || msg.contains("dispatch error") + || msg.contains("dispatch") + || msg.contains("pallet error") + || msg.contains("runtime pallet `"); if dispatch_context { - if let Some((pallet, _variant)) = parse_runtime_pallet_error(&msg) - .or_else(|| last_qualified_pallet_variant(&msg)) + if let Some((pallet, _variant)) = + parse_runtime_pallet_error(&msg).or_else(|| last_qualified_pallet_variant(&msg)) { if is_surfaced_dispatch_pallet(pallet) { return exit_code::CHAIN; @@ -784,9 +783,7 @@ mod tests { ); assert_eq!(classify(&err), exit_code::VALIDATION); let msg = format!("{err:#}"); - assert!( - hint(exit_code::VALIDATION, &msg).is_some_and(|s| s.contains("--max-slippage")) - ); + assert!(hint(exit_code::VALIDATION, &msg).is_some_and(|s| s.contains("--max-slippage"))); } #[test] @@ -845,7 +842,8 @@ mod tests { #[test] fn classify_localnet_readiness_timeout() { - let err = anyhow::anyhow!("Chain at ws://127.0.0.1:9944 did not become ready after 60 seconds"); + let err = + anyhow::anyhow!("Chain at ws://127.0.0.1:9944 did not become ready after 60 seconds"); assert_eq!(classify(&err), exit_code::TIMEOUT); } diff --git a/src/events.rs b/src/events.rs index dc9aa33..a7dcf42 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1525,9 +1525,7 @@ mod tests { #[test] fn extract_netuid_named_no_field() { let composite = make_named_no_netuid(); - assert!( - extract_netuids("SubtensorModule", "TempoSet", &composite).is_empty() - ); + assert!(extract_netuids("SubtensorModule", "TempoSet", &composite).is_empty()); } #[test] @@ -1548,9 +1546,7 @@ mod tests { #[test] fn extract_netuid_unnamed_unknown_variant_returns_empty() { let composite = Composite::Unnamed(vec![subxt::ext::scale_value::Value::u128(42)]); - assert!( - extract_netuids("SubtensorModule", "UnknownEvent", &composite).is_empty() - ); + assert!(extract_netuids("SubtensorModule", "UnknownEvent", &composite).is_empty()); } #[test] @@ -1712,9 +1708,7 @@ mod tests { "netuid".to_string(), subxt::ext::scale_value::Value::u128(65536), )]); - assert!( - extract_netuids("SubtensorModule", "TempoSet", &composite).is_empty() - ); + assert!(extract_netuids("SubtensorModule", "TempoSet", &composite).is_empty()); } #[test] @@ -1725,9 +1719,7 @@ mod tests { "netuid".to_string(), subxt::ext::scale_value::Value::u128(0x0001_0001), )]); - assert!( - extract_netuids("SubtensorModule", "TempoSet", &composite).is_empty() - ); + assert!(extract_netuids("SubtensorModule", "TempoSet", &composite).is_empty()); } // --- Issue 155: saturating arithmetic for gap display --- @@ -1972,33 +1964,36 @@ mod tests { let source = std::fs::read_to_string(source_path).expect("subtensor events source should exist"); let mut in_enum = false; + let mut enum_brace_depth = 0i32; let mut variants = Vec::::new(); for line in source.lines() { let trimmed = line.trim_start(); if trimmed.starts_with("pub enum Event") { in_enum = true; - continue; } if !in_enum { continue; } - if trimmed.starts_with('}') { - break; - } - if trimmed.is_empty() || trimmed.starts_with("#[") || trimmed.starts_with("///") { - continue; - } - let name: String = trimmed - .chars() - .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') - .collect(); - if name.is_empty() { - continue; + if enum_brace_depth == 1 + && !(trimmed.is_empty() || trimmed.starts_with("#[") || trimmed.starts_with("///")) + { + let name: String = trimmed + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + let rest = &trimmed[name.len()..]; + if rest.trim_start().starts_with('(') || rest.trim_start().starts_with('{') { + variants.push(name); + } + } } - let rest = &trimmed[name.len()..]; - if rest.trim_start().starts_with('(') || rest.trim_start().starts_with('{') { - variants.push(name); + + enum_brace_depth += trimmed.matches('{').count() as i32; + enum_brace_depth -= trimmed.matches('}').count() as i32; + if in_enum && enum_brace_depth == 0 { + break; } } diff --git a/src/lib.rs b/src/lib.rs index 19cbde5..965373d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,3 +54,10 @@ pub use chain::Client; pub use config::Config; pub use types::balance::Balance; pub use wallet::Wallet; + +pub mod sdk { + pub use crate::{ + error, types::chain_data, types::network::NetUid, types::Network, Balance, Client, Config, + Wallet, + }; +} diff --git a/src/queries/query_cache.rs b/src/queries/query_cache.rs index e00aed8..28e71a7 100644 --- a/src/queries/query_cache.rs +++ b/src/queries/query_cache.rs @@ -380,7 +380,8 @@ impl QueryCache { self.all_dynamic_at_block .try_get_with(key, async move { if disk { - if let Some(cached) = super::disk_cache::get::>(&dk, DISK_TTL) { + if let Some(cached) = super::disk_cache::get::>(&dk, DISK_TTL) + { tracing::debug!( block_hash = %log_hash, count = cached.len(), @@ -424,7 +425,8 @@ impl QueryCache { self.neurons_lite_at_block .try_get_with(key, async move { if disk { - if let Some(cached) = super::disk_cache::get::>(&dk, DISK_TTL) + if let Some(cached) = + super::disk_cache::get::>(&dk, DISK_TTL) { tracing::debug!( netuid, diff --git a/src/scaffold.rs b/src/scaffold.rs index 6b59d30..f7406ea 100644 --- a/src/scaffold.rs +++ b/src/scaffold.rs @@ -203,15 +203,18 @@ pub struct NeuronResult { pub fn load_config(path: &str) -> Result { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read scaffold config: {}", path))?; - let config: ScaffoldConfig = toml::from_str(&content) - .with_context(|| format!("Failed to parse scaffold TOML '{}' (check for unknown keys or type mismatches)", path))?; + let config: ScaffoldConfig = toml::from_str(&content).with_context(|| { + format!( + "Failed to parse scaffold TOML '{}' (check for unknown keys or type mismatches)", + path + ) + })?; Ok(config) } /// Serialize a scaffold config back to TOML (symmetric with `load_config`). pub fn serialize_config(config: &ScaffoldConfig) -> Result { - toml::to_string_pretty(config) - .context("Failed to serialize scaffold config to TOML") + toml::to_string_pretty(config).context("Failed to serialize scaffold config to TOML") } /// Run the full scaffold: start chain → create wallets → fund → register @@ -881,8 +884,7 @@ name = "miner1" fund_tao = 50.0 register = true "#; - let parsed: ScaffoldConfig = - toml::from_str(toml_input).expect("valid TOML should parse"); + let parsed: ScaffoldConfig = toml::from_str(toml_input).expect("valid TOML should parse"); let serialized = serialize_config(&parsed).expect("serialization should succeed"); let round_tripped: ScaffoldConfig = toml::from_str(&serialized).expect("serialized TOML should re-parse"); @@ -960,9 +962,6 @@ extra = "bad" totally_unexpected = true "#; let result: Result = toml::from_str(bad_toml); - assert!( - result.is_err(), - "Unknown top-level keys should be rejected" - ); + assert!(result.is_err(), "Unknown top-level keys should be rejected"); } } diff --git a/src/utils/explain.rs b/src/utils/explain.rs index f70d9d6..cbd738c 100644 --- a/src/utils/explain.rs +++ b/src/utils/explain.rs @@ -92,7 +92,10 @@ pub fn list_topics() -> Vec<(&'static str, &'static str)> { "Emergency chain protection mode, deposits, and exits", ), ("swap", "Hotkey/coldkey/EVM-key swap and rotation flows"), - ("evm", "EVM bridge calls, withdrawals, and H160 account flow"), + ( + "evm", + "EVM bridge calls, withdrawals, and H160 account flow", + ), ("contracts", "Upload/instantiate/call smart contracts"), ( "ss58-vs-evm-h160", diff --git a/tests/audit_balance_transfer.rs b/tests/audit_balance_transfer.rs index 887613c..1b80cb8 100644 --- a/tests/audit_balance_transfer.rs +++ b/tests/audit_balance_transfer.rs @@ -39,8 +39,7 @@ fn parse_balance_no_args() { #[test] fn parse_balance_with_address() { - let cli = - agcli::cli::Cli::try_parse_from(["agcli", "balance", "--address", ALICE]).unwrap(); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "balance", "--address", ALICE]).unwrap(); match cli.command { agcli::cli::Commands::Balance { address, .. } => { assert_eq!(address.as_deref(), Some(ALICE)); @@ -51,8 +50,7 @@ fn parse_balance_with_address() { #[test] fn parse_balance_with_at_block() { - let cli = - agcli::cli::Cli::try_parse_from(["agcli", "balance", "--at-block", "1000"]).unwrap(); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "balance", "--at-block", "1000"]).unwrap(); match cli.command { agcli::cli::Commands::Balance { at_block, .. } => { assert_eq!(at_block, Some(1000u32)); @@ -64,8 +62,7 @@ fn parse_balance_with_at_block() { #[test] fn parse_balance_watch_no_interval() { // --watch without a value means "use default interval" - let cli = - agcli::cli::Cli::try_parse_from(["agcli", "balance", "--watch"]).unwrap(); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "balance", "--watch"]).unwrap(); match cli.command { agcli::cli::Commands::Balance { watch, .. } => { // watch == Some(None): flag present, no numeric arg @@ -77,8 +74,7 @@ fn parse_balance_watch_no_interval() { #[test] fn parse_balance_watch_with_interval() { - let cli = - agcli::cli::Cli::try_parse_from(["agcli", "balance", "--watch", "30"]).unwrap(); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "balance", "--watch", "30"]).unwrap(); match cli.command { agcli::cli::Commands::Balance { watch, .. } => { assert_eq!(watch, Some(Some(30u64))); @@ -89,8 +85,7 @@ fn parse_balance_watch_with_interval() { #[test] fn parse_balance_with_threshold() { - let cli = - agcli::cli::Cli::try_parse_from(["agcli", "balance", "--threshold", "5.0"]).unwrap(); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "balance", "--threshold", "5.0"]).unwrap(); match cli.command { agcli::cli::Commands::Balance { threshold, .. } => { assert!((threshold.unwrap() - 5.0f64).abs() < 1e-9); @@ -134,10 +129,9 @@ fn parse_balance_all_flags() { #[test] fn parse_transfer_minimal() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", "transfer", "--dest", BOB, "--amount", "1.0", - ]) - .unwrap(); + let cli = + agcli::cli::Cli::try_parse_from(["agcli", "transfer", "--dest", BOB, "--amount", "1.0"]) + .unwrap(); match cli.command { agcli::cli::Commands::Transfer { dest, amount } => { assert_eq!(dest, BOB); @@ -190,8 +184,7 @@ fn parse_transfer_missing_amount_fails() { #[test] fn parse_transfer_all_minimal() { - let cli = agcli::cli::Cli::try_parse_from(["agcli", "transfer-all", "--dest", BOB]) - .unwrap(); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "transfer-all", "--dest", BOB]).unwrap(); match cli.command { agcli::cli::Commands::TransferAll { dest, keep_alive } => { assert_eq!(dest, BOB); @@ -203,10 +196,9 @@ fn parse_transfer_all_minimal() { #[test] fn parse_transfer_all_keep_alive() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", "transfer-all", "--dest", BOB, "--keep-alive", - ]) - .unwrap(); + let cli = + agcli::cli::Cli::try_parse_from(["agcli", "transfer-all", "--dest", BOB, "--keep-alive"]) + .unwrap(); match cli.command { agcli::cli::Commands::TransferAll { dest, keep_alive } => { assert_eq!(dest, BOB); @@ -219,7 +211,12 @@ fn parse_transfer_all_keep_alive() { #[test] fn parse_transfer_all_with_global_yes() { let cli = agcli::cli::Cli::try_parse_from([ - "agcli", "--yes", "transfer-all", "--dest", BOB, "--keep-alive", + "agcli", + "--yes", + "transfer-all", + "--dest", + BOB, + "--keep-alive", ]) .unwrap(); assert!(cli.yes); @@ -293,8 +290,7 @@ fn parse_transfer_keep_alive_missing_dest_fails() { #[test] fn parse_transfer_keep_alive_missing_amount_fails() { - let result = - agcli::cli::Cli::try_parse_from(["agcli", "transfer-keep-alive", "--dest", BOB]); + let result = agcli::cli::Cli::try_parse_from(["agcli", "transfer-keep-alive", "--dest", BOB]); assert!(result.is_err(), "should fail: --amount is required"); } @@ -304,10 +300,21 @@ fn parse_transfer_keep_alive_missing_amount_fails() { fn validate_amount_rejects_negative() { let err = agcli::cli::helpers::validate_amount(-1.0, "transfer amount").unwrap_err(); let msg = format!("{:#}", err); - assert!(msg.contains("transfer amount"), "label should appear in error: {}", msg); - assert!(msg.contains("negative") || msg.contains("cannot be negative"), "{}", msg); + assert!( + msg.contains("transfer amount"), + "label should appear in error: {}", + msg + ); + assert!( + msg.contains("negative") || msg.contains("cannot be negative"), + "{}", + msg + ); // Classifies as VALIDATION exit code - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::VALIDATION); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::VALIDATION + ); } #[test] @@ -315,34 +322,50 @@ fn validate_amount_rejects_zero() { let err = agcli::cli::helpers::validate_amount(0.0, "transfer amount").unwrap_err(); let msg = format!("{:#}", err); assert!(msg.contains("transfer amount"), "{}", msg); - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::VALIDATION); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::VALIDATION + ); } #[test] fn validate_amount_rejects_inf() { - let err = - agcli::cli::helpers::validate_amount(f64::INFINITY, "transfer amount").unwrap_err(); - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::VALIDATION); + let err = agcli::cli::helpers::validate_amount(f64::INFINITY, "transfer amount").unwrap_err(); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::VALIDATION + ); } #[test] fn validate_amount_accepts_positive() { agcli::cli::helpers::validate_amount(1.0, "transfer amount").unwrap(); - agcli::cli::helpers::validate_amount(0.000000001, "transfer amount").unwrap(); // 1 RAO + agcli::cli::helpers::validate_amount(0.000000001, "transfer amount").unwrap(); + // 1 RAO } #[test] fn validate_ss58_rejects_empty() { let err = agcli::cli::helpers::validate_ss58("", "destination").unwrap_err(); let msg = format!("{:#}", err); - assert!(msg.contains("destination") || msg.contains("Invalid"), "{}", msg); - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::VALIDATION); + assert!( + msg.contains("destination") || msg.contains("Invalid"), + "{}", + msg + ); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::VALIDATION + ); } #[test] fn validate_ss58_rejects_garbage() { let err = agcli::cli::helpers::validate_ss58("not_an_ss58", "destination").unwrap_err(); - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::VALIDATION); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::VALIDATION + ); } #[test] @@ -353,22 +376,26 @@ fn validate_ss58_accepts_valid_address() { #[test] fn validate_threshold_rejects_negative() { - let err = - agcli::cli::helpers::validate_threshold(-0.1, "balance --threshold").unwrap_err(); + let err = agcli::cli::helpers::validate_threshold(-0.1, "balance --threshold").unwrap_err(); let msg = format!("{:#}", err); assert!( msg.contains("balance --threshold"), "label should appear: {}", msg ); - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::VALIDATION); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::VALIDATION + ); } #[test] fn validate_threshold_rejects_nan() { - let err = - agcli::cli::helpers::validate_threshold(f64::NAN, "balance --threshold").unwrap_err(); - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::VALIDATION); + let err = agcli::cli::helpers::validate_threshold(f64::NAN, "balance --threshold").unwrap_err(); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::VALIDATION + ); } #[test] @@ -381,9 +408,8 @@ fn validate_threshold_accepts_zero() { #[test] fn classify_insufficient_balance_error_is_chain() { - let err = anyhow::anyhow!( - "Insufficient balance: you have 0.5 TAO but trying to transfer 1.0 TAO." - ); + let err = + anyhow::anyhow!("Insufficient balance: you have 0.5 TAO but trying to transfer 1.0 TAO."); // Contains "insufficient" → CHAIN assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::CHAIN); } @@ -391,13 +417,19 @@ fn classify_insufficient_balance_error_is_chain() { #[test] fn classify_bad_ss58_dest_is_validation() { let err = anyhow::anyhow!("Invalid destination address 'garbage'."); - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::VALIDATION); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::VALIDATION + ); } #[test] fn classify_bad_amount_is_validation() { let err = anyhow::anyhow!("Invalid transfer amount: -1. Amount cannot be negative."); - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::VALIDATION); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::VALIDATION + ); } // ──── balance type round-trip ───────────────────────────────────────────────── @@ -432,12 +464,24 @@ fn balance_zero_is_zero() { fn dry_run_is_global_not_subcommand() { // --dry-run must be before the subcommand let ok = agcli::cli::Cli::try_parse_from([ - "agcli", "--dry-run", "transfer", "--dest", BOB, "--amount", "1.0", + "agcli", + "--dry-run", + "transfer", + "--dest", + BOB, + "--amount", + "1.0", ]); assert!(ok.is_ok()); // --dry-run after the subcommand is not recognized let bad = agcli::cli::Cli::try_parse_from([ - "agcli", "transfer", "--dest", BOB, "--amount", "1.0", "--dry-run", + "agcli", + "transfer", + "--dest", + BOB, + "--amount", + "1.0", + "--dry-run", ]); assert!(bad.is_err(), "dry-run after subcommand should not parse"); } @@ -476,7 +520,10 @@ async fn green_path_balance_transfer_localnet() { .get_balance_ss58(ALICE) .await .expect("get_balance_ss58 must succeed"); - assert!(alice_balance.rao() > 0, "Alice should have funds on devnet-ready"); + assert!( + alice_balance.rao() > 0, + "Alice should have funds on devnet-ready" + ); // 2. Balance query — Bob (may be zero) let bob_before = client @@ -485,10 +532,7 @@ async fn green_path_balance_transfer_localnet() { .expect("get_balance_ss58 Bob must succeed"); // 3. Historical query — block 0 must resolve (devnet starts at 0) - let block_hash = client - .get_block_hash(0) - .await - .expect("block 0 must exist"); + let block_hash = client.get_block_hash(0).await.expect("block 0 must exist"); let _genesis_balance = client .get_balance_at_block(ALICE, block_hash) .await @@ -496,8 +540,8 @@ async fn green_path_balance_transfer_localnet() { // 4. transfer_allow_death: Alice → Bob, 0.001 TAO use sp_core::Pair as _; - let alice_pair = sp_core::sr25519::Pair::from_string("//Alice", None) - .expect("//Alice is a valid dev key"); + let alice_pair = + sp_core::sr25519::Pair::from_string("//Alice", None).expect("//Alice is a valid dev key"); let amount = agcli::types::Balance::from_tao(0.001); let hash = client .transfer(&alice_pair, BOB, amount) diff --git a/tests/audit_batch.rs b/tests/audit_batch.rs index 0a4b03e..dce5f54 100644 --- a/tests/audit_batch.rs +++ b/tests/audit_batch.rs @@ -8,12 +8,19 @@ use clap::Parser; #[test] fn parse_batch_default_mode() { - let cli = - agcli::cli::Cli::try_parse_from(["agcli", "batch", "--file", "calls.json"]); - assert!(cli.is_ok(), "batch --file calls.json should parse: {:?}", cli.err()); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "batch", "--file", "calls.json"]); + assert!( + cli.is_ok(), + "batch --file calls.json should parse: {:?}", + cli.err() + ); let cli = cli.unwrap(); match cli.command { - agcli::cli::Commands::Batch { file, no_atomic, force } => { + agcli::cli::Commands::Batch { + file, + no_atomic, + force, + } => { assert_eq!(file, "calls.json"); assert!(!no_atomic, "no_atomic should default false"); assert!(!force, "force should default false"); @@ -24,12 +31,13 @@ fn parse_batch_default_mode() { #[test] fn parse_batch_no_atomic_flag() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", "batch", "--file", "calls.json", "--no-atomic", - ]); + let cli = + agcli::cli::Cli::try_parse_from(["agcli", "batch", "--file", "calls.json", "--no-atomic"]); assert!(cli.is_ok(), "{:?}", cli.err()); match cli.unwrap().command { - agcli::cli::Commands::Batch { no_atomic, force, .. } => { + agcli::cli::Commands::Batch { + no_atomic, force, .. + } => { assert!(no_atomic, "--no-atomic should be true"); assert!(!force); } @@ -39,12 +47,13 @@ fn parse_batch_no_atomic_flag() { #[test] fn parse_batch_force_flag() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", "batch", "--file", "calls.json", "--force", - ]); + let cli = + agcli::cli::Cli::try_parse_from(["agcli", "batch", "--file", "calls.json", "--force"]); assert!(cli.is_ok(), "{:?}", cli.err()); match cli.unwrap().command { - agcli::cli::Commands::Batch { no_atomic, force, .. } => { + agcli::cli::Commands::Batch { + no_atomic, force, .. + } => { assert!(!no_atomic); assert!(force, "--force should be true"); } @@ -57,7 +66,12 @@ fn parse_batch_force_flag() { #[test] fn parse_batch_both_flags_accepted() { let cli = agcli::cli::Cli::try_parse_from([ - "agcli", "batch", "--file", "calls.json", "--no-atomic", "--force", + "agcli", + "batch", + "--file", + "calls.json", + "--no-atomic", + "--force", ]); assert!( cli.is_ok(), @@ -65,7 +79,9 @@ fn parse_batch_both_flags_accepted() { cli.err() ); match cli.unwrap().command { - agcli::cli::Commands::Batch { no_atomic, force, .. } => { + agcli::cli::Commands::Batch { + no_atomic, force, .. + } => { assert!(no_atomic); assert!(force); } @@ -86,9 +102,7 @@ fn parse_batch_missing_file_is_error() { /// Global --yes flag must propagate to the Cli struct. #[test] fn parse_batch_with_global_yes() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", "--yes", "batch", "--file", "calls.json", - ]); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "--yes", "batch", "--file", "calls.json"]); assert!(cli.is_ok(), "{:?}", cli.err()); let cli = cli.unwrap(); assert!(cli.yes, "--yes should be true on the Cli struct"); @@ -99,7 +113,12 @@ fn parse_batch_with_global_yes() { #[test] fn parse_batch_with_output_json() { let cli = agcli::cli::Cli::try_parse_from([ - "agcli", "--output", "json", "batch", "--file", "calls.json", + "agcli", + "--output", + "json", + "batch", + "--file", + "calls.json", ]); assert!(cli.is_ok(), "{:?}", cli.err()); let cli = cli.unwrap(); @@ -112,9 +131,8 @@ fn parse_batch_with_output_json() { /// Global --batch (non-interactive mode) should parse alongside the batch command. #[test] fn parse_batch_with_global_batch_flag() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", "--batch", "batch", "--file", "calls.json", - ]); + let cli = + agcli::cli::Cli::try_parse_from(["agcli", "--batch", "batch", "--file", "calls.json"]); assert!(cli.is_ok(), "{:?}", cli.err()); assert!(cli.unwrap().batch, "global --batch flag should be true"); } @@ -122,12 +140,7 @@ fn parse_batch_with_global_batch_flag() { /// --file must accept paths with spaces when quoted (arg passing). #[test] fn parse_batch_file_path_with_special_chars() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", - "batch", - "--file", - "/tmp/my calls.json", - ]); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "batch", "--file", "/tmp/my calls.json"]); assert!(cli.is_ok(), "{:?}", cli.err()); match cli.unwrap().command { agcli::cli::Commands::Batch { file, .. } => { @@ -140,9 +153,8 @@ fn parse_batch_file_path_with_special_chars() { /// --file with absolute path must be accepted verbatim. #[test] fn parse_batch_absolute_file_path() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", "batch", "--file", "/etc/agcli/batch_ops.json", - ]); + let cli = + agcli::cli::Cli::try_parse_from(["agcli", "batch", "--file", "/etc/agcli/batch_ops.json"]); assert!(cli.is_ok(), "{:?}", cli.err()); match cli.unwrap().command { agcli::cli::Commands::Batch { file, .. } => { @@ -159,18 +171,13 @@ fn validate_batch_rejects_empty_array() { let result = agcli::cli::helpers::validate_batch_file("[]", "test.json"); assert!(result.is_err(), "empty array must be rejected"); let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("empty"), - "error should mention 'empty': {msg}" - ); + assert!(msg.contains("empty"), "error should mention 'empty': {msg}"); } #[test] fn validate_batch_rejects_non_array_object() { - let result = agcli::cli::helpers::validate_batch_file( - r#"{"pallet": "SubtensorModule"}"#, - "test.json", - ); + let result = + agcli::cli::helpers::validate_batch_file(r#"{"pallet": "SubtensorModule"}"#, "test.json"); assert!(result.is_err(), "non-array JSON must be rejected"); } @@ -204,10 +211,7 @@ fn validate_batch_rejects_missing_call_field() { let result = agcli::cli::helpers::validate_batch_file(json, "test.json"); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("call"), - "error should reference 'call': {msg}" - ); + assert!(msg.contains("call"), "error should reference 'call': {msg}"); } #[test] @@ -216,10 +220,7 @@ fn validate_batch_rejects_missing_args_field() { let result = agcli::cli::helpers::validate_batch_file(json, "test.json"); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("args"), - "error should reference 'args': {msg}" - ); + assert!(msg.contains("args"), "error should reference 'args': {msg}"); } #[test] @@ -233,7 +234,10 @@ fn validate_batch_rejects_args_not_array() { fn validate_batch_rejects_too_many_calls() { // Construct 1001 minimal valid calls, one more than the 1000-call cap. let single = r#"{"pallet":"Balances","call":"transfer_allow_death","args":[]}"#; - let calls = std::iter::repeat(single).take(1001).collect::>().join(","); + let calls = std::iter::repeat(single) + .take(1001) + .collect::>() + .join(","); let json = format!("[{}]", calls); let result = agcli::cli::helpers::validate_batch_file(&json, "test.json"); assert!(result.is_err(), "1001 calls must exceed the 1000-call cap"); @@ -247,17 +251,28 @@ fn validate_batch_rejects_too_many_calls() { #[test] fn validate_batch_accepts_exactly_1000_calls() { let single = r#"{"pallet":"Balances","call":"transfer_allow_death","args":[]}"#; - let calls = std::iter::repeat(single).take(1000).collect::>().join(","); + let calls = std::iter::repeat(single) + .take(1000) + .collect::>() + .join(","); let json = format!("[{}]", calls); let result = agcli::cli::helpers::validate_batch_file(&json, "test.json"); - assert!(result.is_ok(), "1000 calls should be accepted: {:?}", result.err()); + assert!( + result.is_ok(), + "1000 calls should be accepted: {:?}", + result.err() + ); } #[test] fn validate_batch_accepts_valid_single_call() { let json = r#"[{"pallet": "SubtensorModule", "call": "add_stake", "args": [1, 2, 3]}]"#; let result = agcli::cli::helpers::validate_batch_file(json, "test.json"); - assert!(result.is_ok(), "valid single call should be accepted: {:?}", result.err()); + assert!( + result.is_ok(), + "valid single call should be accepted: {:?}", + result.err() + ); assert_eq!(result.unwrap().len(), 1); } @@ -277,14 +292,21 @@ fn validate_batch_accepts_multi_call_mix() { fn validate_batch_accepts_empty_args_array() { let json = r#"[{"pallet": "System", "call": "remark", "args": []}]"#; let result = agcli::cli::helpers::validate_batch_file(json, "test.json"); - assert!(result.is_ok(), "empty args array must be accepted: {:?}", result.err()); + assert!( + result.is_ok(), + "empty args array must be accepted: {:?}", + result.err() + ); } #[test] fn validate_batch_rejects_non_object_call_entry() { let json = r#"["not-an-object"]"#; let result = agcli::cli::helpers::validate_batch_file(json, "test.json"); - assert!(result.is_err(), "array entries that are not objects must be rejected"); + assert!( + result.is_err(), + "array entries that are not objects must be rejected" + ); } // ── Exit-code classification tests for batch error messages ───────────────── @@ -354,8 +376,8 @@ fn exit_code_toomanycalls_on_chain_is_chain() { #[test] #[ignore] fn green_path_batch() { - let ws = std::env::var("AGCLI_LOCALNET_WS") - .unwrap_or_else(|_| "ws://127.0.0.1:9944".to_string()); + let ws = + std::env::var("AGCLI_LOCALNET_WS").unwrap_or_else(|_| "ws://127.0.0.1:9944".to_string()); // Write a minimal System.remark batch JSON. let tmpdir = tempfile::tempdir().expect("tempdir"); @@ -368,16 +390,18 @@ fn green_path_batch() { // agcli handle_batch is not pub; exercise via the binary. // The binary is built to `target/debug/agcli` by `cargo build --bin agcli`. - let bin = std::env::var("AGCLI_BIN") - .unwrap_or_else(|_| "target/debug/agcli".to_string()); + let bin = std::env::var("AGCLI_BIN").unwrap_or_else(|_| "target/debug/agcli".to_string()); let output = std::process::Command::new(&bin) .args([ "--yes", - "--output", "json", - "--network", &ws, + "--output", + "json", + "--network", + &ws, "batch", - "--file", batch_file.to_str().unwrap(), + "--file", + batch_file.to_str().unwrap(), ]) .output() .unwrap_or_else(|e| panic!("failed to run agcli binary '{}': {}", bin, e)); diff --git a/tests/audit_block.rs b/tests/audit_block.rs index bad672c..ec2d6ef 100644 --- a/tests/audit_block.rs +++ b/tests/audit_block.rs @@ -90,8 +90,7 @@ fn block_info_zero_block() { #[test] fn block_info_max_u32() { let max = u32::MAX.to_string(); - let cli = - parse(&["agcli", "block", "info", "--number", &max]).expect("block info u32::MAX"); + let cli = parse(&["agcli", "block", "info", "--number", &max]).expect("block info u32::MAX"); match block_cmd(&cli) { BlockCommands::Info { number } => assert_eq!(*number, u32::MAX), other => panic!("expected Info, got {:?}", other), @@ -109,8 +108,10 @@ fn block_info_overflow_rejected() { #[test] fn block_info_json_output() { - let cli = parse(&["agcli", "--output", "json", "block", "info", "--number", "1"]) - .expect("block info json"); + let cli = parse(&[ + "agcli", "--output", "json", "block", "info", "--number", "1", + ]) + .expect("block info json"); assert!(cli.output.is_json()); assert!(matches!(block_cmd(&cli), BlockCommands::Info { number: 1 })); } @@ -161,10 +162,8 @@ fn block_range_same_block() { #[test] fn block_range_max_u32_both() { let max = u32::MAX.to_string(); - let cli = parse(&[ - "agcli", "block", "range", "--from", &max, "--to", &max, - ]) - .expect("block range max u32"); + let cli = parse(&["agcli", "block", "range", "--from", &max, "--to", &max]) + .expect("block range max u32"); match block_cmd(&cli) { BlockCommands::Range { from, to } => { assert_eq!(*from, u32::MAX); @@ -239,7 +238,13 @@ fn diff_portfolio_parses() { #[test] fn diff_portfolio_without_address_parses() { let cli = parse(&[ - "agcli", "diff", "portfolio", "--block1", "1000", "--block2", "2000", + "agcli", + "diff", + "portfolio", + "--block1", + "1000", + "--block2", + "2000", ]) .expect("diff portfolio no address"); match diff_cmd(&cli) { @@ -251,10 +256,7 @@ fn diff_portfolio_without_address_parses() { #[test] fn diff_portfolio_requires_block1() { assert!( - parse(&[ - "agcli", "diff", "portfolio", "--block2", "2000" - ]) - .is_err(), + parse(&["agcli", "diff", "portfolio", "--block2", "2000"]).is_err(), "--block1 is required" ); } @@ -262,10 +264,7 @@ fn diff_portfolio_requires_block1() { #[test] fn diff_portfolio_requires_block2() { assert!( - parse(&[ - "agcli", "diff", "portfolio", "--block1", "1000" - ]) - .is_err(), + parse(&["agcli", "diff", "portfolio", "--block1", "1000"]).is_err(), "--block2 is required" ); } @@ -334,7 +333,15 @@ fn diff_network_requires_both_blocks() { #[test] fn diff_metagraph_parses() { let cli = parse(&[ - "agcli", "diff", "metagraph", "--netuid", "3", "--block1", "500", "--block2", "600", + "agcli", + "diff", + "metagraph", + "--netuid", + "3", + "--block1", + "500", + "--block2", + "600", ]) .expect("diff metagraph"); match diff_cmd(&cli) { @@ -355,7 +362,13 @@ fn diff_metagraph_parses() { fn diff_metagraph_requires_netuid() { assert!( parse(&[ - "agcli", "diff", "metagraph", "--block1", "500", "--block2", "600" + "agcli", + "diff", + "metagraph", + "--block1", + "500", + "--block2", + "600" ]) .is_err(), "--netuid is required" @@ -366,15 +379,20 @@ fn diff_metagraph_requires_netuid() { #[test] fn block_latest_yes_flag() { - let cli = - parse(&["agcli", "--yes", "block", "latest"]).expect("block latest --yes"); + let cli = parse(&["agcli", "--yes", "block", "latest"]).expect("block latest --yes"); assert!(cli.yes); } #[test] fn block_info_wallet_flag() { let cli = parse(&[ - "agcli", "--wallet", "my_wallet", "block", "info", "--number", "1", + "agcli", + "--wallet", + "my_wallet", + "block", + "info", + "--number", + "1", ]) .expect("block info --wallet"); assert_eq!(cli.wallet, "my_wallet"); @@ -388,7 +406,10 @@ fn range_guard_count_arithmetic() { let from: u32 = 0; let to: u32 = u32::MAX; let count = (to as u64 - from as u64 + 1) as usize; - assert!(count > 1000, "full u32 range must exceed the 1000-block cap"); + assert!( + count > 1000, + "full u32 range must exceed the 1000-block cap" + ); let from: u32 = 100; let to: u32 = 199; @@ -417,13 +438,23 @@ fn get_block_header_returns_four_fields_not_five() { // If the return type ever becomes (u32, H256, H256, H256, H256) the destructure below // will fail to compile, alerting maintainers. fn _check_arity( - r: (u32, subxt::utils::H256, subxt::utils::H256, subxt::utils::H256), + r: ( + u32, + subxt::utils::H256, + subxt::utils::H256, + subxt::utils::H256, + ), ) -> usize { let (_, _, _, _) = r; 4 } assert_eq!( - _check_arity((0, Default::default(), Default::default(), Default::default())), + _check_arity(( + 0, + Default::default(), + Default::default(), + Default::default() + )), 4, "get_block_header returns 4 fields; doc comment says 5" ); @@ -461,12 +492,13 @@ async fn green_path_block() { // block latest: must return a non-zero block number. let block_num = client.get_block_number().await.expect("get_block_number"); - assert!(block_num > 0, "localnet should have produced at least one block"); + assert!( + block_num > 0, + "localnet should have produced at least one block" + ); // block info: round-trip hash lookup. - let block_num_u32: u32 = block_num - .try_into() - .expect("block number within u32 range"); + let block_num_u32: u32 = block_num.try_into().expect("block number within u32 range"); let hash = client .get_block_hash(block_num_u32) .await @@ -480,11 +512,10 @@ async fn green_path_block() { // block range: span of 3 blocks must return 3 hashes. let from = block_num_u32.saturating_sub(2); let to = block_num_u32; - let hashes: Vec<_> = futures::future::try_join_all( - (from..=to).map(|n| client.get_block_hash(n)), - ) - .await - .expect("block range hashes"); + let hashes: Vec<_> = + futures::future::try_join_all((from..=to).map(|n| client.get_block_hash(n))) + .await + .expect("block range hashes"); assert_eq!(hashes.len(), 3, "range of 3 blocks must yield 3 hashes"); // Timestamp: at least one block in the range should have a timestamp. @@ -492,5 +523,8 @@ async fn green_path_block() { .get_block_timestamp(hash) .await .expect("get_block_timestamp"); - assert!(ts.is_some(), "localnet blocks must carry a Timestamp::Now inherent"); + assert!( + ts.is_some(), + "localnet blocks must carry a Timestamp::Now inherent" + ); } diff --git a/tests/audit_commitment.rs b/tests/audit_commitment.rs index 84dcf65..7a4b9ba 100644 --- a/tests/audit_commitment.rs +++ b/tests/audit_commitment.rs @@ -43,15 +43,19 @@ fn parse_surface_commitment_subcommands_with_realistic_args() { for args in scenarios { let parsed = agcli::cli::Cli::try_parse_from(args); - assert!(parsed.is_ok(), "failed to parse args {:?}: {:?}", args, parsed); + assert!( + parsed.is_ok(), + "failed to parse args {:?}: {:?}", + args, + parsed + ); } } #[tokio::test] #[ignore = "requires a running local chain (default ws://127.0.0.1:9944)"] async fn green_path_commitment_local_chain() { - let ws = - std::env::var("AGCLI_LOCAL_WS").unwrap_or_else(|_| "ws://127.0.0.1:9944".to_string()); + let ws = std::env::var("AGCLI_LOCAL_WS").unwrap_or_else(|_| "ws://127.0.0.1:9944".to_string()); let netuid = std::env::var("AGCLI_COMMITMENT_NETUID") .ok() .and_then(|s| s.parse::().ok()) @@ -72,11 +76,15 @@ async fn green_path_commitment_local_chain() { listed.len() ); - if std::env::var("AGCLI_AUDIT_COMMITMENT_WRITE").ok().as_deref() != Some("1") { + if std::env::var("AGCLI_AUDIT_COMMITMENT_WRITE") + .ok() + .as_deref() + != Some("1") + { return; } - use sp_core::{Pair, crypto::Ss58Codec, sr25519}; + use sp_core::{crypto::Ss58Codec, sr25519, Pair}; let signer = sr25519::Pair::from_string("//Alice", None) .unwrap_or_else(|e| panic!("failed to derive //Alice signer: {}", e)); @@ -100,7 +108,9 @@ async fn green_path_commitment_local_chain() { ) }); assert!( - fields.iter().any(|f| f.contains("endpoint:http://127.0.0.1:8091")), + fields + .iter() + .any(|f| f.contains("endpoint:http://127.0.0.1:8091")), "expected endpoint field in returned commitment fields: {:?}", fields ); diff --git a/tests/audit_completions_update.rs b/tests/audit_completions_update.rs index ad3aeef..9ffe1c4 100644 --- a/tests/audit_completions_update.rs +++ b/tests/audit_completions_update.rs @@ -39,8 +39,7 @@ fn parse_completions_fish() { #[test] fn parse_completions_powershell() { - let cli = - agcli::cli::Cli::try_parse_from(["agcli", "completions", "--shell", "powershell"]); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "completions", "--shell", "powershell"]); assert!( cli.is_ok(), "completions --shell powershell: {:?}", @@ -115,26 +114,16 @@ fn parse_update_rejects_extra_args() { /// Global flags before `update` are accepted. #[test] fn parse_update_with_global_network_flag() { - let cli = - agcli::cli::Cli::try_parse_from(["agcli", "--network", "finney", "update"]); - assert!( - cli.is_ok(), - "--network finney update: {:?}", - cli.err() - ); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "--network", "finney", "update"]); + assert!(cli.is_ok(), "--network finney update: {:?}", cli.err()); } // ──── utils convert ────────────────────────────────────────────────────────── #[test] fn parse_utils_convert_rao_to_tao() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", - "utils", - "convert", - "--amount", - "1000000000", - ]); + let cli = + agcli::cli::Cli::try_parse_from(["agcli", "utils", "convert", "--amount", "1000000000"]); assert!( cli.is_ok(), "utils convert --amount 1000000000: {:?}", @@ -161,12 +150,7 @@ fn parse_utils_convert_rao_to_tao() { #[test] fn parse_utils_convert_tao_to_rao() { let cli = agcli::cli::Cli::try_parse_from([ - "agcli", - "utils", - "convert", - "--amount", - "1.5", - "--to-rao", + "agcli", "utils", "convert", "--amount", "1.5", "--to-rao", ]); assert!(cli.is_ok(), "utils convert --to-rao: {:?}", cli.err()); match cli.unwrap().command { @@ -191,9 +175,7 @@ fn parse_utils_convert_tao_to_alpha() { cli.err() ); match cli.unwrap().command { - agcli::cli::Commands::Utils(agcli::cli::UtilsCommands::Convert { - tao, netuid, .. - }) => { + agcli::cli::Commands::Utils(agcli::cli::UtilsCommands::Convert { tao, netuid, .. }) => { assert_eq!(tao, Some(1.0)); assert_eq!(netuid, Some(1)); } @@ -365,8 +347,7 @@ async fn green_path_completions_update() { // already covered above. The placeholder localnet check is left as a stub // for a future CI environment where Docker is available. - let cli = - agcli::cli::Cli::try_parse_from(["agcli", "completions", "--shell", "zsh"]).unwrap(); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "completions", "--shell", "zsh"]).unwrap(); assert!(matches!( cli.command, agcli::cli::Commands::Completions { .. } diff --git a/tests/audit_config.rs b/tests/audit_config.rs index caeb579..8ede0cd 100644 --- a/tests/audit_config.rs +++ b/tests/audit_config.rs @@ -24,9 +24,10 @@ fn parse_config_show() { /// `config set --key network --value finney` parses correctly. #[test] fn parse_config_set_network() { - let cli = - Cli::try_parse_from(["agcli", "config", "set", "--key", "network", "--value", "finney"]) - .expect("config set --key network --value finney should parse"); + let cli = Cli::try_parse_from([ + "agcli", "config", "set", "--key", "network", "--value", "finney", + ]) + .expect("config set --key network --value finney should parse"); match cli.command { agcli::cli::Commands::Config(ConfigCommands::Set { key, value }) => { assert_eq!(key, "network"); @@ -77,9 +78,10 @@ fn parse_config_set_spending_limit() { /// `config set --key batch --value true` parses correctly. #[test] fn parse_config_set_batch() { - let cli = - Cli::try_parse_from(["agcli", "config", "set", "--key", "batch", "--value", "true"]) - .expect("config set batch should parse"); + let cli = Cli::try_parse_from([ + "agcli", "config", "set", "--key", "batch", "--value", "true", + ]) + .expect("config set batch should parse"); match cli.command { agcli::cli::Commands::Config(ConfigCommands::Set { key, value }) => { assert_eq!(key, "batch"); @@ -114,9 +116,10 @@ fn parse_config_set_live_interval() { /// `config set --key output --value json` parses correctly. #[test] fn parse_config_set_output() { - let cli = - Cli::try_parse_from(["agcli", "config", "set", "--key", "output", "--value", "json"]) - .expect("config set output should parse"); + let cli = Cli::try_parse_from([ + "agcli", "config", "set", "--key", "output", "--value", "json", + ]) + .expect("config set output should parse"); match cli.command { agcli::cli::Commands::Config(ConfigCommands::Set { key, value }) => { assert_eq!(key, "output"); @@ -163,9 +166,8 @@ fn parse_config_unset() { /// `config unset --key spending_limit.97` parses correctly. #[test] fn parse_config_unset_spending_limit() { - let cli = - Cli::try_parse_from(["agcli", "config", "unset", "--key", "spending_limit.97"]) - .expect("config unset spending_limit should parse"); + let cli = Cli::try_parse_from(["agcli", "config", "unset", "--key", "spending_limit.97"]) + .expect("config unset spending_limit should parse"); match cli.command { agcli::cli::Commands::Config(ConfigCommands::Unset { key }) => { assert_eq!(key, "spending_limit.97"); @@ -177,8 +179,7 @@ fn parse_config_unset_spending_limit() { /// `config path` parses without error. #[test] fn parse_config_path() { - let cli = - Cli::try_parse_from(["agcli", "config", "path"]).expect("config path should parse"); + let cli = Cli::try_parse_from(["agcli", "config", "path"]).expect("config path should parse"); assert!(matches!( cli.command, agcli::cli::Commands::Config(ConfigCommands::Path) @@ -396,7 +397,8 @@ fn green_path_config_set_and_show_roundtrip() { let mut cfg2 = agcli::Config::load_from(&path).unwrap_or_default(); let limits = cfg2.spending_limits.get_or_insert_with(HashMap::new); limits.insert("1".to_string(), 50.0); - cfg2.save_to(&path).expect("config save with limits should succeed"); + cfg2.save_to(&path) + .expect("config save with limits should succeed"); let loaded2 = agcli::Config::load_from(&path).expect("reload after limits save"); let sl = loaded2 @@ -414,7 +416,10 @@ fn green_path_config_set_and_show_roundtrip() { cfg3.save_to(&path).expect("unset save should succeed"); let loaded3 = agcli::Config::load_from(&path).expect("reload after unset"); - assert!(loaded3.network.is_none(), "network should be None after unset"); + assert!( + loaded3.network.is_none(), + "network should be None after unset" + ); } /// Green-path: `config path` returns a valid filesystem path string. diff --git a/tests/audit_contracts.rs b/tests/audit_contracts.rs index e88f80e..56de8bb 100644 --- a/tests/audit_contracts.rs +++ b/tests/audit_contracts.rs @@ -25,7 +25,11 @@ fn parse_upload_with_storage_deposit_limit() { "--storage-deposit-limit", "5000000000", ]); - assert!(cli.is_ok(), "upload with storage-deposit-limit: {:?}", cli.err()); + assert!( + cli.is_ok(), + "upload with storage-deposit-limit: {:?}", + cli.err() + ); } #[test] @@ -93,7 +97,10 @@ fn parse_instantiate_default_gas_values_accepted() { #[test] fn parse_instantiate_missing_code_hash_rejected() { let cli = Cli::try_parse_from(["agcli", "contracts", "instantiate"]); - assert!(cli.is_err(), "instantiate without --code-hash must be rejected"); + assert!( + cli.is_err(), + "instantiate without --code-hash must be rejected" + ); } // ── call ────────────────────────────────────────────────────────────────────── @@ -136,8 +143,7 @@ fn parse_call_full() { #[test] fn parse_call_missing_contract_rejected() { - let cli = - Cli::try_parse_from(["agcli", "contracts", "call", "--data", "0xdeadbeef"]); + let cli = Cli::try_parse_from(["agcli", "contracts", "call", "--data", "0xdeadbeef"]); assert!(cli.is_err(), "call without --contract must be rejected"); } @@ -170,7 +176,10 @@ fn parse_remove_code_minimal() { #[test] fn parse_remove_code_missing_hash_rejected() { let cli = Cli::try_parse_from(["agcli", "contracts", "remove-code"]); - assert!(cli.is_err(), "remove-code without --code-hash must be rejected"); + assert!( + cli.is_err(), + "remove-code without --code-hash must be rejected" + ); } // ── structural / routing ────────────────────────────────────────────────────── @@ -189,7 +198,10 @@ fn parse_contracts_help_does_not_panic() { fn parse_contracts_no_subcommand_rejected() { // The contracts group requires a subcommand. let cli = Cli::try_parse_from(["agcli", "contracts"]); - assert!(cli.is_err(), "contracts without subcommand must be rejected"); + assert!( + cli.is_err(), + "contracts without subcommand must be rejected" + ); } // ── ignore-gated localnet integration test ──────────────────────────────────── @@ -248,5 +260,8 @@ async fn green_path_contracts() { fs::write(&bad_wasm, b"not wasm bytes").unwrap(); let data = fs::read(&bad_wasm).unwrap(); let result = agcli::cli::helpers::validate_wasm_file(&data, bad_wasm.to_str().unwrap()); - assert!(result.is_err(), "validate_wasm_file should reject non-WASM bytes"); + assert!( + result.is_err(), + "validate_wasm_file should reject non-WASM bytes" + ); } diff --git a/tests/audit_crowdloan.rs b/tests/audit_crowdloan.rs index 4dc54a1..437ddcf 100644 --- a/tests/audit_crowdloan.rs +++ b/tests/audit_crowdloan.rs @@ -106,7 +106,10 @@ fn parse_surface_all_crowdloan_subcommands() { ], ), ("list", &["agcli", "crowdloan", "list"]), - ("info", &["agcli", "crowdloan", "info", "--crowdloan-id", "7"]), + ( + "info", + &["agcli", "crowdloan", "info", "--crowdloan-id", "7"], + ), ( "contributors", &["agcli", "crowdloan", "contributors", "--crowdloan-id", "7"], diff --git a/tests/audit_delegate.rs b/tests/audit_delegate.rs index 939a311..4d10cf1 100644 --- a/tests/audit_delegate.rs +++ b/tests/audit_delegate.rs @@ -145,7 +145,14 @@ fn validate_take_range() { ); } // out-of-range values - for pct in [-0.01_f64, 18.01, 19.0, 100.0, f64::INFINITY, f64::NEG_INFINITY] { + for pct in [ + -0.01_f64, + 18.01, + 19.0, + 100.0, + f64::INFINITY, + f64::NEG_INFINITY, + ] { assert!( agcli::cli::helpers::validate_delegate_take(pct).is_err(), "expected {pct}% to be rejected" @@ -214,8 +221,8 @@ fn error_classify_non_associated_coldkey() { async fn green_path_delegate() { use agcli::chain::Client; - let endpoint = std::env::var("AGCLI_ENDPOINT") - .unwrap_or_else(|_| "ws://127.0.0.1:9944".to_string()); + let endpoint = + std::env::var("AGCLI_ENDPOINT").unwrap_or_else(|_| "ws://127.0.0.1:9944".to_string()); let client = Client::connect(&endpoint) .await diff --git a/tests/audit_diff.rs b/tests/audit_diff.rs index d83f843..ba55ff5 100644 --- a/tests/audit_diff.rs +++ b/tests/audit_diff.rs @@ -31,11 +31,26 @@ fn diff_cmd(cli: Cli) -> DiffCommands { #[test] fn portfolio_minimal_parse() { // --address is optional; --block1 / --block2 are required - let cli = parse(&["agcli", "diff", "portfolio", "--block1", "100", "--block2", "200"]); + let cli = parse(&[ + "agcli", + "diff", + "portfolio", + "--block1", + "100", + "--block2", + "200", + ]); assert!(cli.is_ok(), "portfolio minimal: {:?}", cli.err()); let cmd = diff_cmd(cli.unwrap()); assert!( - matches!(cmd, DiffCommands::Portfolio { address: None, block1: 100, block2: 200 }), + matches!( + cmd, + DiffCommands::Portfolio { + address: None, + block1: 100, + block2: 200 + } + ), "variant mismatch: {cmd:?}" ); } @@ -56,7 +71,14 @@ fn portfolio_with_address() { assert!(cli.is_ok(), "portfolio with address: {:?}", cli.err()); let cmd = diff_cmd(cli.unwrap()); assert!( - matches!(cmd, DiffCommands::Portfolio { address: Some(_), block1: 100, block2: 200 }), + matches!( + cmd, + DiffCommands::Portfolio { + address: Some(_), + block1: 100, + block2: 200 + } + ), "address not captured: {cmd:?}" ); } @@ -65,36 +87,70 @@ fn portfolio_with_address() { fn portfolio_missing_block1_rejected() { let cli = parse(&["agcli", "diff", "portfolio", "--block2", "200"]); assert!(cli.is_err(), "missing --block1 should be rejected"); - assert_eq!(cli.unwrap_err().kind(), clap::error::ErrorKind::MissingRequiredArgument); + assert_eq!( + cli.unwrap_err().kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); } #[test] fn portfolio_missing_block2_rejected() { let cli = parse(&["agcli", "diff", "portfolio", "--block1", "100"]); assert!(cli.is_err(), "missing --block2 should be rejected"); - assert_eq!(cli.unwrap_err().kind(), clap::error::ErrorKind::MissingRequiredArgument); + assert_eq!( + cli.unwrap_err().kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); } #[test] fn portfolio_block_u32_boundary_max() { - let cli = parse(&["agcli", "diff", "portfolio", "--block1", "0", "--block2", "4294967295"]); + let cli = parse(&[ + "agcli", + "diff", + "portfolio", + "--block1", + "0", + "--block2", + "4294967295", + ]); assert!(cli.is_ok(), "u32::MAX should parse: {:?}", cli.err()); - assert!( - matches!(diff_cmd(cli.unwrap()), DiffCommands::Portfolio { block2: 4294967295, .. }), - ); + assert!(matches!( + diff_cmd(cli.unwrap()), + DiffCommands::Portfolio { + block2: 4294967295, + .. + } + ),); } #[test] fn portfolio_block_overflow_rejected() { // 2^32 overflows u32 - let cli = parse(&["agcli", "diff", "portfolio", "--block1", "0", "--block2", "4294967296"]); + let cli = parse(&[ + "agcli", + "diff", + "portfolio", + "--block1", + "0", + "--block2", + "4294967296", + ]); assert!(cli.is_err(), "u32 overflow should fail"); } #[test] fn portfolio_same_block_allowed() { // block1 == block2 is a valid (no-op) diff; the CLI must not reject it - let cli = parse(&["agcli", "diff", "portfolio", "--block1", "500", "--block2", "500"]); + let cli = parse(&[ + "agcli", + "diff", + "portfolio", + "--block1", + "500", + "--block2", + "500", + ]); assert!(cli.is_ok(), "same block should parse: {:?}", cli.err()); } @@ -118,8 +174,16 @@ fn portfolio_json_output_flag() { #[test] fn portfolio_unknown_flag_rejected() { - let cli = - parse(&["agcli", "diff", "portfolio", "--block1", "100", "--block2", "200", "--foo"]); + let cli = parse(&[ + "agcli", + "diff", + "portfolio", + "--block1", + "100", + "--block2", + "200", + "--foo", + ]); assert!(cli.is_err(), "unknown flag should fail"); } @@ -129,24 +193,37 @@ fn portfolio_unknown_flag_rejected() { #[test] fn subnet_minimal_parse() { - let cli = - parse(&["agcli", "diff", "subnet", "--netuid", "1", "--block1", "100", "--block2", "200"]); + let cli = parse(&[ + "agcli", "diff", "subnet", "--netuid", "1", "--block1", "100", "--block2", "200", + ]); assert!(cli.is_ok(), "subnet minimal: {:?}", cli.err()); - assert!( - matches!(diff_cmd(cli.unwrap()), DiffCommands::Subnet { netuid: 1, block1: 100, block2: 200 }), - ); + assert!(matches!( + diff_cmd(cli.unwrap()), + DiffCommands::Subnet { + netuid: 1, + block1: 100, + block2: 200 + } + ),); } #[test] fn subnet_missing_netuid_rejected() { - let cli = parse(&["agcli", "diff", "subnet", "--block1", "100", "--block2", "200"]); + let cli = parse(&[ + "agcli", "diff", "subnet", "--block1", "100", "--block2", "200", + ]); assert!(cli.is_err(), "missing --netuid should fail"); - assert_eq!(cli.unwrap_err().kind(), clap::error::ErrorKind::MissingRequiredArgument); + assert_eq!( + cli.unwrap_err().kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); } #[test] fn subnet_missing_block2_rejected() { - let cli = parse(&["agcli", "diff", "subnet", "--netuid", "1", "--block1", "100"]); + let cli = parse(&[ + "agcli", "diff", "subnet", "--netuid", "1", "--block1", "100", + ]); assert!(cli.is_err(), "missing --block2 should fail"); } @@ -157,7 +234,10 @@ fn subnet_max_netuid() { "agcli", "diff", "subnet", "--netuid", "65535", "--block1", "100", "--block2", "200", ]); assert!(cli.is_ok(), "max netuid: {:?}", cli.err()); - assert!(matches!(diff_cmd(cli.unwrap()), DiffCommands::Subnet { netuid: 65535, .. })); + assert!(matches!( + diff_cmd(cli.unwrap()), + DiffCommands::Subnet { netuid: 65535, .. } + )); } #[test] @@ -184,9 +264,17 @@ fn subnet_zero_netuid_accepted() { #[test] fn network_minimal_parse() { - let cli = parse(&["agcli", "diff", "network", "--block1", "100", "--block2", "200"]); + let cli = parse(&[ + "agcli", "diff", "network", "--block1", "100", "--block2", "200", + ]); assert!(cli.is_ok(), "network minimal: {:?}", cli.err()); - assert!(matches!(diff_cmd(cli.unwrap()), DiffCommands::Network { block1: 100, block2: 200 })); + assert!(matches!( + diff_cmd(cli.unwrap()), + DiffCommands::Network { + block1: 100, + block2: 200 + } + )); } #[test] @@ -209,7 +297,9 @@ fn network_zero_blocks_accepted() { #[test] fn network_same_block_accepted() { - let cli = parse(&["agcli", "diff", "network", "--block1", "500", "--block2", "500"]); + let cli = parse(&[ + "agcli", "diff", "network", "--block1", "500", "--block2", "500", + ]); assert!(cli.is_ok(), "same block: {:?}", cli.err()); } @@ -227,7 +317,10 @@ fn network_global_endpoint_flag() { "2", ]); assert!(cli.is_ok(), "endpoint flag: {:?}", cli.err()); - assert_eq!(cli.unwrap().endpoint, Some("ws://127.0.0.1:9944".to_string())); + assert_eq!( + cli.unwrap().endpoint, + Some("ws://127.0.0.1:9944".to_string()) + ); } #[test] @@ -255,41 +348,91 @@ fn network_archive_network_flag() { #[test] fn metagraph_minimal_parse() { let cli = parse(&[ - "agcli", "diff", "metagraph", "--netuid", "1", "--block1", "100", "--block2", "200", + "agcli", + "diff", + "metagraph", + "--netuid", + "1", + "--block1", + "100", + "--block2", + "200", ]); assert!(cli.is_ok(), "metagraph minimal: {:?}", cli.err()); assert!(matches!( diff_cmd(cli.unwrap()), - DiffCommands::Metagraph { netuid: 1, block1: 100, block2: 200 } + DiffCommands::Metagraph { + netuid: 1, + block1: 100, + block2: 200 + } )); } #[test] fn metagraph_missing_netuid_rejected() { - let cli = parse(&["agcli", "diff", "metagraph", "--block1", "100", "--block2", "200"]); + let cli = parse(&[ + "agcli", + "diff", + "metagraph", + "--block1", + "100", + "--block2", + "200", + ]); assert!(cli.is_err(), "missing --netuid should fail"); - assert_eq!(cli.unwrap_err().kind(), clap::error::ErrorKind::MissingRequiredArgument); + assert_eq!( + cli.unwrap_err().kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); } #[test] fn metagraph_missing_block1_rejected() { - let cli = parse(&["agcli", "diff", "metagraph", "--netuid", "1", "--block2", "200"]); + let cli = parse(&[ + "agcli", + "diff", + "metagraph", + "--netuid", + "1", + "--block2", + "200", + ]); assert!(cli.is_err(), "missing --block1 should fail"); } #[test] fn metagraph_missing_block2_rejected() { - let cli = parse(&["agcli", "diff", "metagraph", "--netuid", "1", "--block1", "100"]); + let cli = parse(&[ + "agcli", + "diff", + "metagraph", + "--netuid", + "1", + "--block1", + "100", + ]); assert!(cli.is_err(), "missing --block2 should fail"); } #[test] fn metagraph_max_netuid() { let cli = parse(&[ - "agcli", "diff", "metagraph", "--netuid", "65535", "--block1", "100", "--block2", "200", + "agcli", + "diff", + "metagraph", + "--netuid", + "65535", + "--block1", + "100", + "--block2", + "200", ]); assert!(cli.is_ok(), "max netuid: {:?}", cli.err()); - assert!(matches!(diff_cmd(cli.unwrap()), DiffCommands::Metagraph { netuid: 65535, .. })); + assert!(matches!( + diff_cmd(cli.unwrap()), + DiffCommands::Metagraph { netuid: 65535, .. } + )); } #[test] @@ -323,7 +466,9 @@ fn diff_without_subcommand_rejected() { #[test] fn diff_unknown_subcommand_rejected() { - let cli = parse(&["agcli", "diff", "balances", "--block1", "100", "--block2", "200"]); + let cli = parse(&[ + "agcli", "diff", "balances", "--block1", "100", "--block2", "200", + ]); assert!(cli.is_err(), "unknown subcommand should fail"); } @@ -338,10 +483,25 @@ fn diff_unknown_subcommand_rejected() { #[test] fn all_diff_subcommands_enumerated() { let variants: &[DiffCommands] = &[ - DiffCommands::Portfolio { address: None, block1: 0, block2: 1 }, - DiffCommands::Subnet { netuid: 1, block1: 0, block2: 1 }, - DiffCommands::Network { block1: 0, block2: 1 }, - DiffCommands::Metagraph { netuid: 1, block1: 0, block2: 1 }, + DiffCommands::Portfolio { + address: None, + block1: 0, + block2: 1, + }, + DiffCommands::Subnet { + netuid: 1, + block1: 0, + block2: 1, + }, + DiffCommands::Network { + block1: 0, + block2: 1, + }, + DiffCommands::Metagraph { + netuid: 1, + block1: 0, + block2: 1, + }, ]; // The only assertion needed is that this compiles — any exhaustiveness // failure will appear as a compile error. @@ -373,8 +533,14 @@ fn audit_subnet_json_missing_tempo_and_owner_hotkey() { "emission_diff": 100i128, // tempo and owner_hotkey are intentionally omitted in the real code }); - assert!(json.get("tempo").is_none(), "tempo is not in diff subnet JSON — this is the documented drift"); - assert!(json.get("owner_hotkey").is_none(), "owner_hotkey is not in diff subnet JSON — documented drift"); + assert!( + json.get("tempo").is_none(), + "tempo is not in diff subnet JSON — this is the documented drift" + ); + assert!( + json.get("owner_hotkey").is_none(), + "owner_hotkey is not in diff subnet JSON — documented drift" + ); assert!(json.get("tao_in").is_some()); assert!(json.get("emission_diff").is_some()); } @@ -395,10 +561,22 @@ fn audit_network_json_no_diff_fields() { "subnet_count": [30usize, 31usize], // No diff scalar fields — documented drift vs portfolio and subnet }); - assert!(json.get("total_issuance_diff").is_none(), "no diff field in network JSON"); - assert!(json.get("total_stake_diff").is_none(), "no diff field in network JSON"); - assert!(json.get("subnet_count_diff").is_none(), "no diff field in network JSON"); - assert_eq!(json.get("subnet_count").unwrap().as_array().unwrap().len(), 2); + assert!( + json.get("total_issuance_diff").is_none(), + "no diff field in network JSON" + ); + assert!( + json.get("total_stake_diff").is_none(), + "no diff field in network JSON" + ); + assert!( + json.get("subnet_count_diff").is_none(), + "no diff field in network JSON" + ); + assert_eq!( + json.get("subnet_count").unwrap().as_array().unwrap().len(), + 2 + ); } /// Audit guard: `diff metagraph` does not track neurons removed between blocks. @@ -425,7 +603,10 @@ fn audit_metagraph_removed_neurons_not_tracked() { } } // No "removed" logic exists in the handler — UID 2 is never in changes - assert!(!changes.iter().any(|&u| u == 2), "UID 2 was removed but not tracked — documented drift"); + assert!( + !changes.iter().any(|&u| u == 2), + "UID 2 was removed but not tracked — documented drift" + ); // The drift: if we wanted completeness we would iterate map1 for UIDs not // present in uids_block2 and emit "change: removed" entries. } @@ -467,7 +648,10 @@ fn exit_code_subnet_not_found_is_validation() { // "Subnet N not found at block B" should classify as VALIDATION (12) // because classify() matches msg.contains("subnet") && msg.contains("not found") let err = anyhow::anyhow!("Subnet 99 not found at block 100"); - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::VALIDATION); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::VALIDATION + ); } #[test] @@ -475,7 +659,10 @@ fn exit_code_network_error_is_network() { use std::io; let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "connection refused"); let err = anyhow::Error::from(io_err); - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::NETWORK); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::NETWORK + ); } #[test] @@ -485,7 +672,10 @@ fn exit_code_no_address_is_generic() { // behaviour per docs/commands/diff.md; listed as a finding because it // should arguably be VALIDATION. let err = anyhow::anyhow!("No address provided and no wallet found. Use --address ."); - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::GENERIC); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::GENERIC + ); } #[test] @@ -494,7 +684,10 @@ fn exit_code_pruned_state_is_generic() { // classify() call on the outer message returns GENERIC (1). let err = anyhow::anyhow!("State already discarded for block 100"); // "discarded" doesn't match any chain/network pattern — exits GENERIC - assert_eq!(agcli::error::classify(&err), agcli::error::exit_code::GENERIC); + assert_eq!( + agcli::error::classify(&err), + agcli::error::exit_code::GENERIC + ); } // ───────────────────────────────────────────────────────────────────────────── @@ -513,18 +706,18 @@ async fn green_path_diff() { use agcli::chain::Client; let endpoint = "ws://127.0.0.1:9944"; - let client = Client::connect(endpoint).await.expect("connect to localnet"); + let client = Client::connect(endpoint) + .await + .expect("connect to localnet"); // Get two adjacent blocks to diff against let block2 = client.get_block_number().await.expect("get latest block") as u32; let block1 = block2.saturating_sub(5); // diff network — no required subnet, simplest to verify - let (hash1, hash2) = tokio::try_join!( - client.get_block_hash(block1), - client.get_block_hash(block2), - ) - .expect("get block hashes"); + let (hash1, hash2) = + tokio::try_join!(client.get_block_hash(block1), client.get_block_hash(block2),) + .expect("get block hashes"); let (issuance1, issuance2) = tokio::try_join!( client.get_total_issuance_at_block(hash1), @@ -545,7 +738,10 @@ async fn green_path_diff() { .expect("get Alice balance at both blocks"); // Alice has genesis balance on localnet - assert!(bal1.rao() > 0, "Alice block1 balance must be non-zero on localnet"); + assert!( + bal1.rao() > 0, + "Alice block1 balance must be non-zero on localnet" + ); let _ = bal2; // may be same as bal1 if no transfers occurred // diff subnet — subnet 1 (root subnet exists on localnet) diff --git a/tests/audit_doctor.rs b/tests/audit_doctor.rs index f1ccd9b..6c4cc22 100644 --- a/tests/audit_doctor.rs +++ b/tests/audit_doctor.rs @@ -73,7 +73,10 @@ async fn doctor_green_path_local_chain() { .get_total_networks() .await .expect("total-networks probe should succeed"); - assert!(head > 0, "local chain should have produced at least one block"); + assert!( + head > 0, + "local chain should have produced at least one block" + ); cli::commands::execute(cli) .await diff --git a/tests/audit_drand.rs b/tests/audit_drand.rs index e4faa82..b4ec66a 100644 --- a/tests/audit_drand.rs +++ b/tests/audit_drand.rs @@ -19,7 +19,12 @@ fn parse_surface_drand_subcommands() { for args in parse_cases { let parsed = Cli::try_parse_from(*args); - assert!(parsed.is_ok(), "failed to parse {:?}: {:?}", args, parsed.err()); + assert!( + parsed.is_ok(), + "failed to parse {:?}: {:?}", + args, + parsed.err() + ); } let parsed = Cli::try_parse_from(parse_cases[0]).expect("drand write-pulse should parse"); @@ -34,16 +39,12 @@ fn parse_surface_drand_subcommands() { #[test] fn parse_drand_write_pulse_requires_payload_and_signature() { - let missing_payload = Cli::try_parse_from([ - "agcli", - "drand", - "write-pulse", - "--signature", - "0x01", - ]); + let missing_payload = + Cli::try_parse_from(["agcli", "drand", "write-pulse", "--signature", "0x01"]); assert!(missing_payload.is_err()); - let missing_signature = Cli::try_parse_from(["agcli", "drand", "write-pulse", "--payload", "0x01"]); + let missing_signature = + Cli::try_parse_from(["agcli", "drand", "write-pulse", "--payload", "0x01"]); assert!(missing_signature.is_err()); } diff --git a/tests/audit_evm.rs b/tests/audit_evm.rs index 6b1fc24..53e2352 100644 --- a/tests/audit_evm.rs +++ b/tests/audit_evm.rs @@ -32,7 +32,10 @@ fn parse_surface_evm_subcommands() { "0x0000000000000000000000000000000000000000000000000000000000000001", ]) .expect("evm call should parse"); - assert!(matches!(call_cli.command, Commands::Evm(EvmCommands::Call { .. }))); + assert!(matches!( + call_cli.command, + Commands::Evm(EvmCommands::Call { .. }) + )); let withdraw_cli = Cli::try_parse_from([ "agcli", @@ -125,7 +128,10 @@ async fn green_path_evm_localnet_call_and_withdraw() -> anyhow::Result<()> { None, ) .await?; - assert!(!call_hash.is_empty(), "evm call tx hash should be populated"); + assert!( + !call_hash.is_empty(), + "evm call tx hash should be populated" + ); let withdraw_hash = client.evm_withdraw(&signer, source, 0).await?; assert!( diff --git a/tests/audit_explain.rs b/tests/audit_explain.rs index cdd435a..1a95984 100644 --- a/tests/audit_explain.rs +++ b/tests/audit_explain.rs @@ -102,7 +102,10 @@ fn parse_explain_surface_for_all_canonical_topics() { let cli = Cli::try_parse_from(["agcli", "explain", "--topic", *topic]) .unwrap_or_else(|e| panic!("failed to parse canonical topic {topic}: {e}")); match &cli.command { - Commands::Explain { topic: parsed, full } => { + Commands::Explain { + topic: parsed, + full, + } => { assert_eq!(parsed.as_deref(), Some(*topic)); assert!(!full); } @@ -117,7 +120,10 @@ fn parse_explain_surface_for_all_alias_topics() { let cli = Cli::try_parse_from(["agcli", "explain", "--topic", *alias]) .unwrap_or_else(|e| panic!("failed to parse alias topic {alias}: {e}")); match &cli.command { - Commands::Explain { topic: parsed, full } => { + Commands::Explain { + topic: parsed, + full, + } => { assert_eq!(parsed.as_deref(), Some(*alias)); assert!(!full); } @@ -148,13 +154,7 @@ fn parse_explain_list_and_full_modes() { } let full_topic = Cli::try_parse_from([ - "agcli", - "--output", - "json", - "explain", - "--topic", - "weights", - "--full", + "agcli", "--output", "json", "explain", "--topic", "weights", "--full", ]) .expect("explain --topic weights --full should parse"); match &full_topic.command { diff --git a/tests/audit_identity.rs b/tests/audit_identity.rs index f0252fd..d96aa16 100644 --- a/tests/audit_identity.rs +++ b/tests/audit_identity.rs @@ -160,13 +160,7 @@ fn parse_identity_clear() { #[test] fn parse_identity_clear_with_wallet_flags() { // Global wallet flags should not interfere with the subcommand parse. - let cli = must_parse(&[ - "agcli", - "--wallet", - "mywallet", - "identity", - "clear", - ]); + let cli = must_parse(&["agcli", "--wallet", "mywallet", "identity", "clear"]); assert!(matches!( cli.command, Commands::Identity(IdentityCommands::Clear) @@ -392,7 +386,12 @@ fn identity_set_subnet_field_surface() { } // Flags that do NOT yet exist on set-subnet. - for absent in &["--discord", "--description", "--logo-url", "--subnet-contact"] { + for absent in &[ + "--discord", + "--description", + "--logo-url", + "--subnet-contact", + ] { let argv = &[ "agcli", "identity", diff --git a/tests/audit_liquidity.rs b/tests/audit_liquidity.rs index e31f9ab..23ff232 100644 --- a/tests/audit_liquidity.rs +++ b/tests/audit_liquidity.rs @@ -102,14 +102,7 @@ fn parse_surface_all_liquidity_subcommands() { other => panic!("expected Modify, got {:?}", other), } - let toggle = parse_liquidity(&[ - "agcli", - "liquidity", - "toggle", - "--netuid", - "7", - "--enable", - ]); + let toggle = parse_liquidity(&["agcli", "liquidity", "toggle", "--netuid", "7", "--enable"]); match toggle { LiquidityCommands::Toggle { netuid, enable } => { assert_eq!(netuid, 7); diff --git a/tests/audit_localnet.rs b/tests/audit_localnet.rs index d8ec6d2..3ae9d8b 100644 --- a/tests/audit_localnet.rs +++ b/tests/audit_localnet.rs @@ -4,7 +4,12 @@ use clap::Parser; fn assert_parses(args: &[&str]) { let parsed = Cli::try_parse_from(args); - assert!(parsed.is_ok(), "failed to parse {:?}: {:?}", args, parsed.err()); + assert!( + parsed.is_ok(), + "failed to parse {:?}: {:?}", + args, + parsed.err() + ); } #[test] @@ -28,13 +33,7 @@ fn parse_surface_localnet_start() { #[test] fn parse_surface_localnet_stop() { - assert_parses(&[ - "agcli", - "localnet", - "stop", - "--container", - "audit-localnet", - ]); + assert_parses(&["agcli", "localnet", "stop", "--container", "audit-localnet"]); } #[test] diff --git a/tests/audit_multisig.rs b/tests/audit_multisig.rs index 04a5abb..7f24da2 100644 --- a/tests/audit_multisig.rs +++ b/tests/audit_multisig.rs @@ -17,8 +17,7 @@ const BOB: &str = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"; const CHARLIE: &str = "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y"; /// 32-byte call hash used throughout -const CALL_HASH: &str = - "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; +const CALL_HASH: &str = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; // ── helper ───────────────────────────────────────────────────────────────── @@ -419,7 +418,10 @@ fn cancel_missing_timepoint_height_rejected() { "--timepoint-index", "0", ]); - assert!(err.is_err(), "cancel without --timepoint-height must be rejected"); + assert!( + err.is_err(), + "cancel without --timepoint-height must be rejected" + ); } #[test] @@ -437,7 +439,10 @@ fn cancel_missing_timepoint_index_rejected() { "--timepoint-height", "12345", ]); - assert!(err.is_err(), "cancel without --timepoint-index must be rejected"); + assert!( + err.is_err(), + "cancel without --timepoint-index must be rejected" + ); } // ══════════════════════════════════════════════════════════════════════════ @@ -646,13 +651,7 @@ fn green_path_multisig_submit_list() { address_cli.err() ); - let list_cli = parse(&[ - "agcli", - "multisig", - "list", - "--address", - ALICE, - ]); + let list_cli = parse(&["agcli", "multisig", "list", "--address", ALICE]); assert!( list_cli.is_ok(), "green-path: list parse: {:?}", diff --git a/tests/audit_preimage.rs b/tests/audit_preimage.rs index 12def83..cf03294 100644 --- a/tests/audit_preimage.rs +++ b/tests/audit_preimage.rs @@ -18,7 +18,11 @@ fn parse_surface_preimage_note_with_args() { "--args", r#"["0x68656c6c6f", 42, true]"#, ]); - assert!(cli.is_ok(), "preimage note with args should parse: {:?}", cli.err()); + assert!( + cli.is_ok(), + "preimage note with args should parse: {:?}", + cli.err() + ); } #[test] diff --git a/tests/audit_proxy.rs b/tests/audit_proxy.rs index 57997a1..9ec1447 100644 --- a/tests/audit_proxy.rs +++ b/tests/audit_proxy.rs @@ -247,41 +247,44 @@ fn proxy_kill_pure_all_opts() { #[test] fn proxy_kill_pure_missing_spawner_rejected() { - assert!( - parse(&["agcli", "proxy", "kill-pure", "--height", "100", "--ext-index", "0"]).is_err() - ); + assert!(parse(&[ + "agcli", + "proxy", + "kill-pure", + "--height", + "100", + "--ext-index", + "0" + ]) + .is_err()); } #[test] fn proxy_kill_pure_missing_height_rejected() { - assert!( - parse(&[ - "agcli", - "proxy", - "kill-pure", - "--spawner", - ALICE, - "--ext-index", - "0" - ]) - .is_err() - ); + assert!(parse(&[ + "agcli", + "proxy", + "kill-pure", + "--spawner", + ALICE, + "--ext-index", + "0" + ]) + .is_err()); } #[test] fn proxy_kill_pure_missing_ext_index_rejected() { - assert!( - parse(&[ - "agcli", - "proxy", - "kill-pure", - "--spawner", - ALICE, - "--height", - "100" - ]) - .is_err() - ); + assert!(parse(&[ + "agcli", + "proxy", + "kill-pure", + "--spawner", + ALICE, + "--height", + "100" + ]) + .is_err()); } // ──────── proxy list ──────── @@ -397,9 +400,7 @@ fn proxy_proxy_announced_with_optional_args() { .unwrap(); match proxy_cmd(cli) { ProxyCommands::ProxyAnnounced { - proxy_type, - args, - .. + proxy_type, args, .. } => { assert_eq!(proxy_type.as_deref(), Some("staking")); assert_eq!(args.as_deref(), Some("[0, 100]")); @@ -468,9 +469,14 @@ fn proxy_reject_announcement_required_args() { #[test] fn proxy_reject_announcement_missing_delegate_rejected() { - assert!( - parse(&["agcli", "proxy", "reject-announcement", "--call-hash", CALL_HASH]).is_err() - ); + assert!(parse(&[ + "agcli", + "proxy", + "reject-announcement", + "--call-hash", + CALL_HASH + ]) + .is_err()); } // ──────── proxy list-announcements ──────── @@ -534,7 +540,14 @@ fn proxy_remove_announcement_required_args() { #[test] fn proxy_remove_announcement_missing_real_rejected() { - assert!(parse(&["agcli", "proxy", "remove-announcement", "--call-hash", CALL_HASH]).is_err()); + assert!(parse(&[ + "agcli", + "proxy", + "remove-announcement", + "--call-hash", + CALL_HASH + ]) + .is_err()); } #[test] @@ -568,7 +581,15 @@ fn proxy_add_all_known_proxy_types_parse() { "non_fungible", "sudo_unchecked_set_code", ] { - let result = parse(&["agcli", "proxy", "add", "--delegate", ALICE, "--proxy-type", pt]); + let result = parse(&[ + "agcli", + "proxy", + "add", + "--delegate", + ALICE, + "--proxy-type", + pt, + ]); assert!( result.is_ok(), "proxy type '{}' should parse at clap level: {:?}", diff --git a/tests/audit_root.rs b/tests/audit_root.rs index c62a07e..f8a88fd 100644 --- a/tests/audit_root.rs +++ b/tests/audit_root.rs @@ -64,9 +64,7 @@ fn parse_root_register_dry_run() { /// `root weights` with a single netuid:weight pair. #[test] fn parse_root_weights_single_pair() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", "root", "weights", "--weights", "1:1000", - ]); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "root", "weights", "--weights", "1:1000"]); assert!( cli.is_ok(), "root weights with single pair should parse: {:?}", @@ -242,7 +240,11 @@ fn root_commands_variant_coverage() { #[test] fn weight_pairs_valid_multi() { let result = agcli::cli::helpers::parse_weight_pairs("1:500,2:300,3:200"); - assert!(result.is_ok(), "valid weight pairs should parse: {:?}", result.err()); + assert!( + result.is_ok(), + "valid weight pairs should parse: {:?}", + result.err() + ); let (uids, weights) = result.unwrap(); assert_eq!(uids, vec![1u16, 2, 3]); assert_eq!(weights, vec![500u16, 300, 200]); @@ -302,10 +304,7 @@ fn weight_pairs_no_colon_rejected() { #[test] fn weight_pairs_extra_colon_rejected() { let result = agcli::cli::helpers::parse_weight_pairs("1:100:extra"); - assert!( - result.is_err(), - "pair with extra ':' should be rejected" - ); + assert!(result.is_err(), "pair with extra ':' should be rejected"); } /// Empty string input is rejected because it cannot form a valid pair. diff --git a/tests/audit_safe_mode.rs b/tests/audit_safe_mode.rs index 3bd3d0c..4eef0d5 100644 --- a/tests/audit_safe_mode.rs +++ b/tests/audit_safe_mode.rs @@ -29,8 +29,7 @@ fn parse_safe_mode_enter_parses() { #[test] fn parse_safe_mode_enter_rejects_unknown_flag() { - let cli = - agcli::cli::Cli::try_parse_from(["agcli", "safe-mode", "enter", "--unknown-flag"]); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "safe-mode", "enter", "--unknown-flag"]); assert!(cli.is_err(), "safe-mode enter with unknown flag must fail"); } @@ -54,8 +53,7 @@ fn parse_safe_mode_extend_parses() { #[test] fn parse_safe_mode_extend_rejects_unknown_flag() { - let cli = - agcli::cli::Cli::try_parse_from(["agcli", "safe-mode", "extend", "--bogus"]); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "safe-mode", "extend", "--bogus"]); assert!(cli.is_err(), "safe-mode extend with unknown flag must fail"); } @@ -65,13 +63,8 @@ fn parse_safe_mode_extend_rejects_unknown_flag() { #[test] fn parse_safe_mode_force_enter_with_duration() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", - "safe-mode", - "force-enter", - "--duration", - "500", - ]); + let cli = + agcli::cli::Cli::try_parse_from(["agcli", "safe-mode", "force-enter", "--duration", "500"]); assert!( cli.is_ok(), "safe-mode force-enter --duration 500: {:?}", @@ -79,9 +72,7 @@ fn parse_safe_mode_force_enter_with_duration() { ); let parsed = cli.unwrap(); match &parsed.command { - agcli::cli::Commands::SafeMode(agcli::cli::SafeModeCommands::ForceEnter { - duration, - }) => { + agcli::cli::Commands::SafeMode(agcli::cli::SafeModeCommands::ForceEnter { duration }) => { assert_eq!(*duration, 500u32, "duration should be 500"); } other => panic!("expected SafeMode::ForceEnter, got {:?}", other), @@ -100,13 +91,8 @@ fn parse_safe_mode_force_enter_missing_duration_fails() { #[test] fn parse_safe_mode_force_enter_duration_zero() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", - "safe-mode", - "force-enter", - "--duration", - "0", - ]); + let cli = + agcli::cli::Cli::try_parse_from(["agcli", "safe-mode", "force-enter", "--duration", "0"]); assert!(cli.is_ok(), "duration 0 is a valid u32: {:?}", cli.err()); } @@ -124,9 +110,8 @@ fn parse_safe_mode_force_enter_duration_max_u32() { "duration u32::MAX should parse: {:?}", cli.err() ); - if let agcli::cli::Commands::SafeMode(agcli::cli::SafeModeCommands::ForceEnter { - duration, - }) = cli.unwrap().command + if let agcli::cli::Commands::SafeMode(agcli::cli::SafeModeCommands::ForceEnter { duration }) = + cli.unwrap().command { assert_eq!(duration, u32::MAX); } @@ -142,10 +127,7 @@ fn parse_safe_mode_force_enter_duration_overflow_fails() { "--duration", "4294967296", ]); - assert!( - cli.is_err(), - "duration > u32::MAX must fail to parse" - ); + assert!(cli.is_err(), "duration > u32::MAX must fail to parse"); } // ────────────────────────────────────────────────────────────────────────────── @@ -191,17 +173,9 @@ fn safe_mode_subcommand_names_match_cli() { ]; for args in cases { let cli = agcli::cli::Cli::try_parse_from(*args); + assert!(cli.is_ok(), "parse failed for {:?}: {:?}", args, cli.err()); assert!( - cli.is_ok(), - "parse failed for {:?}: {:?}", - args, - cli.err() - ); - assert!( - matches!( - cli.unwrap().command, - agcli::cli::Commands::SafeMode(_) - ), + matches!(cli.unwrap().command, agcli::cli::Commands::SafeMode(_)), "expected Commands::SafeMode for {:?}", args ); @@ -214,21 +188,15 @@ fn safe_mode_subcommand_names_match_cli() { #[test] fn safe_mode_enter_accepts_global_yes_flag() { - let cli = - agcli::cli::Cli::try_parse_from(["agcli", "--yes", "safe-mode", "enter"]); + let cli = agcli::cli::Cli::try_parse_from(["agcli", "--yes", "safe-mode", "enter"]); assert!(cli.is_ok(), "--yes + safe-mode enter: {:?}", cli.err()); assert!(cli.unwrap().yes, "--yes flag should be set"); } #[test] fn safe_mode_enter_accepts_wallet_flag() { - let cli = agcli::cli::Cli::try_parse_from([ - "agcli", - "--wallet", - "my_wallet", - "safe-mode", - "enter", - ]); + let cli = + agcli::cli::Cli::try_parse_from(["agcli", "--wallet", "my_wallet", "safe-mode", "enter"]); assert!(cli.is_ok(), "--wallet + safe-mode enter: {:?}", cli.err()); assert_eq!( cli.unwrap().wallet, @@ -248,11 +216,7 @@ fn safe_mode_force_enter_accepts_network_flag() { "--duration", "100", ]); - assert!( - cli.is_ok(), - "--network + force-enter: {:?}", - cli.err() - ); + assert!(cli.is_ok(), "--network + force-enter: {:?}", cli.err()); } // ────────────────────────────────────────────────────────────────────────────── @@ -316,8 +280,7 @@ fn force_enter_duration_arg_exists_in_cli_but_not_in_pallet() { async fn green_path_safe_mode() { use subxt::OnlineClient; - let url = std::env::var("LOCALNET_URL") - .unwrap_or_else(|_| "ws://127.0.0.1:9944".to_string()); + let url = std::env::var("LOCALNET_URL").unwrap_or_else(|_| "ws://127.0.0.1:9944".to_string()); let client = OnlineClient::::from_url(&url) .await @@ -331,7 +294,13 @@ async fn green_path_safe_mode() { .expect("SafeMode pallet must be present in runtime metadata"); // 2. Expected dispatchables. - for call_name in &["enter", "extend", "force_enter", "force_exit", "release_deposit"] { + for call_name in &[ + "enter", + "extend", + "force_enter", + "force_exit", + "release_deposit", + ] { assert!( pallet.call_variant_by_name(call_name).is_some(), "SafeMode pallet must expose '{}' dispatchable", @@ -346,5 +315,8 @@ async fn green_path_safe_mode() { .storage() .map(|s| s.entries().iter().any(|e| e.name() == "EnteredUntil")) .unwrap_or(false); - assert!(has_entered_until, "SafeMode pallet must have EnteredUntil storage item"); + assert!( + has_entered_until, + "SafeMode pallet must have EnteredUntil storage item" + ); } diff --git a/tests/audit_scheduler.rs b/tests/audit_scheduler.rs index 63d497a..370a555 100644 --- a/tests/audit_scheduler.rs +++ b/tests/audit_scheduler.rs @@ -1,6 +1,6 @@ use agcli::chain::Client; use clap::Parser; -use sp_core::{Pair as _, sr25519}; +use sp_core::{sr25519, Pair as _}; use subxt::dynamic::Value; #[test] @@ -79,7 +79,12 @@ fn parse_surface_scheduler_subcommands() { for argv in &cases { let parsed = agcli::cli::Cli::try_parse_from(argv); - assert!(parsed.is_ok(), "failed to parse {:?}: {:?}", argv, parsed.err()); + assert!( + parsed.is_ok(), + "failed to parse {:?}: {:?}", + argv, + parsed.err() + ); } } @@ -106,10 +111,16 @@ async fn green_path_scheduler_named_local_chain() -> anyhow::Result<()> { vec![Value::from_bytes(b"audit-scheduler-green-path".to_vec())], ) .await?; - assert!(!schedule_hash.is_empty(), "schedule tx hash should be non-empty"); + assert!( + !schedule_hash.is_empty(), + "schedule tx hash should be non-empty" + ); let cancel_hash = client.cancel_named_scheduled(&alice, &task_id).await?; - assert!(!cancel_hash.is_empty(), "cancel tx hash should be non-empty"); + assert!( + !cancel_hash.is_empty(), + "cancel tx hash should be non-empty" + ); Ok(()) } diff --git a/tests/audit_serve.rs b/tests/audit_serve.rs index cd0b24c..628bf56 100644 --- a/tests/audit_serve.rs +++ b/tests/audit_serve.rs @@ -37,8 +37,19 @@ fn parse_serve_axon_required_args() { #[test] fn parse_serve_axon_with_protocol_and_version() { assert_parses(&[ - "agcli", "serve", "axon", "--netuid", "1", "--ip", "10.0.0.1", "--port", "8091", - "--protocol", "4", "--version", "720", + "agcli", + "serve", + "axon", + "--netuid", + "1", + "--ip", + "10.0.0.1", + "--port", + "8091", + "--protocol", + "4", + "--version", + "720", ]); } @@ -75,15 +86,26 @@ fn parse_serve_axon_missing_port_fails() { #[test] fn parse_serve_axon_missing_netuid_fails() { - assert_fails(&["agcli", "serve", "axon", "--ip", "1.2.3.4", "--port", "8091"]); + assert_fails(&[ + "agcli", "serve", "axon", "--ip", "1.2.3.4", "--port", "8091", + ]); } // no --ip-type flag exists (audit finding: IPv6 not exposed) #[test] fn parse_serve_axon_no_ip_type_flag() { assert_fails(&[ - "agcli", "serve", "axon", "--netuid", "1", "--ip", "::1", "--port", "8091", - "--ip-type", "6", + "agcli", + "serve", + "axon", + "--netuid", + "1", + "--ip", + "::1", + "--port", + "8091", + "--ip-type", + "6", ]); } @@ -124,10 +146,7 @@ fn parse_serve_batch_axon_missing_file_fails() { #[test] fn parse_serve_batch_axon_file_field() { - let cli = parse(&[ - "agcli", "serve", "batch-axon", "--file", "/tmp/axons.json", - ]) - .unwrap(); + let cli = parse(&["agcli", "serve", "batch-axon", "--file", "/tmp/axons.json"]).unwrap(); match cli.command { agcli::cli::Commands::Serve(agcli::cli::ServeCommands::BatchAxon { file }) => { assert_eq!(file, "/tmp/axons.json"); @@ -141,25 +160,57 @@ fn parse_serve_batch_axon_file_field() { #[test] fn parse_serve_prometheus_required_args() { assert_parses(&[ - "agcli", "serve", "prometheus", "--netuid", "1", "--ip", "1.2.3.4", "--port", "9090", + "agcli", + "serve", + "prometheus", + "--netuid", + "1", + "--ip", + "1.2.3.4", + "--port", + "9090", ]); } #[test] fn parse_serve_prometheus_missing_netuid_fails() { // Audit finding: docs example omits --netuid but CLI requires it - assert_fails(&["agcli", "serve", "prometheus", "--ip", "1.2.3.4", "--port", "9090"]); + assert_fails(&[ + "agcli", + "serve", + "prometheus", + "--ip", + "1.2.3.4", + "--port", + "9090", + ]); } #[test] fn parse_serve_prometheus_missing_ip_fails() { - assert_fails(&["agcli", "serve", "prometheus", "--netuid", "1", "--port", "9090"]); + assert_fails(&[ + "agcli", + "serve", + "prometheus", + "--netuid", + "1", + "--port", + "9090", + ]); } #[test] fn parse_serve_prometheus_version_default_is_0() { let cli = parse(&[ - "agcli", "serve", "prometheus", "--netuid", "1", "--ip", "1.2.3.4", "--port", "9090", + "agcli", + "serve", + "prometheus", + "--netuid", + "1", + "--ip", + "1.2.3.4", + "--port", + "9090", ]) .unwrap(); match cli.command { @@ -182,8 +233,17 @@ fn parse_serve_prometheus_version_default_is_0() { #[test] fn parse_serve_axon_tls_required_args() { assert_parses(&[ - "agcli", "serve", "axon-tls", "--netuid", "1", "--ip", "1.2.3.4", "--port", "8091", - "--cert", "/tmp/cert.pem", + "agcli", + "serve", + "axon-tls", + "--netuid", + "1", + "--ip", + "1.2.3.4", + "--port", + "8091", + "--cert", + "/tmp/cert.pem", ]); } @@ -197,7 +257,14 @@ fn parse_serve_axon_tls_missing_cert_fails() { #[test] fn parse_serve_axon_tls_missing_netuid_fails() { assert_fails(&[ - "agcli", "serve", "axon-tls", "--ip", "1.2.3.4", "--port", "8091", "--cert", + "agcli", + "serve", + "axon-tls", + "--ip", + "1.2.3.4", + "--port", + "8091", + "--cert", "/tmp/cert.pem", ]); } @@ -205,8 +272,21 @@ fn parse_serve_axon_tls_missing_netuid_fails() { #[test] fn parse_serve_axon_tls_fields() { let cli = parse(&[ - "agcli", "serve", "axon-tls", "--netuid", "2", "--ip", "192.168.1.1", "--port", "8091", - "--protocol", "4", "--version", "100", "--cert", "/tmp/cert.der", + "agcli", + "serve", + "axon-tls", + "--netuid", + "2", + "--ip", + "192.168.1.1", + "--port", + "8091", + "--protocol", + "4", + "--version", + "100", + "--cert", + "/tmp/cert.der", ]) .unwrap(); match cli.command { @@ -255,8 +335,17 @@ fn parse_serve_axon_with_global_yes_and_wallet() { #[test] fn parse_serve_axon_with_network_flag() { let cli = parse(&[ - "agcli", "--network", "test", "serve", "axon", "--netuid", "1", "--ip", "1.2.3.4", - "--port", "8091", + "agcli", + "--network", + "test", + "serve", + "axon", + "--netuid", + "1", + "--ip", + "1.2.3.4", + "--port", + "8091", ]) .unwrap(); assert_eq!(cli.network, "test"); @@ -277,7 +366,11 @@ fn parse_serve_unknown_subcommand_fails() { #[test] fn helper_validate_ipv4_valid() { let result = agcli::cli::helpers::validate_ipv4("1.2.3.4"); - assert!(result.is_ok(), "valid IPv4 should parse: {:?}", result.err()); + assert!( + result.is_ok(), + "valid IPv4 should parse: {:?}", + result.err() + ); } #[test] @@ -308,7 +401,11 @@ fn helper_validate_port_valid() { fn helper_validate_batch_axon_json_minimal() { let json = r#"[{"netuid":1,"ip":"1.2.3.4","port":8091}]"#; let result = agcli::cli::helpers::validate_batch_axon_json(json); - assert!(result.is_ok(), "minimal batch entry should be valid: {:?}", result.err()); + assert!( + result.is_ok(), + "minimal batch entry should be valid: {:?}", + result.err() + ); assert_eq!(result.unwrap().len(), 1); } @@ -344,7 +441,10 @@ fn helper_validate_batch_axon_json_empty_array() { // the validator gate means an empty file fails before reaching that path. let json = r#"[]"#; let result = agcli::cli::helpers::validate_batch_axon_json(json); - assert!(result.is_err(), "empty array is rejected by validate_batch_axon_json"); + assert!( + result.is_err(), + "empty array is rejected by validate_batch_axon_json" + ); } // ─── localnet green-path (requires Docker + running subtensor localnet) ───── @@ -374,10 +474,8 @@ async fn green_path_serve_axon_localnet() { // Use Alice's well-known test mnemonic so we don't need to register first. // Alice is pre-funded and registered in the localnet genesis. - let mnemonic = - "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; - let pair = agcli::wallet::keypair::pair_from_mnemonic(mnemonic) - .expect("derive alice pair"); + let mnemonic = "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; + let pair = agcli::wallet::keypair::pair_from_mnemonic(mnemonic).expect("derive alice pair"); let ip_u128 = agcli::cli::helpers::validate_ipv4("1.2.3.4").expect("parse IP"); let axon = agcli::types::chain_data::AxonInfo { @@ -396,15 +494,11 @@ async fn green_path_serve_axon_localnet() { assert!(!hash.is_empty(), "tx hash should be non-empty: {hash}"); let hash2 = client - .serve_prometheus( - &pair, - agcli::types::network::NetUid(0), - 0, - ip_u128, - 9090, - 4, - ) + .serve_prometheus(&pair, agcli::types::network::NetUid(0), 0, ip_u128, 9090, 4) .await .expect("serve_prometheus on root net"); - assert!(!hash2.is_empty(), "prometheus tx hash should be non-empty: {hash2}"); + assert!( + !hash2.is_empty(), + "prometheus tx hash should be non-empty: {hash2}" + ); } diff --git a/tests/audit_stake.rs b/tests/audit_stake.rs index a6fd453..ebece07 100644 --- a/tests/audit_stake.rs +++ b/tests/audit_stake.rs @@ -38,18 +38,30 @@ fn is_stake(cli: &Cli) -> &StakeCommands { fn parse_stake_list_minimal() { let cli = parse(&["agcli", "stake", "list"]); let cmd = is_stake(&cli); - assert!(matches!(cmd, StakeCommands::List { address: None, at_block: None })); + assert!(matches!( + cmd, + StakeCommands::List { + address: None, + at_block: None + } + )); } #[test] fn parse_stake_list_with_address() { let cli = parse(&[ - "agcli", "stake", "list", - "--address", "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "agcli", + "stake", + "list", + "--address", + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::List { address: Some(a), at_block: None } => { + StakeCommands::List { + address: Some(a), + at_block: None, + } => { assert_eq!(a, "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"); } other => panic!("unexpected variant: {other:?}"), @@ -61,7 +73,10 @@ fn parse_stake_list_at_block() { let cli = parse(&["agcli", "stake", "list", "--at-block", "4000000"]); let cmd = is_stake(&cli); match cmd { - StakeCommands::List { address: None, at_block: Some(b) } => assert_eq!(*b, 4_000_000), + StakeCommands::List { + address: None, + at_block: Some(b), + } => assert_eq!(*b, 4_000_000), other => panic!("unexpected: {other:?}"), } } @@ -69,14 +84,21 @@ fn parse_stake_list_at_block() { #[test] fn parse_stake_list_address_and_block() { let cli = parse(&[ - "agcli", "stake", "list", - "--address", "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", - "--at-block", "3500000", + "agcli", + "stake", + "list", + "--address", + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "--at-block", + "3500000", ]); let cmd = is_stake(&cli); assert!(matches!( cmd, - StakeCommands::List { address: Some(_), at_block: Some(_) } + StakeCommands::List { + address: Some(_), + at_block: Some(_) + } )); } @@ -85,14 +107,25 @@ fn parse_stake_list_address_and_block() { #[test] fn parse_stake_add_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "add", - "--amount", "10.0", - "--netuid", "1", + "agcli", + "--yes", + "--password", + "p", + "stake", + "add", + "--amount", + "10.0", + "--netuid", + "1", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::Add { amount, netuid, hotkey: None, max_slippage: None } => { + StakeCommands::Add { + amount, + netuid, + hotkey: None, + max_slippage: None, + } => { assert_eq!(*netuid, 1u16); assert!((*amount - 10.0).abs() < 1e-9); } @@ -103,16 +136,29 @@ fn parse_stake_add_minimal() { #[test] fn parse_stake_add_with_hotkey_and_slippage() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "add", - "--amount", "5.0", - "--netuid", "3", - "--hotkey-address", "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", - "--max-slippage", "2.0", + "agcli", + "--yes", + "--password", + "p", + "stake", + "add", + "--amount", + "5.0", + "--netuid", + "3", + "--hotkey-address", + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + "--max-slippage", + "2.0", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::Add { amount, netuid, hotkey: Some(hk), max_slippage: Some(slip) } => { + StakeCommands::Add { + amount, + netuid, + hotkey: Some(hk), + max_slippage: Some(slip), + } => { assert_eq!(*netuid, 3u16); assert!((amount - 5.0).abs() < 1e-9); assert_eq!(hk, "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"); @@ -145,14 +191,25 @@ fn parse_stake_add_missing_netuid_fails() { #[test] fn parse_stake_remove_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "remove", - "--amount", "1.0", - "--netuid", "1", + "agcli", + "--yes", + "--password", + "p", + "stake", + "remove", + "--amount", + "1.0", + "--netuid", + "1", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::Remove { amount, netuid, hotkey: None, max_slippage: None } => { + StakeCommands::Remove { + amount, + netuid, + hotkey: None, + max_slippage: None, + } => { assert_eq!(*netuid, 1u16); assert!((amount - 1.0).abs() < 1e-9); } @@ -163,16 +220,26 @@ fn parse_stake_remove_minimal() { #[test] fn parse_stake_remove_with_slippage() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "remove", - "--amount", "2.0", - "--netuid", "2", - "--max-slippage", "1.5", + "agcli", + "--yes", + "--password", + "p", + "stake", + "remove", + "--amount", + "2.0", + "--netuid", + "2", + "--max-slippage", + "1.5", ]); let cmd = is_stake(&cli); assert!(matches!( cmd, - StakeCommands::Remove { max_slippage: Some(_), .. } + StakeCommands::Remove { + max_slippage: Some(_), + .. + } )); } @@ -181,15 +248,27 @@ fn parse_stake_remove_with_slippage() { #[test] fn parse_stake_move_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "move", - "--amount", "1.0", - "--from", "1", - "--to", "2", + "agcli", + "--yes", + "--password", + "p", + "stake", + "move", + "--amount", + "1.0", + "--from", + "1", + "--to", + "2", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::Move { amount, from, to, hotkey: None } => { + StakeCommands::Move { + amount, + from, + to, + hotkey: None, + } => { assert_eq!(*from, 1u16); assert_eq!(*to, 2u16); assert!((amount - 1.0).abs() < 1e-9); @@ -201,15 +280,29 @@ fn parse_stake_move_minimal() { #[test] fn parse_stake_move_with_hotkey() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "move", - "--amount", "0.5", - "--from", "1", - "--to", "3", - "--hotkey-address", "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + "agcli", + "--yes", + "--password", + "p", + "stake", + "move", + "--amount", + "0.5", + "--from", + "1", + "--to", + "3", + "--hotkey-address", + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", ]); let cmd = is_stake(&cli); - assert!(matches!(cmd, StakeCommands::Move { hotkey: Some(_), .. })); + assert!(matches!( + cmd, + StakeCommands::Move { + hotkey: Some(_), + .. + } + )); } // ── stake swap ─────────────────────────────────────────────────────────────── @@ -217,11 +310,18 @@ fn parse_stake_move_with_hotkey() { #[test] fn parse_stake_swap_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "swap", - "--amount", "1.0", - "--from", "1", - "--to", "2", + "agcli", + "--yes", + "--password", + "p", + "stake", + "swap", + "--amount", + "1.0", + "--from", + "1", + "--to", + "2", ]); let cmd = is_stake(&cli); assert!(matches!(cmd, StakeCommands::Swap { .. })); @@ -239,9 +339,14 @@ fn parse_stake_unstake_all_minimal() { #[test] fn parse_stake_unstake_all_with_hotkey() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "unstake-all", - "--hotkey-address", "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + "agcli", + "--yes", + "--password", + "p", + "stake", + "unstake-all", + "--hotkey-address", + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", ]); let cmd = is_stake(&cli); assert!(matches!(cmd, StakeCommands::UnstakeAll { hotkey: Some(_) })); @@ -251,20 +356,38 @@ fn parse_stake_unstake_all_with_hotkey() { #[test] fn parse_stake_unstake_all_alpha_minimal() { - let cli = parse(&["agcli", "--yes", "--password", "p", "stake", "unstake-all-alpha"]); + let cli = parse(&[ + "agcli", + "--yes", + "--password", + "p", + "stake", + "unstake-all-alpha", + ]); let cmd = is_stake(&cli); - assert!(matches!(cmd, StakeCommands::UnstakeAllAlpha { hotkey: None })); + assert!(matches!( + cmd, + StakeCommands::UnstakeAllAlpha { hotkey: None } + )); } #[test] fn parse_stake_unstake_all_alpha_with_hotkey() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "unstake-all-alpha", - "--hotkey-address", "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + "agcli", + "--yes", + "--password", + "p", + "stake", + "unstake-all-alpha", + "--hotkey-address", + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", ]); let cmd = is_stake(&cli); - assert!(matches!(cmd, StakeCommands::UnstakeAllAlpha { hotkey: Some(_) })); + assert!(matches!( + cmd, + StakeCommands::UnstakeAllAlpha { hotkey: Some(_) } + )); } // ── stake claim-root ────────────────────────────────────────────────────────── @@ -272,9 +395,14 @@ fn parse_stake_unstake_all_alpha_with_hotkey() { #[test] fn parse_stake_claim_root_with_netuid() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "claim-root", - "--netuid", "1", + "agcli", + "--yes", + "--password", + "p", + "stake", + "claim-root", + "--netuid", + "1", ]); let cmd = is_stake(&cli); match cmd { @@ -297,15 +425,28 @@ fn parse_stake_claim_root_missing_netuid_fails() { #[test] fn parse_stake_add_limit_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "add-limit", - "--amount", "10.0", - "--netuid", "1", - "--price", "0.5", + "agcli", + "--yes", + "--password", + "p", + "stake", + "add-limit", + "--amount", + "10.0", + "--netuid", + "1", + "--price", + "0.5", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::AddLimit { amount, netuid, price, partial, hotkey: None } => { + StakeCommands::AddLimit { + amount, + netuid, + price, + partial, + hotkey: None, + } => { assert!((amount - 10.0).abs() < 1e-9); assert_eq!(*netuid, 1u16); assert!((price - 0.5).abs() < 1e-9); @@ -318,11 +459,18 @@ fn parse_stake_add_limit_minimal() { #[test] fn parse_stake_add_limit_with_partial() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "add-limit", - "--amount", "10.0", - "--netuid", "1", - "--price", "0.5", + "agcli", + "--yes", + "--password", + "p", + "stake", + "add-limit", + "--amount", + "10.0", + "--netuid", + "1", + "--price", + "0.5", "--partial", ]); let cmd = is_stake(&cli); @@ -334,15 +482,28 @@ fn parse_stake_add_limit_with_partial() { #[test] fn parse_stake_remove_limit_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "remove-limit", - "--amount", "5.0", - "--netuid", "1", - "--price", "0.8", + "agcli", + "--yes", + "--password", + "p", + "stake", + "remove-limit", + "--amount", + "5.0", + "--netuid", + "1", + "--price", + "0.8", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::RemoveLimit { amount, netuid, price, partial, hotkey: None } => { + StakeCommands::RemoveLimit { + amount, + netuid, + price, + partial, + hotkey: None, + } => { assert!((amount - 5.0).abs() < 1e-9); assert_eq!(*netuid, 1u16); assert!((price - 0.8).abs() < 1e-9); @@ -357,16 +518,31 @@ fn parse_stake_remove_limit_minimal() { #[test] fn parse_stake_swap_limit_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "swap-limit", - "--amount", "5.0", - "--from", "1", - "--to", "2", - "--price", "0.5", + "agcli", + "--yes", + "--password", + "p", + "stake", + "swap-limit", + "--amount", + "5.0", + "--from", + "1", + "--to", + "2", + "--price", + "0.5", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::SwapLimit { amount, from, to, price, partial, hotkey: None } => { + StakeCommands::SwapLimit { + amount, + from, + to, + price, + partial, + hotkey: None, + } => { assert!((amount - 5.0).abs() < 1e-9); assert_eq!(*from, 1u16); assert_eq!(*to, 2u16); @@ -380,16 +556,27 @@ fn parse_stake_swap_limit_minimal() { #[test] fn parse_stake_swap_limit_with_partial() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "swap-limit", - "--amount", "5.0", - "--from", "1", - "--to", "2", - "--price", "0.5", + "agcli", + "--yes", + "--password", + "p", + "stake", + "swap-limit", + "--amount", + "5.0", + "--from", + "1", + "--to", + "2", + "--price", + "0.5", "--partial", ]); let cmd = is_stake(&cli); - assert!(matches!(cmd, StakeCommands::SwapLimit { partial: true, .. })); + assert!(matches!( + cmd, + StakeCommands::SwapLimit { partial: true, .. } + )); } // ── stake childkey-take ─────────────────────────────────────────────────────── @@ -397,14 +584,24 @@ fn parse_stake_swap_limit_with_partial() { #[test] fn parse_stake_childkey_take_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "childkey-take", - "--take", "10.0", - "--netuid", "1", + "agcli", + "--yes", + "--password", + "p", + "stake", + "childkey-take", + "--take", + "10.0", + "--netuid", + "1", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::ChildkeyTake { take, netuid, hotkey: None } => { + StakeCommands::ChildkeyTake { + take, + netuid, + hotkey: None, + } => { assert!((take - 10.0).abs() < 1e-9); assert_eq!(*netuid, 1u16); } @@ -415,10 +612,16 @@ fn parse_stake_childkey_take_minimal() { #[test] fn parse_stake_childkey_take_max_allowed() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "childkey-take", - "--take", "18.0", - "--netuid", "1", + "agcli", + "--yes", + "--password", + "p", + "stake", + "childkey-take", + "--take", + "18.0", + "--netuid", + "1", ]); let cmd = is_stake(&cli); assert!(matches!(cmd, StakeCommands::ChildkeyTake { .. })); @@ -427,10 +630,16 @@ fn parse_stake_childkey_take_max_allowed() { #[test] fn parse_stake_childkey_take_zero() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "childkey-take", - "--take", "0.0", - "--netuid", "1", + "agcli", + "--yes", + "--password", + "p", + "stake", + "childkey-take", + "--take", + "0.0", + "--netuid", + "1", ]); // Clap parses 0.0 successfully (validation is runtime, not clap-layer) assert!(matches!(is_stake(&cli), StakeCommands::ChildkeyTake { .. })); @@ -441,15 +650,24 @@ fn parse_stake_childkey_take_zero() { #[test] fn parse_stake_set_children_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "set-children", - "--netuid", "1", + "agcli", + "--yes", + "--password", + "p", + "stake", + "set-children", + "--netuid", + "1", "--children", "0.5:5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::SetChildren { netuid, children, hotkey: None } => { + StakeCommands::SetChildren { + netuid, + children, + hotkey: None, + } => { assert_eq!(*netuid, 1u16); assert!(children.contains("5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty")); } @@ -475,14 +693,24 @@ fn parse_stake_set_children_multiple() { #[test] fn parse_stake_recycle_alpha_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "recycle-alpha", - "--amount", "100.0", - "--netuid", "1", + "agcli", + "--yes", + "--password", + "p", + "stake", + "recycle-alpha", + "--amount", + "100.0", + "--netuid", + "1", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::RecycleAlpha { amount, netuid, hotkey: None } => { + StakeCommands::RecycleAlpha { + amount, + netuid, + hotkey: None, + } => { assert!((amount - 100.0).abs() < 1e-9); assert_eq!(*netuid, 1u16); } @@ -495,14 +723,24 @@ fn parse_stake_recycle_alpha_minimal() { #[test] fn parse_stake_burn_alpha_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "burn-alpha", - "--amount", "50.0", - "--netuid", "1", + "agcli", + "--yes", + "--password", + "p", + "stake", + "burn-alpha", + "--amount", + "50.0", + "--netuid", + "1", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::BurnAlpha { amount, netuid, hotkey: None } => { + StakeCommands::BurnAlpha { + amount, + netuid, + hotkey: None, + } => { assert!((amount - 50.0).abs() < 1e-9); assert_eq!(*netuid, 1u16); } @@ -515,13 +753,21 @@ fn parse_stake_burn_alpha_minimal() { #[test] fn parse_stake_set_auto_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "set-auto", - "--netuid", "1", + "agcli", + "--yes", + "--password", + "p", + "stake", + "set-auto", + "--netuid", + "1", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::SetAuto { netuid, hotkey: None } => assert_eq!(*netuid, 1u16), + StakeCommands::SetAuto { + netuid, + hotkey: None, + } => assert_eq!(*netuid, 1u16), other => panic!("unexpected: {other:?}"), } } @@ -529,13 +775,25 @@ fn parse_stake_set_auto_minimal() { #[test] fn parse_stake_set_auto_with_hotkey() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "set-auto", - "--netuid", "1", - "--hotkey-address", "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "agcli", + "--yes", + "--password", + "p", + "stake", + "set-auto", + "--netuid", + "1", + "--hotkey-address", + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", ]); let cmd = is_stake(&cli); - assert!(matches!(cmd, StakeCommands::SetAuto { hotkey: Some(_), .. })); + assert!(matches!( + cmd, + StakeCommands::SetAuto { + hotkey: Some(_), + .. + } + )); } // ── stake show-auto ─────────────────────────────────────────────────────────── @@ -550,8 +808,11 @@ fn parse_stake_show_auto_minimal() { #[test] fn parse_stake_show_auto_with_address() { let cli = parse(&[ - "agcli", "stake", "show-auto", - "--address", "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "agcli", + "stake", + "show-auto", + "--address", + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", ]); let cmd = is_stake(&cli); assert!(matches!(cmd, StakeCommands::ShowAuto { address: Some(_) })); @@ -561,24 +822,41 @@ fn parse_stake_show_auto_with_address() { #[test] fn parse_stake_process_claim_minimal() { - let cli = parse(&["agcli", "--yes", "--password", "p", "stake", "process-claim"]); + let cli = parse(&[ + "agcli", + "--yes", + "--password", + "p", + "stake", + "process-claim", + ]); let cmd = is_stake(&cli); assert!(matches!( cmd, - StakeCommands::ProcessClaim { hotkey: None, netuids: None } + StakeCommands::ProcessClaim { + hotkey: None, + netuids: None + } )); } #[test] fn parse_stake_process_claim_with_netuids() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "process-claim", - "--netuids", "1,2,3", + "agcli", + "--yes", + "--password", + "p", + "stake", + "process-claim", + "--netuids", + "1,2,3", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::ProcessClaim { netuids: Some(n), .. } => { + StakeCommands::ProcessClaim { + netuids: Some(n), .. + } => { assert_eq!(n, "1,2,3"); } other => panic!("unexpected: {other:?}"), @@ -588,15 +866,24 @@ fn parse_stake_process_claim_with_netuids() { #[test] fn parse_stake_process_claim_with_hotkey_and_netuids() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "process-claim", - "--hotkey-address", "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", - "--netuids", "5,10", + "agcli", + "--yes", + "--password", + "p", + "stake", + "process-claim", + "--hotkey-address", + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + "--netuids", + "5,10", ]); let cmd = is_stake(&cli); assert!(matches!( cmd, - StakeCommands::ProcessClaim { hotkey: Some(_), netuids: Some(_) } + StakeCommands::ProcessClaim { + hotkey: Some(_), + netuids: Some(_) + } )); } @@ -605,13 +892,21 @@ fn parse_stake_process_claim_with_hotkey_and_netuids() { #[test] fn parse_stake_set_claim_swap() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "set-claim", - "--claim-type", "swap", + "agcli", + "--yes", + "--password", + "p", + "stake", + "set-claim", + "--claim-type", + "swap", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::SetClaim { claim_type, subnets: None } => { + StakeCommands::SetClaim { + claim_type, + subnets: None, + } => { assert_eq!(claim_type, "swap"); } other => panic!("unexpected: {other:?}"), @@ -621,9 +916,14 @@ fn parse_stake_set_claim_swap() { #[test] fn parse_stake_set_claim_keep() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "set-claim", - "--claim-type", "keep", + "agcli", + "--yes", + "--password", + "p", + "stake", + "set-claim", + "--claim-type", + "keep", ]); let cmd = is_stake(&cli); assert!(matches!(cmd, StakeCommands::SetClaim { claim_type, .. } if claim_type == "keep")); @@ -632,14 +932,23 @@ fn parse_stake_set_claim_keep() { #[test] fn parse_stake_set_claim_keep_subnets() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "set-claim", - "--claim-type", "keep-subnets", - "--subnets", "1,2,3", + "agcli", + "--yes", + "--password", + "p", + "stake", + "set-claim", + "--claim-type", + "keep-subnets", + "--subnets", + "1,2,3", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::SetClaim { claim_type, subnets: Some(s) } => { + StakeCommands::SetClaim { + claim_type, + subnets: Some(s), + } => { assert_eq!(claim_type, "keep-subnets"); assert_eq!(s, "1,2,3"); } @@ -650,8 +959,11 @@ fn parse_stake_set_claim_keep_subnets() { #[test] fn parse_stake_set_claim_invalid_type_fails() { let err = parse_fails(&[ - "agcli", "stake", "set-claim", - "--claim-type", "invalid-type", + "agcli", + "stake", + "set-claim", + "--claim-type", + "invalid-type", ]); // clap value_parser should reject invalid claim types assert!( @@ -665,16 +977,30 @@ fn parse_stake_set_claim_invalid_type_fails() { #[test] fn parse_stake_transfer_stake_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "transfer-stake", - "--dest", "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", - "--amount", "10.0", - "--from", "1", - "--to", "2", + "agcli", + "--yes", + "--password", + "p", + "stake", + "transfer-stake", + "--dest", + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "--amount", + "10.0", + "--from", + "1", + "--to", + "2", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::TransferStake { dest, amount, from, to, hotkey: None } => { + StakeCommands::TransferStake { + dest, + amount, + from, + to, + hotkey: None, + } => { assert_eq!(dest, "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"); assert!((amount - 10.0).abs() < 1e-9); assert_eq!(*from, 1u16); @@ -687,10 +1013,15 @@ fn parse_stake_transfer_stake_minimal() { #[test] fn parse_stake_transfer_stake_missing_dest_fails() { let err = parse_fails(&[ - "agcli", "stake", "transfer-stake", - "--amount", "10.0", - "--from", "1", - "--to", "2", + "agcli", + "stake", + "transfer-stake", + "--amount", + "10.0", + "--from", + "1", + "--to", + "2", ]); assert!( err.contains("dest") || err.contains("required"), @@ -703,14 +1034,24 @@ fn parse_stake_transfer_stake_missing_dest_fails() { #[test] fn parse_stake_remove_full_limit_minimal() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "remove-full-limit", - "--netuid", "1", - "--price", "0.001", + "agcli", + "--yes", + "--password", + "p", + "stake", + "remove-full-limit", + "--netuid", + "1", + "--price", + "0.001", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::RemoveFullLimit { netuid, price, hotkey: None } => { + StakeCommands::RemoveFullLimit { + netuid, + price, + hotkey: None, + } => { assert_eq!(*netuid, 1u16); assert!((price - 0.001).abs() < 1e-9); } @@ -721,14 +1062,27 @@ fn parse_stake_remove_full_limit_minimal() { #[test] fn parse_stake_remove_full_limit_with_hotkey() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "remove-full-limit", - "--netuid", "1", - "--price", "0.5", - "--hotkey-address", "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + "agcli", + "--yes", + "--password", + "p", + "stake", + "remove-full-limit", + "--netuid", + "1", + "--price", + "0.5", + "--hotkey-address", + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", ]); let cmd = is_stake(&cli); - assert!(matches!(cmd, StakeCommands::RemoveFullLimit { hotkey: Some(_), .. })); + assert!(matches!( + cmd, + StakeCommands::RemoveFullLimit { + hotkey: Some(_), + .. + } + )); } // ── stake wizard ────────────────────────────────────────────────────────────── @@ -736,15 +1090,26 @@ fn parse_stake_remove_full_limit_with_hotkey() { #[test] fn parse_stake_wizard_all_flags() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "wizard", - "--netuid", "1", - "--amount", "5.0", - "--hotkey-address", "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + "agcli", + "--yes", + "--password", + "p", + "stake", + "wizard", + "--netuid", + "1", + "--amount", + "5.0", + "--hotkey-address", + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", ]); let cmd = is_stake(&cli); match cmd { - StakeCommands::Wizard { netuid: Some(n), amount: Some(a), hotkey: Some(_) } => { + StakeCommands::Wizard { + netuid: Some(n), + amount: Some(a), + hotkey: Some(_), + } => { assert_eq!(*n, 1u16); assert!((a - 5.0).abs() < 1e-9); } @@ -759,7 +1124,11 @@ fn parse_stake_wizard_minimal_no_flags() { let cmd = is_stake(&cli); assert!(matches!( cmd, - StakeCommands::Wizard { netuid: None, amount: None, hotkey: None } + StakeCommands::Wizard { + netuid: None, + amount: None, + hotkey: None + } )); } @@ -768,10 +1137,17 @@ fn parse_stake_wizard_minimal_no_flags() { #[test] fn parse_stake_add_with_mev_flag() { let cli = parse(&[ - "agcli", "--yes", "--password", "p", "--mev", - "stake", "add", - "--amount", "10.0", - "--netuid", "1", + "agcli", + "--yes", + "--password", + "p", + "--mev", + "stake", + "add", + "--amount", + "10.0", + "--netuid", + "1", ]); assert!(cli.mev, "--mev flag should set mev=true"); assert!(matches!(is_stake(&cli), StakeCommands::Add { .. })); @@ -854,8 +1230,14 @@ fn safe_rao_consistent_with_balance_from_tao() { #[test] fn validate_limit_price_rejects_zero_and_negative() { use agcli::cli::helpers::validate_limit_price; - assert!(validate_limit_price(0.0, "price").is_err(), "zero price should fail"); - assert!(validate_limit_price(-0.1, "price").is_err(), "negative price should fail"); + assert!( + validate_limit_price(0.0, "price").is_err(), + "zero price should fail" + ); + assert!( + validate_limit_price(-0.1, "price").is_err(), + "negative price should fail" + ); } #[test] @@ -889,12 +1271,20 @@ fn process_claim_netuid_parsing_warns_on_invalid() { fn set_claim_empty_subnets_string_does_not_panic() { // The SetClaim handler splits on ',' and skips empty tokens — verify parse accepts optional let cli = parse(&[ - "agcli", "--yes", "--password", "p", - "stake", "set-claim", - "--claim-type", "keep-subnets", + "agcli", + "--yes", + "--password", + "p", + "stake", + "set-claim", + "--claim-type", + "keep-subnets", ]); // Missing --subnets is fine — it's Option - assert!(matches!(is_stake(&cli), StakeCommands::SetClaim { subnets: None, .. })); + assert!(matches!( + is_stake(&cli), + StakeCommands::SetClaim { subnets: None, .. } + )); } // ── error classification cross-check ───────────────────────────────────────── @@ -975,10 +1365,14 @@ fn green_path_stake_localnet() { // 1. stake list — read-only, no wallet required let out = Command::new(&bin) .args([ - "--endpoint", "ws://127.0.0.1:9944", - "--output", "json", - "stake", "list", - "--address", "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "--endpoint", + "ws://127.0.0.1:9944", + "--output", + "json", + "stake", + "list", + "--address", + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", ]) .output() .expect("failed to run agcli stake list"); @@ -989,8 +1383,9 @@ fn green_path_stake_localnet() { ); let stdout = String::from_utf8_lossy(&out.stdout); // JSON output must be valid JSON (array or object) - let parsed: serde_json::Value = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("stake list --output json produced invalid JSON: {e}\nstdout: {stdout}")); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("stake list --output json produced invalid JSON: {e}\nstdout: {stdout}") + }); assert!( parsed.is_array() || parsed.is_object(), "stake list JSON must be array or object" @@ -999,9 +1394,12 @@ fn green_path_stake_localnet() { // 2. stake show-auto — read-only let out = Command::new(&bin) .args([ - "--endpoint", "ws://127.0.0.1:9944", - "stake", "show-auto", - "--address", "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "--endpoint", + "ws://127.0.0.1:9944", + "stake", + "show-auto", + "--address", + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", ]) .output() .expect("failed to run agcli stake show-auto"); diff --git a/tests/audit_subnet.rs b/tests/audit_subnet.rs index 97bdce1..a6f88d8 100644 --- a/tests/audit_subnet.rs +++ b/tests/audit_subnet.rs @@ -7,7 +7,15 @@ fn parse_surface_subnet_all_subcommands() { vec!["agcli", "subnet", "list", "--at-block", "500000"], vec!["agcli", "subnet", "show", "--netuid", "1"], vec!["agcli", "subnet", "info", "--netuid", "1"], - vec!["agcli", "subnet", "hyperparams", "--netuid", "1", "--at-block", "400000"], + vec![ + "agcli", + "subnet", + "hyperparams", + "--netuid", + "1", + "--at-block", + "400000", + ], vec![ "agcli", "subnet", @@ -19,7 +27,15 @@ fn parse_surface_subnet_all_subcommands() { "--full", "--save", ], - vec!["agcli", "subnet", "cache-load", "--netuid", "1", "--block", "777"], + vec![ + "agcli", + "subnet", + "cache-load", + "--netuid", + "1", + "--block", + "777", + ], vec!["agcli", "subnet", "cache-list", "--netuid", "1"], vec![ "agcli", @@ -32,7 +48,15 @@ fn parse_surface_subnet_all_subcommands() { "--to-block", "777", ], - vec!["agcli", "subnet", "cache-prune", "--netuid", "1", "--keep", "5"], + vec![ + "agcli", + "subnet", + "cache-prune", + "--netuid", + "1", + "--keep", + "5", + ], vec![ "agcli", "subnet", @@ -67,13 +91,27 @@ fn parse_surface_subnet_all_subcommands() { "--additional", "integration-audit", ], - vec!["agcli", "subnet", "register-leased", "--end-block", "123456"], + vec![ + "agcli", + "subnet", + "register-leased", + "--end-block", + "123456", + ], vec!["agcli", "subnet", "terminate-lease", "--netuid", "1"], vec!["agcli", "subnet", "root-dissolve", "--netuid", "1"], vec!["agcli", "subnet", "register-neuron", "--netuid", "1"], vec!["agcli", "subnet", "pow", "--netuid", "1", "--threads", "8"], vec!["agcli", "subnet", "dissolve", "--netuid", "1"], - vec!["agcli", "subnet", "watch", "--netuid", "1", "--interval", "12"], + vec![ + "agcli", + "subnet", + "watch", + "--netuid", + "1", + "--interval", + "12", + ], vec!["agcli", "subnet", "liquidity"], vec!["agcli", "subnet", "liquidity", "--netuid", "1"], vec![ @@ -99,7 +137,15 @@ fn parse_surface_subnet_all_subcommands() { "--hotkey-address", "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", ], - vec!["agcli", "subnet", "set-param", "--netuid", "1", "--param", "list"], + vec![ + "agcli", + "subnet", + "set-param", + "--netuid", + "1", + "--param", + "list", + ], vec![ "agcli", "subnet", @@ -111,9 +157,25 @@ fn parse_surface_subnet_all_subcommands() { "--value", "360", ], - vec!["agcli", "subnet", "set-symbol", "--netuid", "1", "--symbol", "SN1"], + vec![ + "agcli", + "subnet", + "set-symbol", + "--netuid", + "1", + "--symbol", + "SN1", + ], vec!["agcli", "subnet", "emission-split", "--netuid", "1"], - vec!["agcli", "subnet", "trim", "--netuid", "1", "--max-uids", "256"], + vec![ + "agcli", + "subnet", + "trim", + "--netuid", + "1", + "--max-uids", + "256", + ], vec!["agcli", "subnet", "check-start", "--netuid", "1"], vec!["agcli", "subnet", "start", "--netuid", "1"], vec!["agcli", "subnet", "mechanism-count", "--netuid", "1"], @@ -153,7 +215,12 @@ fn parse_surface_subnet_all_subcommands() { for argv in cases { let parsed = agcli::cli::Cli::try_parse_from(argv.clone()); - assert!(parsed.is_ok(), "failed to parse {:?}: {:?}", argv, parsed.err()); + assert!( + parsed.is_ok(), + "failed to parse {:?}: {:?}", + argv, + parsed.err() + ); } } @@ -172,5 +239,8 @@ async fn green_path_subnet_local_chain_smoke() { .expect("query subnet registration cost"); let subnets = client.get_all_subnets().await.expect("query all subnets"); - assert!(!subnets.is_empty(), "expected at least one subnet on local chain"); + assert!( + !subnets.is_empty(), + "expected at least one subnet on local chain" + ); } diff --git a/tests/audit_subscribe.rs b/tests/audit_subscribe.rs index 70664c4..108b3f7 100644 --- a/tests/audit_subscribe.rs +++ b/tests/audit_subscribe.rs @@ -30,8 +30,7 @@ fn subscribe_blocks_parses() { #[test] fn subscribe_blocks_with_json_output() { - let cli = - parse(&["agcli", "--output", "json", "subscribe", "blocks"]).expect("should parse"); + let cli = parse(&["agcli", "--output", "json", "subscribe", "blocks"]).expect("should parse"); assert!(matches!(subscribe_cmd(&cli), SubscribeCommands::Blocks)); assert!(cli.output.is_json()); } @@ -112,7 +111,13 @@ filter_parses!(filter_fund_alias, "fund"); #[test] fn subscribe_events_with_netuid() { let cli = parse(&[ - "agcli", "subscribe", "events", "--filter", "staking", "--netuid", "1", + "agcli", + "subscribe", + "events", + "--filter", + "staking", + "--netuid", + "1", ]) .expect("should parse"); match subscribe_cmd(&cli) { @@ -185,9 +190,7 @@ fn subscribe_events_rejects_missing_netuid_value() { #[test] fn subscribe_events_rejects_non_numeric_netuid() { - let result = parse(&[ - "agcli", "subscribe", "events", "--netuid", "notanumber", - ]); + let result = parse(&["agcli", "subscribe", "events", "--netuid", "notanumber"]); assert!(result.is_err(), "non-numeric --netuid must be rejected"); } @@ -200,10 +203,33 @@ fn validate_event_filter_accepts_all_known_aliases() { use agcli::cli::helpers::validate_event_filter; let valid = &[ - "all", "staking", "stake", "registration", "register", "reg", "transfer", "transfers", - "weights", "weight", "subnet", "subnets", "delegation", "delegate", "delegates", "keys", - "key", "swap", "dex", "liquidity", "governance", "gov", "sudo", "safemode", "crowdloan", - "crowdloans", "fund", + "all", + "staking", + "stake", + "registration", + "register", + "reg", + "transfer", + "transfers", + "weights", + "weight", + "subnet", + "subnets", + "delegation", + "delegate", + "delegates", + "keys", + "key", + "swap", + "dex", + "liquidity", + "governance", + "gov", + "sudo", + "safemode", + "crowdloan", + "crowdloans", + "fund", ]; for alias in valid { assert!( @@ -229,17 +255,44 @@ fn event_filter_from_str_canonical() { use agcli::events::EventFilter; use std::str::FromStr; - assert_eq!(EventFilter::from_str("staking").unwrap(), EventFilter::Staking); - assert_eq!(EventFilter::from_str("stake").unwrap(), EventFilter::Staking); - assert_eq!(EventFilter::from_str("registration").unwrap(), EventFilter::Registration); - assert_eq!(EventFilter::from_str("transfer").unwrap(), EventFilter::Transfer); - assert_eq!(EventFilter::from_str("weights").unwrap(), EventFilter::Weights); - assert_eq!(EventFilter::from_str("subnet").unwrap(), EventFilter::Subnet); - assert_eq!(EventFilter::from_str("delegation").unwrap(), EventFilter::Delegation); + assert_eq!( + EventFilter::from_str("staking").unwrap(), + EventFilter::Staking + ); + assert_eq!( + EventFilter::from_str("stake").unwrap(), + EventFilter::Staking + ); + assert_eq!( + EventFilter::from_str("registration").unwrap(), + EventFilter::Registration + ); + assert_eq!( + EventFilter::from_str("transfer").unwrap(), + EventFilter::Transfer + ); + assert_eq!( + EventFilter::from_str("weights").unwrap(), + EventFilter::Weights + ); + assert_eq!( + EventFilter::from_str("subnet").unwrap(), + EventFilter::Subnet + ); + assert_eq!( + EventFilter::from_str("delegation").unwrap(), + EventFilter::Delegation + ); assert_eq!(EventFilter::from_str("keys").unwrap(), EventFilter::Keys); assert_eq!(EventFilter::from_str("swap").unwrap(), EventFilter::Swap); - assert_eq!(EventFilter::from_str("governance").unwrap(), EventFilter::Governance); - assert_eq!(EventFilter::from_str("crowdloan").unwrap(), EventFilter::Crowdloan); + assert_eq!( + EventFilter::from_str("governance").unwrap(), + EventFilter::Governance + ); + assert_eq!( + EventFilter::from_str("crowdloan").unwrap(), + EventFilter::Crowdloan + ); assert_eq!(EventFilter::from_str("all").unwrap(), EventFilter::All); } @@ -248,9 +301,18 @@ fn event_filter_from_str_case_insensitive() { use agcli::events::EventFilter; use std::str::FromStr; - assert_eq!(EventFilter::from_str("STAKING").unwrap(), EventFilter::Staking); - assert_eq!(EventFilter::from_str("Transfer").unwrap(), EventFilter::Transfer); - assert_eq!(EventFilter::from_str("WEIGHTS").unwrap(), EventFilter::Weights); + assert_eq!( + EventFilter::from_str("STAKING").unwrap(), + EventFilter::Staking + ); + assert_eq!( + EventFilter::from_str("Transfer").unwrap(), + EventFilter::Transfer + ); + assert_eq!( + EventFilter::from_str("WEIGHTS").unwrap(), + EventFilter::Weights + ); } // NOTE: EventFilter::from_str has `Infallible` error type, so unknown strings @@ -263,7 +325,10 @@ fn event_filter_from_str_unknown_falls_back_to_all() { use std::str::FromStr; // Intentional silent fallback — documented audit finding. - assert_eq!(EventFilter::from_str("typo_filter").unwrap(), EventFilter::All); + assert_eq!( + EventFilter::from_str("typo_filter").unwrap(), + EventFilter::All + ); } // ──── green-path integration test (requires localnet on 127.0.0.1:9944) ────── diff --git a/tests/audit_swap_keys.rs b/tests/audit_swap_keys.rs index 2290bf9..dd0c186 100644 --- a/tests/audit_swap_keys.rs +++ b/tests/audit_swap_keys.rs @@ -104,7 +104,7 @@ const SIG_HEX: &str = concat!( "0x", "0000000000000000000000000000000000000000000000000000000000000000", // r (32 bytes) "0000000000000000000000000000000000000000000000000000000000000000", // s (32 bytes) - "1b" // v = 27 (1 byte) + "1b" // v = 27 (1 byte) ); /// A minimal, syntactically-valid 20-byte EVM address. diff --git a/tests/audit_utils_cli.rs b/tests/audit_utils_cli.rs index e31a031..e75ff23 100644 --- a/tests/audit_utils_cli.rs +++ b/tests/audit_utils_cli.rs @@ -66,9 +66,7 @@ fn parse_convert_tao_to_rao() { #[test] fn parse_convert_tao_to_alpha() { - let cmd = utils_cmd(&[ - "agcli", "utils", "convert", "--tao", "2.5", "--netuid", "1", - ]); + let cmd = utils_cmd(&["agcli", "utils", "convert", "--tao", "2.5", "--netuid", "1"]); match cmd { UtilsCommands::Convert { amount: None, @@ -112,7 +110,9 @@ fn parse_convert_alpha_to_tao() { fn parse_convert_zero_amount() { let cmd = utils_cmd(&["agcli", "utils", "convert", "--amount", "0"]); match cmd { - UtilsCommands::Convert { amount: Some(a), .. } => { + UtilsCommands::Convert { + amount: Some(a), .. + } => { assert!((a - 0.0).abs() < f64::EPSILON); } other => panic!("unexpected variant: {:?}", other), @@ -168,7 +168,10 @@ fn parse_convert_with_json_output() { ]) .expect("parse should succeed"); assert_eq!(cli.output, OutputFormat::Json); - assert!(matches!(cli.command, Commands::Utils(UtilsCommands::Convert { .. }))); + assert!(matches!( + cli.command, + Commands::Utils(UtilsCommands::Convert { .. }) + )); } // ─── convert: unknown flag rejected ────────────────────────────────────────── @@ -176,7 +179,10 @@ fn parse_convert_with_json_output() { #[test] fn parse_convert_unknown_flag_rejected() { let result = parse(&["agcli", "utils", "convert", "--rao", "1000"]); - assert!(result.is_err(), "--rao is not a valid flag; clap must reject it"); + assert!( + result.is_err(), + "--rao is not a valid flag; clap must reject it" + ); } // ─── latency: defaults ─────────────────────────────────────────────────────── @@ -217,7 +223,10 @@ fn parse_latency_extra_single() { "ws://127.0.0.1:9944", ]); match cmd { - UtilsCommands::Latency { extra: Some(e), pings: 5 } => { + UtilsCommands::Latency { + extra: Some(e), + pings: 5, + } => { assert_eq!(e, "ws://127.0.0.1:9944"); } other => panic!("unexpected variant: {:?}", other), @@ -238,7 +247,10 @@ fn parse_latency_extra_comma_separated() { "3", ]); match cmd { - UtilsCommands::Latency { extra: Some(e), pings: 3 } => { + UtilsCommands::Latency { + extra: Some(e), + pings: 3, + } => { let parts: Vec<&str> = e.split(',').collect(); assert_eq!(parts.len(), 2); } @@ -341,7 +353,9 @@ fn classify_invalid_rao_amount_is_validation() { // "Invalid RAO amount: ... (must be a finite non-negative number within u64 range)" // contains the substring "must be " which matches the VALIDATION heuristic in // error::classify(). This is correct behaviour (VALIDATION = 12). - let err = anyhow::anyhow!("Invalid RAO amount: inf (must be a finite non-negative number within u64 range)"); + let err = anyhow::anyhow!( + "Invalid RAO amount: inf (must be a finite non-negative number within u64 range)" + ); let code = agcli::error::classify(&err); assert_eq!(code, agcli::error::exit_code::VALIDATION); } @@ -364,7 +378,14 @@ fn green_path_utils() { // utils convert: RAO → TAO (no chain needed) let out = Command::new(&bin) - .args(["--output", "json", "utils", "convert", "--amount", "1000000000"]) + .args([ + "--output", + "json", + "utils", + "convert", + "--amount", + "1000000000", + ]) .output() .expect("failed to spawn agcli"); assert!(out.status.success(), "convert RAO→TAO failed: {:?}", out); @@ -375,7 +396,9 @@ fn green_path_utils() { // utils convert: TAO → RAO (no chain needed) let out = Command::new(&bin) - .args(["--output", "json", "utils", "convert", "--amount", "1.5", "--to-rao"]) + .args([ + "--output", "json", "utils", "convert", "--amount", "1.5", "--to-rao", + ]) .output() .expect("failed to spawn agcli"); assert!(out.status.success(), "convert TAO→RAO failed: {:?}", out); @@ -387,19 +410,31 @@ fn green_path_utils() { // utils latency: one ping to localhost let out = Command::new(&bin) .args([ - "--network", "local", - "--output", "json", - "utils", "latency", - "--pings", "1", + "--network", + "local", + "--output", + "json", + "utils", + "latency", + "--pings", + "1", ]) .output() .expect("failed to spawn agcli"); assert!(out.status.success(), "latency failed: {:?}", out); let json: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); - let results = json["latency"].as_array().expect("latency key must be array"); + let results = json["latency"] + .as_array() + .expect("latency key must be array"); assert!(!results.is_empty(), "must have at least one result"); let first = &results[0]; - assert!(first["connected"].as_bool().unwrap_or(false), "must connect to localnet"); - assert!(first["avg_ms"].is_number(), "avg_ms must be present and numeric"); + assert!( + first["connected"].as_bool().unwrap_or(false), + "must connect to localnet" + ); + assert!( + first["avg_ms"].is_number(), + "avg_ms must be present and numeric" + ); } diff --git a/tests/audit_view.rs b/tests/audit_view.rs index 123fe0e..a972061 100644 --- a/tests/audit_view.rs +++ b/tests/audit_view.rs @@ -278,7 +278,15 @@ fn parse_view_history_default_limit() { #[test] fn parse_view_history_explicit_args() { - let cli = parse(&["agcli", "view", "history", "--address", ALICE, "--limit", "50"]); + let cli = parse(&[ + "agcli", + "view", + "history", + "--address", + ALICE, + "--limit", + "50", + ]); match view_cmd(cli) { ViewCommands::History { address: Some(addr), @@ -376,7 +384,9 @@ fn parse_view_staking_analytics_with_address() { #[test] fn parse_view_swap_sim_tao_direction() { - let cli = parse(&["agcli", "view", "swap-sim", "--netuid", "1", "--tao", "10.0"]); + let cli = parse(&[ + "agcli", "view", "swap-sim", "--netuid", "1", "--tao", "10.0", + ]); match view_cmd(cli) { ViewCommands::SwapSim { netuid, @@ -393,13 +403,7 @@ fn parse_view_swap_sim_tao_direction() { #[test] fn parse_view_swap_sim_alpha_direction() { let cli = parse(&[ - "agcli", - "view", - "swap-sim", - "--netuid", - "2", - "--alpha", - "500.5", + "agcli", "view", "swap-sim", "--netuid", "2", "--alpha", "500.5", ]); match view_cmd(cli) { ViewCommands::SwapSim { @@ -802,8 +806,10 @@ fn green_path_view_network_localnet() { .await .expect("connect to localnet"); - let (block, total_stake, total_networks, _total_issuance, _emission) = - client.get_network_overview().await.expect("get_network_overview"); + let (block, total_stake, total_networks, _total_issuance, _emission) = client + .get_network_overview() + .await + .expect("get_network_overview"); assert!(block > 0, "block number should be positive"); // localnet starts with at least the root network (netuid 0) diff --git a/tests/audit_wallet.rs b/tests/audit_wallet.rs index 47d3182..9af5c40 100644 --- a/tests/audit_wallet.rs +++ b/tests/audit_wallet.rs @@ -28,7 +28,7 @@ fn parse_wallet_create_minimal() { hotkey_name, password, no_mnemonic, - }) => { + }) => { assert_eq!(name, "default"); // Audit finding: WalletCommands::Create::hotkey_name conflicts with the global // Cli::hotkey_name (flag --hotkey-name, default "default"). Clap resolves this by @@ -117,7 +117,11 @@ fn parse_wallet_import_with_mnemonic() { ]) .unwrap(); match cli.command { - agcli::cli::Commands::Wallet(WalletCommands::Import { name, mnemonic, password }) => { + agcli::cli::Commands::Wallet(WalletCommands::Import { + name, + mnemonic, + password, + }) => { assert_eq!(name, "imported"); assert!(mnemonic.is_some()); assert_eq!(password.as_deref(), Some("pw")); @@ -168,8 +172,7 @@ fn parse_wallet_regen_coldkey_with_mnemonic() { #[test] fn parse_wallet_regen_hotkey() { - let cli = Cli::try_parse_from(["agcli", "wallet", "regen-hotkey", "--name", "miner1"]) - .unwrap(); + let cli = Cli::try_parse_from(["agcli", "wallet", "regen-hotkey", "--name", "miner1"]).unwrap(); match cli.command { agcli::cli::Commands::Wallet(WalletCommands::RegenHotkey { name, .. }) => { assert_eq!(name, "miner1"); @@ -420,8 +423,7 @@ fn parse_wallet_show_mnemonic_minimal() { #[test] fn parse_wallet_show_mnemonic_with_password() { let cli = - Cli::try_parse_from(["agcli", "wallet", "show-mnemonic", "--password", "hunter2"]) - .unwrap(); + Cli::try_parse_from(["agcli", "wallet", "show-mnemonic", "--password", "hunter2"]).unwrap(); match cli.command { agcli::cli::Commands::Wallet(WalletCommands::ShowMnemonic { password }) => { assert_eq!(password.as_deref(), Some("hunter2")); @@ -455,14 +457,17 @@ async fn green_path_wallet_create() { .await; assert!(result.is_ok(), "wallet create failed: {:?}", result.err()); assert!(dir.path().join("audit_test").join("coldkey").exists()); - assert!(dir.path().join("audit_test").join("coldkeypub.txt").exists()); - assert!( - dir.path() - .join("audit_test") - .join("hotkeys") - .join("default") - .exists() - ); + assert!(dir + .path() + .join("audit_test") + .join("coldkeypub.txt") + .exists()); + assert!(dir + .path() + .join("audit_test") + .join("hotkeys") + .join("default") + .exists()); } #[tokio::test] @@ -573,13 +578,12 @@ async fn green_path_wallet_regen_hotkey() { "wallet regen-hotkey failed: {:?}", result.err() ); - assert!( - dir.path() - .join("regen_hk") - .join("hotkeys") - .join("newhotkey") - .exists() - ); + assert!(dir + .path() + .join("regen_hk") + .join("hotkeys") + .join("newhotkey") + .exists()); } #[tokio::test] @@ -601,13 +605,12 @@ async fn green_path_wallet_new_hotkey() { "wallet new-hotkey failed: {:?}", result.err() ); - assert!( - dir.path() - .join("newhk_test") - .join("hotkeys") - .join("miner99") - .exists() - ); + assert!(dir + .path() + .join("newhk_test") + .join("hotkeys") + .join("miner99") + .exists()); } #[tokio::test] @@ -616,8 +619,7 @@ async fn green_path_wallet_derive_pubkey() { // Alice's known public key in 0x hex (32 bytes) let result = handle_wallet( WalletCommands::Derive { - input: "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d" - .to_string(), + input: "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d".to_string(), }, dir.path().to_str().unwrap(), "default", @@ -625,7 +627,11 @@ async fn green_path_wallet_derive_pubkey() { OutputFormat::Json, ) .await; - assert!(result.is_ok(), "wallet derive pubkey failed: {:?}", result.err()); + assert!( + result.is_ok(), + "wallet derive pubkey failed: {:?}", + result.err() + ); } #[tokio::test] @@ -672,7 +678,9 @@ async fn green_path_wallet_dev_key() { std::fs::read_to_string(dir.path().join("alice").join("coldkeypub.txt")).unwrap(); // Alice's known hex public key (32 bytes, no 0x prefix) assert!( - pub_content.trim().contains("d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"), + pub_content + .trim() + .contains("d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"), "Alice's hex pubkey not found in coldkeypub.txt: {}", pub_content ); @@ -681,9 +689,13 @@ async fn green_path_wallet_dev_key() { #[tokio::test] async fn green_path_wallet_sign_and_verify() { let dir = tempfile::tempdir().unwrap(); - let (wallet, _, _) = - agcli::Wallet::create(dir.path().to_str().unwrap(), "sv_audit", "signpw", "default") - .unwrap(); + let (wallet, _, _) = agcli::Wallet::create( + dir.path().to_str().unwrap(), + "sv_audit", + "signpw", + "default", + ) + .unwrap(); let coldkey_ss58 = wallet.coldkey_ss58().unwrap().to_string(); // Sign a message @@ -697,7 +709,11 @@ async fn green_path_wallet_sign_and_verify() { OutputFormat::Json, ) .await; - assert!(sign_result.is_ok(), "wallet sign failed: {:?}", sign_result.err()); + assert!( + sign_result.is_ok(), + "wallet sign failed: {:?}", + sign_result.err() + ); // Verify always uses the signer's public key — just test parse path here let verify_result = handle_wallet( @@ -832,7 +848,11 @@ async fn wallet_sign_hex_message_roundtrip() { OutputFormat::Json, ) .await; - assert!(result.is_ok(), "hex message sign failed: {:?}", result.err()); + assert!( + result.is_ok(), + "hex message sign failed: {:?}", + result.err() + ); } // ───────────────────────────────────────────────────────────────────────────── @@ -853,8 +873,7 @@ async fn green_path_associate_hotkey_localnet() { let base = dir.path().to_str().unwrap(); // Create Alice wallet (well-funded dev account on localnet) - let alice_wallet = - agcli::Wallet::create_from_uri(base, "//Alice", "alicepw").unwrap(); + let alice_wallet = agcli::Wallet::create_from_uri(base, "//Alice", "alicepw").unwrap(); let coldkey_ss58 = alice_wallet.coldkey_ss58().unwrap().to_string(); // Create a fresh hotkey @@ -862,9 +881,9 @@ async fn green_path_associate_hotkey_localnet() { let hk_ss58 = agcli::wallet::keypair::to_ss58(&hk_pair.public(), 42); // Connect to local chain - let client = agcli::Client::connect("ws://127.0.0.1:9944").await.expect( - "localnet not reachable — ensure `agcli localnet start` is running on port 9944", - ); + let client = agcli::Client::connect("ws://127.0.0.1:9944") + .await + .expect("localnet not reachable — ensure `agcli localnet start` is running on port 9944"); // Unlock Alice's coldkey let mut wallet = agcli::Wallet::open(format!("{}/alice", base)).unwrap(); @@ -879,7 +898,11 @@ async fn green_path_associate_hotkey_localnet() { result.err() ); let tx_hash = result.unwrap(); - assert!(tx_hash.starts_with("0x"), "expected tx hash, got: {}", tx_hash); + assert!( + tx_hash.starts_with("0x"), + "expected tx hash, got: {}", + tx_hash + ); println!( "associate-hotkey green path: coldkey={} hotkey={} tx={}", diff --git a/tests/audit_weights.rs b/tests/audit_weights.rs index 14681b1..8094608 100644 --- a/tests/audit_weights.rs +++ b/tests/audit_weights.rs @@ -317,13 +317,7 @@ fn reveal_missing_salt_is_error() { #[test] fn reveal_missing_weights_is_error() { err(&[ - "agcli", - "weights", - "reveal", - "--netuid", - "1", - "--salt", - "abc", + "agcli", "weights", "reveal", "--netuid", "1", "--salt", "abc", ]); } @@ -527,13 +521,7 @@ fn commit_reveal_file_weights() { #[test] fn commit_reveal_missing_netuid_is_error() { - err(&[ - "agcli", - "weights", - "commit-reveal", - "--weights", - "0:100", - ]); + err(&["agcli", "weights", "commit-reveal", "--weights", "0:100"]); } #[test] @@ -941,8 +929,8 @@ fn commit_hash_changes_with_different_uids() { #[test] fn commit_hash_is_32_bytes() { - let h = agcli::extrinsics::compute_weight_commit_hash(&[0, 1], &[100, 200], b"salt") - .expect("hash"); + let h = + agcli::extrinsics::compute_weight_commit_hash(&[0, 1], &[100, 200], b"salt").expect("hash"); assert_eq!(h.len(), 32); } @@ -996,10 +984,7 @@ fn salt_u16_roundtrip_matches_raw_bytes_for_even_length() { let raw_bytes = salt.as_bytes(); let encoded = encode_salt_u16(salt); // Reconstruct raw bytes from u16 LE - let reconstructed: Vec = encoded - .iter() - .flat_map(|w| w.to_le_bytes()) - .collect(); + let reconstructed: Vec = encoded.iter().flat_map(|w| w.to_le_bytes()).collect(); assert_eq!(raw_bytes, reconstructed.as_slice()); } @@ -1238,11 +1223,5 @@ fn green_path_weights_localnet() { "--weights", "0:100", ]); - ok(&[ - "agcli", - "weights", - "status", - "--netuid", - "1", - ]); + ok(&["agcli", "weights", "status", "--netuid", "1"]); }