From 1815ec291d839445479cb08092705124633db814 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Fri, 10 Jul 2026 14:40:41 -0700 Subject: [PATCH 01/72] typescript sdk --- .github/workflows/typescript-e2e.yml | 59 +- Cargo.lock | 102 +- Cargo.toml | 1 + sdk/typescript-sdk/.gitignore | 7 + sdk/typescript-sdk/README.md | 71 + sdk/typescript-sdk/native/Cargo.toml | 29 + sdk/typescript-sdk/native/build.rs | 3 + sdk/typescript-sdk/native/src/digest.rs | 54 + sdk/typescript-sdk/native/src/errors.rs | 37 + sdk/typescript-sdk/native/src/keys.rs | 271 +++ sdk/typescript-sdk/native/src/ledger.rs | 73 + sdk/typescript-sdk/native/src/lib.rs | 51 + sdk/typescript-sdk/native/src/mlkem.rs | 35 + sdk/typescript-sdk/native/src/runtime.rs | 1036 +++++++++ sdk/typescript-sdk/native/src/timelock.rs | 376 ++++ sdk/typescript-sdk/native/src/values.rs | 295 +++ sdk/typescript-sdk/package-lock.json | 1874 +++++++++++++++++ sdk/typescript-sdk/package.json | 58 + sdk/typescript-sdk/scripts/clean.cjs | 14 + sdk/typescript-sdk/scripts/generate-esm.cjs | 48 + sdk/typescript-sdk/src/crypto.ts | 95 + sdk/typescript-sdk/src/errors.ts | 95 + sdk/typescript-sdk/src/index.ts | 34 + sdk/typescript-sdk/src/keys.ts | 478 +++++ sdk/typescript-sdk/src/ledger.ts | 51 + sdk/typescript-sdk/src/native.ts | 218 ++ sdk/typescript-sdk/src/runtime.ts | 433 ++++ sdk/typescript-sdk/src/timelock.ts | 266 +++ sdk/typescript-sdk/src/types.ts | 136 ++ sdk/typescript-sdk/src/wire.ts | 163 ++ sdk/typescript-sdk/test/basic.test.cjs | 96 + sdk/typescript-sdk/tsconfig.json | 18 + ts-tests/package.json | 3 +- ts-tests/pnpm-lock.yaml | 9 +- .../suites/dev/sdk/test-typescript-sdk.ts | 27 + ts-tests/utils/account.ts | 29 +- ts-tests/utils/address.ts | 27 +- ts-tests/utils/admin_utils.ts | 8 +- ts-tests/utils/balance.ts | 5 +- ts-tests/utils/children.ts | 5 +- ts-tests/utils/coldkey_swap.ts | 20 +- ts-tests/utils/evm.ts | 6 +- ts-tests/utils/index.ts | 1 + ts-tests/utils/limit-orders.ts | 18 +- ts-tests/utils/shield_helpers.ts | 22 +- ts-tests/utils/subnet.ts | 5 +- 46 files changed, 6668 insertions(+), 94 deletions(-) create mode 100644 sdk/typescript-sdk/.gitignore create mode 100644 sdk/typescript-sdk/README.md create mode 100644 sdk/typescript-sdk/native/Cargo.toml create mode 100644 sdk/typescript-sdk/native/build.rs create mode 100644 sdk/typescript-sdk/native/src/digest.rs create mode 100644 sdk/typescript-sdk/native/src/errors.rs create mode 100644 sdk/typescript-sdk/native/src/keys.rs create mode 100644 sdk/typescript-sdk/native/src/ledger.rs create mode 100644 sdk/typescript-sdk/native/src/lib.rs create mode 100644 sdk/typescript-sdk/native/src/mlkem.rs create mode 100644 sdk/typescript-sdk/native/src/runtime.rs create mode 100644 sdk/typescript-sdk/native/src/timelock.rs create mode 100644 sdk/typescript-sdk/native/src/values.rs create mode 100644 sdk/typescript-sdk/package-lock.json create mode 100644 sdk/typescript-sdk/package.json create mode 100644 sdk/typescript-sdk/scripts/clean.cjs create mode 100644 sdk/typescript-sdk/scripts/generate-esm.cjs create mode 100644 sdk/typescript-sdk/src/crypto.ts create mode 100644 sdk/typescript-sdk/src/errors.ts create mode 100644 sdk/typescript-sdk/src/index.ts create mode 100644 sdk/typescript-sdk/src/keys.ts create mode 100644 sdk/typescript-sdk/src/ledger.ts create mode 100644 sdk/typescript-sdk/src/native.ts create mode 100644 sdk/typescript-sdk/src/runtime.ts create mode 100644 sdk/typescript-sdk/src/timelock.ts create mode 100644 sdk/typescript-sdk/src/types.ts create mode 100644 sdk/typescript-sdk/src/wire.ts create mode 100644 sdk/typescript-sdk/test/basic.test.cjs create mode 100644 sdk/typescript-sdk/tsconfig.json create mode 100644 ts-tests/suites/dev/sdk/test-typescript-sdk.ts diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index e2632d0618..50594bc524 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -74,6 +74,12 @@ jobs: -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ build-essential clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config + - name: Install TypeScript SDK dependencies + run: npm --prefix sdk/typescript-sdk ci + + - name: Type-check TypeScript SDK + run: npm --prefix sdk/typescript-sdk run typecheck + - name: Install e2e dependencies working-directory: ts-tests run: pnpm install --frozen-lockfile @@ -83,6 +89,51 @@ jobs: cd ts-tests pnpm run fmt + build-typescript-sdk: + runs-on: [self-hosted, fireactions-heavy] + needs: [trusted-pr, typescript-formatting] + timeout-minutes: 60 + steps: + - name: Check-out repository + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update + sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ + -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ + build-essential clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config + + - name: Install Rust + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + + - name: Utilize Shared Rust Cache + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: typescript-sdk + cache-on-failure: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: ts-tests/.nvmrc + + - name: Build and test TypeScript SDK + run: | + npm --prefix sdk/typescript-sdk ci + npm --prefix sdk/typescript-sdk run check + + - name: Upload TypeScript SDK build + uses: actions/upload-artifact@v4 + with: + name: bittensor-typescript-sdk + path: | + sdk/typescript-sdk/dist + sdk/typescript-sdk/native.cjs + sdk/typescript-sdk/native.generated.d.ts + sdk/typescript-sdk/*.node + if-no-files-found: error + # Build the node binary in both variants and share as artifacts. build: runs-on: [self-hosted, fireactions-heavy] @@ -129,7 +180,7 @@ jobs: if-no-files-found: error run-e2e-tests: - needs: [trusted-pr, build] + needs: [trusted-pr, build, build-typescript-sdk] runs-on: [self-hosted, fireactions-heavy] timeout-minutes: 30 @@ -165,6 +216,12 @@ jobs: - name: Make binary executable run: chmod +x target/release/node-subtensor + - name: Download TypeScript SDK build + uses: actions/download-artifact@v4 + with: + name: bittensor-typescript-sdk + path: sdk/typescript-sdk + - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/Cargo.lock b/Cargo.lock index a53bd9068e..5e6277dfc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1673,6 +1673,20 @@ dependencies = [ "scale-info", ] +[[package]] +name = "bittensor-typescript-sdk-native" +version = "0.1.0" +dependencies = [ + "bittensor-core", + "hex", + "napi", + "napi-build", + "napi-derive", + "parity-scale-codec", + "scale-info", + "serde_json", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -2374,7 +2388,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] @@ -2597,6 +2611,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2923,6 +2946,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ctor" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb22e947478ccf9dc44d8922042c677a63fbb88f2cb468521d1145816e5087cb" + [[package]] name = "ctr" version = "0.6.0" @@ -3956,7 +3985,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version 0.4.1", @@ -7284,6 +7313,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.15" @@ -8483,6 +8522,65 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +[[package]] +name = "napi" +version = "3.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c71997d6f7ad4a756966e452426848ac27d3b37a295302d63afbbcce0270f93" +dependencies = [ + "bitflags 2.9.4", + "ctor", + "futures", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash 2.1.1", + "serde", + "serde_json", +] + +[[package]] +name = "napi-build" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" + +[[package]] +name = "napi-derive" +version = "3.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba572deef53e2c386759a8c2014175a62679d74ff83adc205c8bc0e0285727" +dependencies = [ + "convert_case 0.11.0", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "napi-derive-backend" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd961eb2aa8965e3f29722d754f3a86907eb1984e2fbcbe3fe87b9a02d6bfba" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "semver 1.0.27", + "syn 2.0.106", +] + +[[package]] +name = "napi-sys" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400" +dependencies = [ + "libloading 0.9.0", +] + [[package]] name = "native-tls" version = "0.2.14" diff --git a/Cargo.toml b/Cargo.toml index c64197148f..d0e6fec59b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ "primitives/*", "runtime", "sdk/bittensor-core", + "sdk/typescript-sdk/native", "sdk/bittensor-core-py", "support/*", "chain-extensions", diff --git a/sdk/typescript-sdk/.gitignore b/sdk/typescript-sdk/.gitignore new file mode 100644 index 0000000000..7f970a905d --- /dev/null +++ b/sdk/typescript-sdk/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +native.cjs +native.generated.d.ts +*.node +*.node.map +npm-debug.log* diff --git a/sdk/typescript-sdk/README.md b/sdk/typescript-sdk/README.md new file mode 100644 index 0000000000..df1848d2d9 --- /dev/null +++ b/sdk/typescript-sdk/README.md @@ -0,0 +1,71 @@ +# `@bittensor/sdk` + +The monorepo's TypeScript SDK. It lives in its own `sdk/typescript-sdk` +package and is a deliberately thin Node-API wrapper around the sibling +`bittensor-core` Rust crate. + +All chain-defined work runs in Rust: + +- sr25519/ed25519 keys, signatures, SS58, and wallet keyfiles; +- SCALE encoding and decoding, runtime metadata, storage keys, calls, and + signed extrinsics; +- RFC-0078 metadata digests and Ledger proofs; +- drand timelock encryption and epoch-schedule simulation; +- ML-KEM/XChaCha20 MEV-shield envelopes; and +- Ledger HID signing. + +The TypeScript layer is limited to JavaScript-friendly names and defaults, +lossless `Buffer`/`bigint`/`Map` boundary conversion, error classes, and +compatibility adapters for the signer objects expected by Polkadot.js, +Polkadot API, and Moonwall. + +The package exposes both CommonJS and ESM entrypoints. It also exports the +raw generated Node-API module as `@bittensor/sdk/native`, so every native +entry point is callable even when an ergonomic wrapper has not yet been +added. + +## Build locally + +From the repository root: + +```sh +cargo test -p bittensor-typescript-sdk-native --all-features +npm --prefix sdk/typescript-sdk ci +npm --prefix sdk/typescript-sdk run check +``` + +The native crate is isolated under `sdk/typescript-sdk/native`; it links +`sdk/bittensor-core` directly and contains binding glue only. No chain +algorithm is reimplemented in TypeScript. + +Node.js 20.17 or newer is required. + +## Example + +```ts +import { + Keypair, + Runtime, + createKeyringPairFromUri, + sealMevShieldTransaction, +} from '@bittensor/sdk' + +const alice = Keypair.fromUri('//Alice') +const signature = alice.sign(Buffer.from('hello')) +console.log(alice.verify(Buffer.from('hello'), signature)) + +// Compatible with tx.signAsync(...) and Moonwall helpers, while the secret +// key and signing operation remain in Rust. +const signer = createKeyringPairFromUri('//Alice') + +const runtime = new Runtime(metadataBytes, specVersion, transactionVersion) +const call = runtime.composeCall('System', 'remark', { + remark: Buffer.from('hello'), +}) + +const ciphertext = sealMevShieldTransaction(mlKemPublicKey, call) +``` + +Large SCALE integers are returned as `bigint`; byte carriers are returned as +`Buffer`. A decoded SCALE dictionary with non-string keys is returned as a +`Map`, so no key information is lost. diff --git a/sdk/typescript-sdk/native/Cargo.toml b/sdk/typescript-sdk/native/Cargo.toml new file mode 100644 index 0000000000..0fd4862988 --- /dev/null +++ b/sdk/typescript-sdk/native/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bittensor-typescript-sdk-native" +version = "0.1.0" +edition = "2021" +publish = false +description = "Native Node-API bridge used by the Bittensor TypeScript SDK" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[features] +default = ["ledger"] +ledger = ["bittensor-core/ledger"] + +[dependencies] +bittensor-core = { path = "../../bittensor-core" } +codec = { workspace = true } +hex = "0.4.3" +napi = { version = "3.10.3", default-features = false, features = ["napi8", "serde-json", "dyn-symbols"] } +napi-derive = "3.5.9" +scale-info = { workspace = true, features = ["std", "serde"] } +serde_json = { version = "1.0.132", features = ["arbitrary_precision"] } + +[build-dependencies] +napi-build = "2.3.2" + +[lints] +workspace = true diff --git a/sdk/typescript-sdk/native/build.rs b/sdk/typescript-sdk/native/build.rs new file mode 100644 index 0000000000..0f1b01002b --- /dev/null +++ b/sdk/typescript-sdk/native/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/sdk/typescript-sdk/native/src/digest.rs b/sdk/typescript-sdk/native/src/digest.rs new file mode 100644 index 0000000000..5242298407 --- /dev/null +++ b/sdk/typescript-sdk/native/src/digest.rs @@ -0,0 +1,54 @@ +use bittensor_core::digest::{self, ChainInfo}; +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; + +use crate::errors::{CoreResultExt, NapiResult}; + +#[napi(object)] +pub struct NativeChainInfo { + pub spec_version: u32, + pub spec_name: String, + pub base58_prefix: u16, + pub decimals: u8, + pub token_symbol: String, +} + +impl From for ChainInfo { + fn from(value: NativeChainInfo) -> Self { + Self { + spec_version: value.spec_version, + spec_name: value.spec_name, + base58_prefix: value.base58_prefix, + decimals: value.decimals, + token_symbol: value.token_symbol, + } + } +} + +#[napi(js_name = "metadataDigest")] +pub fn metadata_digest(metadata_bytes: Buffer, info: NativeChainInfo) -> NapiResult { + let info = ChainInfo::from(info); + digest::metadata_digest(metadata_bytes.as_ref(), &info) + .napi() + .map(|value| value.to_vec().into()) +} + +#[napi(js_name = "generateExtrinsicProof")] +pub fn generate_extrinsic_proof( + call_data: Buffer, + included_in_extrinsic: Buffer, + included_in_signed_data: Buffer, + metadata_bytes: Buffer, + info: NativeChainInfo, +) -> NapiResult { + let info = ChainInfo::from(info); + digest::generate_extrinsic_proof( + call_data.as_ref(), + included_in_extrinsic.as_ref(), + included_in_signed_data.as_ref(), + metadata_bytes.as_ref(), + &info, + ) + .napi() + .map(Into::into) +} diff --git a/sdk/typescript-sdk/native/src/errors.rs b/sdk/typescript-sdk/native/src/errors.rs new file mode 100644 index 0000000000..ba45f8da37 --- /dev/null +++ b/sdk/typescript-sdk/native/src/errors.rs @@ -0,0 +1,37 @@ +use bittensor_core::CoreError; +use napi::{Error, Status}; + +pub type NapiResult = napi::Result; + +pub fn into_napi(error: CoreError) -> Error { + let (code, status, message) = match error { + CoreError::Keyfile(message) => ("KEYFILE", Status::GenericFailure, message), + CoreError::WrongPassword(message) => { + ("WRONG_PASSWORD", Status::GenericFailure, message) + } + CoreError::NotInRuntime(message) => { + ("NOT_IN_RUNTIME", Status::InvalidArg, message) + } + CoreError::Codec(message) => ("CODEC", Status::InvalidArg, message), + CoreError::Crypto(message) => ("CRYPTO", Status::InvalidArg, message), + CoreError::Device(message) => ("DEVICE", Status::GenericFailure, message), + }; + Error::new(status, format!("[BITTENSOR_CORE:{code}] {message}")) +} + +pub fn invalid_arg(message: impl Into) -> Error { + Error::new( + Status::InvalidArg, + format!("[BITTENSOR_CORE:CODEC] {}", message.into()), + ) +} + +pub trait CoreResultExt { + fn napi(self) -> NapiResult; +} + +impl CoreResultExt for Result { + fn napi(self) -> NapiResult { + self.map_err(into_napi) + } +} diff --git a/sdk/typescript-sdk/native/src/keys.rs b/sdk/typescript-sdk/native/src/keys.rs new file mode 100644 index 0000000000..d59a71a4ff --- /dev/null +++ b/sdk/typescript-sdk/native/src/keys.rs @@ -0,0 +1,271 @@ +use bittensor_core::keyfiles; +use bittensor_core::keys::{ + self, Keypair, CRYPTO_ED25519, CRYPTO_SR25519, DEFAULT_SS58_FORMAT, +}; +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; + +use crate::errors::{invalid_arg, CoreResultExt, NapiResult}; + +#[napi] +pub struct NativeKeypair { + pub(crate) inner: Keypair, +} + +impl NativeKeypair { + fn new(inner: Keypair) -> Self { + Self { inner } + } +} + +#[napi] +impl NativeKeypair { + #[napi(getter)] + pub fn crypto_type(&self) -> u8 { + self.inner.crypto_type() + } + + #[napi(getter)] + pub fn public_key(&self) -> Buffer { + self.inner.public_key_bytes().to_vec().into() + } + + #[napi(getter)] + pub fn private_key(&self) -> Option { + self.inner.private_key_bytes().map(Into::into) + } + + #[napi(getter)] + pub fn ss58_address(&self) -> String { + self.inner.ss58_address() + } + + #[napi(getter)] + pub fn ss58_format(&self) -> u16 { + self.inner.ss58_format() + } + + #[napi] + pub fn sign(&self, message: Buffer) -> NapiResult { + self.inner.sign(message.as_ref()).napi().map(Into::into) + } + + #[napi] + pub fn verify(&self, message: Buffer, signature: Buffer) -> NapiResult { + self.inner + .verify(message.as_ref(), signature.as_ref()) + .napi() + } + + #[napi] + pub fn encrypt(&self, message: Buffer) -> NapiResult { + self.inner + .encrypt(message.as_ref()) + .napi() + .map(Into::into) + } + + #[napi] + pub fn decrypt(&self, ciphertext: Buffer) -> NapiResult { + self.inner + .decrypt(ciphertext.as_ref()) + .napi() + .map(Into::into) + } +} + +#[napi(js_name = "keypairNew")] +pub fn keypair_new( + ss58_address: Option, + public_key: Option, + crypto_type: u8, + ss58_format: u16, +) -> NapiResult { + Keypair::new( + ss58_address.as_deref(), + public_key.as_ref().map(|value| value.as_ref()), + crypto_type, + ss58_format, + ) + .napi() + .map(NativeKeypair::new) +} + +#[napi(js_name = "keypairFromMnemonic")] +pub fn keypair_from_mnemonic( + mnemonic: String, + crypto_type: u8, + password: Option, +) -> NapiResult { + Keypair::from_mnemonic(&mnemonic, crypto_type, password.as_deref()) + .napi() + .map(NativeKeypair::new) +} + +#[napi(js_name = "keypairFromSeed")] +pub fn keypair_from_seed(seed: Buffer, crypto_type: u8) -> NapiResult { + Keypair::from_seed(seed.as_ref(), crypto_type) + .napi() + .map(NativeKeypair::new) +} + +#[napi(js_name = "keypairFromUri")] +pub fn keypair_from_uri(uri: String, crypto_type: u8) -> NapiResult { + Keypair::from_uri(&uri, crypto_type) + .napi() + .map(NativeKeypair::new) +} + +#[napi(js_name = "keypairFromPrivateKey")] +pub fn keypair_from_private_key( + private_key: String, + crypto_type: u8, +) -> NapiResult { + Keypair::from_private_key(&private_key, crypto_type) + .napi() + .map(NativeKeypair::new) +} + +#[napi(js_name = "keypairFromEncryptedJson")] +pub fn keypair_from_encrypted_json( + json_data: String, + passphrase: String, +) -> NapiResult { + Keypair::from_encrypted_json(&json_data, &passphrase) + .napi() + .map(NativeKeypair::new) +} + +#[napi(js_name = "generateMnemonic")] +pub fn generate_mnemonic(n_words: u32) -> NapiResult { + let n_words = usize::try_from(n_words) + .map_err(|_| invalid_arg("mnemonic word count does not fit usize"))?; + Keypair::generate_mnemonic(n_words).napi() +} + +#[napi(js_name = "encryptFor")] +pub fn encrypt_for( + ss58_address: String, + message: Buffer, + crypto_type: u8, +) -> NapiResult { + Keypair::encrypt_for(&ss58_address, message.as_ref(), crypto_type) + .napi() + .map(Into::into) +} + +#[napi(js_name = "verifySignature")] +pub fn verify_signature( + message: Buffer, + signature: Buffer, + ss58_address: String, + crypto_type: u8, +) -> NapiResult { + keys::verify( + message.as_ref(), + signature.as_ref(), + &ss58_address, + crypto_type, + ) + .napi() +} + +#[napi(js_name = "publicKeyFromSs58")] +pub fn public_key_from_ss58(ss58_address: String) -> NapiResult { + keys::public_key_from_ss58(&ss58_address) + .napi() + .map(|value| value.to_vec().into()) +} + +#[napi(js_name = "ss58FromPublic")] +pub fn ss58_from_public(public_key: Buffer, ss58_format: u16) -> NapiResult { + let public_key: [u8; 32] = public_key + .as_ref() + .try_into() + .map_err(|_| invalid_arg("public key must be exactly 32 bytes"))?; + Ok(keys::ss58_from_public(public_key, ss58_format)) +} + +#[napi(js_name = "serializeKeypair")] +pub fn serialize_keypair(keypair: &NativeKeypair) -> NapiResult { + keyfiles::serialized_keypair_to_keyfile_data(&keypair.inner) + .napi() + .map(Into::into) +} + +#[napi(js_name = "deserializeKeypair")] +pub fn deserialize_keypair(keyfile_data: Buffer) -> NapiResult { + keyfiles::deserialize_keypair_from_keyfile_data(keyfile_data.as_ref()) + .napi() + .map(NativeKeypair::new) +} + +#[napi(js_name = "encryptKeyfileData")] +pub fn encrypt_keyfile_data(keyfile_data: Buffer, password: String) -> NapiResult { + keyfiles::encrypt_keyfile_data(keyfile_data.as_ref(), &password) + .napi() + .map(Into::into) +} + +#[napi(js_name = "decryptKeyfileData")] +pub fn decrypt_keyfile_data( + keyfile_data: Buffer, + password: Option, +) -> NapiResult { + keyfiles::decrypt_keyfile_data(keyfile_data.as_ref(), password.as_deref()) + .napi() + .map(Into::into) +} + +#[napi(js_name = "keyfileDataIsEncrypted")] +pub fn keyfile_data_is_encrypted(keyfile_data: Buffer) -> bool { + keyfiles::keyfile_data_is_encrypted(keyfile_data.as_ref()) +} + +#[napi(js_name = "keyfileDataIsEncryptedNacl")] +pub fn keyfile_data_is_encrypted_nacl(keyfile_data: Buffer) -> bool { + keyfiles::keyfile_data_is_encrypted_nacl(keyfile_data.as_ref()) +} + +#[napi(js_name = "keyfileDataIsEncryptedAnsible")] +pub fn keyfile_data_is_encrypted_ansible(keyfile_data: Buffer) -> bool { + keyfiles::keyfile_data_is_encrypted_ansible(keyfile_data.as_ref()) +} + +#[napi(js_name = "keyfileDataIsEncryptedLegacy")] +pub fn keyfile_data_is_encrypted_legacy(keyfile_data: Buffer) -> bool { + keyfiles::keyfile_data_is_encrypted_legacy(keyfile_data.as_ref()) +} + +#[napi(js_name = "keyfileDataEncryptionMethod")] +pub fn keyfile_data_encryption_method(keyfile_data: Buffer) -> String { + keyfiles::keyfile_data_encryption_method(keyfile_data.as_ref()).to_owned() +} + +#[napi(js_name = "getPasswordFromEnvironment")] +pub fn get_password_from_environment(env_var_name: String) -> NapiResult> { + keyfiles::get_password_from_environment(&env_var_name).napi() +} + +#[napi(js_name = "savePasswordToEnvironment")] +pub fn save_password_to_environment( + env_var_name: String, + password: String, +) -> NapiResult { + keyfiles::save_password_to_environment(&env_var_name, &password).napi() +} + +#[napi(js_name = "cryptoEd25519")] +pub fn crypto_ed25519() -> u8 { + CRYPTO_ED25519 +} + +#[napi(js_name = "cryptoSr25519")] +pub fn crypto_sr25519() -> u8 { + CRYPTO_SR25519 +} + +#[napi(js_name = "defaultSs58Format")] +pub fn default_ss58_format() -> u16 { + DEFAULT_SS58_FORMAT +} diff --git a/sdk/typescript-sdk/native/src/ledger.rs b/sdk/typescript-sdk/native/src/ledger.rs new file mode 100644 index 0000000000..ce992ce0e8 --- /dev/null +++ b/sdk/typescript-sdk/native/src/ledger.rs @@ -0,0 +1,73 @@ +use bittensor_core::signers::ledger::LedgerDevice; +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; + +use crate::errors::{CoreResultExt, NapiResult}; + +#[napi(object)] +pub struct NativeLedgerVersion { + pub major: u16, + pub minor: u16, + pub patch: u16, +} + +#[napi(object)] +pub struct NativeLedgerAddress { + pub public_key: Buffer, + pub ss58_address: String, +} + +#[napi] +pub struct NativeLedgerDevice { + inner: LedgerDevice, +} + +#[napi] +impl NativeLedgerDevice { + #[napi(factory)] + pub fn open() -> NapiResult { + LedgerDevice::open().napi().map(|inner| Self { inner }) + } + + #[napi] + pub fn app_version(&self) -> NapiResult { + let (major, minor, patch) = self.inner.app_version().napi()?; + Ok(NativeLedgerVersion { + major, + minor, + patch, + }) + } + + #[napi] + pub fn address( + &self, + account: u32, + index: u32, + ss58_prefix: u16, + confirm: bool, + ) -> NapiResult { + let address = self + .inner + .address(account, index, ss58_prefix, confirm) + .napi()?; + Ok(NativeLedgerAddress { + public_key: address.public_key.to_vec().into(), + ss58_address: address.ss58_address, + }) + } + + #[napi] + pub fn sign( + &self, + account: u32, + index: u32, + payload: Buffer, + proof: Buffer, + ) -> NapiResult { + self.inner + .sign(account, index, payload.as_ref(), proof.as_ref()) + .napi() + .map(Into::into) + } +} diff --git a/sdk/typescript-sdk/native/src/lib.rs b/sdk/typescript-sdk/native/src/lib.rs new file mode 100644 index 0000000000..6e630164b3 --- /dev/null +++ b/sdk/typescript-sdk/native/src/lib.rs @@ -0,0 +1,51 @@ +mod digest; +mod errors; +mod keys; +#[cfg(feature = "ledger")] +mod ledger; +mod mlkem; +mod runtime; +mod timelock; +mod values; + +use bittensor_core::codec::value::{to_corpus_json, u256_decimal}; +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; +use serde_json::Value as JsonValue; + +use crate::errors::{invalid_arg, NapiResult}; +use crate::values::{from_wire, to_wire, WIRE_TAG}; + +#[napi(js_name = "bindingVersion")] +pub fn binding_version() -> String { + env!("CARGO_PKG_VERSION").to_owned() +} + +#[napi(js_name = "ledgerEnabled")] +pub fn ledger_enabled() -> bool { + cfg!(feature = "ledger") +} + +#[napi(js_name = "wireTag")] +pub fn wire_tag() -> String { + WIRE_TAG.to_owned() +} + +#[napi(js_name = "wireRoundtrip")] +pub fn wire_roundtrip(value: JsonValue) -> NapiResult { + to_wire(&from_wire(value)?) +} + +#[napi(js_name = "valueToCorpusJson")] +pub fn value_to_corpus_json(value: JsonValue) -> NapiResult { + Ok(to_corpus_json(&from_wire(value)?)) +} + +#[napi(js_name = "u256LeToDecimal")] +pub fn u256_le_to_decimal(raw: Buffer) -> NapiResult { + let bytes: [u8; 32] = raw + .as_ref() + .try_into() + .map_err(|_| invalid_arg("u256 value must be exactly 32 little-endian bytes"))?; + Ok(u256_decimal(&bytes)) +} diff --git a/sdk/typescript-sdk/native/src/mlkem.rs b/sdk/typescript-sdk/native/src/mlkem.rs new file mode 100644 index 0000000000..b20aeab222 --- /dev/null +++ b/sdk/typescript-sdk/native/src/mlkem.rs @@ -0,0 +1,35 @@ +use bittensor_core::mlkem; +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; + +use crate::errors::{CoreResultExt, NapiResult}; + +#[napi(js_name = "mlkemSeal")] +pub fn seal( + public_key: Buffer, + plaintext: Buffer, + include_key_hash: bool, +) -> NapiResult { + mlkem::seal( + public_key.as_ref(), + plaintext.as_ref(), + include_key_hash, + ) + .napi() + .map(Into::into) +} + +#[napi(js_name = "mlkemTwox128")] +pub fn twox_128(data: Buffer) -> Buffer { + mlkem::twox_128(data.as_ref()).to_vec().into() +} + +#[napi(js_name = "mlkemNonceLength")] +pub fn nonce_length() -> u32 { + u32::try_from(mlkem::MLKEM_NONCE_LEN).unwrap_or(u32::MAX) +} + +#[napi(js_name = "mlkemKdfId")] +pub fn kdf_id() -> Buffer { + mlkem::KDF_ID.to_vec().into() +} diff --git a/sdk/typescript-sdk/native/src/runtime.rs b/sdk/typescript-sdk/native/src/runtime.rs new file mode 100644 index 0000000000..d8e2380a56 --- /dev/null +++ b/sdk/typescript-sdk/native/src/runtime.rs @@ -0,0 +1,1036 @@ +#![allow( + clippy::arithmetic_side_effects, + clippy::indexing_slicing, + clippy::too_many_arguments +)] + +use std::sync::Arc; + +use bittensor_core::codec::batch::PARALLEL_THRESHOLD; +use bittensor_core::codec::decode::{compact_len, compact_u128, Cursor}; +use bittensor_core::codec::encode::compact; +use bittensor_core::codec::extrinsic::{ + era_birth, multisig_account_id, multisig_ss58, TxParams, +}; +use bittensor_core::codec::storage::{concat_hash_len, hash_param, storage_prefix}; +use bittensor_core::codec::Value; +use bittensor_core::runtime::type_string::{Primitive, TypeSpec}; +use bittensor_core::runtime::{PalletInfo, Runtime, StorageInfo}; +use bittensor_core::CoreError; +use napi::bindgen_prelude::{BigInt, Buffer}; +use napi_derive::napi; +use scale_info::TypeDef; +use serde_json::{json, Map, Value as JsonValue}; + +use crate::errors::{into_napi, invalid_arg, CoreResultExt, NapiResult}; +use crate::values::{from_wire, to_wire, values_from_wire, values_to_wire}; + +#[napi(object)] +pub struct NativeStorageEntry { + pub pallet: String, + pub name: String, + pub prefix: String, + pub modifier: String, + pub value_type: String, + pub value_type_id: u32, + pub param_types: Vec, + pub param_type_ids: Vec, + pub param_hashers: Vec, + pub default_bytes: Buffer, +} + +#[napi(object)] +pub struct NativeStorageChange { + pub key: String, + pub value: Option, +} + +#[napi(object)] +pub struct NativeMapPair { + pub key: JsonValue, + pub value: JsonValue, +} + +#[napi(object)] +pub struct NativeOptionalValue { + pub found: bool, + pub value: JsonValue, +} + +#[napi(object)] +pub struct NativeModuleError { + pub name: String, + pub docs: Vec, +} + +#[napi(object)] +pub struct NativeTxParams { + pub era: JsonValue, + pub nonce: BigInt, + pub tip: BigInt, + pub tip_asset_id: Option, + pub genesis_hash: Buffer, + pub era_block_hash: Buffer, + pub metadata_hash: Option, +} + +#[napi(object)] +pub struct NativeExtrinsicParams { + pub era: JsonValue, + pub nonce: BigInt, + pub tip: BigInt, + pub tip_asset_id: Option, + pub metadata_hash_enabled: bool, +} + +#[napi(object)] +pub struct NativePayloadParts { + pub included_in_extrinsic: Buffer, + pub included_in_signed_data: Buffer, +} + +#[napi(object)] +pub struct NativeSignedExtrinsic { + pub bytes: Buffer, + pub hash: Buffer, +} + +#[napi(object)] +pub struct NativeMultisigAccount { + pub account_id: Buffer, + pub sorted_signatories: Vec, +} + +#[napi(object)] +pub struct NativePartialDecode { + pub value: JsonValue, + pub offset: u32, + pub remaining: u32, +} + +#[napi(object)] +pub struct NativeCompactDecode { + pub value: BigInt, + pub offset: u32, + pub remaining: u32, +} + +#[napi] +pub struct NativeRuntime { + inner: Arc, +} + +impl NativeRuntime { + fn entry(&self, pallet: &str, name: &str) -> NapiResult<&StorageInfo> { + self.inner.storage_entry(pallet, name).ok_or_else(|| { + into_napi(CoreError::NotInRuntime(format!( + "storage function {pallet}.{name}" + ))) + }) + } + + fn tx_params(&self, params: NativeTxParams) -> NapiResult { + Ok(TxParams { + era: from_wire(params.era)?, + nonce: bigint_u64("nonce", ¶ms.nonce)?, + tip: bigint_u128("tip", ¶ms.tip)?, + tip_asset_id: params + .tip_asset_id + .as_ref() + .map(|value| bigint_u128("tipAssetId", value)) + .transpose()?, + genesis_hash: h256("genesisHash", params.genesis_hash.as_ref())?, + era_block_hash: h256("eraBlockHash", params.era_block_hash.as_ref())?, + metadata_hash: params + .metadata_hash + .as_ref() + .map(|value| h256("metadataHash", value.as_ref())) + .transpose()?, + }) + } +} + +#[napi] +impl NativeRuntime { + #[napi(factory, js_name = "fromMetadata")] + pub fn from_metadata( + metadata_bytes: Buffer, + spec_version: u32, + transaction_version: u32, + ss58_format: u16, + ) -> NapiResult { + let inner = Runtime::parse( + metadata_bytes.as_ref(), + spec_version, + transaction_version, + ss58_format, + ) + .napi()?; + Ok(Self { + inner: Arc::new(inner), + }) + } + + #[napi(getter)] + pub fn spec_version(&self) -> u32 { + self.inner.spec_version + } + + #[napi(getter)] + pub fn transaction_version(&self) -> u32 { + self.inner.transaction_version + } + + #[napi(getter)] + pub fn ss58_format(&self) -> u16 { + self.inner.ss58_format + } + + #[napi(getter)] + pub fn is_v15(&self) -> bool { + self.inner.is_v15 + } + + #[napi(getter)] + pub fn extrinsic_version(&self) -> u8 { + self.inner.extrinsic.version + } + + #[napi(getter)] + pub fn outer_event_type(&self) -> Option { + self.inner.outer_event_type + } + + #[napi(getter)] + pub fn metadata_bytes(&self) -> Buffer { + self.inner.metadata_bytes.clone().into() + } + + #[napi] + pub fn decode( + &self, + type_string: String, + data: Buffer, + strict: bool, + ) -> NapiResult { + let spec = self.inner.type_spec(&type_string).napi()?; + let value = self + .inner + .decode_spec(&spec, data.as_ref(), strict) + .napi()?; + to_wire(&value) + } + + #[napi] + pub fn decode_partial( + &self, + type_string: String, + data: Buffer, + offset: u32, + strict: bool, + ) -> NapiResult { + let spec = self.inner.type_spec(&type_string).napi()?; + let offset = usize::try_from(offset) + .map_err(|_| invalid_arg("offset does not fit usize"))?; + let tail = data + .as_ref() + .get(offset..) + .ok_or_else(|| invalid_arg("offset is beyond the input buffer"))?; + let mut cursor = Cursor::new(tail); + cursor.strict = strict; + let value = self.inner.decode_value(&spec, &mut cursor).napi()?; + let absolute = offset.saturating_add(cursor.offset); + Ok(NativePartialDecode { + value: to_wire(&value)?, + offset: u32::try_from(absolute) + .map_err(|_| invalid_arg("decoded offset exceeds u32"))?, + remaining: u32::try_from(cursor.remaining()) + .map_err(|_| invalid_arg("remaining byte count exceeds u32"))?, + }) + } + + #[napi] + pub fn decode_type_id( + &self, + type_id: u32, + data: Buffer, + strict: bool, + ) -> NapiResult { + let value = self + .inner + .decode_spec(&TypeSpec::Id(type_id), data.as_ref(), strict) + .napi()?; + to_wire(&value) + } + + #[napi] + pub fn decode_type_id_partial( + &self, + type_id: u32, + data: Buffer, + offset: u32, + strict: bool, + ) -> NapiResult { + let offset = usize::try_from(offset) + .map_err(|_| invalid_arg("offset does not fit usize"))?; + let tail = data + .as_ref() + .get(offset..) + .ok_or_else(|| invalid_arg("offset is beyond the input buffer"))?; + let mut cursor = Cursor::new(tail); + cursor.strict = strict; + let value = self.inner.decode_id(type_id, &mut cursor).napi()?; + let absolute = offset.saturating_add(cursor.offset); + Ok(NativePartialDecode { + value: to_wire(&value)?, + offset: u32::try_from(absolute) + .map_err(|_| invalid_arg("decoded offset exceeds u32"))?, + remaining: u32::try_from(cursor.remaining()) + .map_err(|_| invalid_arg("remaining byte count exceeds u32"))?, + }) + } + + #[napi] + pub fn decode_batch( + &self, + type_strings: Vec, + datas: Vec, + ) -> NapiResult> { + let datas: Vec> = datas + .into_iter() + .map(|value| value.as_ref().to_vec()) + .collect(); + let values = self.inner.decode_batch(&type_strings, &datas).napi()?; + values.iter().map(to_wire).collect() + } + + #[napi] + pub fn encode(&self, type_string: String, value: JsonValue) -> NapiResult { + let spec = self.inner.type_spec(&type_string).napi()?; + self.inner + .encode_spec(&spec, &from_wire(value)?) + .napi() + .map(Into::into) + } + + #[napi] + pub fn encode_type_id(&self, type_id: u32, value: JsonValue) -> NapiResult { + self.inner + .encode_spec(&TypeSpec::Id(type_id), &from_wire(value)?) + .napi() + .map(Into::into) + } + + #[napi] + pub fn type_id_of(&self, name: String) -> Option { + self.inner.type_id_of(&name) + } + + #[napi] + pub fn type_name_of(&self, id: u32) -> Option { + self.inner.type_name_of(id).map(ToOwned::to_owned) + } + + #[napi] + pub fn type_spec(&self, type_string: String) -> NapiResult { + let spec = self.inner.type_spec(&type_string).napi()?; + Ok(type_spec_json(&spec)) + } + + #[napi] + pub fn resolve_type(&self, id: u32) -> NapiResult { + let ty = self.inner.resolve(id).napi()?; + serde_json::to_value(ty) + .map_err(|error| invalid_arg(format!("type serialization failed: {error}"))) + } + + #[napi] + pub fn registry_json(&self) -> NapiResult { + self.inner.registry_json().napi() + } + + #[napi] + pub fn registry(&self) -> NapiResult { + let json = self.inner.registry_json().napi()?; + serde_json::from_str(&json) + .map_err(|error| invalid_arg(format!("registry JSON parse failed: {error}"))) + } + + #[napi] + pub fn pallet(&self, name: String) -> Option { + self.inner.pallet(&name).map(pallet_json) + } + + #[napi] + pub fn pallet_at(&self, index: u8) -> Option { + self.inner.pallet_at(index).map(pallet_json) + } + + #[napi] + pub fn pallets(&self) -> Vec { + self.inner.pallets.iter().map(pallet_json).collect() + } + + #[napi] + pub fn extrinsic_info(&self) -> JsonValue { + extrinsic_json(&self.inner) + } + + #[napi] + pub fn runtime_apis(&self) -> JsonValue { + runtime_api_map_json(&self.inner) + } + + #[napi] + pub fn runtime_snapshot(&self) -> JsonValue { + json!({ + "specVersion": self.inner.spec_version, + "transactionVersion": self.inner.transaction_version, + "ss58Format": self.inner.ss58_format, + "isV15": self.inner.is_v15, + "outerEventType": self.inner.outer_event_type, + "pallets": self.inner.pallets.iter().map(pallet_json).collect::>(), + "extrinsic": extrinsic_json(&self.inner), + "runtimeApis": runtime_api_map_json(&self.inner), + }) + } + + #[napi] + pub fn compose_call( + &self, + pallet: String, + function: String, + params: JsonValue, + ) -> NapiResult { + self.inner + .compose_call(&pallet, &function, &from_wire(params)?) + .napi() + .map(Into::into) + } + + #[napi] + pub fn decode_call(&self, data: Buffer) -> NapiResult { + let mut cursor = Cursor::new(data.as_ref()); + let value = self.inner.decode_call_value(&mut cursor).napi()?; + if cursor.remaining() != 0 { + return Err(invalid_arg(format!( + "{} undecoded bytes remain after the call", + cursor.remaining() + ))); + } + to_wire(&value) + } + + #[napi] + pub fn storage_entry( + &self, + pallet: String, + storage_function: String, + ) -> NapiResult { + Ok(storage_entry_native( + &pallet, + self.entry(&pallet, &storage_function)?, + )) + } + + #[napi] + pub fn storage_prefix( + &self, + pallet: String, + storage_function: String, + ) -> NapiResult { + Ok(storage_prefix(self.entry(&pallet, &storage_function)?).into()) + } + + #[napi] + pub fn storage_key( + &self, + pallet: String, + storage_function: String, + params: JsonValue, + ) -> NapiResult { + let entry = self.entry(&pallet, &storage_function)?; + let values = values_from_wire(params)?; + self.inner + .storage_key(entry, &values) + .napi() + .map(Into::into) + } + + #[napi] + pub fn storage_key_batch( + &self, + pallet: String, + storage_function: String, + params_list: JsonValue, + ) -> NapiResult> { + let JsonValue::Array(rows) = params_list else { + return Err(invalid_arg("paramsList must be an array of arrays")); + }; + let entry = self.entry(&pallet, &storage_function)?; + rows.into_iter() + .map(|row| { + let values = values_from_wire(row)?; + self.inner + .storage_key(entry, &values) + .napi() + .map(Into::into) + }) + .collect() + } + + #[napi] + pub fn decode_storage_key_params( + &self, + pallet: String, + storage_function: String, + key: Buffer, + fixed: u32, + ) -> NapiResult { + let fixed = usize::try_from(fixed).map_err(|_| invalid_arg("fixed does not fit usize"))?; + let values = self + .inner + .decode_storage_key_params( + self.entry(&pallet, &storage_function)?, + key.as_ref(), + fixed, + ) + .napi()?; + values_to_wire(&values) + } + + #[napi] + pub fn decode_map_pairs( + &self, + pallet: String, + storage_function: String, + raw_keys: Vec, + raw_values: Vec, + fixed: u32, + ) -> NapiResult> { + let fixed = usize::try_from(fixed).map_err(|_| invalid_arg("fixed does not fit usize"))?; + let raw_keys: Vec> = raw_keys + .into_iter() + .map(|value| value.as_ref().to_vec()) + .collect(); + let raw_values: Vec> = raw_values + .into_iter() + .map(|value| value.as_ref().to_vec()) + .collect(); + let decoded = self + .inner + .decode_map_page( + self.entry(&pallet, &storage_function)?, + &raw_keys, + &raw_values, + fixed, + ) + .napi()?; + decoded.into_iter().map(map_pair_native).collect() + } + + #[napi] + pub fn decode_map_changes( + &self, + pallet: String, + storage_function: String, + changes: Vec, + fixed: u32, + ) -> NapiResult> { + let fixed = usize::try_from(fixed).map_err(|_| invalid_arg("fixed does not fit usize"))?; + let changes: Vec<(String, Option)> = changes + .into_iter() + .map(|change| (change.key, change.value)) + .collect(); + let decoded = self + .inner + .decode_map_changes( + self.entry(&pallet, &storage_function)?, + &changes, + fixed, + ) + .napi()?; + decoded.into_iter().map(map_pair_native).collect() + } + + #[napi] + pub fn constant( + &self, + pallet: String, + name: String, + ) -> NapiResult { + let Some(constant) = self.inner.constant(&pallet, &name) else { + return Ok(NativeOptionalValue { + found: false, + value: JsonValue::Null, + }); + }; + let mut cursor = Cursor::new(&constant.value); + let value = self.inner.decode_id(constant.ty, &mut cursor).napi()?; + Ok(NativeOptionalValue { + found: true, + value: to_wire(&value)?, + }) + } + + #[napi] + pub fn constant_info(&self, pallet: String, name: String) -> Option { + self.inner.constant(&pallet, &name).map(|constant| { + json!({ + "name": constant.name, + "typeId": constant.ty, + "type": format!("scale_info::{}", constant.ty), + "valueHex": format!("0x{}", hex::encode(&constant.value)), + "docs": constant.docs, + }) + }) + } + + #[napi] + pub fn module_error( + &self, + module_index: u8, + error_index: u8, + ) -> NapiResult { + let (name, docs) = self.inner.module_error(module_index, error_index).napi()?; + Ok(NativeModuleError { name, docs }) + } + + #[napi] + pub fn signed_extension_identifiers(&self) -> Vec { + self.inner + .extrinsic + .signed_extensions + .iter() + .map(|extension| extension.identifier.clone()) + .collect() + } + + #[napi] + pub fn encode_era(&self, era: JsonValue) -> NapiResult { + let mut output = Vec::new(); + self.inner + .encode_era_value(&from_wire(era)?, &mut output) + .napi()?; + Ok(output.into()) + } + + #[napi] + pub fn signature_payload_parts( + &self, + params: NativeTxParams, + ) -> NapiResult { + let params = self.tx_params(params)?; + let (extra, additional) = self.inner.signature_payload_parts(¶ms).napi()?; + Ok(NativePayloadParts { + included_in_extrinsic: extra.into(), + included_in_signed_data: additional.into(), + }) + } + + #[napi] + pub fn signature_payload( + &self, + call_data: Buffer, + params: NativeTxParams, + ) -> NapiResult { + let params = self.tx_params(params)?; + self.inner + .signature_payload(call_data.as_ref(), ¶ms) + .napi() + .map(Into::into) + } + + #[napi] + pub fn encode_signed_extrinsic( + &self, + call_data: Buffer, + public_key: Buffer, + signature: Buffer, + signature_version: u8, + params: NativeExtrinsicParams, + ) -> NapiResult { + let tx_params = TxParams { + era: from_wire(params.era)?, + nonce: bigint_u64("nonce", ¶ms.nonce)?, + tip: bigint_u128("tip", ¶ms.tip)?, + tip_asset_id: params + .tip_asset_id + .as_ref() + .map(|value| bigint_u128("tipAssetId", value)) + .transpose()?, + genesis_hash: [0; 32], + era_block_hash: [0; 32], + metadata_hash: params.metadata_hash_enabled.then_some([0; 32]), + }; + let public_key = h256("publicKey", public_key.as_ref())?; + let (bytes, hash) = self + .inner + .encode_signed_extrinsic( + call_data.as_ref(), + public_key, + signature.as_ref(), + signature_version, + &tx_params, + ) + .napi()?; + Ok(NativeSignedExtrinsic { + bytes: bytes.into(), + hash: hash.to_vec().into(), + }) + } + + #[napi] + pub fn decode_extrinsic(&self, data: Buffer, strict: bool) -> NapiResult { + let value = self + .inner + .decode_extrinsic(data.as_ref(), strict) + .napi()?; + to_wire(&value) + } + + #[napi] + pub fn runtime_api_map(&self) -> JsonValue { + runtime_api_map_json(&self.inner) + } + + #[napi] + pub fn metadata_ir(&self) -> NapiResult { + metadata_ir_json(&self.inner) + } +} + +fn h256(name: &str, raw: &[u8]) -> NapiResult<[u8; 32]> { + raw.try_into() + .map_err(|_| invalid_arg(format!("{name} must be exactly 32 bytes"))) +} + +fn bigint_u64(name: &str, value: &BigInt) -> NapiResult { + let (negative, value, lossless) = value.get_u64(); + if negative || !lossless { + return Err(invalid_arg(format!( + "{name} must be an unsigned 64-bit bigint" + ))); + } + Ok(value) +} + +fn bigint_u128(name: &str, value: &BigInt) -> NapiResult { + let (negative, value, lossless) = value.get_u128(); + if negative || !lossless { + return Err(invalid_arg(format!( + "{name} must be an unsigned 128-bit bigint" + ))); + } + Ok(value) +} + +fn storage_entry_native(pallet: &str, info: &StorageInfo) -> NativeStorageEntry { + NativeStorageEntry { + pallet: pallet.to_owned(), + name: info.name.clone(), + prefix: info.prefix.clone(), + modifier: info.modifier.clone(), + value_type: format!("scale_info::{}", info.value_type), + value_type_id: info.value_type, + param_types: info + .key_types + .iter() + .map(|id| format!("scale_info::{id}")) + .collect(), + param_type_ids: info.key_types.clone(), + param_hashers: info.hashers.clone(), + default_bytes: info.default_bytes.clone().into(), + } +} + +fn map_pair_native(pair: (Vec, Value)) -> NapiResult { + let (keys, value) = pair; + let key = match keys.as_slice() { + [single] => single.clone(), + _ => Value::Tuple(keys), + }; + Ok(NativeMapPair { + key: to_wire(&key)?, + value: to_wire(&value)?, + }) +} + +fn type_spec_json(spec: &TypeSpec) -> JsonValue { + match spec { + TypeSpec::Id(id) => json!({"kind": "id", "id": id}), + TypeSpec::Primitive(primitive) => { + json!({"kind": "primitive", "name": primitive_name(*primitive)}) + } + TypeSpec::Sequence(inner) => { + json!({"kind": "sequence", "inner": type_spec_json(inner)}) + } + TypeSpec::Option(inner) => json!({"kind": "option", "inner": type_spec_json(inner)}), + TypeSpec::Array(inner, len) => { + json!({"kind": "array", "inner": type_spec_json(inner), "length": len}) + } + TypeSpec::Tuple(items) => json!({ + "kind": "tuple", + "items": items.iter().map(type_spec_json).collect::>() + }), + TypeSpec::Compact(inner) => { + json!({"kind": "compact", "inner": type_spec_json(inner)}) + } + TypeSpec::Bytes => json!({"kind": "bytes"}), + TypeSpec::AccountId => json!({"kind": "accountId"}), + TypeSpec::Era => json!({"kind": "era"}), + TypeSpec::Call => json!({"kind": "call"}), + TypeSpec::Extrinsic => json!({"kind": "extrinsic"}), + } +} + +fn primitive_name(primitive: Primitive) -> &'static str { + match primitive { + Primitive::Bool => "bool", + Primitive::Char => "char", + Primitive::Str => "str", + Primitive::U8 => "u8", + Primitive::U16 => "u16", + Primitive::U32 => "u32", + Primitive::U64 => "u64", + Primitive::U128 => "u128", + Primitive::U256 => "u256", + Primitive::I8 => "i8", + Primitive::I16 => "i16", + Primitive::I32 => "i32", + Primitive::I64 => "i64", + Primitive::I128 => "i128", + Primitive::I256 => "i256", + } +} + +fn pallet_json(pallet: &PalletInfo) -> JsonValue { + json!({ + "name": pallet.name, + "index": pallet.index, + "callsType": pallet.calls_type, + "eventsType": pallet.events_type, + "errorsType": pallet.errors_type, + "constants": pallet.constants.iter().map(|constant| json!({ + "name": constant.name, + "typeId": constant.ty, + "type": format!("scale_info::{}", constant.ty), + "valueHex": format!("0x{}", hex::encode(&constant.value)), + "docs": constant.docs, + })).collect::>(), + "storage": pallet.storage.iter().map(|storage| json!({ + "name": storage.name, + "prefix": storage.prefix, + "modifier": storage.modifier, + "hashers": storage.hashers, + "keyTypeIds": storage.key_types, + "keyTypes": storage.key_types.iter().map(|id| format!("scale_info::{id}")).collect::>(), + "valueTypeId": storage.value_type, + "valueType": format!("scale_info::{}", storage.value_type), + "defaultHex": format!("0x{}", hex::encode(&storage.default_bytes)), + })).collect::>(), + }) +} + +fn extrinsic_json(runtime: &Runtime) -> JsonValue { + json!({ + "version": runtime.extrinsic.version, + "addressType": runtime.extrinsic.address_type, + "callType": runtime.extrinsic.call_type, + "signatureType": runtime.extrinsic.signature_type, + "signedExtensions": runtime.extrinsic.signed_extensions.iter().map(|extension| json!({ + "identifier": extension.identifier, + "typeId": extension.ty, + "type": format!("scale_info::{}", extension.ty), + "additionalSignedTypeId": extension.additional_signed, + "additionalSignedType": format!("scale_info::{}", extension.additional_signed), + })).collect::>(), + }) +} + +fn runtime_api_map_json(runtime: &Runtime) -> JsonValue { + let mut apis = Map::new(); + for api in &runtime.apis { + let mut methods = Map::new(); + for method in &api.methods { + methods.insert( + method.name.clone(), + json!({ + "name": method.name, + "inputs": method.inputs.iter().map(|param| json!([ + param.name, + format!("scale_info::{}", param.ty), + ])).collect::>(), + "output": format!("scale_info::{}", method.output), + "outputTypeId": method.output, + "docs": method.docs, + }), + ); + } + apis.insert(api.name.clone(), JsonValue::Object(methods)); + } + JsonValue::Object(apis) +} + +fn metadata_ir_json(runtime: &Runtime) -> NapiResult { + let join_docs = |docs: &[String]| -> String { + docs.iter() + .map(|doc| doc.trim()) + .collect::>() + .join(" ") + .trim() + .to_owned() + }; + + let mut pallets = Vec::with_capacity(runtime.pallets.len()); + for pallet in &runtime.pallets { + let mut calls = Vec::new(); + if let Some(calls_type) = pallet.calls_type { + let ty = runtime.resolve(calls_type).napi()?; + if let TypeDef::Variant(variant) = &ty.type_def { + for call in &variant.variants { + calls.push(json!({ + "name": call.name, + "index": call.index, + "args": call.fields.iter().map(|field| field.name.clone().unwrap_or_default()).collect::>(), + "argTypes": call.fields.iter().map(|field| format!("scale_info::{}", field.ty.id)).collect::>(), + "docs": join_docs(&call.docs), + })); + } + } + } + + let mut errors = Vec::new(); + if let Some(errors_type) = pallet.errors_type { + let ty = runtime.resolve(errors_type).napi()?; + if let TypeDef::Variant(variant) = &ty.type_def { + for error in &variant.variants { + errors.push(json!({ + "index": error.index, + "name": error.name, + "docs": join_docs(&error.docs), + })); + } + } + } + + pallets.push(json!({ + "name": pallet.name, + "index": pallet.index, + "calls": calls, + "errors": errors, + "storage": pallet.storage.iter().filter(|storage| !storage.name.contains(':')).map(|storage| storage.name.clone()).collect::>(), + "constants": pallet.constants.iter().map(|constant| constant.name.clone()).collect::>(), + })); + } + + Ok(json!({ + "specVersion": runtime.spec_version, + "pallets": pallets, + "runtimeApis": runtime.apis.iter().map(|api| json!({ + "name": api.name, + "methods": api.methods.iter().map(|method| method.name.clone()).collect::>(), + })).collect::>(), + })) +} + +#[napi(js_name = "eraBirth")] +pub fn era_birth_native(period: BigInt, current: BigInt) -> NapiResult { + Ok(BigInt::from(era_birth( + bigint_u64("period", &period)?, + bigint_u64("current", ¤t)?, + ))) +} + +#[napi(js_name = "multisigAccountId")] +pub fn multisig_account_id_native( + signatories: Vec, + threshold: u16, +) -> NapiResult { + let keys = signatories + .iter() + .enumerate() + .map(|(index, raw)| { + raw.as_ref().try_into().map_err(|_| { + invalid_arg(format!( + "signatory #{} must be exactly 32 bytes", + index.saturating_add(1) + )) + }) + }) + .collect::>>()?; + let (account_id, sorted_signatories) = multisig_account_id(&keys, threshold).napi()?; + Ok(NativeMultisigAccount { + account_id: account_id.to_vec().into(), + sorted_signatories: sorted_signatories + .into_iter() + .map(|key| key.to_vec().into()) + .collect(), + }) +} + +#[napi(js_name = "multisigSs58")] +pub fn multisig_ss58_native(account_id: Buffer, ss58_format: u16) -> NapiResult { + Ok(multisig_ss58( + h256("accountId", account_id.as_ref())?, + ss58_format, + )) +} + +#[napi(js_name = "encodeCompact")] +pub fn encode_compact(value: BigInt) -> NapiResult { + let value = bigint_u128("value", &value)?; + let mut output = Vec::new(); + compact(value, &mut output).napi()?; + Ok(output.into()) +} + +#[napi(js_name = "decodeCompactU128")] +pub fn decode_compact_u128( + data: Buffer, + strict: bool, +) -> NapiResult { + let mut cursor = Cursor::new(data.as_ref()); + cursor.strict = strict; + let value = compact_u128(&mut cursor).napi()?; + Ok(NativeCompactDecode { + value: BigInt::from(value), + offset: u32::try_from(cursor.offset) + .map_err(|_| invalid_arg("compact offset exceeds u32"))?, + remaining: u32::try_from(cursor.remaining()) + .map_err(|_| invalid_arg("compact remaining byte count exceeds u32"))?, + }) +} + +#[napi(js_name = "decodeCompactLength")] +pub fn decode_compact_length( + data: Buffer, + strict: bool, +) -> NapiResult { + let mut cursor = Cursor::new(data.as_ref()); + cursor.strict = strict; + let value = compact_len(&mut cursor).napi()?; + Ok(NativeCompactDecode { + value: BigInt::from(value as u128), + offset: u32::try_from(cursor.offset) + .map_err(|_| invalid_arg("compact offset exceeds u32"))?, + remaining: u32::try_from(cursor.remaining()) + .map_err(|_| invalid_arg("compact remaining byte count exceeds u32"))?, + }) +} + +#[napi(js_name = "hashStorageParam")] +pub fn hash_storage_param(hasher: String, data: Buffer) -> NapiResult { + hash_param(&hasher, data.as_ref()).napi().map(Into::into) +} + +#[napi(js_name = "concatHashLength")] +pub fn concat_hash_length(hasher: String) -> NapiResult { + let length = concat_hash_len(&hasher).napi()?; + u32::try_from(length).map_err(|_| invalid_arg("hash length exceeds u32")) +} + +#[napi(js_name = "parallelDecodeThreshold")] +pub fn parallel_decode_threshold() -> u32 { + u32::try_from(PARALLEL_THRESHOLD).unwrap_or(u32::MAX) +} diff --git a/sdk/typescript-sdk/native/src/timelock.rs b/sdk/typescript-sdk/native/src/timelock.rs new file mode 100644 index 0000000000..5c83b2a50e --- /dev/null +++ b/sdk/typescript-sdk/native/src/timelock.rs @@ -0,0 +1,376 @@ +#![allow( + clippy::arithmetic_side_effects, + clippy::indexing_slicing, + clippy::too_many_arguments +)] + +use bittensor_core::timelock::constants; +use bittensor_core::timelock::epoch_schedule::{self, EpochScheduleState}; +use bittensor_core::timelock::{self, UserData, WeightsTlockPayload}; +use codec::{Decode, Encode}; +use napi::bindgen_prelude::{BigInt, Buffer}; +use napi_derive::napi; + +use crate::errors::{invalid_arg, CoreResultExt, NapiResult}; + +#[napi(object)] +pub struct NativeEpochScheduleState { + pub last_epoch_block: BigInt, + pub pending_epoch_at: BigInt, + pub subnet_epoch_index: BigInt, + pub tempo: u16, + pub blocks_since_last_step: BigInt, + pub current_block: BigInt, +} + +#[napi(object)] +pub struct NativeCiphertextRound { + pub ciphertext: Buffer, + pub reveal_round: BigInt, +} + +#[napi(object)] +pub struct NativeDrandResponse { + pub round: BigInt, + pub signature: String, +} + +#[napi(object)] +pub struct NativeWeightsTlockPayload { + pub hotkey: Buffer, + pub uids: Vec, + pub values: Vec, + pub version_key: BigInt, +} + +#[napi(object)] +pub struct NativeUserData { + pub encrypted_data: Buffer, + pub reveal_round: BigInt, +} + +fn bigint_u64(name: &str, value: &BigInt) -> NapiResult { + let (negative, value, lossless) = value.get_u64(); + if negative || !lossless { + return Err(invalid_arg(format!( + "{name} must be an unsigned 64-bit bigint" + ))); + } + Ok(value) +} + +fn state_from_native(value: &NativeEpochScheduleState) -> NapiResult { + Ok(EpochScheduleState { + last_epoch_block: bigint_u64("lastEpochBlock", &value.last_epoch_block)?, + pending_epoch_at: bigint_u64("pendingEpochAt", &value.pending_epoch_at)?, + subnet_epoch_index: bigint_u64("subnetEpochIndex", &value.subnet_epoch_index)?, + tempo: value.tempo, + blocks_since_last_step: bigint_u64( + "blocksSinceLastStep", + &value.blocks_since_last_step, + )?, + current_block: bigint_u64("currentBlock", &value.current_block)?, + }) +} + +fn state_to_native(value: EpochScheduleState) -> NativeEpochScheduleState { + NativeEpochScheduleState { + last_epoch_block: BigInt::from(value.last_epoch_block), + pending_epoch_at: BigInt::from(value.pending_epoch_at), + subnet_epoch_index: BigInt::from(value.subnet_epoch_index), + tempo: value.tempo, + blocks_since_last_step: BigInt::from(value.blocks_since_last_step), + current_block: BigInt::from(value.current_block), + } +} + +fn ciphertext_round(value: (Vec, u64)) -> NativeCiphertextRound { + NativeCiphertextRound { + ciphertext: value.0.into(), + reveal_round: BigInt::from(value.1), + } +} + +#[napi(js_name = "timelockEncryptAndCompress")] +pub fn encrypt_and_compress(data: Buffer, reveal_round: BigInt) -> NapiResult { + timelock::encrypt_and_compress( + data.as_ref(), + bigint_u64("revealRound", &reveal_round)?, + ) + .napi() + .map(Into::into) +} + +#[napi(js_name = "timelockDecryptAndDecompress")] +pub fn decrypt_and_decompress( + encrypted_data: Buffer, + signature_bytes: Buffer, +) -> NapiResult { + timelock::decrypt_and_decompress(encrypted_data.as_ref(), signature_bytes.as_ref()) + .napi() + .map(Into::into) +} + +#[napi(js_name = "timelockGenerateCommitV2")] +pub fn generate_commit_v2( + uids: Vec, + values: Vec, + version_key: BigInt, + state: NativeEpochScheduleState, + subnet_reveal_period_epochs: BigInt, + block_time: f64, + hotkey: Buffer, +) -> NapiResult { + timelock::generate_commit_v2( + uids, + values, + bigint_u64("versionKey", &version_key)?, + state_from_native(&state)?, + bigint_u64( + "subnetRevealPeriodEpochs", + &subnet_reveal_period_epochs, + )?, + block_time, + hotkey.as_ref().to_vec(), + ) + .napi() + .map(ciphertext_round) +} + +#[napi(js_name = "timelockEncryptCommitment")] +pub fn encrypt_commitment( + data: String, + blocks_until_reveal: BigInt, + block_time: f64, +) -> NapiResult { + timelock::encrypt_commitment( + &data, + bigint_u64("blocksUntilReveal", &blocks_until_reveal)?, + block_time, + ) + .napi() + .map(ciphertext_round) +} + +#[napi(js_name = "timelockEncryptNBlocks")] +pub fn encrypt_n_blocks( + data: Buffer, + n_blocks: BigInt, + block_time: f64, +) -> NapiResult { + timelock::encrypt_n_blocks( + data.as_ref(), + bigint_u64("nBlocks", &n_blocks)?, + block_time, + ) + .napi() + .map(ciphertext_round) +} + +#[napi(js_name = "timelockEncryptAtRound")] +pub fn encrypt_at_round(data: Buffer, reveal_round: BigInt) -> NapiResult { + timelock::encrypt_at_round( + data.as_ref(), + bigint_u64("revealRound", &reveal_round)?, + ) + .napi() + .map(ciphertext_round) +} + +#[napi(js_name = "timelockGetRoundInfo")] +pub fn get_round_info(round: Option) -> NapiResult { + let round = round + .as_ref() + .map(|value| bigint_u64("round", value)) + .transpose()?; + let response = timelock::get_round_info(round).napi()?; + Ok(NativeDrandResponse { + round: BigInt::from(response.round), + signature: response.signature, + }) +} + +#[napi(js_name = "timelockGetRevealRoundSignature")] +pub fn get_reveal_round_signature( + reveal_round: Option, + no_errors: bool, +) -> NapiResult> { + let reveal_round = reveal_round + .as_ref() + .map(|value| bigint_u64("revealRound", value)) + .transpose()?; + timelock::get_reveal_round_signature(reveal_round, no_errors).napi() +} + +#[napi(js_name = "timelockDecrypt")] +pub fn decrypt(encrypted_data: Buffer, no_errors: bool) -> NapiResult> { + timelock::decrypt(encrypted_data.as_ref(), no_errors) + .napi() + .map(|value| value.map(Into::into)) +} + +#[napi(js_name = "timelockDecryptWithSignature")] +pub fn decrypt_with_signature( + encrypted_data: Buffer, + signature_hex: String, +) -> NapiResult { + timelock::decrypt_with_signature(encrypted_data.as_ref(), &signature_hex) + .napi() + .map(Into::into) +} + +#[napi(js_name = "epochShouldRun")] +pub fn should_run_epoch(state: NativeEpochScheduleState, block: BigInt) -> NapiResult { + Ok(epoch_schedule::should_run_epoch( + &state_from_native(&state)?, + bigint_u64("block", &block)?, + )) +} + +#[napi(js_name = "epochCurrentPreRunCoinbase")] +pub fn current_epoch_pre_run_coinbase( + state: NativeEpochScheduleState, + block: BigInt, +) -> NapiResult { + Ok(BigInt::from(epoch_schedule::current_epoch_pre_run_coinbase( + &state_from_native(&state)?, + bigint_u64("block", &block)?, + ))) +} + +#[napi(js_name = "epochSimulateRunCoinbase")] +pub fn simulate_run_coinbase( + state: NativeEpochScheduleState, + block: BigInt, +) -> NapiResult { + Ok(state_to_native(epoch_schedule::simulate_run_coinbase( + &state_from_native(&state)?, + bigint_u64("block", &block)?, + ))) +} + +#[napi(js_name = "epochAdvanceBlocks")] +pub fn advance_blocks( + state: NativeEpochScheduleState, + start: BigInt, + end: BigInt, +) -> NapiResult { + Ok(state_to_native(epoch_schedule::advance_blocks( + &state_from_native(&state)?, + bigint_u64("start", &start)?, + bigint_u64("end", &end)?, + ))) +} + +#[napi(js_name = "epochPredictFirstRevealBlock")] +pub fn predict_first_reveal_block( + state: NativeEpochScheduleState, + reveal_period_epochs: BigInt, +) -> NapiResult { + epoch_schedule::predict_first_reveal_block( + &state_from_native(&state)?, + bigint_u64("revealPeriodEpochs", &reveal_period_epochs)?, + ) + .map(BigInt::from) + .map_err(|error| invalid_arg(error.to_string())) +} + +#[napi(js_name = "encodeWeightsTlockPayload")] +pub fn encode_weights_tlock_payload(value: NativeWeightsTlockPayload) -> NapiResult { + Ok(WeightsTlockPayload { + hotkey: value.hotkey.as_ref().to_vec(), + uids: value.uids, + values: value.values, + version_key: bigint_u64("versionKey", &value.version_key)?, + } + .encode() + .into()) +} + +#[napi(js_name = "decodeWeightsTlockPayload")] +pub fn decode_weights_tlock_payload(data: Buffer) -> NapiResult { + let value = WeightsTlockPayload::decode(&mut &data.as_ref()[..]) + .map_err(|error| invalid_arg(format!("invalid weights timelock payload: {error}")))?; + Ok(NativeWeightsTlockPayload { + hotkey: value.hotkey.into(), + uids: value.uids, + values: value.values, + version_key: BigInt::from(value.version_key), + }) +} + +#[napi(js_name = "encodeTimelockUserData")] +pub fn encode_user_data(value: NativeUserData) -> NapiResult { + Ok(UserData { + encrypted_data: value.encrypted_data.as_ref().to_vec(), + reveal_round: bigint_u64("revealRound", &value.reveal_round)?, + } + .encode() + .into()) +} + +#[napi(js_name = "decodeTimelockUserData")] +pub fn decode_user_data(data: Buffer) -> NapiResult { + let value = UserData::decode(&mut &data.as_ref()[..]) + .map_err(|error| invalid_arg(format!("invalid timelock user data: {error}")))?; + Ok(NativeUserData { + encrypted_data: value.encrypted_data.into(), + reveal_round: BigInt::from(value.reveal_round), + }) +} + +#[napi(js_name = "timelockMaxTempo")] +pub fn max_tempo() -> u16 { + constants::MAX_TEMPO +} + +#[napi(js_name = "timelockMaxTempoU64")] +pub fn max_tempo_u64() -> BigInt { + BigInt::from(constants::MAX_TEMPO_U64) +} + +#[napi(js_name = "timelockDrandPublicKey")] +pub fn drand_public_key() -> String { + constants::DRAND_PUBLIC_KEY.to_owned() +} + +#[napi(js_name = "timelockGenesisTime")] +pub fn genesis_time() -> BigInt { + BigInt::from(constants::GENESIS_TIME) +} + +#[napi(js_name = "timelockDrandPeriod")] +pub fn drand_period() -> BigInt { + BigInt::from(constants::DRAND_PERIOD) +} + +#[napi(js_name = "timelockQuicknetChainHash")] +pub fn quicknet_chain_hash() -> String { + constants::QUICKNET_CHAIN_HASH.to_owned() +} + +#[napi(js_name = "timelockDrandEndpoints")] +pub fn drand_endpoints() -> Vec { + constants::DRAND_ENDPOINTS + .iter() + .map(|endpoint| (*endpoint).to_owned()) + .collect() +} + +#[napi(js_name = "timelockSecurityBlockOffset")] +pub fn security_block_offset() -> BigInt { + BigInt::from(constants::SECURITY_BLOCK_OFFSET) +} + +#[napi(js_name = "timelockCommitInclusionBlockOffset")] +pub fn commit_inclusion_block_offset() -> BigInt { + BigInt::from(constants::COMMIT_INCLUSION_BLOCK_OFFSET) +} + +#[napi(js_name = "timelockMaxSimulationBlocks")] +pub fn max_simulation_blocks(reveal_period_epochs: BigInt) -> NapiResult { + Ok(BigInt::from(constants::max_simulation_blocks(bigint_u64( + "revealPeriodEpochs", + &reveal_period_epochs, + )?))) +} diff --git a/sdk/typescript-sdk/native/src/values.rs b/sdk/typescript-sdk/native/src/values.rs new file mode 100644 index 0000000000..688fe6e695 --- /dev/null +++ b/sdk/typescript-sdk/native/src/values.rs @@ -0,0 +1,295 @@ +//! Lossless boundary representation for dynamic SCALE values. +//! +//! Node-API itself handles ordinary scalars and objects, while the tiny tagged +//! representation below preserves JavaScript `bigint`, bytes, and dictionaries +//! whose keys are not strings. The public TypeScript layer applies/removes +//! these tags. All actual SCALE semantics remain in `bittensor-core`. + +#![allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)] + +use std::collections::HashSet; + +use bittensor_core::codec::value::{u256_decimal, Value}; +use serde_json::{Map, Number, Value as JsonValue}; + +use crate::errors::{invalid_arg, NapiResult}; + +pub const WIRE_TAG: &str = "__bittensor_core_wire__"; +const TAG_BIGINT: &str = "bigint"; +const TAG_BYTES: &str = "bytes"; +const TAG_DICT: &str = "dict"; +const MAX_SAFE_INTEGER: i128 = 9_007_199_254_740_991; +const MIN_SAFE_INTEGER: i128 = -MAX_SAFE_INTEGER; +const MAX_WIRE_DEPTH: usize = 256; + +pub fn from_wire(value: JsonValue) -> NapiResult { + from_wire_at(value, 0) +} + +fn from_wire_at(value: JsonValue, depth: usize) -> NapiResult { + if depth > MAX_WIRE_DEPTH { + return Err(invalid_arg("value nesting exceeds 256 levels")); + } + match value { + JsonValue::Null => Ok(Value::Null), + JsonValue::Bool(value) => Ok(Value::Bool(value)), + JsonValue::Number(number) => number_to_value(number), + JsonValue::String(value) => Ok(Value::Str(value)), + JsonValue::Array(values) => values + .into_iter() + .map(|value| from_wire_at(value, depth + 1)) + .collect::>>() + .map(Value::List), + JsonValue::Object(map) => object_from_wire(map, depth + 1), + } +} + +fn number_to_value(number: Number) -> NapiResult { + if let Some(value) = number.as_i64() { + return Ok(Value::Int(i128::from(value))); + } + if let Some(value) = number.as_u64() { + let value = u128::from(value); + return Ok(if value <= i128::MAX as u128 { + Value::Int(value as i128) + } else { + Value::Uint(value) + }); + } + Err(invalid_arg(format!( + "SCALE values must use integers; got {number}" + ))) +} + +fn object_from_wire(mut map: Map, depth: usize) -> NapiResult { + let tag = map.get(WIRE_TAG).and_then(JsonValue::as_str); + match tag { + Some(TAG_BIGINT) => { + let decimal = map + .remove("value") + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| invalid_arg("bigint wire value is missing decimal `value`"))?; + bigint_value(&decimal) + } + Some(TAG_BYTES) => { + let hex_value = map + .remove("hex") + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| invalid_arg("bytes wire value is missing `hex`"))?; + let raw = hex::decode(hex_value.trim_start_matches("0x")) + .map_err(|error| invalid_arg(format!("invalid bytes hex: {error}")))?; + Ok(Value::Bytes(raw)) + } + Some(TAG_DICT) => { + let entries = map + .remove("entries") + .and_then(|value| value.as_array().cloned()) + .ok_or_else(|| invalid_arg("dict wire value is missing `entries`"))?; + let mut output = Vec::with_capacity(entries.len()); + for entry in entries { + let JsonValue::Array(pair) = entry else { + return Err(invalid_arg("dict entry must be a [key, value] pair")); + }; + if pair.len() != 2 { + return Err(invalid_arg("dict entry must contain exactly two values")); + } + let mut pair = pair.into_iter(); + let key = pair + .next() + .ok_or_else(|| invalid_arg("dict entry is missing its key"))?; + let value = pair + .next() + .ok_or_else(|| invalid_arg("dict entry is missing its value"))?; + output.push(( + from_wire_at(key, depth + 1)?, + from_wire_at(value, depth + 1)?, + )); + } + Ok(Value::Dict(output)) + } + _ => map + .into_iter() + .map(|(key, value)| { + Ok(( + Value::Str(key), + from_wire_at(value, depth + 1)?, + )) + }) + .collect::>>() + .map(Value::Dict), + } +} + +fn bigint_value(decimal: &str) -> NapiResult { + let decimal = decimal.trim(); + if decimal.is_empty() { + return Err(invalid_arg("empty bigint decimal string")); + } + if decimal.starts_with('-') { + let value = decimal + .parse::() + .map_err(|_| invalid_arg("negative bigint is outside the i128 SCALE range"))?; + return Ok(Value::Int(value)); + } + let unsigned = decimal.strip_prefix('+').unwrap_or(decimal); + if unsigned.is_empty() || !unsigned.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid_arg(format!( + "invalid bigint decimal string {decimal:?}" + ))); + } + if let Ok(value) = unsigned.parse::() { + return Ok(Value::Int(value)); + } + if let Ok(value) = unsigned.parse::() { + return Ok(Value::Uint(value)); + } + Ok(Value::U256(decimal_to_u256_le(unsigned)?)) +} + +fn decimal_to_u256_le(decimal: &str) -> NapiResult<[u8; 32]> { + let mut output = [0u8; 32]; + for digit in decimal.bytes() { + let digit = u16::from(digit - b'0'); + let mut carry = digit; + for byte in &mut output { + let value = u16::from(*byte) * 10 + carry; + *byte = (value & 0xff) as u8; + carry = value >> 8; + } + if carry != 0 { + return Err(invalid_arg("bigint is outside the unsigned 256-bit range")); + } + } + Ok(output) +} + +pub fn to_wire(value: &Value) -> NapiResult { + to_wire_at(value, 0) +} + +fn to_wire_at(value: &Value, depth: usize) -> NapiResult { + if depth > MAX_WIRE_DEPTH { + return Err(invalid_arg("decoded value nesting exceeds 256 levels")); + } + match value { + Value::Null => Ok(JsonValue::Null), + Value::Bool(value) => Ok(JsonValue::Bool(*value)), + Value::Int(value) if (MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(value) => { + let integer = i64::try_from(*value) + .map_err(|_| invalid_arg("safe integer conversion failed"))?; + Ok(JsonValue::Number(Number::from(integer))) + } + Value::Int(value) => Ok(tagged_bigint(value.to_string())), + Value::Uint(value) if *value <= MAX_SAFE_INTEGER as u128 => { + let integer = u64::try_from(*value) + .map_err(|_| invalid_arg("safe unsigned integer conversion failed"))?; + Ok(JsonValue::Number(Number::from(integer))) + } + Value::Uint(value) => Ok(tagged_bigint(value.to_string())), + Value::U256(value) => Ok(tagged_bigint(u256_decimal(value))), + Value::Str(value) => Ok(JsonValue::String(value.clone())), + Value::Bytes(value) => { + let mut map = Map::new(); + map.insert(WIRE_TAG.into(), JsonValue::String(TAG_BYTES.into())); + map.insert("hex".into(), JsonValue::String(hex::encode(value))); + Ok(JsonValue::Object(map)) + } + Value::List(values) | Value::Tuple(values) => values + .iter() + .map(|value| to_wire_at(value, depth + 1)) + .collect::>>() + .map(JsonValue::Array), + Value::Dict(entries) => dict_to_wire(entries, depth + 1), + } +} + +fn tagged_bigint(decimal: String) -> JsonValue { + let mut map = Map::new(); + map.insert(WIRE_TAG.into(), JsonValue::String(TAG_BIGINT.into())); + map.insert("value".into(), JsonValue::String(decimal)); + JsonValue::Object(map) +} + +fn dict_to_wire(entries: &[(Value, Value)], depth: usize) -> NapiResult { + let mut names = HashSet::with_capacity(entries.len()); + let plain_object = entries.iter().all(|(key, _)| match key { + Value::Str(name) => name != WIRE_TAG && names.insert(name.clone()), + _ => false, + }); + + if plain_object { + let mut map = Map::new(); + for (key, value) in entries { + let Value::Str(name) = key else { + return Err(invalid_arg("internal string-key conversion failed")); + }; + map.insert(name.clone(), to_wire_at(value, depth + 1)?); + } + return Ok(JsonValue::Object(map)); + } + + let mut pairs = Vec::with_capacity(entries.len()); + for (key, value) in entries { + pairs.push(JsonValue::Array(vec![ + to_wire_at(key, depth + 1)?, + to_wire_at(value, depth + 1)?, + ])); + } + let mut map = Map::new(); + map.insert(WIRE_TAG.into(), JsonValue::String(TAG_DICT.into())); + map.insert("entries".into(), JsonValue::Array(pairs)); + Ok(JsonValue::Object(map)) +} + +pub fn values_from_wire(value: JsonValue) -> NapiResult> { + let JsonValue::Array(values) = value else { + return Err(invalid_arg("expected an array of SCALE values")); + }; + values.into_iter().map(from_wire).collect() +} + +pub fn values_to_wire(values: &[Value]) -> NapiResult { + values + .iter() + .map(to_wire) + .collect::>>() + .map(JsonValue::Array) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + #[test] + fn bigint_roundtrip_covers_u256() { + let decimal = "115792089237316195423570985008687907853269984665640564039457584007913129639935"; + let value = bigint_value(decimal).expect("u256 parses"); + assert!(matches!(value, Value::U256(_))); + let wire = to_wire(&value).expect("u256 renders"); + assert_eq!(wire["value"], decimal); + } + + #[test] + fn bare_plus_is_not_a_bigint() { + assert!(bigint_value("+").is_err()); + } + + #[test] + fn non_string_dict_keys_use_entry_wire_shape() { + let value = Value::Dict(vec![(Value::Int(7), Value::Str("seven".into()))]); + let wire = to_wire(&value).expect("dict renders"); + assert_eq!(wire[WIRE_TAG], TAG_DICT); + let decoded = from_wire(wire).expect("dict parses"); + assert_eq!(decoded, value); + } + + #[test] + fn bytes_roundtrip_without_json_arrays() { + let value = Value::Bytes(vec![0, 1, 0xfe, 0xff]); + let wire = to_wire(&value).expect("bytes render"); + assert_eq!(wire["hex"], "0001feff"); + assert_eq!(from_wire(wire).expect("bytes parse"), value); + } +} diff --git a/sdk/typescript-sdk/package-lock.json b/sdk/typescript-sdk/package-lock.json new file mode 100644 index 0000000000..a80aa8ded7 --- /dev/null +++ b/sdk/typescript-sdk/package-lock.json @@ -0,0 +1,1874 @@ +{ + "name": "@bittensor/sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@bittensor/sdk", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@napi-rs/cli": "3.7.2", + "@types/node": "22.15.0", + "typescript": "5.8.3" + }, + "engines": { + "node": ">=20.17" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@napi-rs/cli": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-3.7.2.tgz", + "integrity": "sha512-shDW0Td/XZQpP04Yy+OsMt1ILMKGGkoLcy1zVAsSAK0fLfWm0Upgkmfs/NOV2ZhMQwkgpR3ZEdyHmTwgrUDQuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^8.5.2", + "@napi-rs/cross-toolchain": "^1.0.3", + "@napi-rs/wasm-tools": "^1.0.1", + "@octokit/rest": "^22.0.1", + "clipanion": "^4.0.0-rc.4", + "colorette": "^2.0.20", + "emnapi": "^1.11.1", + "es-toolkit": "^1.47.0", + "js-yaml": "^4.2.0", + "obug": "^2.1.2", + "semver": "^7.8.2", + "typanion": "^3.14.0" + }, + "bin": { + "napi": "dist/cli.js", + "napi-raw": "cli.mjs" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/runtime": "^1.7.1" + }, + "peerDependenciesMeta": { + "@emnapi/runtime": { + "optional": true + } + } + }, + "node_modules/@napi-rs/cross-toolchain": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/cross-toolchain/-/cross-toolchain-1.0.3.tgz", + "integrity": "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==", + "dev": true, + "license": "MIT", + "workspaces": [ + ".", + "arm64/*", + "x64/*" + ], + "dependencies": { + "@napi-rs/lzma": "^1.4.5", + "@napi-rs/tar": "^1.1.0", + "debug": "^4.4.1" + }, + "peerDependencies": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" + }, + "peerDependenciesMeta": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-x86_64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-x86_64": { + "optional": true + } + } + }, + "node_modules/@napi-rs/lzma": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma/-/lzma-1.4.5.tgz", + "integrity": "sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/lzma-android-arm-eabi": "1.4.5", + "@napi-rs/lzma-android-arm64": "1.4.5", + "@napi-rs/lzma-darwin-arm64": "1.4.5", + "@napi-rs/lzma-darwin-x64": "1.4.5", + "@napi-rs/lzma-freebsd-x64": "1.4.5", + "@napi-rs/lzma-linux-arm-gnueabihf": "1.4.5", + "@napi-rs/lzma-linux-arm64-gnu": "1.4.5", + "@napi-rs/lzma-linux-arm64-musl": "1.4.5", + "@napi-rs/lzma-linux-ppc64-gnu": "1.4.5", + "@napi-rs/lzma-linux-riscv64-gnu": "1.4.5", + "@napi-rs/lzma-linux-s390x-gnu": "1.4.5", + "@napi-rs/lzma-linux-x64-gnu": "1.4.5", + "@napi-rs/lzma-linux-x64-musl": "1.4.5", + "@napi-rs/lzma-wasm32-wasi": "1.4.5", + "@napi-rs/lzma-win32-arm64-msvc": "1.4.5", + "@napi-rs/lzma-win32-ia32-msvc": "1.4.5", + "@napi-rs/lzma-win32-x64-msvc": "1.4.5" + } + }, + "node_modules/@napi-rs/lzma-android-arm-eabi": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm-eabi/-/lzma-android-arm-eabi-1.4.5.tgz", + "integrity": "sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-android-arm64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm64/-/lzma-android-arm64-1.4.5.tgz", + "integrity": "sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-darwin-arm64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-arm64/-/lzma-darwin-arm64-1.4.5.tgz", + "integrity": "sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-darwin-x64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-x64/-/lzma-darwin-x64-1.4.5.tgz", + "integrity": "sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-freebsd-x64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-freebsd-x64/-/lzma-freebsd-x64-1.4.5.tgz", + "integrity": "sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-arm-gnueabihf": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm-gnueabihf/-/lzma-linux-arm-gnueabihf-1.4.5.tgz", + "integrity": "sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-gnu/-/lzma-linux-arm64-gnu-1.4.5.tgz", + "integrity": "sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-musl": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-musl/-/lzma-linux-arm64-musl-1.4.5.tgz", + "integrity": "sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-ppc64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-ppc64-gnu/-/lzma-linux-ppc64-gnu-1.4.5.tgz", + "integrity": "sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-riscv64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-riscv64-gnu/-/lzma-linux-riscv64-gnu-1.4.5.tgz", + "integrity": "sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-s390x-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-s390x-gnu/-/lzma-linux-s390x-gnu-1.4.5.tgz", + "integrity": "sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.4.5.tgz", + "integrity": "sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-musl": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-musl/-/lzma-linux-x64-musl-1.4.5.tgz", + "integrity": "sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-wasm32-wasi": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-wasm32-wasi/-/lzma-wasm32-wasi-1.4.5.tgz", + "integrity": "sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/lzma-win32-arm64-msvc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-arm64-msvc/-/lzma-win32-arm64-msvc-1.4.5.tgz", + "integrity": "sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-win32-ia32-msvc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-ia32-msvc/-/lzma-win32-ia32-msvc-1.4.5.tgz", + "integrity": "sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-win32-x64-msvc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-x64-msvc/-/lzma-win32-x64-msvc-1.4.5.tgz", + "integrity": "sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar/-/tar-1.1.0.tgz", + "integrity": "sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/tar-android-arm-eabi": "1.1.0", + "@napi-rs/tar-android-arm64": "1.1.0", + "@napi-rs/tar-darwin-arm64": "1.1.0", + "@napi-rs/tar-darwin-x64": "1.1.0", + "@napi-rs/tar-freebsd-x64": "1.1.0", + "@napi-rs/tar-linux-arm-gnueabihf": "1.1.0", + "@napi-rs/tar-linux-arm64-gnu": "1.1.0", + "@napi-rs/tar-linux-arm64-musl": "1.1.0", + "@napi-rs/tar-linux-ppc64-gnu": "1.1.0", + "@napi-rs/tar-linux-s390x-gnu": "1.1.0", + "@napi-rs/tar-linux-x64-gnu": "1.1.0", + "@napi-rs/tar-linux-x64-musl": "1.1.0", + "@napi-rs/tar-wasm32-wasi": "1.1.0", + "@napi-rs/tar-win32-arm64-msvc": "1.1.0", + "@napi-rs/tar-win32-ia32-msvc": "1.1.0", + "@napi-rs/tar-win32-x64-msvc": "1.1.0" + } + }, + "node_modules/@napi-rs/tar-android-arm-eabi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm-eabi/-/tar-android-arm-eabi-1.1.0.tgz", + "integrity": "sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-android-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm64/-/tar-android-arm64-1.1.0.tgz", + "integrity": "sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-arm64/-/tar-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-x64/-/tar-darwin-x64-1.1.0.tgz", + "integrity": "sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-freebsd-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-freebsd-x64/-/tar-freebsd-x64-1.1.0.tgz", + "integrity": "sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm-gnueabihf": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm-gnueabihf/-/tar-linux-arm-gnueabihf-1.1.0.tgz", + "integrity": "sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-gnu/-/tar-linux-arm64-gnu-1.1.0.tgz", + "integrity": "sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-musl/-/tar-linux-arm64-musl-1.1.0.tgz", + "integrity": "sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-ppc64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-ppc64-gnu/-/tar-linux-ppc64-gnu-1.1.0.tgz", + "integrity": "sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-s390x-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-s390x-gnu/-/tar-linux-s390x-gnu-1.1.0.tgz", + "integrity": "sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-gnu/-/tar-linux-x64-gnu-1.1.0.tgz", + "integrity": "sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-musl/-/tar-linux-x64-musl-1.1.0.tgz", + "integrity": "sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-wasm32-wasi/-/tar-wasm32-wasi-1.1.0.tgz", + "integrity": "sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/tar-win32-arm64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-arm64-msvc/-/tar-win32-arm64-msvc-1.1.0.tgz", + "integrity": "sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-ia32-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-ia32-msvc/-/tar-win32-ia32-msvc-1.1.0.tgz", + "integrity": "sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-x64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-x64-msvc/-/tar-win32-x64-msvc-1.1.0.tgz", + "integrity": "sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@napi-rs/wasm-tools": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools/-/wasm-tools-1.0.1.tgz", + "integrity": "sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/wasm-tools-android-arm-eabi": "1.0.1", + "@napi-rs/wasm-tools-android-arm64": "1.0.1", + "@napi-rs/wasm-tools-darwin-arm64": "1.0.1", + "@napi-rs/wasm-tools-darwin-x64": "1.0.1", + "@napi-rs/wasm-tools-freebsd-x64": "1.0.1", + "@napi-rs/wasm-tools-linux-arm64-gnu": "1.0.1", + "@napi-rs/wasm-tools-linux-arm64-musl": "1.0.1", + "@napi-rs/wasm-tools-linux-x64-gnu": "1.0.1", + "@napi-rs/wasm-tools-linux-x64-musl": "1.0.1", + "@napi-rs/wasm-tools-wasm32-wasi": "1.0.1", + "@napi-rs/wasm-tools-win32-arm64-msvc": "1.0.1", + "@napi-rs/wasm-tools-win32-ia32-msvc": "1.0.1", + "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm-eabi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm-eabi/-/wasm-tools-android-arm-eabi-1.0.1.tgz", + "integrity": "sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm64/-/wasm-tools-android-arm64-1.0.1.tgz", + "integrity": "sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-arm64/-/wasm-tools-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-x64/-/wasm-tools-darwin-x64-1.0.1.tgz", + "integrity": "sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-freebsd-x64/-/wasm-tools-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-gnu/-/wasm-tools-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-musl/-/wasm-tools-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-gnu/-/wasm-tools-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-musl/-/wasm-tools-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-wasm32-wasi/-/wasm-tools-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-arm64-msvc/-/wasm-tools-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-ia32-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-ia32-msvc/-/wasm-tools-win32-ia32-msvc-1.0.1.tgz", + "integrity": "sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-x64-msvc/-/wasm-tools-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.11.tgz", + "integrity": "sha512-+s7HUxjfFqOMS9VlIwDffq0MikjSAK0gSpG73W+meAvVAvX4MBrHYTK5Bj3Uot55qFT4gzUtfzE4mGWY4Br8/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "content-type": "^2.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "22.15.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.0.tgz", + "integrity": "sha512-99S8dWD2DkeE6PBaEDw+In3aar7hdoBvjyJMR6vaKBTzpvR0P00ClzJMOoVrj9D2+Sy/YCwACYHnBTpMhg1UCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clipanion": { + "version": "4.0.0-rc.4", + "resolved": "https://registry.npmjs.org/clipanion/-/clipanion-4.0.0-rc.4.tgz", + "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ], + "dependencies": { + "typanion": "^3.8.0" + }, + "peerDependencies": { + "typanion": "*" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/emnapi": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/emnapi/-/emnapi-1.11.2.tgz", + "integrity": "sha512-iMt/XQc69fFn2EvcU6tm14HmXKwyy0lnABugsQlqp6xFuZIUuO+ONVSg2mz+MTVF8WbC+bic65AvRXdoldALKg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "node-addon-api": ">= 6.1.0" + }, + "peerDependenciesMeta": { + "node-addon-api": { + "optional": true + } + } + }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typanion": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz", + "integrity": "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ] + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/sdk/typescript-sdk/package.json b/sdk/typescript-sdk/package.json new file mode 100644 index 0000000000..fc747ff9e9 --- /dev/null +++ b/sdk/typescript-sdk/package.json @@ -0,0 +1,58 @@ +{ + "name": "@bittensor/sdk", + "version": "0.1.0", + "private": true, + "description": "Thin TypeScript SDK backed by the monorepo's bittensor-core Rust SDK", + "license": "Apache-2.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "default": "./dist/index.js" + }, + "./native": { + "types": "./native.generated.d.ts", + "import": "./native.cjs", + "require": "./native.cjs", + "default": "./native.cjs" + } + }, + "files": [ + "dist", + "native.cjs", + "native.generated.d.ts", + "*.node", + "README.md" + ], + "engines": { + "node": ">=20.17" + }, + "scripts": { + "build:native": "napi build --manifest-path native/Cargo.toml --output-dir . --platform --release --all-features --js native.cjs --dts native.generated.d.ts", + "build:ts": "tsc -p tsconfig.json && node scripts/generate-esm.cjs", + "build": "npm run build:native && npm run build:ts", + "check": "npm run build && npm test", + "test": "node --test test/*.test.cjs", + "clean": "node scripts/clean.cjs", + "prepack": "npm run build", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "napi": { + "binaryName": "bittensor_sdk" + }, + "devDependencies": { + "@napi-rs/cli": "3.7.2", + "@types/node": "22.15.0", + "typescript": "5.8.3" + }, + "repository": { + "type": "git", + "url": "https://github.com/RaoFoundation/subtensor.git", + "directory": "sdk/typescript-sdk" + }, + "type": "commonjs", + "module": "dist/index.mjs" +} diff --git a/sdk/typescript-sdk/scripts/clean.cjs b/sdk/typescript-sdk/scripts/clean.cjs new file mode 100644 index 0000000000..a14b13ef15 --- /dev/null +++ b/sdk/typescript-sdk/scripts/clean.cjs @@ -0,0 +1,14 @@ +'use strict' + +const { rmSync, readdirSync } = require('node:fs') +const { join } = require('node:path') + +const root = join(__dirname, '..') +rmSync(join(root, 'dist'), { recursive: true, force: true }) +rmSync(join(root, 'native.cjs'), { force: true }) +rmSync(join(root, 'native.generated.d.ts'), { force: true }) +for (const entry of readdirSync(root)) { + if (entry.endsWith('.node') || entry.endsWith('.node.map')) { + rmSync(join(root, entry), { force: true }) + } +} diff --git a/sdk/typescript-sdk/scripts/generate-esm.cjs b/sdk/typescript-sdk/scripts/generate-esm.cjs new file mode 100644 index 0000000000..da683b10bb --- /dev/null +++ b/sdk/typescript-sdk/scripts/generate-esm.cjs @@ -0,0 +1,48 @@ +'use strict' + +const { readFileSync, writeFileSync } = require('node:fs') +const { dirname, join, resolve } = require('node:path') + +const root = join(__dirname, '..') +const cjsPath = join(root, 'dist', 'index.js') +const esmPath = join(root, 'dist', 'index.mjs') +const identifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/ +const seen = new Set() +const names = new Set() + +function collectExports(filePath) { + const normalized = resolve(filePath) + if (seen.has(normalized)) return + seen.add(normalized) + + const source = readFileSync(normalized, 'utf8') + for (const match of source.matchAll(/\bexports\.([A-Za-z_$][A-Za-z0-9_$]*)\s*=/g)) { + names.add(match[1]) + } + for (const match of source.matchAll(/Object\.defineProperty\(exports,\s*["']([^"']+)["']/g)) { + if (match[1] !== '__esModule') names.add(match[1]) + } + for (const match of source.matchAll(/__exportStar\(require\(["'](.+?)["']\),\s*exports\)/g)) { + collectExports(resolve(dirname(normalized), `${match[1]}.js`)) + } +} + +collectExports(cjsPath) +names.delete('default') + +const sorted = [...names].sort() +for (const name of sorted) { + if (!identifier.test(name)) { + throw new Error(`Cannot emit ESM named export for ${JSON.stringify(name)}`) + } +} + +const lines = [ + "import sdk from './index.js'", + '', + ...sorted.map((name) => `export const ${name} = sdk.${name}`), + '', + 'export default sdk', + '', +] +writeFileSync(esmPath, lines.join('\n')) diff --git a/sdk/typescript-sdk/src/crypto.ts b/sdk/typescript-sdk/src/crypto.ts new file mode 100644 index 0000000000..420e61d748 --- /dev/null +++ b/sdk/typescript-sdk/src/crypto.ts @@ -0,0 +1,95 @@ +import native from './native' +import { nativeCall } from './errors' +import { toBuffer } from './wire' +import type { ByteLike, ChainInfo } from './types' + +function nativeChainInfo(info: ChainInfo): Record { + return { + specVersion: info.specVersion, + specName: info.specName, + base58Prefix: info.base58Prefix, + decimals: info.decimals, + tokenSymbol: info.tokenSymbol, + } +} + +export function metadataDigest(metadataBytes: ByteLike, info: ChainInfo): Buffer { + return nativeCall(() => + native.metadataDigest(toBuffer(metadataBytes, 'metadataBytes'), nativeChainInfo(info)), + ) +} + +export function generateExtrinsicProof( + callData: ByteLike, + includedInExtrinsic: ByteLike, + includedInSignedData: ByteLike, + metadataBytes: ByteLike, + info: ChainInfo, +): Buffer { + return nativeCall(() => + native.generateExtrinsicProof( + toBuffer(callData, 'callData'), + toBuffer(includedInExtrinsic, 'includedInExtrinsic'), + toBuffer(includedInSignedData, 'includedInSignedData'), + toBuffer(metadataBytes, 'metadataBytes'), + nativeChainInfo(info), + ), + ) +} + +export const MLKEM_NONCE_LENGTH = native.mlkemNonceLength() +export const MLKEM_KDF_ID = Buffer.from(native.mlkemKdfId()) + +export function mlkemSeal( + publicKey: ByteLike, + plaintext: ByteLike, + includeKeyHash = false, +): Buffer { + return nativeCall(() => + native.mlkemSeal( + toBuffer(publicKey, 'publicKey'), + toBuffer(plaintext, 'plaintext'), + includeKeyHash, + ), + ) +} + +export function mlkemTwox128(data: ByteLike): Buffer { + return nativeCall(() => native.mlkemTwox128(toBuffer(data, 'data'))) +} + + +/** Substrate-compatible twox_128, implemented by bittensor-core Rust. */ +export function twox_128(data: ByteLike): Buffer { + return mlkemTwox128(data) +} + +/** Substrate-compatible BLAKE2b-256, implemented by bittensor-core Rust. */ +export function blake2_256(data: ByteLike): Buffer { + return nativeCall(() => native.hashStorageParam('Blake2_256', toBuffer(data, 'data'))) +} + +export function bytesToHex(data: ByteLike, prefix = true): string { + const hex = toBuffer(data, 'data').toString('hex') + return prefix ? `0x${hex}` : hex +} + +export function hexToBytes(value: string, name = 'hex'): Buffer { + const raw = value.startsWith('0x') ? value.slice(2) : value + if (raw.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(raw)) { + throw new TypeError(`${name} must be an even-length hexadecimal string`) + } + return Buffer.from(raw, 'hex') +} + +/** + * Build the exact MEV-shield ciphertext envelope expected by pallet-shield. + * The key hash, ML-KEM encapsulation, nonce generation, and XChaCha20-Poly1305 + * encryption all execute in bittensor-core Rust. + */ +export function sealMevShieldTransaction( + publicKey: ByteLike, + plaintext: ByteLike, +): Buffer { + return mlkemSeal(publicKey, plaintext, true) +} diff --git a/sdk/typescript-sdk/src/errors.ts b/sdk/typescript-sdk/src/errors.ts new file mode 100644 index 0000000000..eec9d5af40 --- /dev/null +++ b/sdk/typescript-sdk/src/errors.ts @@ -0,0 +1,95 @@ +export type BittensorCoreErrorCode = + | 'KEYFILE' + | 'WRONG_PASSWORD' + | 'NOT_IN_RUNTIME' + | 'CODEC' + | 'CRYPTO' + | 'DEVICE' + | 'UNKNOWN' + +export class BittensorCoreError extends Error { + readonly code: BittensorCoreErrorCode + + constructor(code: BittensorCoreErrorCode, message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'BittensorCoreError' + this.code = code + } +} + +export class KeyfileError extends BittensorCoreError { + constructor(message: string, options?: ErrorOptions) { + super('KEYFILE', message, options) + this.name = 'KeyfileError' + } +} + +export class WrongPasswordError extends BittensorCoreError { + constructor(message: string, options?: ErrorOptions) { + super('WRONG_PASSWORD', message, options) + this.name = 'WrongPasswordError' + } +} + +export class NotInRuntimeError extends BittensorCoreError { + constructor(message: string, options?: ErrorOptions) { + super('NOT_IN_RUNTIME', message, options) + this.name = 'NotInRuntimeError' + } +} + +export class CodecError extends BittensorCoreError { + constructor(message: string, options?: ErrorOptions) { + super('CODEC', message, options) + this.name = 'CodecError' + } +} + +export class CryptoError extends BittensorCoreError { + constructor(message: string, options?: ErrorOptions) { + super('CRYPTO', message, options) + this.name = 'CryptoError' + } +} + +export class DeviceError extends BittensorCoreError { + constructor(message: string, options?: ErrorOptions) { + super('DEVICE', message, options) + this.name = 'DeviceError' + } +} + +const MARKER = /\[BITTENSOR_CORE:(KEYFILE|WRONG_PASSWORD|NOT_IN_RUNTIME|CODEC|CRYPTO|DEVICE)\]\s*/ + +export function mapNativeError(error: unknown): Error { + if (error instanceof BittensorCoreError) return error + if (!(error instanceof Error)) return new BittensorCoreError('UNKNOWN', String(error)) + + const match = MARKER.exec(error.message) + if (!match) return error + const code = match[1] as Exclude + const message = error.message.replace(MARKER, '') + const options = { cause: error } + switch (code) { + case 'KEYFILE': + return new KeyfileError(message, options) + case 'WRONG_PASSWORD': + return new WrongPasswordError(message, options) + case 'NOT_IN_RUNTIME': + return new NotInRuntimeError(message, options) + case 'CODEC': + return new CodecError(message, options) + case 'CRYPTO': + return new CryptoError(message, options) + case 'DEVICE': + return new DeviceError(message, options) + } +} + +export function nativeCall(operation: () => T): T { + try { + return operation() + } catch (error) { + throw mapNativeError(error) + } +} diff --git a/sdk/typescript-sdk/src/index.ts b/sdk/typescript-sdk/src/index.ts new file mode 100644 index 0000000000..eb10e44afa --- /dev/null +++ b/sdk/typescript-sdk/src/index.ts @@ -0,0 +1,34 @@ +import nativeBinding from './native' +import { fromWire, toBuffer, toWire, WIRE_TAG } from './wire' +import type { ByteLike, ScaleValue } from './types' + +export * from './crypto' +export * from './errors' +export * from './keys' +export * from './ledger' +export * from './runtime' +export * from './timelock' +export * from './types' +export { fromWire, toWire, WIRE_TAG } + +/** + * Direct access to the generated Node-API module. This is intentionally + * exported so every native entry point remains callable even before an + * ergonomic TypeScript convenience wrapper is added. + */ +export const native = Object.freeze(nativeBinding) + +export const BINDING_VERSION = nativeBinding.bindingVersion() +export const LEDGER_ENABLED = nativeBinding.ledgerEnabled() + +export function wireRoundtrip(value: ScaleValue): ScaleValue { + return fromWire(nativeBinding.wireRoundtrip(toWire(value))) +} + +export function valueToCorpusJson(value: ScaleValue): unknown { + return nativeBinding.valueToCorpusJson(toWire(value)) +} + +export function u256LeToDecimal(raw: ByteLike): string { + return nativeBinding.u256LeToDecimal(toBuffer(raw, 'raw')) +} diff --git a/sdk/typescript-sdk/src/keys.ts b/sdk/typescript-sdk/src/keys.ts new file mode 100644 index 0000000000..69f36b5868 --- /dev/null +++ b/sdk/typescript-sdk/src/keys.ts @@ -0,0 +1,478 @@ +import native, { type NativeKeypairHandle } from './native' +import { nativeCall } from './errors' +import { coerceMessage, toBuffer } from './wire' +import type { ByteLike } from './types' + +export const CRYPTO_ED25519 = nativeCall(() => native.cryptoEd25519()) +export const CRYPTO_SR25519 = nativeCall(() => native.cryptoSr25519()) +export const DEFAULT_SS58_FORMAT = nativeCall(() => native.defaultSs58Format()) + +export type SubstrateKeyType = 'sr25519' | 'ed25519' + +export interface KeypairMetadata { + address?: string + name?: string + suri?: string + type?: SubstrateKeyType + [key: string]: unknown +} + +export interface KeypairSignOptions { + /** Prefix the raw signature with its Substrate MultiSignature variant byte. */ + withType?: boolean +} + +/** + * Structural Polkadot.js/Moonwall keyring-pair interface. Public-key + * derivation and signatures stay inside bittensor-core Rust. + */ +export interface PolkadotCompatibleKeypair { + readonly address: string + readonly addressRaw: Uint8Array + readonly publicKey: Uint8Array + readonly type: SubstrateKeyType + readonly meta: KeypairMetadata + readonly isLocked: boolean + sign(message: string | Uint8Array, options?: KeypairSignOptions): Uint8Array + verify( + message: string | Uint8Array, + signature: Uint8Array, + signerPublic?: string | Uint8Array, + ): boolean + derive(suri: string, meta?: KeypairMetadata): PolkadotCompatibleKeypair + setMeta(meta: KeypairMetadata): void + encodePkcs8(passphrase?: string): Uint8Array + decodePkcs8(passphrase?: string, encoded?: Uint8Array): void + lock(): void + unlock(passphrase?: string): void + toJson(passphrase?: string): unknown + vrfSign( + message: string | Uint8Array, + context?: string | Uint8Array, + extra?: string | Uint8Array, + ): Uint8Array + vrfVerify( + message: string | Uint8Array, + vrfResult: Uint8Array, + signerPublic: string | Uint8Array, + context?: string | Uint8Array, + extra?: string | Uint8Array, + ): boolean +} + +function unsupported(operation: string): never { + throw new Error( + `${operation} is not part of bittensor-core; use the native keyfile APIs instead`, + ) +} + +export function cryptoTypeForKeyType(type: SubstrateKeyType): number { + return type === 'ed25519' ? CRYPTO_ED25519 : CRYPTO_SR25519 +} + +export function keyTypeForCryptoType(cryptoType: number): SubstrateKeyType { + if (cryptoType === CRYPTO_ED25519) return 'ed25519' + if (cryptoType === CRYPTO_SR25519) return 'sr25519' + throw new RangeError(`unsupported crypto type ${cryptoType}`) +} + +export class Keypair implements PolkadotCompatibleKeypair { + private handle!: NativeKeypairHandle + private metadata: KeypairMetadata = {} + private sourceUri?: string + + constructor( + ss58Address?: string | null, + publicKey?: ByteLike | null, + cryptoType = CRYPTO_SR25519, + ss58Format = DEFAULT_SS58_FORMAT, + ) { + this.handle = nativeCall(() => + native.keypairNew( + ss58Address ?? undefined, + publicKey == null ? undefined : toBuffer(publicKey, 'publicKey'), + cryptoType, + ss58Format, + ), + ) + } + + private static wrap( + handle: NativeKeypairHandle, + sourceUri?: string, + metadata: KeypairMetadata = {}, + ): Keypair { + const keypair = Object.create(Keypair.prototype) as Keypair + keypair.handle = handle + keypair.sourceUri = sourceUri + keypair.metadata = { ...metadata } + return keypair + } + + static fromMnemonic( + mnemonic: string, + cryptoType = CRYPTO_SR25519, + password?: string | null, + ): Keypair { + return Keypair.wrap( + nativeCall(() => native.keypairFromMnemonic(mnemonic, cryptoType, password ?? undefined)), + password == null ? mnemonic : undefined, + { suri: password == null ? mnemonic : undefined, type: keyTypeForCryptoType(cryptoType) }, + ) + } + + static createFromMnemonic( + mnemonic: string, + cryptoType = CRYPTO_SR25519, + password?: string | null, + ): Keypair { + return Keypair.fromMnemonic(mnemonic, cryptoType, password) + } + + static fromSeed(seed: ByteLike, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.wrap( + nativeCall(() => native.keypairFromSeed(toBuffer(seed, 'seed'), cryptoType)), + undefined, + { type: keyTypeForCryptoType(cryptoType) }, + ) + } + + static createFromSeed(seed: ByteLike, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.fromSeed(seed, cryptoType) + } + + static fromUri(uri: string, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.wrap( + nativeCall(() => native.keypairFromUri(uri, cryptoType)), + uri, + { suri: uri, type: keyTypeForCryptoType(cryptoType) }, + ) + } + + static createFromUri(uri: string, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.fromUri(uri, cryptoType) + } + + static fromPrivateKey(privateKey: string, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.wrap( + nativeCall(() => native.keypairFromPrivateKey(privateKey, cryptoType)), + undefined, + { type: keyTypeForCryptoType(cryptoType) }, + ) + } + + static createFromPrivateKey(privateKey: string, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.fromPrivateKey(privateKey, cryptoType) + } + + static fromEncryptedJson(jsonData: string, passphrase: string): Keypair { + return Keypair.wrap( + nativeCall(() => native.keypairFromEncryptedJson(jsonData, passphrase)), + ) + } + + static createFromEncryptedJson(jsonData: string, passphrase: string): Keypair { + return Keypair.fromEncryptedJson(jsonData, passphrase) + } + + static generateMnemonic(nWords = 12): string { + return nativeCall(() => native.generateMnemonic(nWords)) + } + + static generate( + cryptoType = CRYPTO_SR25519, + nWords = 12, + password?: string | null, + ): Keypair { + const mnemonic = Keypair.generateMnemonic(nWords) + return Keypair.fromMnemonic(mnemonic, cryptoType, password) + } + + static encryptFor( + ss58Address: string, + message: string | ByteLike, + cryptoType = CRYPTO_ED25519, + ): Buffer { + return nativeCall(() => native.encryptFor(ss58Address, coerceMessage(message), cryptoType)) + } + + static deserialize(keyfileData: ByteLike): Keypair { + return Keypair.wrap( + nativeCall(() => native.deserializeKeypair(toBuffer(keyfileData, 'keyfileData'))), + ) + } + + get cryptoType(): number { + return this.handle.cryptoType + } + + get publicKey(): Buffer { + return Buffer.from(this.handle.publicKey) + } + + get addressRaw(): Buffer { + return this.publicKey + } + + get privateKey(): Buffer | null { + const value = this.handle.privateKey + return value == null ? null : Buffer.from(value) + } + + get ss58Address(): string { + return this.handle.ss58Address + } + + /** Polkadot.js-compatible alias used by Moonwall and PAPI signer helpers. */ + get address(): string { + return this.ss58Address + } + + get type(): SubstrateKeyType { + return keyTypeForCryptoType(this.cryptoType) + } + + get scheme(): 'Ed25519' | 'Sr25519' { + return this.cryptoType === CRYPTO_ED25519 ? 'Ed25519' : 'Sr25519' + } + + get ss58Format(): number { + return this.handle.ss58Format + } + + get meta(): KeypairMetadata { + return this.metadata + } + + get isLocked(): boolean { + return false + } + + sign(message: string | ByteLike, options: KeypairSignOptions = {}): Buffer { + const signature = nativeCall(() => this.handle.sign(coerceMessage(message))) + if (!options.withType) return Buffer.from(signature) + return Buffer.concat([Buffer.from([this.cryptoType]), Buffer.from(signature)]) + } + + verify( + message: string | ByteLike, + signature: ByteLike, + signerPublic?: string | ByteLike, + ): boolean { + const suppliedSignature = toBuffer(signature, 'signature') + const hasType = suppliedSignature.length === 65 && suppliedSignature[0] <= CRYPTO_SR25519 + const cryptoType = hasType ? suppliedSignature[0] : this.cryptoType + const rawSignature = hasType ? suppliedSignature.subarray(1) : suppliedSignature + + if (signerPublic == null) { + return nativeCall(() => this.handle.verify(coerceMessage(message), rawSignature)) + } + if (typeof signerPublic === 'string' && !signerPublic.startsWith('0x')) { + return verifySignature(message, rawSignature, signerPublic, cryptoType) + } + const publicKey = + typeof signerPublic === 'string' + ? Buffer.from(signerPublic.slice(2), 'hex') + : toBuffer(signerPublic, 'signerPublic') + return new Keypair(undefined, publicKey, cryptoType, this.ss58Format).verify( + message, + rawSignature, + ) + } + + derive(suri: string, meta: KeypairMetadata = {}): Keypair { + if (this.sourceUri == null) { + throw new Error('derivation requires a keypair created from a mnemonic or secret URI') + } + const derived = Keypair.fromUri(`${this.sourceUri}${suri}`, this.cryptoType) + derived.setMeta(meta) + return derived + } + + setMeta(meta: KeypairMetadata): void { + this.metadata = { ...this.metadata, ...meta } + } + + encodePkcs8(_passphrase?: string): Uint8Array { + return unsupported('PKCS#8 encoding') + } + + decodePkcs8(_passphrase?: string, _encoded?: Uint8Array): void { + unsupported('PKCS#8 decoding') + } + + lock(): void { + unsupported('in-memory key locking') + } + + unlock(_passphrase?: string): void { + unsupported('in-memory key unlocking') + } + + toJson(_passphrase?: string): never { + return unsupported('Polkadot.js JSON export') + } + + vrfSign( + _message: string | Uint8Array, + _context?: string | Uint8Array, + _extra?: string | Uint8Array, + ): never { + return unsupported('VRF signing') + } + + vrfVerify( + _message: string | Uint8Array, + _vrfResult: Uint8Array, + _signerPublic: string | Uint8Array, + _context?: string | Uint8Array, + _extra?: string | Uint8Array, + ): never { + return unsupported('VRF verification') + } + + encrypt(message: string | ByteLike): Buffer { + return nativeCall(() => this.handle.encrypt(coerceMessage(message))) + } + + decrypt(ciphertext: ByteLike): Buffer { + return nativeCall(() => this.handle.decrypt(toBuffer(ciphertext, 'ciphertext'))) + } + + serialize(): Buffer { + return nativeCall(() => native.serializeKeypair(this.handle)) + } +} + +export function createKeyringPairFromUri( + uri: string, + type: SubstrateKeyType = 'sr25519', + meta: KeypairMetadata = {}, +): Keypair { + const pair = Keypair.fromUri(uri, cryptoTypeForKeyType(type)) + pair.setMeta(meta) + return pair +} + +export function createKeyringPairFromMnemonic( + mnemonic: string, + type: SubstrateKeyType = 'sr25519', + password?: string | null, + meta: KeypairMetadata = {}, +): Keypair { + const pair = Keypair.fromMnemonic(mnemonic, cryptoTypeForKeyType(type), password) + pair.setMeta(meta) + return pair +} + +export function generateKeyringPair( + type: SubstrateKeyType = 'sr25519', + nWords = 12, + meta: KeypairMetadata = {}, +): Keypair { + const pair = Keypair.generate(cryptoTypeForKeyType(type), nWords) + pair.setMeta(meta) + return pair +} + +export function generateMnemonic(nWords = 12): string { + return Keypair.generateMnemonic(nWords) +} + +export function verifySignature( + message: string | ByteLike, + signature: ByteLike, + ss58Address: string, + cryptoType = CRYPTO_SR25519, +): boolean { + return nativeCall(() => + native.verifySignature( + coerceMessage(message), + toBuffer(signature, 'signature'), + ss58Address, + cryptoType, + ), + ) +} + +export const verify = verifySignature + +export function publicKeyFromSs58(ss58Address: string): Buffer { + return nativeCall(() => native.publicKeyFromSs58(ss58Address)) +} + +export const ss58Decode = publicKeyFromSs58 +export const decodeSs58 = publicKeyFromSs58 + +export function ss58FromPublic( + publicKey: ByteLike, + ss58Format = DEFAULT_SS58_FORMAT, +): string { + return nativeCall(() => native.ss58FromPublic(toBuffer(publicKey, 'publicKey'), ss58Format)) +} + +export const ss58Encode = ss58FromPublic +export const encodeSs58 = ss58FromPublic + +export function encryptFor( + ss58Address: string, + message: string | ByteLike, + cryptoType = CRYPTO_ED25519, +): Buffer { + return Keypair.encryptFor(ss58Address, message, cryptoType) +} + +export function serializeKeypair(keypair: Keypair): Buffer { + return keypair.serialize() +} + +export const serializedKeypairToKeyfileData = serializeKeypair + +export function deserializeKeypair(keyfileData: ByteLike): Keypair { + return Keypair.deserialize(keyfileData) +} + +export const deserializeKeypairFromKeyfileData = deserializeKeypair + +export function encryptKeyfileData(keyfileData: ByteLike, password: string): Buffer { + return nativeCall(() => + native.encryptKeyfileData(toBuffer(keyfileData, 'keyfileData'), password), + ) +} + +export function decryptKeyfileData( + keyfileData: ByteLike, + password?: string | null, +): Buffer { + return nativeCall(() => + native.decryptKeyfileData(toBuffer(keyfileData, 'keyfileData'), password ?? undefined), + ) +} + +export function keyfileDataIsEncrypted(keyfileData: ByteLike): boolean { + return native.keyfileDataIsEncrypted(toBuffer(keyfileData, 'keyfileData')) +} + +export function keyfileDataIsEncryptedNacl(keyfileData: ByteLike): boolean { + return native.keyfileDataIsEncryptedNacl(toBuffer(keyfileData, 'keyfileData')) +} + +export function keyfileDataIsEncryptedAnsible(keyfileData: ByteLike): boolean { + return native.keyfileDataIsEncryptedAnsible(toBuffer(keyfileData, 'keyfileData')) +} + +export function keyfileDataIsEncryptedLegacy(keyfileData: ByteLike): boolean { + return native.keyfileDataIsEncryptedLegacy(toBuffer(keyfileData, 'keyfileData')) +} + +export function keyfileDataEncryptionMethod(keyfileData: ByteLike): string { + return native.keyfileDataEncryptionMethod(toBuffer(keyfileData, 'keyfileData')) +} + +export function getPasswordFromEnvironment(name: string): string | null { + return nativeCall(() => native.getPasswordFromEnvironment(name) ?? null) +} + +export function savePasswordToEnvironment(name: string, password: string): string { + return nativeCall(() => native.savePasswordToEnvironment(name, password)) +} diff --git a/sdk/typescript-sdk/src/ledger.ts b/sdk/typescript-sdk/src/ledger.ts new file mode 100644 index 0000000000..bdd5b1bb18 --- /dev/null +++ b/sdk/typescript-sdk/src/ledger.ts @@ -0,0 +1,51 @@ +import native, { type NativeLedgerHandle } from './native' +import { nativeCall } from './errors' +import { toBuffer } from './wire' +import type { ByteLike, LedgerAddress, LedgerVersion } from './types' + +export class LedgerDevice { + private readonly handle: NativeLedgerHandle + + constructor() { + this.handle = nativeCall(() => native.NativeLedgerDevice.open()) + } + + private static wrap(handle: NativeLedgerHandle): LedgerDevice { + const device = Object.create(LedgerDevice.prototype) as LedgerDevice + Object.defineProperty(device, 'handle', { value: handle }) + return device + } + + static open(): LedgerDevice { + return LedgerDevice.wrap(nativeCall(() => native.NativeLedgerDevice.open())) + } + + appVersion(): LedgerVersion { + return nativeCall(() => this.handle.appVersion()) + } + + address( + account = 0, + index = 0, + ss58Prefix = 42, + confirm = false, + ): LedgerAddress { + return nativeCall(() => this.handle.address(account, index, ss58Prefix, confirm)) + } + + sign( + account: number, + index: number, + payload: ByteLike, + proof: ByteLike, + ): Buffer { + return nativeCall(() => + this.handle.sign( + account, + index, + toBuffer(payload, 'payload'), + toBuffer(proof, 'proof'), + ), + ) + } +} diff --git a/sdk/typescript-sdk/src/native.ts b/sdk/typescript-sdk/src/native.ts new file mode 100644 index 0000000000..98f38367a7 --- /dev/null +++ b/sdk/typescript-sdk/src/native.ts @@ -0,0 +1,218 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export interface NativeKeypairHandle { + readonly cryptoType: number + readonly publicKey: Buffer + readonly privateKey?: Buffer | null + readonly ss58Address: string + readonly ss58Format: number + sign(message: Buffer): Buffer + verify(message: Buffer, signature: Buffer): boolean + encrypt(message: Buffer): Buffer + decrypt(ciphertext: Buffer): Buffer +} + +export interface NativeStorageEntry { + pallet: string + name: string + prefix: string + modifier: string + valueType: string + valueTypeId: number + paramTypes: string[] + paramTypeIds: number[] + paramHashers: string[] + defaultBytes: Buffer +} + +export interface NativeStorageChange { + key: string + value?: string | null +} + +export interface NativeMapPair { + key: unknown + value: unknown +} + +export interface NativeTxParams { + era: unknown + nonce: bigint + tip: bigint + tipAssetId?: bigint | null + genesisHash: Buffer + eraBlockHash: Buffer + metadataHash?: Buffer | null +} + +export interface NativeExtrinsicParams { + era: unknown + nonce: bigint + tip: bigint + tipAssetId?: bigint | null + metadataHashEnabled: boolean +} + +export interface NativeRuntimeHandle { + readonly specVersion: number + readonly transactionVersion: number + readonly ss58Format: number + readonly isV15: boolean + readonly extrinsicVersion: number + readonly outerEventType?: number | null + readonly metadataBytes: Buffer + decode(typeString: string, data: Buffer, strict: boolean): unknown + decodePartial(typeString: string, data: Buffer, offset: number, strict: boolean): { value: unknown; offset: number; remaining: number } + decodeTypeId(typeId: number, data: Buffer, strict: boolean): unknown + decodeTypeIdPartial(typeId: number, data: Buffer, offset: number, strict: boolean): { value: unknown; offset: number; remaining: number } + decodeBatch(typeStrings: string[], data: Buffer[]): unknown[] + encode(typeString: string, value: unknown): Buffer + encodeTypeId(typeId: number, value: unknown): Buffer + typeIdOf(name: string): number | null | undefined + typeNameOf(id: number): string | null | undefined + typeSpec(typeString: string): unknown + resolveType(id: number): unknown + registryJson(): string + registry(): unknown + pallet(name: string): unknown | null | undefined + palletAt(index: number): unknown | null | undefined + pallets(): unknown[] + extrinsicInfo(): unknown + runtimeApis(): unknown + runtimeSnapshot(): unknown + composeCall(pallet: string, fn: string, params: unknown): Buffer + decodeCall(data: Buffer): unknown + storageEntry(pallet: string, storageFunction: string): NativeStorageEntry + storagePrefix(pallet: string, storageFunction: string): Buffer + storageKey(pallet: string, storageFunction: string, params: unknown): Buffer + storageKeyBatch(pallet: string, storageFunction: string, paramsList: unknown): Buffer[] + decodeStorageKeyParams(pallet: string, storageFunction: string, key: Buffer, fixed: number): unknown + decodeMapPairs(pallet: string, storageFunction: string, rawKeys: Buffer[], rawValues: Buffer[], fixed: number): NativeMapPair[] + decodeMapChanges(pallet: string, storageFunction: string, changes: NativeStorageChange[], fixed: number): NativeMapPair[] + constant(pallet: string, name: string): { found: boolean; value: unknown } + constantInfo(pallet: string, name: string): unknown | null | undefined + moduleError(moduleIndex: number, errorIndex: number): { name: string; docs: string[] } + signedExtensionIdentifiers(): string[] + encodeEra(era: unknown): Buffer + signaturePayloadParts(params: NativeTxParams): { includedInExtrinsic: Buffer; includedInSignedData: Buffer } + signaturePayload(callData: Buffer, params: NativeTxParams): Buffer + encodeSignedExtrinsic(callData: Buffer, publicKey: Buffer, signature: Buffer, signatureVersion: number, params: NativeExtrinsicParams): { bytes: Buffer; hash: Buffer } + decodeExtrinsic(data: Buffer, strict: boolean): unknown + runtimeApiMap(): unknown + metadataIr(): unknown +} + +export interface NativeRuntimeConstructor { + fromMetadata(metadataBytes: Buffer, specVersion: number, transactionVersion: number, ss58Format: number): NativeRuntimeHandle +} + +export interface NativeEpochScheduleState { + lastEpochBlock: bigint + pendingEpochAt: bigint + subnetEpochIndex: bigint + tempo: number + blocksSinceLastStep: bigint + currentBlock: bigint +} + +export interface NativeLedgerHandle { + appVersion(): { major: number; minor: number; patch: number } + address(account: number, index: number, ss58Prefix: number, confirm: boolean): { publicKey: Buffer; ss58Address: string } + sign(account: number, index: number, payload: Buffer, proof: Buffer): Buffer +} + +export interface NativeLedgerConstructor { + open(): NativeLedgerHandle +} + +export interface NativeBinding { + NativeRuntime: NativeRuntimeConstructor + NativeLedgerDevice: NativeLedgerConstructor + + bindingVersion(): string + ledgerEnabled(): boolean + wireTag(): string + wireRoundtrip(value: unknown): unknown + valueToCorpusJson(value: unknown): unknown + u256LeToDecimal(raw: Buffer): string + + keypairNew(ss58Address: string | null | undefined, publicKey: Buffer | null | undefined, cryptoType: number, ss58Format: number): NativeKeypairHandle + keypairFromMnemonic(mnemonic: string, cryptoType: number, password?: string | null): NativeKeypairHandle + keypairFromSeed(seed: Buffer, cryptoType: number): NativeKeypairHandle + keypairFromUri(uri: string, cryptoType: number): NativeKeypairHandle + keypairFromPrivateKey(privateKey: string, cryptoType: number): NativeKeypairHandle + keypairFromEncryptedJson(jsonData: string, passphrase: string): NativeKeypairHandle + generateMnemonic(nWords: number): string + encryptFor(ss58Address: string, message: Buffer, cryptoType: number): Buffer + verifySignature(message: Buffer, signature: Buffer, ss58Address: string, cryptoType: number): boolean + publicKeyFromSs58(ss58Address: string): Buffer + ss58FromPublic(publicKey: Buffer, ss58Format: number): string + serializeKeypair(keypair: NativeKeypairHandle): Buffer + deserializeKeypair(keyfileData: Buffer): NativeKeypairHandle + encryptKeyfileData(keyfileData: Buffer, password: string): Buffer + decryptKeyfileData(keyfileData: Buffer, password?: string | null): Buffer + keyfileDataIsEncrypted(keyfileData: Buffer): boolean + keyfileDataIsEncryptedNacl(keyfileData: Buffer): boolean + keyfileDataIsEncryptedAnsible(keyfileData: Buffer): boolean + keyfileDataIsEncryptedLegacy(keyfileData: Buffer): boolean + keyfileDataEncryptionMethod(keyfileData: Buffer): string + getPasswordFromEnvironment(name: string): string | null | undefined + savePasswordToEnvironment(name: string, password: string): string + cryptoEd25519(): number + cryptoSr25519(): number + defaultSs58Format(): number + + eraBirth(period: bigint, current: bigint): bigint + multisigAccountId(signatories: Buffer[], threshold: number): { accountId: Buffer; sortedSignatories: Buffer[] } + multisigSs58(accountId: Buffer, ss58Format: number): string + encodeCompact(value: bigint): Buffer + decodeCompactU128(data: Buffer, strict: boolean): { value: bigint; offset: number; remaining: number } + decodeCompactLength(data: Buffer, strict: boolean): { value: bigint; offset: number; remaining: number } + hashStorageParam(hasher: string, data: Buffer): Buffer + concatHashLength(hasher: string): number + parallelDecodeThreshold(): number + + metadataDigest(metadataBytes: Buffer, info: Record): Buffer + generateExtrinsicProof(callData: Buffer, includedInExtrinsic: Buffer, includedInSignedData: Buffer, metadataBytes: Buffer, info: Record): Buffer + + mlkemSeal(publicKey: Buffer, plaintext: Buffer, includeKeyHash: boolean): Buffer + mlkemTwox128(data: Buffer): Buffer + mlkemNonceLength(): number + mlkemKdfId(): Buffer + + timelockEncryptAndCompress(data: Buffer, revealRound: bigint): Buffer + timelockDecryptAndDecompress(encryptedData: Buffer, signatureBytes: Buffer): Buffer + timelockGenerateCommitV2(uids: number[], values: number[], versionKey: bigint, state: NativeEpochScheduleState, subnetRevealPeriodEpochs: bigint, blockTime: number, hotkey: Buffer): { ciphertext: Buffer; revealRound: bigint } + timelockEncryptCommitment(data: string, blocksUntilReveal: bigint, blockTime: number): { ciphertext: Buffer; revealRound: bigint } + timelockEncryptNBlocks(data: Buffer, nBlocks: bigint, blockTime: number): { ciphertext: Buffer; revealRound: bigint } + timelockEncryptAtRound(data: Buffer, revealRound: bigint): { ciphertext: Buffer; revealRound: bigint } + timelockGetRoundInfo(round?: bigint | null): { round: bigint; signature: string } + timelockGetRevealRoundSignature(revealRound: bigint | null | undefined, noErrors: boolean): string | null | undefined + timelockDecrypt(encryptedData: Buffer, noErrors: boolean): Buffer | null | undefined + timelockDecryptWithSignature(encryptedData: Buffer, signatureHex: string): Buffer + epochShouldRun(state: NativeEpochScheduleState, block: bigint): boolean + epochCurrentPreRunCoinbase(state: NativeEpochScheduleState, block: bigint): bigint + epochSimulateRunCoinbase(state: NativeEpochScheduleState, block: bigint): NativeEpochScheduleState + epochAdvanceBlocks(state: NativeEpochScheduleState, start: bigint, end: bigint): NativeEpochScheduleState + epochPredictFirstRevealBlock(state: NativeEpochScheduleState, revealPeriodEpochs: bigint): bigint + encodeWeightsTlockPayload(value: Record): Buffer + decodeWeightsTlockPayload(data: Buffer): { hotkey: Buffer; uids: number[]; values: number[]; versionKey: bigint } + encodeTimelockUserData(value: Record): Buffer + decodeTimelockUserData(data: Buffer): { encryptedData: Buffer; revealRound: bigint } + timelockMaxTempo(): number + timelockMaxTempoU64(): bigint + timelockDrandPublicKey(): string + timelockGenesisTime(): bigint + timelockDrandPeriod(): bigint + timelockQuicknetChainHash(): string + timelockDrandEndpoints(): string[] + timelockSecurityBlockOffset(): bigint + timelockCommitInclusionBlockOffset(): bigint + timelockMaxSimulationBlocks(revealPeriodEpochs: bigint): bigint +} + +// The generated napi-rs loader sits one directory above dist/native.js. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const native = require('../native.cjs') as NativeBinding + +export default native diff --git a/sdk/typescript-sdk/src/runtime.ts b/sdk/typescript-sdk/src/runtime.ts new file mode 100644 index 0000000000..53f9fcbad8 --- /dev/null +++ b/sdk/typescript-sdk/src/runtime.ts @@ -0,0 +1,433 @@ +import native, { + type NativeExtrinsicParams, + type NativeRuntimeHandle, + type NativeStorageChange, + type NativeTxParams, +} from './native' +import { nativeCall } from './errors' +import { fromWire, toBigInt, toBuffer, toWire } from './wire' +import type { + ByteLike, + CompactDecode, + IntegerLike, + MapPair, + ModuleError, + MultisigAccount, + PartialDecode, + PayloadParts, + ScaleValue, + SignedExtrinsic, + SignedExtrinsicParams, + StorageChange, + StorageEntry, + TransactionParams, +} from './types' + +function nativeTxParams(params: TransactionParams): NativeTxParams { + return { + era: toWire(params.era), + nonce: toBigInt(params.nonce, 'nonce'), + tip: toBigInt(params.tip ?? 0, 'tip'), + tipAssetId: + params.tipAssetId == null ? undefined : toBigInt(params.tipAssetId, 'tipAssetId'), + genesisHash: toBuffer(params.genesisHash, 'genesisHash'), + eraBlockHash: toBuffer(params.eraBlockHash, 'eraBlockHash'), + metadataHash: + params.metadataHash == null ? undefined : toBuffer(params.metadataHash, 'metadataHash'), + } +} + +function nativeExtrinsicParams(params: SignedExtrinsicParams): NativeExtrinsicParams { + return { + era: toWire(params.era), + nonce: toBigInt(params.nonce, 'nonce'), + tip: toBigInt(params.tip ?? 0, 'tip'), + tipAssetId: + params.tipAssetId == null ? undefined : toBigInt(params.tipAssetId, 'tipAssetId'), + metadataHashEnabled: params.metadataHashEnabled ?? false, + } +} + +function storageEntry(value: import('./native').NativeStorageEntry): StorageEntry { + return { + ...value, + defaultBytes: Buffer.from(value.defaultBytes), + } +} + +export class Runtime { + private readonly handle: NativeRuntimeHandle + + constructor( + metadataBytes: ByteLike, + specVersion: number, + transactionVersion: number, + ss58Format = 42, + ) { + this.handle = nativeCall( + () => + native.NativeRuntime.fromMetadata( + toBuffer(metadataBytes, 'metadataBytes'), + specVersion, + transactionVersion, + ss58Format, + ), + ) + } + + get specVersion(): number { + return this.handle.specVersion + } + + get transactionVersion(): number { + return this.handle.transactionVersion + } + + get ss58Format(): number { + return this.handle.ss58Format + } + + get isV15(): boolean { + return this.handle.isV15 + } + + get extrinsicVersion(): number { + return this.handle.extrinsicVersion + } + + get outerEventType(): number | null { + return this.handle.outerEventType ?? null + } + + get metadataBytes(): Buffer { + return Buffer.from(this.handle.metadataBytes) + } + + decode( + typeString: string, + data: ByteLike, + strict = true, + ): T { + return nativeCall( + () => fromWire(this.handle.decode(typeString, toBuffer(data, 'data'), strict)) as T, + ) + } + + decodePartial( + typeString: string, + data: ByteLike, + offset = 0, + strict = true, + ): PartialDecode { + return nativeCall(() => { + const decoded = this.handle.decodePartial( + typeString, + toBuffer(data, 'data'), + offset, + strict, + ) + return { ...decoded, value: fromWire(decoded.value) as T } + }) + } + + decodeTypeId( + typeId: number, + data: ByteLike, + strict = true, + ): T { + return nativeCall( + () => fromWire(this.handle.decodeTypeId(typeId, toBuffer(data, 'data'), strict)) as T, + ) + } + + decodeTypeIdPartial( + typeId: number, + data: ByteLike, + offset = 0, + strict = true, + ): PartialDecode { + return nativeCall(() => { + const decoded = this.handle.decodeTypeIdPartial( + typeId, + toBuffer(data, 'data'), + offset, + strict, + ) + return { ...decoded, value: fromWire(decoded.value) as T } + }) + } + + decodeBatch( + typeStrings: string[], + data: ByteLike[], + ): T[] { + return nativeCall(() => + this.handle + .decodeBatch(typeStrings, data.map((value) => toBuffer(value, 'data'))) + .map((value) => fromWire(value) as T), + ) + } + + encode(typeString: string, value: ScaleValue): Buffer { + return nativeCall(() => this.handle.encode(typeString, toWire(value))) + } + + encodeTypeId(typeId: number, value: ScaleValue): Buffer { + return nativeCall(() => this.handle.encodeTypeId(typeId, toWire(value))) + } + + typeIdOf(name: string): number | null { + return this.handle.typeIdOf(name) ?? null + } + + typeNameOf(id: number): string | null { + return this.handle.typeNameOf(id) ?? null + } + + typeSpec(typeString: string): unknown { + return nativeCall(() => this.handle.typeSpec(typeString)) + } + + resolveType(id: number): unknown { + return nativeCall(() => this.handle.resolveType(id)) + } + + registryJson(): string { + return nativeCall(() => this.handle.registryJson()) + } + + registry(): unknown { + return nativeCall(() => this.handle.registry()) + } + + pallet(name: string): unknown | null { + return this.handle.pallet(name) ?? null + } + + palletAt(index: number): unknown | null { + return this.handle.palletAt(index) ?? null + } + + pallets(): unknown[] { + return this.handle.pallets() + } + + extrinsicInfo(): unknown { + return this.handle.extrinsicInfo() + } + + runtimeApis(): unknown { + return this.handle.runtimeApis() + } + + runtimeSnapshot(): unknown { + return this.handle.runtimeSnapshot() + } + + composeCall(pallet: string, fn: string, params: ScaleValue): Buffer { + return nativeCall(() => this.handle.composeCall(pallet, fn, toWire(params))) + } + + decodeCall(data: ByteLike): T { + return nativeCall( + () => fromWire(this.handle.decodeCall(toBuffer(data, 'data'))) as T, + ) + } + + storageEntry(pallet: string, storageFunction: string): StorageEntry { + return nativeCall(() => storageEntry(this.handle.storageEntry(pallet, storageFunction))) + } + + storagePrefix(pallet: string, storageFunction: string): Buffer { + return nativeCall(() => this.handle.storagePrefix(pallet, storageFunction)) + } + + storageKey( + pallet: string, + storageFunction: string, + params: ScaleValue[] = [], + ): Buffer { + return nativeCall(() => + this.handle.storageKey(pallet, storageFunction, toWire(params)), + ) + } + + storageKeyBatch( + pallet: string, + storageFunction: string, + paramsList: ScaleValue[][], + ): Buffer[] { + return nativeCall(() => + this.handle.storageKeyBatch(pallet, storageFunction, toWire(paramsList)), + ) + } + + decodeStorageKeyParams( + pallet: string, + storageFunction: string, + key: ByteLike, + fixed = 0, + ): T[] { + return nativeCall( + () => + fromWire( + this.handle.decodeStorageKeyParams( + pallet, + storageFunction, + toBuffer(key, 'key'), + fixed, + ), + ) as T[], + ) + } + + decodeMapPairs( + pallet: string, + storageFunction: string, + rawKeys: ByteLike[], + rawValues: ByteLike[], + fixed = 0, + ): MapPair[] { + return nativeCall(() => + this.handle + .decodeMapPairs( + pallet, + storageFunction, + rawKeys.map((value) => toBuffer(value, 'rawKey')), + rawValues.map((value) => toBuffer(value, 'rawValue')), + fixed, + ) + .map((pair) => ({ key: fromWire(pair.key) as K, value: fromWire(pair.value) as V })), + ) + } + + decodeMapChanges( + pallet: string, + storageFunction: string, + changes: StorageChange[], + fixed = 0, + ): MapPair[] { + const nativeChanges: NativeStorageChange[] = changes.map((change) => ({ + key: change.key, + value: change.value ?? undefined, + })) + return nativeCall(() => + this.handle + .decodeMapChanges(pallet, storageFunction, nativeChanges, fixed) + .map((pair) => ({ key: fromWire(pair.key) as K, value: fromWire(pair.value) as V })), + ) + } + + constant(pallet: string, name: string): T | undefined { + return nativeCall(() => { + const value = this.handle.constant(pallet, name) + return value.found ? (fromWire(value.value) as T) : undefined + }) + } + + constantInfo(pallet: string, name: string): unknown | null { + return this.handle.constantInfo(pallet, name) ?? null + } + + moduleError(moduleIndex: number, errorIndex: number): ModuleError { + return nativeCall(() => this.handle.moduleError(moduleIndex, errorIndex)) + } + + signedExtensionIdentifiers(): string[] { + return this.handle.signedExtensionIdentifiers() + } + + encodeEra(era: ScaleValue): Buffer { + return nativeCall(() => this.handle.encodeEra(toWire(era))) + } + + signaturePayloadParts(params: TransactionParams): PayloadParts { + return nativeCall(() => this.handle.signaturePayloadParts(nativeTxParams(params))) + } + + signaturePayload(callData: ByteLike, params: TransactionParams): Buffer { + return nativeCall(() => + this.handle.signaturePayload(toBuffer(callData, 'callData'), nativeTxParams(params)), + ) + } + + encodeSignedExtrinsic( + callData: ByteLike, + publicKey: ByteLike, + signature: ByteLike, + signatureVersion: number, + params: SignedExtrinsicParams, + ): SignedExtrinsic { + return nativeCall(() => + this.handle.encodeSignedExtrinsic( + toBuffer(callData, 'callData'), + toBuffer(publicKey, 'publicKey'), + toBuffer(signature, 'signature'), + signatureVersion, + nativeExtrinsicParams(params), + ), + ) + } + + decodeExtrinsic( + data: ByteLike, + strict = true, + ): T { + return nativeCall( + () => fromWire(this.handle.decodeExtrinsic(toBuffer(data, 'data'), strict)) as T, + ) + } + + runtimeApiMap(): unknown { + return this.handle.runtimeApiMap() + } + + metadataIr(): unknown { + return nativeCall(() => this.handle.metadataIr()) + } +} + +export function eraBirth(period: IntegerLike, current: IntegerLike): bigint { + return nativeCall(() => native.eraBirth(toBigInt(period, 'period'), toBigInt(current, 'current'))) +} + +export function multisigAccountId( + signatories: ByteLike[], + threshold: number, +): MultisigAccount { + return nativeCall(() => + native.multisigAccountId( + signatories.map((value) => toBuffer(value, 'signatory')), + threshold, + ), + ) +} + +export function multisigSs58( + accountId: ByteLike, + ss58Format = 42, +): string { + return nativeCall(() => native.multisigSs58(toBuffer(accountId, 'accountId'), ss58Format)) +} + +export function encodeCompact(value: IntegerLike): Buffer { + return nativeCall(() => native.encodeCompact(toBigInt(value))) +} + +export function decodeCompactU128(data: ByteLike, strict = true): CompactDecode { + return nativeCall(() => native.decodeCompactU128(toBuffer(data, 'data'), strict)) +} + +export function decodeCompactLength(data: ByteLike, strict = true): CompactDecode { + return nativeCall(() => native.decodeCompactLength(toBuffer(data, 'data'), strict)) +} + +export function hashStorageParam(hasher: string, data: ByteLike): Buffer { + return nativeCall(() => native.hashStorageParam(hasher, toBuffer(data, 'data'))) +} + +export function concatHashLength(hasher: string): number { + return nativeCall(() => native.concatHashLength(hasher)) +} + +export const PARALLEL_DECODE_THRESHOLD = native.parallelDecodeThreshold() diff --git a/sdk/typescript-sdk/src/timelock.ts b/sdk/typescript-sdk/src/timelock.ts new file mode 100644 index 0000000000..13750d0ef1 --- /dev/null +++ b/sdk/typescript-sdk/src/timelock.ts @@ -0,0 +1,266 @@ +import native, { type NativeEpochScheduleState } from './native' +import { nativeCall } from './errors' +import { toBigInt, toBuffer } from './wire' +import type { + ByteLike, + CiphertextRound, + DrandResponse, + EpochScheduleState, + IntegerLike, + TimelockUserData, + WeightsTlockPayload, +} from './types' + +function nativeState(state: EpochScheduleState): NativeEpochScheduleState { + return { + lastEpochBlock: toBigInt(state.lastEpochBlock, 'lastEpochBlock'), + pendingEpochAt: toBigInt(state.pendingEpochAt, 'pendingEpochAt'), + subnetEpochIndex: toBigInt(state.subnetEpochIndex, 'subnetEpochIndex'), + tempo: state.tempo, + blocksSinceLastStep: toBigInt(state.blocksSinceLastStep, 'blocksSinceLastStep'), + currentBlock: toBigInt(state.currentBlock, 'currentBlock'), + } +} + +function publicState(state: NativeEpochScheduleState): EpochScheduleState { + return { + lastEpochBlock: state.lastEpochBlock, + pendingEpochAt: state.pendingEpochAt, + subnetEpochIndex: state.subnetEpochIndex, + tempo: state.tempo, + blocksSinceLastStep: state.blocksSinceLastStep, + currentBlock: state.currentBlock, + } +} + +export const MAX_TEMPO = native.timelockMaxTempo() +export const MAX_TEMPO_U64 = native.timelockMaxTempoU64() +export const DRAND_PUBLIC_KEY = native.timelockDrandPublicKey() +export const DRAND_GENESIS_TIME = native.timelockGenesisTime() +export const DRAND_PERIOD = native.timelockDrandPeriod() +export const QUICKNET_CHAIN_HASH = native.timelockQuicknetChainHash() +export const DRAND_ENDPOINTS = Object.freeze(native.timelockDrandEndpoints()) +export const SECURITY_BLOCK_OFFSET = native.timelockSecurityBlockOffset() +export const COMMIT_INCLUSION_BLOCK_OFFSET = native.timelockCommitInclusionBlockOffset() + +export function maxSimulationBlocks(revealPeriodEpochs: IntegerLike): bigint { + return nativeCall(() => + native.timelockMaxSimulationBlocks( + toBigInt(revealPeriodEpochs, 'revealPeriodEpochs'), + ), + ) +} + +export function encryptAndCompress(data: ByteLike, revealRound: IntegerLike): Buffer { + return nativeCall(() => + native.timelockEncryptAndCompress( + toBuffer(data, 'data'), + toBigInt(revealRound, 'revealRound'), + ), + ) +} + +export function decryptAndDecompress( + encryptedData: ByteLike, + signatureBytes: ByteLike, +): Buffer { + return nativeCall(() => + native.timelockDecryptAndDecompress( + toBuffer(encryptedData, 'encryptedData'), + toBuffer(signatureBytes, 'signatureBytes'), + ), + ) +} + +export function generateCommitV2( + uids: number[], + values: number[], + versionKey: IntegerLike, + state: EpochScheduleState, + subnetRevealPeriodEpochs: IntegerLike, + blockTime: number, + hotkey: ByteLike, +): CiphertextRound { + return nativeCall(() => + native.timelockGenerateCommitV2( + uids, + values, + toBigInt(versionKey, 'versionKey'), + nativeState(state), + toBigInt(subnetRevealPeriodEpochs, 'subnetRevealPeriodEpochs'), + blockTime, + toBuffer(hotkey, 'hotkey'), + ), + ) +} + +export function encryptCommitment( + data: string, + blocksUntilReveal: IntegerLike, + blockTime: number, +): CiphertextRound { + return nativeCall(() => + native.timelockEncryptCommitment( + data, + toBigInt(blocksUntilReveal, 'blocksUntilReveal'), + blockTime, + ), + ) +} + +export function encryptNBlocks( + data: ByteLike, + nBlocks: IntegerLike, + blockTime: number, +): CiphertextRound { + return nativeCall(() => + native.timelockEncryptNBlocks( + toBuffer(data, 'data'), + toBigInt(nBlocks, 'nBlocks'), + blockTime, + ), + ) +} + +export function encryptAtRound(data: ByteLike, revealRound: IntegerLike): CiphertextRound { + return nativeCall(() => + native.timelockEncryptAtRound( + toBuffer(data, 'data'), + toBigInt(revealRound, 'revealRound'), + ), + ) +} + +export function getRoundInfo(round?: IntegerLike | null): DrandResponse { + return nativeCall(() => + native.timelockGetRoundInfo(round == null ? undefined : toBigInt(round, 'round')), + ) +} + +export function getRevealRoundSignature( + revealRound?: IntegerLike | null, + noErrors = true, +): string | null { + return nativeCall( + () => + native.timelockGetRevealRoundSignature( + revealRound == null ? undefined : toBigInt(revealRound, 'revealRound'), + noErrors, + ) ?? null, + ) +} + +export function decrypt(encryptedData: ByteLike, noErrors = true): Buffer | null { + return nativeCall( + () => native.timelockDecrypt(toBuffer(encryptedData, 'encryptedData'), noErrors) ?? null, + ) +} + +export function decryptWithSignature( + encryptedData: ByteLike, + signatureHex: string, +): Buffer { + return nativeCall(() => + native.timelockDecryptWithSignature( + toBuffer(encryptedData, 'encryptedData'), + signatureHex, + ), + ) +} + +export function shouldRunEpoch(state: EpochScheduleState, block: IntegerLike): boolean { + return native.epochShouldRun(nativeState(state), toBigInt(block, 'block')) +} + +export function currentEpochPreRunCoinbase( + state: EpochScheduleState, + block: IntegerLike, +): bigint { + return native.epochCurrentPreRunCoinbase(nativeState(state), toBigInt(block, 'block')) +} + +export function simulateRunCoinbase( + state: EpochScheduleState, + block: IntegerLike, +): EpochScheduleState { + return publicState( + native.epochSimulateRunCoinbase(nativeState(state), toBigInt(block, 'block')), + ) +} + +export function advanceBlocks( + state: EpochScheduleState, + start: IntegerLike, + end: IntegerLike, +): EpochScheduleState { + return publicState( + native.epochAdvanceBlocks( + nativeState(state), + toBigInt(start, 'start'), + toBigInt(end, 'end'), + ), + ) +} + +export function predictFirstRevealBlock( + state: EpochScheduleState, + revealPeriodEpochs: IntegerLike, +): bigint { + return nativeCall(() => + native.epochPredictFirstRevealBlock( + nativeState(state), + toBigInt(revealPeriodEpochs, 'revealPeriodEpochs'), + ), + ) +} + +export function encodeWeightsTlockPayload(payload: WeightsTlockPayload): Buffer { + return nativeCall(() => + native.encodeWeightsTlockPayload({ + hotkey: toBuffer(payload.hotkey, 'hotkey'), + uids: payload.uids, + values: payload.values, + versionKey: toBigInt(payload.versionKey, 'versionKey'), + }), + ) +} + +export function decodeWeightsTlockPayload(data: ByteLike): WeightsTlockPayload { + return nativeCall(() => native.decodeWeightsTlockPayload(toBuffer(data, 'data'))) +} + +export function encodeTimelockUserData(value: TimelockUserData): Buffer { + return nativeCall(() => + native.encodeTimelockUserData({ + encryptedData: toBuffer(value.encryptedData, 'encryptedData'), + revealRound: toBigInt(value.revealRound, 'revealRound'), + }), + ) +} + +export function decodeTimelockUserData(data: ByteLike): TimelockUserData { + return nativeCall(() => native.decodeTimelockUserData(toBuffer(data, 'data'))) +} + +export const timelock = Object.freeze({ + encryptAndCompress, + decryptAndDecompress, + generateCommitV2, + encryptCommitment, + encryptNBlocks, + encryptAtRound, + getRoundInfo, + getRevealRoundSignature, + decrypt, + decryptWithSignature, + shouldRunEpoch, + currentEpochPreRunCoinbase, + simulateRunCoinbase, + advanceBlocks, + predictFirstRevealBlock, + encodeWeightsTlockPayload, + decodeWeightsTlockPayload, + encodeTimelockUserData, + decodeTimelockUserData, + maxSimulationBlocks, +}) diff --git a/sdk/typescript-sdk/src/types.ts b/sdk/typescript-sdk/src/types.ts new file mode 100644 index 0000000000..37669c95c1 --- /dev/null +++ b/sdk/typescript-sdk/src/types.ts @@ -0,0 +1,136 @@ +export type ByteLike = Buffer | Uint8Array +export type IntegerLike = number | bigint + +export type ScaleValue = + | null + | boolean + | number + | bigint + | string + | ByteLike + | ScaleValue[] + | { [key: string]: ScaleValue } + | Map + +export interface PartialDecode { + value: T + offset: number + remaining: number +} + +export interface CompactDecode { + value: bigint + offset: number + remaining: number +} + +export interface StorageEntry { + pallet: string + name: string + prefix: string + modifier: string + valueType: string + valueTypeId: number + paramTypes: string[] + paramTypeIds: number[] + paramHashers: string[] + defaultBytes: Buffer +} + +export interface StorageChange { + key: string + value?: string | null +} + +export interface MapPair { + key: K + value: V +} + +export interface ModuleError { + name: string + docs: string[] +} + +export interface TransactionParams { + era: ScaleValue + nonce: IntegerLike + tip?: IntegerLike + tipAssetId?: IntegerLike | null + genesisHash: ByteLike + eraBlockHash: ByteLike + metadataHash?: ByteLike | null +} + +export interface SignedExtrinsicParams { + era: ScaleValue + nonce: IntegerLike + tip?: IntegerLike + tipAssetId?: IntegerLike | null + metadataHashEnabled?: boolean +} + +export interface PayloadParts { + includedInExtrinsic: Buffer + includedInSignedData: Buffer +} + +export interface SignedExtrinsic { + bytes: Buffer + hash: Buffer +} + +export interface MultisigAccount { + accountId: Buffer + sortedSignatories: Buffer[] +} + +export interface ChainInfo { + specVersion: number + specName: string + base58Prefix: number + decimals: number + tokenSymbol: string +} + +export interface CiphertextRound { + ciphertext: Buffer + revealRound: bigint +} + +export interface DrandResponse { + round: bigint + signature: string +} + +export interface EpochScheduleState { + lastEpochBlock: IntegerLike + pendingEpochAt: IntegerLike + subnetEpochIndex: IntegerLike + tempo: number + blocksSinceLastStep: IntegerLike + currentBlock: IntegerLike +} + +export interface WeightsTlockPayload { + hotkey: ByteLike + uids: number[] + values: number[] + versionKey: IntegerLike +} + +export interface TimelockUserData { + encryptedData: ByteLike + revealRound: IntegerLike +} + +export interface LedgerVersion { + major: number + minor: number + patch: number +} + +export interface LedgerAddress { + publicKey: Buffer + ss58Address: string +} diff --git a/sdk/typescript-sdk/src/wire.ts b/sdk/typescript-sdk/src/wire.ts new file mode 100644 index 0000000000..d030510987 --- /dev/null +++ b/sdk/typescript-sdk/src/wire.ts @@ -0,0 +1,163 @@ +import { Buffer } from 'node:buffer' + +import type { ByteLike, IntegerLike, ScaleValue } from './types' + +export const WIRE_TAG = '__bittensor_core_wire__' as const +const BIGINT_TAG = 'bigint' +const BYTES_TAG = 'bytes' +const DICT_TAG = 'dict' +const MAX_DEPTH = 256 + +type WireValue = + | null + | boolean + | number + | string + | WireValue[] + | { [key: string]: WireValue } + +function isByteLike(value: unknown): value is ByteLike { + return Buffer.isBuffer(value) || value instanceof Uint8Array +} + +function isPlainObject(value: object): value is Record { + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +export function toBuffer(value: ByteLike, name = 'value'): Buffer { + if (!isByteLike(value)) { + throw new TypeError(`${name} must be a Buffer or Uint8Array`) + } + return Buffer.from(value.buffer, value.byteOffset, value.byteLength) +} + +export function toBigInt(value: IntegerLike, name = 'value'): bigint { + if (typeof value === 'bigint') return value + if (!Number.isSafeInteger(value)) { + throw new RangeError(`${name} must be a safe integer or bigint`) + } + return BigInt(value) +} + +export function coerceMessage(value: string | ByteLike, name = 'message'): Buffer { + if (typeof value !== 'string') return toBuffer(value, name) + if (!value.startsWith('0x')) return Buffer.from(value, 'utf8') + const hex = value.slice(2) + if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) { + throw new TypeError(`${name} contains invalid 0x-prefixed hex`) + } + return Buffer.from(hex, 'hex') +} + +export function toWire(value: ScaleValue): WireValue { + return toWireAt(value, 0) +} + +function toWireAt(value: ScaleValue, depth: number): WireValue { + if (depth > MAX_DEPTH) throw new RangeError('value nesting exceeds 256 levels') + if (value === null || typeof value === 'boolean' || typeof value === 'string') { + return value + } + if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) { + throw new RangeError('SCALE number values must be safe integers; use bigint otherwise') + } + // napi-rs' serde bridge represents JS numbers outside i32/u32 as f64. + // Tag those integers so Rust receives their exact decimal representation. + if (value < -0x8000_0000 || value > 0xffff_ffff) { + return { [WIRE_TAG]: BIGINT_TAG, value: value.toString(10) } + } + return value + } + if (typeof value === 'bigint') { + return { [WIRE_TAG]: BIGINT_TAG, value: value.toString(10) } + } + if (isByteLike(value)) { + return { [WIRE_TAG]: BYTES_TAG, hex: toBuffer(value).toString('hex') } + } + if (Array.isArray(value)) { + return value.map((item) => toWireAt(item, depth + 1)) + } + if (value instanceof Map) { + return { + [WIRE_TAG]: DICT_TAG, + entries: [...value.entries()].map(([key, item]) => [ + toWireAt(key, depth + 1), + toWireAt(item, depth + 1), + ]), + } + } + if (!isPlainObject(value)) { + throw new TypeError('SCALE values must use plain objects, arrays, Map, bigint, or bytes') + } + + const entries = Object.entries(value) + if (Object.prototype.hasOwnProperty.call(value, WIRE_TAG)) { + return { + [WIRE_TAG]: DICT_TAG, + entries: entries.map(([key, item]) => [key, toWireAt(item as ScaleValue, depth + 1)]), + } + } + + const output: Record = {} + for (const [key, item] of entries) { + if (item === undefined) { + throw new TypeError(`SCALE object field ${JSON.stringify(key)} is undefined`) + } + output[key] = toWireAt(item as ScaleValue, depth + 1) + } + return output +} + +export function fromWire(value: unknown): ScaleValue { + return fromWireAt(value, 0) +} + +function fromWireAt(value: unknown, depth: number): ScaleValue { + if (depth > MAX_DEPTH) throw new RangeError('decoded value nesting exceeds 256 levels') + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'bigint' + ) { + return value + } + if (isByteLike(value)) return Buffer.from(value) + if (Array.isArray(value)) return value.map((item) => fromWireAt(item, depth + 1)) + if (typeof value !== 'object') { + throw new TypeError(`unexpected native SCALE value type: ${typeof value}`) + } + + const object = value as Record + const tag = object[WIRE_TAG] + if (tag === BIGINT_TAG) { + if (typeof object.value !== 'string') throw new TypeError('invalid native bigint wire value') + return BigInt(object.value) + } + if (tag === BYTES_TAG) { + if (typeof object.hex !== 'string' || !/^[0-9a-fA-F]*$/.test(object.hex)) { + throw new TypeError('invalid native bytes wire value') + } + return Buffer.from(object.hex, 'hex') + } + if (tag === DICT_TAG) { + if (!Array.isArray(object.entries)) throw new TypeError('invalid native dict wire value') + const output = new Map() + for (const entry of object.entries) { + if (!Array.isArray(entry) || entry.length !== 2) { + throw new TypeError('invalid native dict entry') + } + output.set(fromWireAt(entry[0], depth + 1), fromWireAt(entry[1], depth + 1)) + } + return output + } + + const output: Record = {} + for (const [key, item] of Object.entries(object)) { + output[key] = fromWireAt(item, depth + 1) + } + return output +} diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs new file mode 100644 index 0000000000..fa8a6121c4 --- /dev/null +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -0,0 +1,96 @@ +'use strict' + +const assert = require('node:assert/strict') +const test = require('node:test') + +const core = require('../dist/index.js') + +test('sr25519 keypair forwards signing and SS58 work to Rust', () => { + const alice = core.Keypair.fromUri('//Alice') + assert.equal( + alice.ss58Address, + '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + ) + const message = Buffer.from('hello') + const signature = alice.sign(message) + assert.equal(signature.length, 64) + assert.equal(alice.verify(message, signature), true) + assert.deepEqual(core.publicKeyFromSs58(alice.ss58Address), alice.publicKey) +}) + +test('Rust keypair is compatible with Polkadot.js and Moonwall signer expectations', () => { + const alice = core.createKeyringPairFromUri('//Alice') + assert.equal(alice.address, alice.ss58Address) + assert.equal(alice.type, 'sr25519') + assert.deepEqual(alice.addressRaw, alice.publicKey) + + const payload = Buffer.from('polkadot-compatible') + const raw = alice.sign(payload) + const typed = alice.sign(payload, { withType: true }) + assert.equal(raw.length, 64) + assert.equal(typed.length, 65) + assert.equal(typed[0], core.CRYPTO_SR25519) + assert.deepEqual(typed.subarray(1), raw) + assert.equal(alice.verify(payload, typed, alice.publicKey), true) +}) + +test('fallible Runtime construction uses the native factory', () => { + assert.throws( + () => new core.Runtime(Buffer.from([0, 1, 2, 3]), 1, 1), + (error) => error instanceof core.CodecError, + ) +}) + +test('compact codec is the Rust implementation', () => { + const values = [0n, 63n, 64n, 16383n, 16384n, 2n ** 64n, 2n ** 127n] + for (const value of values) { + const encoded = core.encodeCompact(value) + const decoded = core.decodeCompactU128(encoded) + assert.equal(decoded.value, value) + assert.equal(decoded.remaining, 0) + } +}) + +test('dynamic boundary preserves bigint, bytes, and non-string map keys', () => { + const input = new Map([ + [7n, Buffer.from([0, 1, 254, 255])], + ['nested', { value: 2n ** 200n, wideSafeNumber: Number.MAX_SAFE_INTEGER }], + ]) + const output = core.wireRoundtrip(input) + assert.ok(output instanceof Map) + assert.deepEqual(output.get(7n), Buffer.from([0, 1, 254, 255])) + assert.equal(output.get('nested').value, 2n ** 200n) + assert.equal(output.get('nested').wideSafeNumber, Number.MAX_SAFE_INTEGER) +}) + +test('public constants and low-level hashes are exposed', () => { + assert.equal(core.CRYPTO_ED25519, 0) + assert.equal(core.CRYPTO_SR25519, 1) + assert.equal(core.MLKEM_NONCE_LENGTH, 24) + assert.equal( + core.twox_128(Buffer.from('System')).toString('hex'), + '26aa394eea5630e07c48ae0c9558cef7', + ) + assert.equal(core.blake2_256(Buffer.from('System')).length, 32) + assert.ok(core.PARALLEL_DECODE_THRESHOLD > 0) +}) + +test('epoch schedule functions stay in Rust', () => { + const state = { + lastEpochBlock: 0n, + pendingEpochAt: 0n, + subnetEpochIndex: 0n, + tempo: 100, + blocksSinceLastStep: 0n, + currentBlock: 0n, + } + const advanced = core.advanceBlocks(state, 1n, 10n) + assert.equal(typeof advanced.currentBlock, 'bigint') + assert.equal(advanced.currentBlock, 10n) +}) + +test('ESM consumers receive the same named exports', async () => { + const esm = await import('../dist/index.mjs') + assert.equal(esm.BINDING_VERSION, core.BINDING_VERSION) + assert.equal(esm.Keypair, core.Keypair) +}) diff --git a/sdk/typescript-sdk/tsconfig.json b/sdk/typescript-sdk/tsconfig.json new file mode 100644 index 0000000000..1be4484930 --- /dev/null +++ b/sdk/typescript-sdk/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/ts-tests/package.json b/ts-tests/package.json index 64be03d159..d2e7302362 100644 --- a/ts-tests/package.json +++ b/ts-tests/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@inquirer/prompts": "7.3.1", - "@noble/ciphers": "^2.1.1", + "@bittensor/sdk": "link:../sdk/typescript-sdk", "@polkadot-api/ink-contracts": "^0.4.1", "@polkadot-api/merkleize-metadata": "^1.1.15", "@polkadot-api/sdk-ink": "^0.5.1", @@ -50,7 +50,6 @@ "@polkadot/util-crypto": "*", "@zombienet/orchestrator": "0.0.105", "ethereum-cryptography": "3.1.0", - "mlkem": "^2.7.0", "polkadot-api": "^1.22.0", "ps-node": "0.1.6" }, diff --git a/ts-tests/pnpm-lock.yaml b/ts-tests/pnpm-lock.yaml index 000cacf22c..4b1e86bc6e 100644 --- a/ts-tests/pnpm-lock.yaml +++ b/ts-tests/pnpm-lock.yaml @@ -23,9 +23,9 @@ importers: '@inquirer/prompts': specifier: 7.3.1 version: 7.3.1(@types/node@25.9.2) - '@noble/ciphers': - specifier: ^2.1.1 - version: 2.2.0 + '@bittensor/sdk': + specifier: link:../sdk/typescript-sdk + version: link:../sdk/typescript-sdk '@polkadot-api/ink-contracts': specifier: ^0.4.1 version: 0.4.6 @@ -62,9 +62,6 @@ importers: ethereum-cryptography: specifier: 3.1.0 version: 3.1.0 - mlkem: - specifier: ^2.7.0 - version: 2.7.0 polkadot-api: specifier: ^1.22.0 version: 1.23.3(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(yaml@2.8.3) diff --git a/ts-tests/suites/dev/sdk/test-typescript-sdk.ts b/ts-tests/suites/dev/sdk/test-typescript-sdk.ts new file mode 100644 index 0000000000..dd5eeb5ee3 --- /dev/null +++ b/ts-tests/suites/dev/sdk/test-typescript-sdk.ts @@ -0,0 +1,27 @@ +import { BINDING_VERSION, blake2_256 } from "@bittensor/sdk"; +import type { ApiPromise } from "@polkadot/api"; +import { describeSuite, expect } from "@moonwall/cli"; +import { keyringPairFromUri } from "../../../utils/account.ts"; + +describeSuite({ + id: "DEV_TYPESCRIPT_SDK_01", + title: "Rust-backed TypeScript SDK integration", + foundationMethods: "dev", + testCases: ({ it, context }) => { + it({ + id: "T01", + title: "signs and submits a transaction with the Rust keypair", + test: async () => { + const polkadotJs: ApiPromise = context.polkadotJs(); + const alice = keyringPairFromUri("//Alice"); + const remark = blake2_256(Buffer.from(`typescript-sdk:${BINDING_VERSION}`)); + const tx = polkadotJs.tx.system.remark(remark); + + await context.createBlock([await tx.signAsync(alice)]); + + const events = await polkadotJs.query.system.events(); + expect(events.some(({ event }) => event.method === "ExtrinsicSuccess")).to.be.true; + }, + }); + }, +}); diff --git a/ts-tests/utils/account.ts b/ts-tests/utils/account.ts index 38137aa708..6b73aea8e9 100644 --- a/ts-tests/utils/account.ts +++ b/ts-tests/utils/account.ts @@ -1,9 +1,12 @@ +import { + createKeyringPairFromMnemonic as createSdkKeyringPairFromMnemonic, + createKeyringPairFromUri as createSdkKeyringPairFromUri, + generateKeyringPair as generateSdkKeyringPair, +} from "@bittensor/sdk"; import type { KeyringPair } from "@moonwall/util"; -import type { PolkadotSigner, TypedApi } from "polkadot-api"; import type { subtensor } from "@polkadot-api/descriptors"; +import type { PolkadotSigner, TypedApi } from "polkadot-api"; import { getPolkadotSigner } from "polkadot-api/signer"; -import { mnemonicGenerate } from "@polkadot/util-crypto"; -import { Keyring } from "@polkadot/keyring"; export const getAccountNonce = async (api: TypedApi, address: string): Promise => { const account = await api.query.System.Account.getValue(address, { at: "best" }); @@ -11,10 +14,24 @@ export const getAccountNonce = async (api: TypedApi, address: }; export function getSignerFromKeypair(keypair: KeyringPair): PolkadotSigner { - return getPolkadotSigner(keypair.publicKey, "Sr25519", keypair.sign); + const scheme = keypair.type === "ed25519" ? "Ed25519" : "Sr25519"; + return getPolkadotSigner(keypair.publicKey, scheme, (payload) => keypair.sign(payload)); +} + +/** Create a Moonwall/Polkadot.js-compatible pair backed entirely by the Rust SDK. */ +export function keyringPairFromUri(uri: string, type: "sr25519" | "ed25519" = "sr25519"): KeyringPair { + return createSdkKeyringPairFromUri(uri, type); +} + +/** Create a compatible pair from a mnemonic without invoking JavaScript crypto. */ +export function keyringPairFromMnemonic( + mnemonic: string, + type: "sr25519" | "ed25519" = "sr25519" +): KeyringPair { + return createSdkKeyringPairFromMnemonic(mnemonic, type); } +/** Generate and derive an e2e account inside the Rust SDK. */ export function generateKeyringPair(type: "sr25519" | "ed25519" = "sr25519"): KeyringPair { - const keyring = new Keyring({ type }); - return keyring.addFromMnemonic(mnemonicGenerate()); + return generateSdkKeyringPair(type); } diff --git a/ts-tests/utils/address.ts b/ts-tests/utils/address.ts index eb4b1fe905..cb0dedbe15 100644 --- a/ts-tests/utils/address.ts +++ b/ts-tests/utils/address.ts @@ -1,5 +1,4 @@ -import { hexToU8a } from "@polkadot/util"; -import { blake2AsU8a, decodeAddress, encodeAddress } from "@polkadot/util-crypto"; +import { blake2_256, bytesToHex, hexToBytes, publicKeyFromSs58, ss58FromPublic } from "@bittensor/sdk"; import { Binary } from "polkadot-api"; import type { Address } from "viem"; @@ -10,35 +9,27 @@ export function toViemAddress(address: string): Address { return `0x${addressNoPrefix}`; } -export function convertH160ToPublicKey(ethAddress: string) { - const prefix = "evm:"; - const prefixBytes = new TextEncoder().encode(prefix); - const addressBytes = hexToU8a(ethAddress.startsWith("0x") ? ethAddress : `0x${ethAddress}`); - const combined = new Uint8Array(prefixBytes.length + addressBytes.length); - combined.set(prefixBytes); - combined.set(addressBytes, prefixBytes.length); - return blake2AsU8a(combined); +export function convertH160ToPublicKey(ethAddress: string): Uint8Array { + const addressBytes = hexToBytes(ethAddress, "ethAddress"); + return blake2_256(Buffer.concat([Buffer.from("evm:", "utf8"), addressBytes])); } export function convertH160ToSS58(ethAddress: string): string { - return encodeAddress(convertH160ToPublicKey(ethAddress), SS58_PREFIX); + return ss58FromPublic(convertH160ToPublicKey(ethAddress), SS58_PREFIX); } export function convertPublicKeyToSs58(publicKey: Uint8Array): string { - return encodeAddress(publicKey, SS58_PREFIX); + return ss58FromPublic(publicKey, SS58_PREFIX); } export function ss58ToEthAddress(ss58Address: string): string { - const publicKey = decodeAddress(ss58Address); - const ethereumAddressBytes = publicKey.slice(0, 20); - return `0x${Buffer.from(ethereumAddressBytes).toString("hex")}`; + return bytesToHex(publicKeyFromSs58(ss58Address).subarray(0, 20)); } export function ss58ToH160(ss58Address: string): Binary { - const publicKey = decodeAddress(ss58Address); - return new Binary(publicKey.slice(0, 20)); + return new Binary(publicKeyFromSs58(ss58Address).subarray(0, 20)); } export function ethAddressToH160(ethAddress: string): Binary { - return new Binary(hexToU8a(ethAddress.startsWith("0x") ? ethAddress : `0x${ethAddress}`)); + return new Binary(hexToBytes(ethAddress, "ethAddress")); } diff --git a/ts-tests/utils/admin_utils.ts b/ts-tests/utils/admin_utils.ts index e6ecb6f953..f7c792ccc3 100644 --- a/ts-tests/utils/admin_utils.ts +++ b/ts-tests/utils/admin_utils.ts @@ -1,11 +1,10 @@ import type { subtensor } from "@polkadot-api/descriptors"; -import { Keyring } from "@polkadot/keyring"; import type { TypedApi } from "polkadot-api"; +import { keyringPairFromUri } from "./account.ts"; import { waitForTransactionWithRetry } from "./transactions.js"; export async function sudoSetStakeThreshold(api: TypedApi, threshold: bigint): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const inner = api.tx.AdminUtils.sudo_set_stake_threshold({ min_stake: threshold }); const tx = api.tx.Sudo.sudo({ call: inner.decodedCall }); await waitForTransactionWithRetry(api, tx, alice, "sudo_set_stake_threshold"); @@ -15,8 +14,7 @@ export async function setTargetRegistrationsPerInterval( api: TypedApi, netuid: number ): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalTx = api.tx.AdminUtils.sudo_set_target_registrations_per_interval({ netuid, target_registrations_per_interval: 1000, diff --git a/ts-tests/utils/balance.ts b/ts-tests/utils/balance.ts index ce739e57c2..a7f886368e 100644 --- a/ts-tests/utils/balance.ts +++ b/ts-tests/utils/balance.ts @@ -1,6 +1,6 @@ import type { subtensor } from "@polkadot-api/descriptors"; -import { Keyring } from "@polkadot/keyring"; import type { TypedApi } from "polkadot-api"; +import { keyringPairFromUri } from "./account.ts"; import { waitForFinalizedBlocks, waitForTransactionWithRetry } from "./transactions.js"; export const TAO = BigInt(1000000000); // 10^9 RAO per TAO export const GWEI = BigInt(1000000000); @@ -30,8 +30,7 @@ export async function forceSetBalance( amount: bigint = tao(1e10) ): Promise { const { MultiAddress } = await import("@polkadot-api/descriptors"); - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.Balances.force_set_balance({ who: MultiAddress.Id(ss58Address), new_free: amount, diff --git a/ts-tests/utils/children.ts b/ts-tests/utils/children.ts index f779ed486c..206150cfde 100644 --- a/ts-tests/utils/children.ts +++ b/ts-tests/utils/children.ts @@ -1,8 +1,8 @@ +import { keyringPairFromUri } from "./account.ts"; import { waitForTransactionWithRetry } from "./transactions.js"; import type { TypedApi } from "polkadot-api"; import type { subtensor } from "@polkadot-api/descriptors"; import type { KeyringPair } from "@moonwall/util"; -import { Keyring } from "@polkadot/keyring"; export async function setAutoParentDelegationEnabled( api: TypedApi, @@ -24,8 +24,7 @@ export async function getChildren( } export async function sudoSetPendingChildKeyCooldown(api: TypedApi, cooldown: bigint): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const inner = api.tx.SubtensorModule.set_pending_childkey_cooldown({ cooldown }); const tx = api.tx.Sudo.sudo({ call: inner.decodedCall }); await waitForTransactionWithRetry(api, tx, alice, "sudo_set_pending_childkey_cooldown"); diff --git a/ts-tests/utils/coldkey_swap.ts b/ts-tests/utils/coldkey_swap.ts index 003c3c9eba..cefc033603 100644 --- a/ts-tests/utils/coldkey_swap.ts +++ b/ts-tests/utils/coldkey_swap.ts @@ -1,6 +1,6 @@ -import { Keyring } from "@polkadot/keyring"; -import { blake2AsHex } from "@polkadot/util-crypto"; +import { blake2_256, bytesToHex } from "@bittensor/sdk"; import type { KeyringPair } from "@moonwall/util"; +import { keyringPairFromUri } from "./account.ts"; import { waitForTransactionWithRetry } from "./transactions.js"; import type { TypedApi } from "polkadot-api"; import type { subtensor } from "@polkadot-api/descriptors"; @@ -11,19 +11,18 @@ export const REANNOUNCEMENT_DELAY = 20; /** Compute BLAKE2-256 hash of a keypair's public key as a FixedSizeBinary (used for announcements). */ export function coldkeyHashBinary(pair: KeyringPair): FixedSizeBinary<32> { - return FixedSizeBinary.fromHex(blake2AsHex(pair.publicKey, 256)); + return FixedSizeBinary.fromHex(bytesToHex(blake2_256(pair.publicKey))); } /** Compute BLAKE2-256 hash of a keypair's public key as hex string. */ export function coldkeyHash(pair: KeyringPair): string { - return blake2AsHex(pair.publicKey, 256); + return bytesToHex(blake2_256(pair.publicKey)); } // ── Sudo configuration ────────────────────────────────────────────────── export async function sudoSetAnnouncementDelay(api: TypedApi, delay: number): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.AdminUtils.sudo_set_coldkey_swap_announcement_delay({ duration: delay, }); @@ -32,8 +31,7 @@ export async function sudoSetAnnouncementDelay(api: TypedApi, } export async function sudoSetReannouncementDelay(api: TypedApi, delay: number): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.AdminUtils.sudo_set_coldkey_swap_reannouncement_delay({ duration: delay, }); @@ -79,8 +77,7 @@ export async function clearColdkeySwapAnnouncement( } export async function sudoResetColdkeySwap(api: TypedApi, coldkeyAddress: string): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.SubtensorModule.reset_coldkey_swap({ coldkey: coldkeyAddress, }); @@ -94,8 +91,7 @@ export async function sudoSwapColdkey( newAddress: string, swapCost = 0n ): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.SubtensorModule.swap_coldkey({ old_coldkey: oldAddress, new_coldkey: newAddress, diff --git a/ts-tests/utils/evm.ts b/ts-tests/utils/evm.ts index c4f35c1fba..689bbeb7c1 100644 --- a/ts-tests/utils/evm.ts +++ b/ts-tests/utils/evm.ts @@ -1,7 +1,7 @@ import { subtensor } from "@polkadot-api/descriptors"; -import { Keyring } from "@polkadot/keyring"; import { ethers } from "ethers"; import type { TypedApi } from "polkadot-api"; +import { keyringPairFromUri } from "./account.ts"; import { waitForTransactionWithRetry } from "./transactions.js"; export async function disableWhiteListCheck(api: TypedApi, disabled: boolean): Promise { @@ -10,7 +10,7 @@ export async function disableWhiteListCheck(api: TypedApi, dis return; } - const alice = new Keyring({ type: "sr25519" }).addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.EVM.disable_whitelist({ disabled }); const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); await waitForTransactionWithRetry(api, tx, alice, "disable_whitelist", 5); @@ -37,7 +37,7 @@ export async function forceSetChainID(api: TypedApi, chainId: return; } - const alice = new Keyring({ type: "sr25519" }).addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.AdminUtils.sudo_set_evm_chain_id({ chain_id: chainId }); const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); await waitForTransactionWithRetry(api, tx, alice, "sudo_set_evm_chain_id", 5); diff --git a/ts-tests/utils/index.ts b/ts-tests/utils/index.ts index 8cbfbf9ce4..7e4dd55d2f 100644 --- a/ts-tests/utils/index.ts +++ b/ts-tests/utils/index.ts @@ -1,3 +1,4 @@ +export { BINDING_VERSION as BITTENSOR_SDK_BINDING_VERSION } from "@bittensor/sdk"; export * from "./account.ts"; export * from "./address.ts"; export * from "./admin_utils.ts"; diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts index 0ffbe177e0..be4e58c614 100644 --- a/ts-tests/utils/limit-orders.ts +++ b/ts-tests/utils/limit-orders.ts @@ -1,9 +1,8 @@ import type { KeyringPair } from "@moonwall/util"; import type { TypedApi } from "polkadot-api"; import type { subtensor } from "@polkadot-api/descriptors"; -import { Keyring } from "@polkadot/keyring"; -import { u8aToHex } from "@polkadot/util"; -import { blake2AsHex } from "@polkadot/util-crypto"; +import { blake2_256, bytesToHex } from "@bittensor/sdk"; +import { keyringPairFromUri } from "./account.ts"; import { waitForTransactionWithRetry } from "./transactions.js"; import { MultiAddress } from "@polkadot-api/descriptors"; @@ -91,7 +90,7 @@ export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { return { order: versionedOrder, - signature: { Sr25519: u8aToHex(sig) as `0x${string}` }, + signature: { Sr25519: bytesToHex(sig) as `0x${string}` }, partial_fill: null, }; } @@ -102,7 +101,7 @@ export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { */ export function orderId(api: any, order: VersionedOrder): `0x${string}` { const encoded = api.registry.createType("LimitVersionedOrder", order); - return blake2AsHex(encoded.toU8a(), 256) as `0x${string}`; + return bytesToHex(blake2_256(encoded.toU8a())) as `0x${string}`; } // ── Registry ────────────────────────────────────────────────────────────────── @@ -163,8 +162,7 @@ export async function getAlphaPrice(api: TypedApi, netuid: num /** Enable the subtoken for a subnet (required for swaps to work). */ export async function enableSubtoken(api: TypedApi, netuid: number): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.AdminUtils.sudo_set_subtoken_enabled({ netuid, subtoken_enabled: true, @@ -175,8 +173,7 @@ export async function enableSubtoken(api: TypedApi, netuid: nu /** Sudo-enable or disable the limit-orders pallet. */ export async function setPalletStatus(api: TypedApi, enabled: boolean): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const tx = api.tx.Sudo.sudo({ call: api.tx.LimitOrders.set_pallet_status({ enabled }).decodedCall, }); @@ -257,8 +254,7 @@ export async function executeBatchedOrders( netuid: number, orders: SignedOrder[] ): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const tx = api.tx.LimitOrders.execute_batched_orders({ netuid, orders, diff --git a/ts-tests/utils/shield_helpers.ts b/ts-tests/utils/shield_helpers.ts index a437e6591c..60b42fa456 100644 --- a/ts-tests/utils/shield_helpers.ts +++ b/ts-tests/utils/shield_helpers.ts @@ -1,10 +1,6 @@ +import { hexToBytes, sealMevShieldTransaction } from "@bittensor/sdk"; import type { KeyringPair } from "@moonwall/util"; -import { xchacha20poly1305 } from "@noble/ciphers/chacha.js"; import type { subtensor } from "@polkadot-api/descriptors"; -import { hexToU8a } from "@polkadot/util"; -import { xxhashAsU8a } from "@polkadot/util-crypto"; -import { randomBytes } from "ethers"; -import { MlKem768 } from "mlkem"; import { type TypedApi, Binary } from "polkadot-api"; import { getSignerFromKeypair } from "./account.ts"; import { waitForFinalizedBlocks } from "./transactions.ts"; @@ -17,7 +13,7 @@ const keyToBytes = (key: unknown): Uint8Array => { return (key as Binary).asBytes(); } if (typeof key === "string") { - return hexToU8a(key); + return hexToBytes(key, "MEV shield key"); } throw new Error(`Unexpected MEV shield key type: ${typeof key}`); }; @@ -57,19 +53,7 @@ export const getCurrentKey = async (api: TypedApi): Promise => { - const keyHash = xxhashAsU8a(publicKey, 128); - - const mlKem = new MlKem768(); - const [kemCt, sharedSecret] = await mlKem.encap(publicKey); - - const nonce = randomBytes(24); - const chacha = xchacha20poly1305(sharedSecret, nonce); - const aeadCt = chacha.encrypt(plaintext); - - const kemLenBytes = new Uint8Array(2); - new DataView(kemLenBytes.buffer).setUint16(0, kemCt.length, true); - - return new Uint8Array([...keyHash, ...kemLenBytes, ...kemCt, ...nonce, ...aeadCt]); + return sealMevShieldTransaction(publicKey, plaintext); }; export const submitEncrypted = async ( diff --git a/ts-tests/utils/subnet.ts b/ts-tests/utils/subnet.ts index 5f9d9e8a33..7462dbdf64 100644 --- a/ts-tests/utils/subnet.ts +++ b/ts-tests/utils/subnet.ts @@ -1,8 +1,8 @@ import type { KeyringPair } from "@moonwall/util"; import type { subtensor } from "@polkadot-api/descriptors"; -import { Keyring } from "@polkadot/keyring"; import type { TypedApi } from "polkadot-api"; import { log } from "./logger.js"; +import { keyringPairFromUri } from "./account.ts"; import { waitForTransactionWithRetry } from "./transactions.js"; export async function addNewSubnetwork( @@ -10,8 +10,7 @@ export async function addNewSubnetwork( hotkey: KeyringPair, coldkey: KeyringPair ): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const totalNetworks = await api.query.SubtensorModule.TotalNetworks.getValue(); // Disable network rate limit for testing From dc041b100d71ccfb08d8d03828f676822ea5adb5 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Fri, 10 Jul 2026 15:30:02 -0700 Subject: [PATCH 02/72] fix mnemonics --- sdk/typescript-sdk/src/keys.ts | 39 ++++++++++++++++++-------- sdk/typescript-sdk/test/basic.test.cjs | 26 +++++++++++++++++ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/sdk/typescript-sdk/src/keys.ts b/sdk/typescript-sdk/src/keys.ts index 69f36b5868..f9ed5ab939 100644 --- a/sdk/typescript-sdk/src/keys.ts +++ b/sdk/typescript-sdk/src/keys.ts @@ -12,7 +12,6 @@ export type SubstrateKeyType = 'sr25519' | 'ed25519' export interface KeypairMetadata { address?: string name?: string - suri?: string type?: SubstrateKeyType [key: string]: unknown } @@ -66,6 +65,21 @@ function unsupported(operation: string): never { ) } +/** + * Keep derivation secrets and mutable metadata outside the JavaScript object + * shape. TypeScript's `private` keyword is compile-time only and would leave + * class fields enumerable at runtime. + */ +const metadataStore = new WeakMap() +const sourceUriStore = new WeakMap() + +function sanitizeMetadata(metadata: KeypairMetadata): KeypairMetadata { + const sanitized = { ...metadata } + // `suri` may contain a mnemonic, seed, password, and derivation path. + delete sanitized.suri + return sanitized +} + export function cryptoTypeForKeyType(type: SubstrateKeyType): number { return type === 'ed25519' ? CRYPTO_ED25519 : CRYPTO_SR25519 } @@ -78,8 +92,6 @@ export function keyTypeForCryptoType(cryptoType: number): SubstrateKeyType { export class Keypair implements PolkadotCompatibleKeypair { private handle!: NativeKeypairHandle - private metadata: KeypairMetadata = {} - private sourceUri?: string constructor( ss58Address?: string | null, @@ -95,6 +107,7 @@ export class Keypair implements PolkadotCompatibleKeypair { ss58Format, ), ) + metadataStore.set(this, {}) } private static wrap( @@ -104,8 +117,8 @@ export class Keypair implements PolkadotCompatibleKeypair { ): Keypair { const keypair = Object.create(Keypair.prototype) as Keypair keypair.handle = handle - keypair.sourceUri = sourceUri - keypair.metadata = { ...metadata } + metadataStore.set(keypair, sanitizeMetadata(metadata)) + if (sourceUri != null) sourceUriStore.set(keypair, sourceUri) return keypair } @@ -117,7 +130,7 @@ export class Keypair implements PolkadotCompatibleKeypair { return Keypair.wrap( nativeCall(() => native.keypairFromMnemonic(mnemonic, cryptoType, password ?? undefined)), password == null ? mnemonic : undefined, - { suri: password == null ? mnemonic : undefined, type: keyTypeForCryptoType(cryptoType) }, + { type: keyTypeForCryptoType(cryptoType) }, ) } @@ -145,7 +158,7 @@ export class Keypair implements PolkadotCompatibleKeypair { return Keypair.wrap( nativeCall(() => native.keypairFromUri(uri, cryptoType)), uri, - { suri: uri, type: keyTypeForCryptoType(cryptoType) }, + { type: keyTypeForCryptoType(cryptoType) }, ) } @@ -241,7 +254,7 @@ export class Keypair implements PolkadotCompatibleKeypair { } get meta(): KeypairMetadata { - return this.metadata + return { ...(metadataStore.get(this) ?? {}) } } get isLocked(): boolean { @@ -281,16 +294,20 @@ export class Keypair implements PolkadotCompatibleKeypair { } derive(suri: string, meta: KeypairMetadata = {}): Keypair { - if (this.sourceUri == null) { + const sourceUri = sourceUriStore.get(this) + if (sourceUri == null) { throw new Error('derivation requires a keypair created from a mnemonic or secret URI') } - const derived = Keypair.fromUri(`${this.sourceUri}${suri}`, this.cryptoType) + const derived = Keypair.fromUri(`${sourceUri}${suri}`, this.cryptoType) derived.setMeta(meta) return derived } setMeta(meta: KeypairMetadata): void { - this.metadata = { ...this.metadata, ...meta } + metadataStore.set( + this, + sanitizeMetadata({ ...(metadataStore.get(this) ?? {}), ...meta }), + ) } encodePkcs8(_passphrase?: string): Uint8Array { diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index fa8a6121c4..e13ebbefe6 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -34,6 +34,32 @@ test('Rust keypair is compatible with Polkadot.js and Moonwall signer expectatio assert.equal(alice.verify(payload, typed, alice.publicKey), true) }) +test('mnemonics and secret URIs never appear in public keypair metadata', () => { + const mnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + const fromMnemonic = core.Keypair.fromMnemonic(mnemonic) + + assert.equal(fromMnemonic.meta.suri, undefined) + assert.equal(Object.prototype.hasOwnProperty.call(fromMnemonic, 'sourceUri'), false) + assert.equal(Object.prototype.hasOwnProperty.call(fromMnemonic, 'metadata'), false) + + fromMnemonic.setMeta({ name: 'safe', suri: mnemonic }) + assert.equal(fromMnemonic.meta.name, 'safe') + assert.equal(fromMnemonic.meta.suri, undefined) + + const metadataView = fromMnemonic.meta + metadataView.suri = mnemonic + assert.equal(fromMnemonic.meta.suri, undefined) + + const secretUri = `${mnemonic}//review-secret` + const fromUri = core.Keypair.fromUri(secretUri) + assert.equal(fromUri.meta.suri, undefined) + assert.equal(Object.prototype.hasOwnProperty.call(fromUri, 'sourceUri'), false) + + const derived = fromUri.derive('//child') + assert.equal(derived.meta.suri, undefined) +}) + test('fallible Runtime construction uses the native factory', () => { assert.throws( () => new core.Runtime(Buffer.from([0, 1, 2, 3]), 1, 1), From d5bf752f2f624e5a1cc394b7f634991c6862c425 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Fri, 10 Jul 2026 16:06:26 -0700 Subject: [PATCH 03/72] fix formatting --- ts-tests/utils/account.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ts-tests/utils/account.ts b/ts-tests/utils/account.ts index 6b73aea8e9..a3f94c7fec 100644 --- a/ts-tests/utils/account.ts +++ b/ts-tests/utils/account.ts @@ -24,10 +24,7 @@ export function keyringPairFromUri(uri: string, type: "sr25519" | "ed25519" = "s } /** Create a compatible pair from a mnemonic without invoking JavaScript crypto. */ -export function keyringPairFromMnemonic( - mnemonic: string, - type: "sr25519" | "ed25519" = "sr25519" -): KeyringPair { +export function keyringPairFromMnemonic(mnemonic: string, type: "sr25519" | "ed25519" = "sr25519"): KeyringPair { return createSdkKeyringPairFromMnemonic(mnemonic, type); } From ebe31a7853be69c0cca7fe170782b3d19b917752 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 08:27:18 -0700 Subject: [PATCH 04/72] ts e2es use ts sdk --- sdk/typescript-sdk/native/src/errors.rs | 8 +- sdk/typescript-sdk/native/src/keys.rs | 42 +- sdk/typescript-sdk/native/src/lib.rs | 143 ++++- sdk/typescript-sdk/native/src/mlkem.rs | 16 +- sdk/typescript-sdk/native/src/runtime.rs | 601 ++++++++++++++++++++-- sdk/typescript-sdk/native/src/timelock.rs | 91 ++-- sdk/typescript-sdk/native/src/values.rs | 229 ++++++++- sdk/typescript-sdk/src/crypto.ts | 3 + sdk/typescript-sdk/src/index.ts | 2 + sdk/typescript-sdk/src/keys.ts | 5 + sdk/typescript-sdk/src/modules.ts | 126 +++++ sdk/typescript-sdk/src/native.ts | 263 +++++++++- sdk/typescript-sdk/src/runtime.ts | 389 +++++++++++++- sdk/typescript-sdk/src/timelock.ts | 26 + sdk/typescript-sdk/src/types.ts | 194 ++++++- sdk/typescript-sdk/src/value.ts | 170 ++++++ sdk/typescript-sdk/src/wire.ts | 4 +- sdk/typescript-sdk/test/basic.test.cjs | 123 +++++ 18 files changed, 2241 insertions(+), 194 deletions(-) create mode 100644 sdk/typescript-sdk/src/modules.ts create mode 100644 sdk/typescript-sdk/src/value.ts diff --git a/sdk/typescript-sdk/native/src/errors.rs b/sdk/typescript-sdk/native/src/errors.rs index ba45f8da37..afc3b39b31 100644 --- a/sdk/typescript-sdk/native/src/errors.rs +++ b/sdk/typescript-sdk/native/src/errors.rs @@ -6,12 +6,8 @@ pub type NapiResult = napi::Result; pub fn into_napi(error: CoreError) -> Error { let (code, status, message) = match error { CoreError::Keyfile(message) => ("KEYFILE", Status::GenericFailure, message), - CoreError::WrongPassword(message) => { - ("WRONG_PASSWORD", Status::GenericFailure, message) - } - CoreError::NotInRuntime(message) => { - ("NOT_IN_RUNTIME", Status::InvalidArg, message) - } + CoreError::WrongPassword(message) => ("WRONG_PASSWORD", Status::GenericFailure, message), + CoreError::NotInRuntime(message) => ("NOT_IN_RUNTIME", Status::InvalidArg, message), CoreError::Codec(message) => ("CODEC", Status::InvalidArg, message), CoreError::Crypto(message) => ("CRYPTO", Status::InvalidArg, message), CoreError::Device(message) => ("DEVICE", Status::GenericFailure, message), diff --git a/sdk/typescript-sdk/native/src/keys.rs b/sdk/typescript-sdk/native/src/keys.rs index d59a71a4ff..1b83dd32d1 100644 --- a/sdk/typescript-sdk/native/src/keys.rs +++ b/sdk/typescript-sdk/native/src/keys.rs @@ -1,7 +1,5 @@ use bittensor_core::keyfiles; -use bittensor_core::keys::{ - self, Keypair, CRYPTO_ED25519, CRYPTO_SR25519, DEFAULT_SS58_FORMAT, -}; +use bittensor_core::keys::{self, Keypair, CRYPTO_ED25519, CRYPTO_SR25519, DEFAULT_SS58_FORMAT}; use napi::bindgen_prelude::Buffer; use napi_derive::napi; @@ -25,6 +23,18 @@ impl NativeKeypair { self.inner.crypto_type() } + #[napi(getter)] + pub fn kind(&self) -> String { + if self.inner.private_key_bytes().is_none() { + return "PublicOnly".to_owned(); + } + match self.inner.crypto_type() { + CRYPTO_ED25519 => "Ed25519".to_owned(), + CRYPTO_SR25519 => "Sr25519".to_owned(), + _ => "PublicOnly".to_owned(), + } + } + #[napi(getter)] pub fn public_key(&self) -> Buffer { self.inner.public_key_bytes().to_vec().into() @@ -59,10 +69,7 @@ impl NativeKeypair { #[napi] pub fn encrypt(&self, message: Buffer) -> NapiResult { - self.inner - .encrypt(message.as_ref()) - .napi() - .map(Into::into) + self.inner.encrypt(message.as_ref()).napi().map(Into::into) } #[napi] @@ -117,10 +124,7 @@ pub fn keypair_from_uri(uri: String, crypto_type: u8) -> NapiResult NapiResult { +pub fn keypair_from_private_key(private_key: String, crypto_type: u8) -> NapiResult { Keypair::from_private_key(&private_key, crypto_type) .napi() .map(NativeKeypair::new) @@ -144,11 +148,7 @@ pub fn generate_mnemonic(n_words: u32) -> NapiResult { } #[napi(js_name = "encryptFor")] -pub fn encrypt_for( - ss58_address: String, - message: Buffer, - crypto_type: u8, -) -> NapiResult { +pub fn encrypt_for(ss58_address: String, message: Buffer, crypto_type: u8) -> NapiResult { Keypair::encrypt_for(&ss58_address, message.as_ref(), crypto_type) .napi() .map(Into::into) @@ -208,10 +208,7 @@ pub fn encrypt_keyfile_data(keyfile_data: Buffer, password: String) -> NapiResul } #[napi(js_name = "decryptKeyfileData")] -pub fn decrypt_keyfile_data( - keyfile_data: Buffer, - password: Option, -) -> NapiResult { +pub fn decrypt_keyfile_data(keyfile_data: Buffer, password: Option) -> NapiResult { keyfiles::decrypt_keyfile_data(keyfile_data.as_ref(), password.as_deref()) .napi() .map(Into::into) @@ -248,10 +245,7 @@ pub fn get_password_from_environment(env_var_name: String) -> NapiResult NapiResult { +pub fn save_password_to_environment(env_var_name: String, password: String) -> NapiResult { keyfiles::save_password_to_environment(&env_var_name, &password).napi() } diff --git a/sdk/typescript-sdk/native/src/lib.rs b/sdk/typescript-sdk/native/src/lib.rs index 6e630164b3..bec8a0028b 100644 --- a/sdk/typescript-sdk/native/src/lib.rs +++ b/sdk/typescript-sdk/native/src/lib.rs @@ -9,12 +9,25 @@ mod timelock; mod values; use bittensor_core::codec::value::{to_corpus_json, u256_decimal}; -use napi::bindgen_prelude::Buffer; +use bittensor_core::codec::Value; +use napi::bindgen_prelude::{BigInt, Buffer}; use napi_derive::napi; use serde_json::Value as JsonValue; use crate::errors::{invalid_arg, NapiResult}; -use crate::values::{from_wire, to_wire, WIRE_TAG}; +use crate::values::{from_descriptor, from_wire, to_descriptor, to_wire, WIRE_TAG}; + +#[napi(object)] +pub struct NativeCoreValueField { + pub name: String, + pub value: JsonValue, +} + +#[napi(object)] +pub struct NativeCoreValueEntry { + pub key: JsonValue, + pub value: JsonValue, +} #[napi(js_name = "bindingVersion")] pub fn binding_version() -> String { @@ -49,3 +62,129 @@ pub fn u256_le_to_decimal(raw: Buffer) -> NapiResult { .map_err(|_| invalid_arg("u256 value must be exactly 32 little-endian bytes"))?; Ok(u256_decimal(&bytes)) } + +#[napi(js_name = "coreValueDescriptorRoundtrip")] +pub fn core_value_descriptor_roundtrip(value: JsonValue) -> NapiResult { + to_descriptor(&from_descriptor(value)?) +} + +#[napi(js_name = "coreValueDescriptorToWire")] +pub fn core_value_descriptor_to_wire(value: JsonValue) -> NapiResult { + to_wire(&from_descriptor(value)?) +} + +#[napi(js_name = "wireToCoreValueDescriptor")] +pub fn wire_to_core_value_descriptor(value: JsonValue) -> NapiResult { + to_descriptor(&from_wire(value)?) +} + +#[napi(js_name = "coreValueDescriptorToCorpusJson")] +pub fn core_value_descriptor_to_corpus_json(value: JsonValue) -> NapiResult { + Ok(to_corpus_json(&from_descriptor(value)?)) +} + +#[napi(js_name = "coreValueDescriptorDisplay")] +pub fn core_value_descriptor_display(value: JsonValue) -> NapiResult { + Ok(from_descriptor(value)?.to_string()) +} + +#[napi(js_name = "coreValueNull")] +pub fn core_value_null() -> NapiResult { + to_descriptor(&Value::Null) +} + +#[napi(js_name = "coreValueBool")] +pub fn core_value_bool(value: bool) -> NapiResult { + to_descriptor(&Value::Bool(value)) +} + +#[napi(js_name = "coreValueInt")] +pub fn core_value_int(value: BigInt) -> NapiResult { + let (negative, magnitude, lossless) = value.get_u128(); + if !lossless { + return Err(invalid_arg("integer value must fit the Rust i128 range")); + } + let value = if negative { + if magnitude == i128::MIN.unsigned_abs() { + i128::MIN + } else { + i128::try_from(magnitude) + .ok() + .and_then(i128::checked_neg) + .ok_or_else(|| invalid_arg("integer value must fit the Rust i128 range"))? + } + } else { + i128::try_from(magnitude) + .map_err(|_| invalid_arg("integer value must fit the Rust i128 range"))? + }; + to_descriptor(&Value::Int(value)) +} + +#[napi(js_name = "coreValueUint")] +pub fn core_value_uint(value: BigInt) -> NapiResult { + let (negative, value, lossless) = value.get_u128(); + if negative || !lossless { + return Err(invalid_arg("unsigned value must fit the Rust u128 range")); + } + to_descriptor(&Value::Uint(value)) +} + +#[napi(js_name = "coreValueU256Le")] +pub fn core_value_u256_le(raw: Buffer) -> NapiResult { + let raw: [u8; 32] = raw + .as_ref() + .try_into() + .map_err(|_| invalid_arg("u256 value must be exactly 32 little-endian bytes"))?; + to_descriptor(&Value::U256(raw)) +} + +#[napi(js_name = "coreValueString")] +pub fn core_value_string(value: String) -> NapiResult { + to_descriptor(&Value::str(value)) +} + +#[napi(js_name = "coreValueBytes")] +pub fn core_value_bytes(value: Buffer) -> NapiResult { + to_descriptor(&Value::Bytes(value.as_ref().to_vec())) +} + +#[napi(js_name = "coreValueList")] +pub fn core_value_list(items: Vec) -> NapiResult { + let items = items + .into_iter() + .map(from_descriptor) + .collect::>>()?; + to_descriptor(&Value::List(items)) +} + +#[napi(js_name = "coreValueTuple")] +pub fn core_value_tuple(items: Vec) -> NapiResult { + let items = items + .into_iter() + .map(from_descriptor) + .collect::>>()?; + to_descriptor(&Value::Tuple(items)) +} + +#[napi(js_name = "coreValueDict")] +pub fn core_value_dict(entries: Vec) -> NapiResult { + let entries = entries + .into_iter() + .map(|entry| Ok((from_descriptor(entry.key)?, from_descriptor(entry.value)?))) + .collect::>>()?; + to_descriptor(&Value::Dict(entries)) +} + +#[napi(js_name = "coreValueRecord")] +pub fn core_value_record(fields: Vec) -> NapiResult { + let fields = fields + .into_iter() + .map(|field| Ok((field.name, from_descriptor(field.value)?))) + .collect::>>()?; + to_descriptor(&Value::record(fields)) +} + +#[napi(js_name = "coreValueHex")] +pub fn core_value_hex(value: Buffer) -> NapiResult { + to_descriptor(&Value::hex(value.as_ref())) +} diff --git a/sdk/typescript-sdk/native/src/mlkem.rs b/sdk/typescript-sdk/native/src/mlkem.rs index b20aeab222..083aead1fa 100644 --- a/sdk/typescript-sdk/native/src/mlkem.rs +++ b/sdk/typescript-sdk/native/src/mlkem.rs @@ -5,18 +5,10 @@ use napi_derive::napi; use crate::errors::{CoreResultExt, NapiResult}; #[napi(js_name = "mlkemSeal")] -pub fn seal( - public_key: Buffer, - plaintext: Buffer, - include_key_hash: bool, -) -> NapiResult { - mlkem::seal( - public_key.as_ref(), - plaintext.as_ref(), - include_key_hash, - ) - .napi() - .map(Into::into) +pub fn seal(public_key: Buffer, plaintext: Buffer, include_key_hash: bool) -> NapiResult { + mlkem::seal(public_key.as_ref(), plaintext.as_ref(), include_key_hash) + .napi() + .map(Into::into) } #[napi(js_name = "mlkemTwox128")] diff --git a/sdk/typescript-sdk/native/src/runtime.rs b/sdk/typescript-sdk/native/src/runtime.rs index d8e2380a56..2f82a24c25 100644 --- a/sdk/typescript-sdk/native/src/runtime.rs +++ b/sdk/typescript-sdk/native/src/runtime.rs @@ -7,11 +7,9 @@ use std::sync::Arc; use bittensor_core::codec::batch::PARALLEL_THRESHOLD; -use bittensor_core::codec::decode::{compact_len, compact_u128, Cursor}; +use bittensor_core::codec::decode::{compact_len, compact_u128, convert_type_string, Cursor}; use bittensor_core::codec::encode::compact; -use bittensor_core::codec::extrinsic::{ - era_birth, multisig_account_id, multisig_ss58, TxParams, -}; +use bittensor_core::codec::extrinsic::{era_birth, multisig_account_id, multisig_ss58, TxParams}; use bittensor_core::codec::storage::{concat_hash_len, hash_param, storage_prefix}; use bittensor_core::codec::Value; use bittensor_core::runtime::type_string::{Primitive, TypeSpec}; @@ -23,7 +21,9 @@ use scale_info::TypeDef; use serde_json::{json, Map, Value as JsonValue}; use crate::errors::{into_napi, invalid_arg, CoreResultExt, NapiResult}; -use crate::values::{from_wire, to_wire, values_from_wire, values_to_wire}; +use crate::values::{ + from_descriptor, from_wire, to_descriptor, to_wire, values_from_wire, values_to_wire, +}; #[napi(object)] pub struct NativeStorageEntry { @@ -115,6 +115,121 @@ pub struct NativeCompactDecode { pub remaining: u32, } +#[napi] +pub struct NativeCursor { + data: Vec, + offset: usize, + strict: bool, +} + +impl NativeCursor { + fn consume( + &mut self, + operation: impl FnOnce(&mut Cursor<'_>) -> Result, + ) -> NapiResult { + let tail = self + .data + .get(self.offset..) + .ok_or_else(|| invalid_arg("cursor offset is beyond the input buffer"))?; + let mut cursor = Cursor::new(tail); + cursor.strict = self.strict; + let result = operation(&mut cursor).napi()?; + self.offset = self.offset.saturating_add(cursor.offset); + Ok(result) + } +} + +#[napi] +impl NativeCursor { + #[napi(factory, js_name = "fromBytes")] + pub fn from_bytes(data: Buffer, strict: bool, offset: u32) -> NapiResult { + let offset = + usize::try_from(offset).map_err(|_| invalid_arg("cursor offset does not fit usize"))?; + if offset > data.len() { + return Err(invalid_arg("cursor offset is beyond the input buffer")); + } + Ok(Self { + data: data.as_ref().to_vec(), + offset, + strict, + }) + } + + #[napi(getter)] + pub fn data(&self) -> Buffer { + self.data.clone().into() + } + + #[napi(getter)] + pub fn offset(&self) -> NapiResult { + u32::try_from(self.offset).map_err(|_| invalid_arg("cursor offset exceeds u32")) + } + + #[napi(getter)] + pub fn remaining(&self) -> NapiResult { + u32::try_from(self.data.len().saturating_sub(self.offset)) + .map_err(|_| invalid_arg("cursor remaining byte count exceeds u32")) + } + + #[napi(getter)] + pub fn strict(&self) -> bool { + self.strict + } + + #[napi(js_name = "setStrict")] + pub fn set_strict(&mut self, strict: bool) { + self.strict = strict; + } + + #[napi] + pub fn seek(&mut self, offset: u32) -> NapiResult<()> { + let offset = + usize::try_from(offset).map_err(|_| invalid_arg("cursor offset does not fit usize"))?; + if offset > self.data.len() { + return Err(invalid_arg("cursor offset is beyond the input buffer")); + } + self.offset = offset; + Ok(()) + } + + #[napi] + pub fn reset(&mut self, data: Buffer, strict: bool, offset: u32) -> NapiResult<()> { + let offset = + usize::try_from(offset).map_err(|_| invalid_arg("cursor offset does not fit usize"))?; + if offset > data.len() { + return Err(invalid_arg("cursor offset is beyond the input buffer")); + } + self.data = data.as_ref().to_vec(); + self.offset = offset; + self.strict = strict; + Ok(()) + } + + #[napi] + pub fn take(&mut self, length: u32) -> NapiResult { + let length = + usize::try_from(length).map_err(|_| invalid_arg("take length does not fit usize"))?; + self.consume(|cursor| cursor.take(length).map(ToOwned::to_owned)) + .map(Into::into) + } + + #[napi] + pub fn byte(&mut self) -> NapiResult { + self.consume(Cursor::byte) + } + + #[napi(js_name = "decodeCompactU128")] + pub fn decode_compact_u128(&mut self) -> NapiResult { + self.consume(compact_u128).map(BigInt::from) + } + + #[napi(js_name = "decodeCompactLength")] + pub fn decode_compact_length(&mut self) -> NapiResult { + self.consume(compact_len) + .map(|value| BigInt::from(value as u128)) + } +} + #[napi] pub struct NativeRuntime { inner: Arc, @@ -148,6 +263,86 @@ impl NativeRuntime { .transpose()?, }) } + + fn partial_decode( + &self, + value: &Value, + base_offset: usize, + cursor: &Cursor<'_>, + descriptor: bool, + ) -> NapiResult { + let absolute = base_offset.saturating_add(cursor.offset); + Ok(NativePartialDecode { + value: if descriptor { + to_descriptor(value)? + } else { + to_wire(value)? + }, + offset: u32::try_from(absolute) + .map_err(|_| invalid_arg("decoded offset exceeds u32"))?, + remaining: u32::try_from(cursor.remaining()) + .map_err(|_| invalid_arg("remaining byte count exceeds u32"))?, + }) + } + + fn decode_value_inner( + &self, + spec: &TypeSpec, + data: Buffer, + offset: u32, + strict: bool, + descriptor: bool, + ) -> NapiResult { + let offset = + usize::try_from(offset).map_err(|_| invalid_arg("offset does not fit usize"))?; + let tail = data + .as_ref() + .get(offset..) + .ok_or_else(|| invalid_arg("offset is beyond the input buffer"))?; + let mut cursor = Cursor::new(tail); + cursor.strict = strict; + let value = self.inner.decode_value(spec, &mut cursor).napi()?; + self.partial_decode(&value, offset, &cursor, descriptor) + } + + fn decode_id_inner( + &self, + type_id: u32, + data: Buffer, + offset: u32, + strict: bool, + descriptor: bool, + ) -> NapiResult { + let offset = + usize::try_from(offset).map_err(|_| invalid_arg("offset does not fit usize"))?; + let tail = data + .as_ref() + .get(offset..) + .ok_or_else(|| invalid_arg("offset is beyond the input buffer"))?; + let mut cursor = Cursor::new(tail); + cursor.strict = strict; + let value = self.inner.decode_id(type_id, &mut cursor).napi()?; + self.partial_decode(&value, offset, &cursor, descriptor) + } + + fn decode_call_value_inner( + &self, + data: Buffer, + offset: u32, + strict: bool, + descriptor: bool, + ) -> NapiResult { + let offset = + usize::try_from(offset).map_err(|_| invalid_arg("offset does not fit usize"))?; + let tail = data + .as_ref() + .get(offset..) + .ok_or_else(|| invalid_arg("offset is beyond the input buffer"))?; + let mut cursor = Cursor::new(tail); + cursor.strict = strict; + let value = self.inner.decode_call_value(&mut cursor).napi()?; + self.partial_decode(&value, offset, &cursor, descriptor) + } } #[napi] @@ -207,12 +402,7 @@ impl NativeRuntime { } #[napi] - pub fn decode( - &self, - type_string: String, - data: Buffer, - strict: bool, - ) -> NapiResult { + pub fn decode(&self, type_string: String, data: Buffer, strict: bool) -> NapiResult { let spec = self.inner.type_spec(&type_string).napi()?; let value = self .inner @@ -230,8 +420,8 @@ impl NativeRuntime { strict: bool, ) -> NapiResult { let spec = self.inner.type_spec(&type_string).napi()?; - let offset = usize::try_from(offset) - .map_err(|_| invalid_arg("offset does not fit usize"))?; + let offset = + usize::try_from(offset).map_err(|_| invalid_arg("offset does not fit usize"))?; let tail = data .as_ref() .get(offset..) @@ -271,8 +461,8 @@ impl NativeRuntime { offset: u32, strict: bool, ) -> NapiResult { - let offset = usize::try_from(offset) - .map_err(|_| invalid_arg("offset does not fit usize"))?; + let offset = + usize::try_from(offset).map_err(|_| invalid_arg("offset does not fit usize"))?; let tail = data .as_ref() .get(offset..) @@ -337,6 +527,189 @@ impl NativeRuntime { Ok(type_spec_json(&spec)) } + #[napi(js_name = "decodeSpec")] + pub fn decode_spec_native( + &self, + spec: JsonValue, + data: Buffer, + strict: bool, + ) -> NapiResult { + let spec = type_spec_from_json(spec)?; + let value = self + .inner + .decode_spec(&spec, data.as_ref(), strict) + .napi()?; + to_wire(&value) + } + + #[napi(js_name = "decodeSpecDescriptor")] + pub fn decode_spec_descriptor( + &self, + spec: JsonValue, + data: Buffer, + strict: bool, + ) -> NapiResult { + let spec = type_spec_from_json(spec)?; + let value = self + .inner + .decode_spec(&spec, data.as_ref(), strict) + .napi()?; + to_descriptor(&value) + } + + #[napi(js_name = "decodeValue")] + pub fn decode_value_native( + &self, + spec: JsonValue, + data: Buffer, + offset: u32, + strict: bool, + ) -> NapiResult { + let spec = type_spec_from_json(spec)?; + self.decode_value_inner(&spec, data, offset, strict, false) + } + + #[napi(js_name = "decodeValueDescriptor")] + pub fn decode_value_descriptor( + &self, + spec: JsonValue, + data: Buffer, + offset: u32, + strict: bool, + ) -> NapiResult { + let spec = type_spec_from_json(spec)?; + self.decode_value_inner(&spec, data, offset, strict, true) + } + + #[napi(js_name = "decodeTypeIdDescriptor")] + pub fn decode_type_id_descriptor( + &self, + type_id: u32, + data: Buffer, + strict: bool, + ) -> NapiResult { + let value = self + .inner + .decode_spec(&TypeSpec::Id(type_id), data.as_ref(), strict) + .napi()?; + to_descriptor(&value) + } + + #[napi(js_name = "decodeTypeIdDescriptorPartial")] + pub fn decode_type_id_descriptor_partial( + &self, + type_id: u32, + data: Buffer, + offset: u32, + strict: bool, + ) -> NapiResult { + self.decode_id_inner(type_id, data, offset, strict, true) + } + + #[napi(js_name = "encodeSpec")] + pub fn encode_spec_native(&self, spec: JsonValue, value: JsonValue) -> NapiResult { + let spec = type_spec_from_json(spec)?; + self.inner + .encode_spec(&spec, &from_wire(value)?) + .napi() + .map(Into::into) + } + + #[napi(js_name = "encodeSpecDescriptor")] + pub fn encode_spec_descriptor(&self, spec: JsonValue, value: JsonValue) -> NapiResult { + let spec = type_spec_from_json(spec)?; + self.inner + .encode_spec(&spec, &from_descriptor(value)?) + .napi() + .map(Into::into) + } + + #[napi(js_name = "encodeValue")] + pub fn encode_value_native( + &self, + spec: JsonValue, + value: JsonValue, + prefix: Option, + ) -> NapiResult { + let spec = type_spec_from_json(spec)?; + let mut output = prefix + .as_ref() + .map(|value| value.as_ref().to_vec()) + .unwrap_or_default(); + self.inner + .encode_value(&spec, &from_wire(value)?, &mut output) + .napi()?; + Ok(output.into()) + } + + #[napi(js_name = "encodeValueDescriptor")] + pub fn encode_value_descriptor( + &self, + spec: JsonValue, + value: JsonValue, + prefix: Option, + ) -> NapiResult { + let spec = type_spec_from_json(spec)?; + let mut output = prefix + .as_ref() + .map(|value| value.as_ref().to_vec()) + .unwrap_or_default(); + self.inner + .encode_value(&spec, &from_descriptor(value)?, &mut output) + .napi()?; + Ok(output.into()) + } + + #[napi(js_name = "encodeId")] + pub fn encode_id_native( + &self, + type_id: u32, + value: JsonValue, + prefix: Option, + ) -> NapiResult { + let mut output = prefix + .as_ref() + .map(|value| value.as_ref().to_vec()) + .unwrap_or_default(); + self.inner + .encode_id(type_id, &from_wire(value)?, &mut output) + .napi()?; + Ok(output.into()) + } + + #[napi(js_name = "encodeIdDescriptor")] + pub fn encode_id_descriptor( + &self, + type_id: u32, + value: JsonValue, + prefix: Option, + ) -> NapiResult { + let mut output = prefix + .as_ref() + .map(|value| value.as_ref().to_vec()) + .unwrap_or_default(); + self.inner + .encode_id(type_id, &from_descriptor(value)?, &mut output) + .napi()?; + Ok(output.into()) + } + + #[napi(js_name = "coerceAccountId")] + pub fn coerce_account_id_native(&self, value: JsonValue) -> NapiResult { + self.inner + .coerce_account_id(&from_wire(value)?) + .napi() + .map(|value| value.to_vec().into()) + } + + #[napi(js_name = "coerceAccountIdDescriptor")] + pub fn coerce_account_id_descriptor(&self, value: JsonValue) -> NapiResult { + self.inner + .coerce_account_id(&from_descriptor(value)?) + .napi() + .map(|value| value.to_vec().into()) + } + #[napi] pub fn resolve_type(&self, id: u32) -> NapiResult { let ty = self.inner.resolve(id).napi()?; @@ -381,6 +754,11 @@ impl NativeRuntime { runtime_api_map_json(&self.inner) } + #[napi] + pub fn runtime_api_infos(&self) -> JsonValue { + runtime_api_infos_json(&self.inner) + } + #[napi] pub fn runtime_snapshot(&self) -> JsonValue { json!({ @@ -392,6 +770,7 @@ impl NativeRuntime { "pallets": self.inner.pallets.iter().map(pallet_json).collect::>(), "extrinsic": extrinsic_json(&self.inner), "runtimeApis": runtime_api_map_json(&self.inner), + "runtimeApiInfos": runtime_api_infos_json(&self.inner), }) } @@ -421,6 +800,26 @@ impl NativeRuntime { to_wire(&value) } + #[napi(js_name = "decodeCallValue")] + pub fn decode_call_value_native( + &self, + data: Buffer, + offset: u32, + strict: bool, + ) -> NapiResult { + self.decode_call_value_inner(data, offset, strict, false) + } + + #[napi(js_name = "decodeCallValueDescriptor")] + pub fn decode_call_value_descriptor( + &self, + data: Buffer, + offset: u32, + strict: bool, + ) -> NapiResult { + self.decode_call_value_inner(data, offset, strict, true) + } + #[napi] pub fn storage_entry( &self, @@ -434,11 +833,7 @@ impl NativeRuntime { } #[napi] - pub fn storage_prefix( - &self, - pallet: String, - storage_function: String, - ) -> NapiResult { + pub fn storage_prefix(&self, pallet: String, storage_function: String) -> NapiResult { Ok(storage_prefix(self.entry(&pallet, &storage_function)?).into()) } @@ -490,11 +885,7 @@ impl NativeRuntime { let fixed = usize::try_from(fixed).map_err(|_| invalid_arg("fixed does not fit usize"))?; let values = self .inner - .decode_storage_key_params( - self.entry(&pallet, &storage_function)?, - key.as_ref(), - fixed, - ) + .decode_storage_key_params(self.entry(&pallet, &storage_function)?, key.as_ref(), fixed) .napi()?; values_to_wire(&values) } @@ -544,21 +935,13 @@ impl NativeRuntime { .collect(); let decoded = self .inner - .decode_map_changes( - self.entry(&pallet, &storage_function)?, - &changes, - fixed, - ) + .decode_map_changes(self.entry(&pallet, &storage_function)?, &changes, fixed) .napi()?; decoded.into_iter().map(map_pair_native).collect() } #[napi] - pub fn constant( - &self, - pallet: String, - name: String, - ) -> NapiResult { + pub fn constant(&self, pallet: String, name: String) -> NapiResult { let Some(constant) = self.inner.constant(&pallet, &name) else { return Ok(NativeOptionalValue { found: false, @@ -587,11 +970,7 @@ impl NativeRuntime { } #[napi] - pub fn module_error( - &self, - module_index: u8, - error_index: u8, - ) -> NapiResult { + pub fn module_error(&self, module_index: u8, error_index: u8) -> NapiResult { let (name, docs) = self.inner.module_error(module_index, error_index).napi()?; Ok(NativeModuleError { name, docs }) } @@ -682,10 +1061,7 @@ impl NativeRuntime { #[napi] pub fn decode_extrinsic(&self, data: Buffer, strict: bool) -> NapiResult { - let value = self - .inner - .decode_extrinsic(data.as_ref(), strict) - .napi()?; + let value = self.inner.decode_extrinsic(data.as_ref(), strict).napi()?; to_wire(&value) } @@ -784,6 +1160,75 @@ fn type_spec_json(spec: &TypeSpec) -> JsonValue { } } +fn type_spec_from_json(value: JsonValue) -> NapiResult { + type_spec_from_json_at(value, 0) +} + +fn type_spec_from_json_at(value: JsonValue, depth: usize) -> NapiResult { + if depth > 256 { + return Err(invalid_arg("type spec nesting exceeds 256 levels")); + } + let JsonValue::Object(mut map) = value else { + return Err(invalid_arg("type spec must be an object")); + }; + let kind = map + .remove("kind") + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| invalid_arg("type spec is missing string `kind`"))?; + let inner = |map: &mut Map| -> NapiResult { + let value = map + .remove("inner") + .ok_or_else(|| invalid_arg(format!("{kind} type spec is missing `inner`")))?; + type_spec_from_json_at(value, depth + 1) + }; + match kind.as_str() { + "id" => map + .remove("id") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .map(TypeSpec::Id) + .ok_or_else(|| invalid_arg("id type spec needs a u32 `id`")), + "primitive" => { + let name = map + .remove("name") + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| invalid_arg("primitive type spec needs string `name`"))?; + Primitive::from_name(&name) + .map(TypeSpec::Primitive) + .ok_or_else(|| invalid_arg(format!("unknown primitive type {name:?}"))) + } + "sequence" => inner(&mut map).map(|value| TypeSpec::Sequence(Box::new(value))), + "option" => inner(&mut map).map(|value| TypeSpec::Option(Box::new(value))), + "array" => { + let inner = inner(&mut map)?; + let length = map + .remove("length") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| invalid_arg("array type spec needs a u32 `length`"))?; + Ok(TypeSpec::Array(Box::new(inner), length)) + } + "tuple" => { + let items = map + .remove("items") + .and_then(|value| value.as_array().cloned()) + .ok_or_else(|| invalid_arg("tuple type spec needs array `items`"))?; + items + .into_iter() + .map(|item| type_spec_from_json_at(item, depth + 1)) + .collect::>>() + .map(TypeSpec::Tuple) + } + "compact" => inner(&mut map).map(|value| TypeSpec::Compact(Box::new(value))), + "bytes" => Ok(TypeSpec::Bytes), + "accountId" => Ok(TypeSpec::AccountId), + "era" => Ok(TypeSpec::Era), + "call" => Ok(TypeSpec::Call), + "extrinsic" => Ok(TypeSpec::Extrinsic), + _ => Err(invalid_arg(format!("unknown type spec kind {kind:?}"))), + } +} + fn primitive_name(primitive: Primitive) -> &'static str { match primitive { Primitive::Bool => "bool", @@ -872,6 +1317,35 @@ fn runtime_api_map_json(runtime: &Runtime) -> JsonValue { JsonValue::Object(apis) } +fn runtime_api_infos_json(runtime: &Runtime) -> JsonValue { + JsonValue::Array( + runtime + .apis + .iter() + .map(|api| { + json!({ + "name": api.name, + "methods": api.methods.iter().map(|method| { + json!({ + "name": method.name, + "inputs": method.inputs.iter().map(|param| { + json!({ + "name": param.name, + "typeId": param.ty, + "type": format!("scale_info::{}", param.ty), + }) + }).collect::>(), + "output": method.output, + "outputType": format!("scale_info::{}", method.output), + "docs": method.docs, + }) + }).collect::>(), + }) + }) + .collect(), + ) +} + fn metadata_ir_json(runtime: &Runtime) -> NapiResult { let join_docs = |docs: &[String]| -> String { docs.iter() @@ -934,6 +1408,21 @@ fn metadata_ir_json(runtime: &Runtime) -> NapiResult { })) } +#[napi(js_name = "convertTypeString")] +pub fn convert_type_string_native(name: String) -> String { + convert_type_string(&name) +} + +#[napi(js_name = "primitiveFromName")] +pub fn primitive_from_name_native(name: String) -> Option { + Primitive::from_name(&name).map(|primitive| primitive_name(primitive).to_owned()) +} + +#[napi(js_name = "normalizeTypeSpec")] +pub fn normalize_type_spec(spec: JsonValue) -> NapiResult { + Ok(type_spec_json(&type_spec_from_json(spec)?)) +} + #[napi(js_name = "eraBirth")] pub fn era_birth_native(period: BigInt, current: BigInt) -> NapiResult { Ok(BigInt::from(era_birth( @@ -986,10 +1475,7 @@ pub fn encode_compact(value: BigInt) -> NapiResult { } #[napi(js_name = "decodeCompactU128")] -pub fn decode_compact_u128( - data: Buffer, - strict: bool, -) -> NapiResult { +pub fn decode_compact_u128(data: Buffer, strict: bool) -> NapiResult { let mut cursor = Cursor::new(data.as_ref()); cursor.strict = strict; let value = compact_u128(&mut cursor).napi()?; @@ -1003,10 +1489,7 @@ pub fn decode_compact_u128( } #[napi(js_name = "decodeCompactLength")] -pub fn decode_compact_length( - data: Buffer, - strict: bool, -) -> NapiResult { +pub fn decode_compact_length(data: Buffer, strict: bool) -> NapiResult { let mut cursor = Cursor::new(data.as_ref()); cursor.strict = strict; let value = compact_len(&mut cursor).napi()?; @@ -1024,6 +1507,20 @@ pub fn hash_storage_param(hasher: String, data: Buffer) -> NapiResult { hash_param(&hasher, data.as_ref()).napi().map(Into::into) } +#[napi(js_name = "storagePrefixFor")] +pub fn storage_prefix_for(prefix: String, name: String) -> Buffer { + storage_prefix(&StorageInfo { + name, + prefix, + modifier: "Optional".to_owned(), + hashers: Vec::new(), + key_types: Vec::new(), + value_type: 0, + default_bytes: Vec::new(), + }) + .into() +} + #[napi(js_name = "concatHashLength")] pub fn concat_hash_length(hasher: String) -> NapiResult { let length = concat_hash_len(&hasher).napi()?; diff --git a/sdk/typescript-sdk/native/src/timelock.rs b/sdk/typescript-sdk/native/src/timelock.rs index 5c83b2a50e..a64f8728a2 100644 --- a/sdk/typescript-sdk/native/src/timelock.rs +++ b/sdk/typescript-sdk/native/src/timelock.rs @@ -5,7 +5,7 @@ )] use bittensor_core::timelock::constants; -use bittensor_core::timelock::epoch_schedule::{self, EpochScheduleState}; +use bittensor_core::timelock::epoch_schedule::{self, EpochScheduleError, EpochScheduleState}; use bittensor_core::timelock::{self, UserData, WeightsTlockPayload}; use codec::{Decode, Encode}; use napi::bindgen_prelude::{BigInt, Buffer}; @@ -35,6 +35,13 @@ pub struct NativeDrandResponse { pub signature: String, } +#[napi(object)] +pub struct NativeEpochScheduleResult { + pub ok: bool, + pub block: Option, + pub error: Option, +} + #[napi(object)] pub struct NativeWeightsTlockPayload { pub hotkey: Buffer, @@ -65,10 +72,7 @@ fn state_from_native(value: &NativeEpochScheduleState) -> NapiResult, u64)) -> NativeCiphertextRound { #[napi(js_name = "timelockEncryptAndCompress")] pub fn encrypt_and_compress(data: Buffer, reveal_round: BigInt) -> NapiResult { - timelock::encrypt_and_compress( - data.as_ref(), - bigint_u64("revealRound", &reveal_round)?, - ) - .napi() - .map(Into::into) + timelock::encrypt_and_compress(data.as_ref(), bigint_u64("revealRound", &reveal_round)?) + .napi() + .map(Into::into) } #[napi(js_name = "timelockDecryptAndDecompress")] @@ -126,10 +127,7 @@ pub fn generate_commit_v2( values, bigint_u64("versionKey", &version_key)?, state_from_native(&state)?, - bigint_u64( - "subnetRevealPeriodEpochs", - &subnet_reveal_period_epochs, - )?, + bigint_u64("subnetRevealPeriodEpochs", &subnet_reveal_period_epochs)?, block_time, hotkey.as_ref().to_vec(), ) @@ -158,23 +156,16 @@ pub fn encrypt_n_blocks( n_blocks: BigInt, block_time: f64, ) -> NapiResult { - timelock::encrypt_n_blocks( - data.as_ref(), - bigint_u64("nBlocks", &n_blocks)?, - block_time, - ) - .napi() - .map(ciphertext_round) + timelock::encrypt_n_blocks(data.as_ref(), bigint_u64("nBlocks", &n_blocks)?, block_time) + .napi() + .map(ciphertext_round) } #[napi(js_name = "timelockEncryptAtRound")] pub fn encrypt_at_round(data: Buffer, reveal_round: BigInt) -> NapiResult { - timelock::encrypt_at_round( - data.as_ref(), - bigint_u64("revealRound", &reveal_round)?, - ) - .napi() - .map(ciphertext_round) + timelock::encrypt_at_round(data.as_ref(), bigint_u64("revealRound", &reveal_round)?) + .napi() + .map(ciphertext_round) } #[napi(js_name = "timelockGetRoundInfo")] @@ -210,10 +201,7 @@ pub fn decrypt(encrypted_data: Buffer, no_errors: bool) -> NapiResult NapiResult { +pub fn decrypt_with_signature(encrypted_data: Buffer, signature_hex: String) -> NapiResult { timelock::decrypt_with_signature(encrypted_data.as_ref(), &signature_hex) .napi() .map(Into::into) @@ -232,10 +220,12 @@ pub fn current_epoch_pre_run_coinbase( state: NativeEpochScheduleState, block: BigInt, ) -> NapiResult { - Ok(BigInt::from(epoch_schedule::current_epoch_pre_run_coinbase( - &state_from_native(&state)?, - bigint_u64("block", &block)?, - ))) + Ok(BigInt::from( + epoch_schedule::current_epoch_pre_run_coinbase( + &state_from_native(&state)?, + bigint_u64("block", &block)?, + ), + )) } #[napi(js_name = "epochSimulateRunCoinbase")] @@ -275,6 +265,35 @@ pub fn predict_first_reveal_block( .map_err(|error| invalid_arg(error.to_string())) } +#[napi(js_name = "epochPredictFirstRevealBlockResult")] +pub fn predict_first_reveal_block_result( + state: NativeEpochScheduleState, + reveal_period_epochs: BigInt, +) -> NapiResult { + let result = epoch_schedule::predict_first_reveal_block( + &state_from_native(&state)?, + bigint_u64("revealPeriodEpochs", &reveal_period_epochs)?, + ); + Ok(match result { + Ok(block) => NativeEpochScheduleResult { + ok: true, + block: Some(BigInt::from(block)), + error: None, + }, + Err(error) => NativeEpochScheduleResult { + ok: false, + block: None, + error: Some( + match error { + EpochScheduleError::BoundExceeded => "BoundExceeded", + EpochScheduleError::TempoIsZero => "TempoIsZero", + } + .to_owned(), + ), + }, + }) +} + #[napi(js_name = "encodeWeightsTlockPayload")] pub fn encode_weights_tlock_payload(value: NativeWeightsTlockPayload) -> NapiResult { Ok(WeightsTlockPayload { diff --git a/sdk/typescript-sdk/native/src/values.rs b/sdk/typescript-sdk/native/src/values.rs index 688fe6e695..7697487bda 100644 --- a/sdk/typescript-sdk/native/src/values.rs +++ b/sdk/typescript-sdk/native/src/values.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use bittensor_core::codec::value::{u256_decimal, Value}; -use serde_json::{Map, Number, Value as JsonValue}; +use serde_json::{json, Map, Number, Value as JsonValue}; use crate::errors::{invalid_arg, NapiResult}; @@ -109,12 +109,7 @@ fn object_from_wire(mut map: Map, depth: usize) -> NapiResult } _ => map .into_iter() - .map(|(key, value)| { - Ok(( - Value::Str(key), - from_wire_at(value, depth + 1)?, - )) - }) + .map(|(key, value)| Ok((Value::Str(key), from_wire_at(value, depth + 1)?))) .collect::>>() .map(Value::Dict), } @@ -175,8 +170,8 @@ fn to_wire_at(value: &Value, depth: usize) -> NapiResult { Value::Null => Ok(JsonValue::Null), Value::Bool(value) => Ok(JsonValue::Bool(*value)), Value::Int(value) if (MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(value) => { - let integer = i64::try_from(*value) - .map_err(|_| invalid_arg("safe integer conversion failed"))?; + let integer = + i64::try_from(*value).map_err(|_| invalid_arg("safe integer conversion failed"))?; Ok(JsonValue::Number(Number::from(integer))) } Value::Int(value) => Ok(tagged_bigint(value.to_string())), @@ -213,7 +208,7 @@ fn tagged_bigint(decimal: String) -> JsonValue { fn dict_to_wire(entries: &[(Value, Value)], depth: usize) -> NapiResult { let mut names = HashSet::with_capacity(entries.len()); let plain_object = entries.iter().all(|(key, _)| match key { - Value::Str(name) => name != WIRE_TAG && names.insert(name.clone()), + Value::Str(name) => safe_plain_object_key(name) && names.insert(name.clone()), _ => false, }); @@ -241,6 +236,197 @@ fn dict_to_wire(entries: &[(Value, Value)], depth: usize) -> NapiResult bool { + name != WIRE_TAG && !matches!(name, "__proto__" | "constructor" | "prototype") +} + +/// Parse the exact public descriptor for Rust's `codec::Value` enum. +pub fn from_descriptor(value: JsonValue) -> NapiResult { + from_descriptor_at(value, 0) +} + +fn descriptor_string( + map: &mut Map, + field: &str, + kind: &str, +) -> NapiResult { + map.remove(field) + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| invalid_arg(format!("{kind} descriptor is missing string `{field}`"))) +} + +fn descriptor_items( + map: &mut Map, + field: &str, + kind: &str, +) -> NapiResult> { + map.remove(field) + .and_then(|value| value.as_array().cloned()) + .ok_or_else(|| invalid_arg(format!("{kind} descriptor is missing array `{field}`"))) +} + +fn from_descriptor_at(value: JsonValue, depth: usize) -> NapiResult { + if depth > MAX_WIRE_DEPTH { + return Err(invalid_arg( + "core value descriptor nesting exceeds 256 levels", + )); + } + let JsonValue::Object(mut map) = value else { + return Err(invalid_arg("core value descriptor must be an object")); + }; + let kind = map + .remove("kind") + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| invalid_arg("core value descriptor is missing string `kind`"))?; + match kind.as_str() { + "null" => Ok(Value::Null), + "bool" => map + .remove("value") + .and_then(|value| value.as_bool()) + .map(Value::Bool) + .ok_or_else(|| invalid_arg("bool descriptor is missing boolean `value`")), + "int" => { + let decimal = descriptor_string(&mut map, "value", "int")?; + let value = decimal + .parse::() + .map_err(|_| invalid_arg("int descriptor is outside the Rust i128 range"))?; + Ok(Value::Int(value)) + } + "uint" => { + let decimal = descriptor_string(&mut map, "value", "uint")?; + let value = decimal + .parse::() + .map_err(|_| invalid_arg("uint descriptor is outside the Rust u128 range"))?; + Ok(Value::Uint(value)) + } + "u256" => { + let raw = descriptor_string(&mut map, "littleEndianHex", "u256")?; + let raw = hex::decode(raw.trim_start_matches("0x")) + .map_err(|error| invalid_arg(format!("invalid u256 little-endian hex: {error}")))?; + let raw: [u8; 32] = raw + .as_slice() + .try_into() + .map_err(|_| invalid_arg("u256 descriptor must contain exactly 32 bytes"))?; + Ok(Value::U256(raw)) + } + "str" => descriptor_string(&mut map, "value", "str").map(Value::Str), + "bytes" => { + let raw = descriptor_string(&mut map, "hex", "bytes")?; + let raw = hex::decode(raw.trim_start_matches("0x")) + .map_err(|error| invalid_arg(format!("invalid bytes descriptor hex: {error}")))?; + Ok(Value::Bytes(raw)) + } + "list" | "tuple" => { + let items = descriptor_items(&mut map, "items", &kind)?; + let values = items + .into_iter() + .map(|item| from_descriptor_at(item, depth + 1)) + .collect::>>()?; + Ok(if kind == "list" { + Value::List(values) + } else { + Value::Tuple(values) + }) + } + "dict" => { + let entries = descriptor_items(&mut map, "entries", "dict")?; + let mut output = Vec::with_capacity(entries.len()); + for entry in entries { + match entry { + JsonValue::Object(mut entry) => { + let key = entry + .remove("key") + .ok_or_else(|| invalid_arg("dict descriptor entry is missing `key`"))?; + let value = entry.remove("value").ok_or_else(|| { + invalid_arg("dict descriptor entry is missing `value`") + })?; + output.push(( + from_descriptor_at(key, depth + 1)?, + from_descriptor_at(value, depth + 1)?, + )); + } + JsonValue::Array(mut pair) if pair.len() == 2 => { + let value = pair + .pop() + .ok_or_else(|| invalid_arg("dict descriptor entry is missing value"))?; + let key = pair + .pop() + .ok_or_else(|| invalid_arg("dict descriptor entry is missing key"))?; + output.push(( + from_descriptor_at(key, depth + 1)?, + from_descriptor_at(value, depth + 1)?, + )); + } + _ => { + return Err(invalid_arg( + "dict descriptor entry must be {key, value} or [key, value]", + )); + } + } + } + Ok(Value::Dict(output)) + } + _ => Err(invalid_arg(format!( + "unknown core value descriptor kind {kind:?}" + ))), + } +} + +/// Render Rust's `codec::Value` without collapsing List/Tuple or Int/Uint. +pub fn to_descriptor(value: &Value) -> NapiResult { + to_descriptor_at(value, 0) +} + +fn to_descriptor_at(value: &Value, depth: usize) -> NapiResult { + if depth > MAX_WIRE_DEPTH { + return Err(invalid_arg( + "core value descriptor nesting exceeds 256 levels", + )); + } + let descriptor = match value { + Value::Null => json!({"kind": "null"}), + Value::Bool(value) => json!({"kind": "bool", "value": value}), + Value::Int(value) => json!({"kind": "int", "value": value.to_string()}), + Value::Uint(value) => json!({"kind": "uint", "value": value.to_string()}), + Value::U256(value) => json!({ + "kind": "u256", + "littleEndianHex": format!("0x{}", hex::encode(value)), + }), + Value::Str(value) => json!({"kind": "str", "value": value}), + Value::Bytes(value) => json!({ + "kind": "bytes", + "hex": format!("0x{}", hex::encode(value)), + }), + Value::List(items) | Value::Tuple(items) => { + let kind = if matches!(value, Value::List(_)) { + "list" + } else { + "tuple" + }; + json!({ + "kind": kind, + "items": items + .iter() + .map(|item| to_descriptor_at(item, depth + 1)) + .collect::>>()?, + }) + } + Value::Dict(entries) => json!({ + "kind": "dict", + "entries": entries + .iter() + .map(|(key, value)| { + Ok(json!({ + "key": to_descriptor_at(key, depth + 1)?, + "value": to_descriptor_at(value, depth + 1)?, + })) + }) + .collect::>>()?, + }), + }; + Ok(descriptor) +} + pub fn values_from_wire(value: JsonValue) -> NapiResult> { let JsonValue::Array(values) = value else { return Err(invalid_arg("expected an array of SCALE values")); @@ -264,7 +450,8 @@ mod tests { #[test] fn bigint_roundtrip_covers_u256() { - let decimal = "115792089237316195423570985008687907853269984665640564039457584007913129639935"; + let decimal = + "115792089237316195423570985008687907853269984665640564039457584007913129639935"; let value = bigint_value(decimal).expect("u256 parses"); assert!(matches!(value, Value::U256(_))); let wire = to_wire(&value).expect("u256 renders"); @@ -292,4 +479,24 @@ mod tests { assert_eq!(wire["hex"], "0001feff"); assert_eq!(from_wire(wire).expect("bytes parse"), value); } + + #[test] + fn prototype_sensitive_dict_keys_use_entry_wire_shape() { + let value = Value::Dict(vec![(Value::Str("__proto__".into()), Value::Bool(true))]); + let wire = to_wire(&value).expect("dict renders"); + assert_eq!(wire[WIRE_TAG], TAG_DICT); + assert_eq!(from_wire(wire).expect("dict parses"), value); + } + + #[test] + fn descriptor_roundtrip_preserves_variants() { + let value = Value::Tuple(vec![ + Value::List(vec![Value::Bytes(vec![1, 2, 3])]), + Value::Uint(u128::MAX), + Value::U256([0xff; 32]), + ]); + let descriptor = to_descriptor(&value).expect("descriptor"); + let decoded = from_descriptor(descriptor).expect("roundtrip"); + assert_eq!(decoded, value); + } } diff --git a/sdk/typescript-sdk/src/crypto.ts b/sdk/typescript-sdk/src/crypto.ts index 420e61d748..2be51d7ed5 100644 --- a/sdk/typescript-sdk/src/crypto.ts +++ b/sdk/typescript-sdk/src/crypto.ts @@ -39,6 +39,9 @@ export function generateExtrinsicProof( export const MLKEM_NONCE_LENGTH = native.mlkemNonceLength() export const MLKEM_KDF_ID = Buffer.from(native.mlkemKdfId()) +/** Rust-name aliases. */ +export const MLKEM_NONCE_LEN = MLKEM_NONCE_LENGTH +export const KDF_ID = MLKEM_KDF_ID export function mlkemSeal( publicKey: ByteLike, diff --git a/sdk/typescript-sdk/src/index.ts b/sdk/typescript-sdk/src/index.ts index eb10e44afa..3c10204a78 100644 --- a/sdk/typescript-sdk/src/index.ts +++ b/sdk/typescript-sdk/src/index.ts @@ -6,9 +6,11 @@ export * from './crypto' export * from './errors' export * from './keys' export * from './ledger' +export * from './modules' export * from './runtime' export * from './timelock' export * from './types' +export * from './value' export { fromWire, toWire, WIRE_TAG } /** diff --git a/sdk/typescript-sdk/src/keys.ts b/sdk/typescript-sdk/src/keys.ts index f9ed5ab939..33edbd60c5 100644 --- a/sdk/typescript-sdk/src/keys.ts +++ b/sdk/typescript-sdk/src/keys.ts @@ -8,6 +8,7 @@ export const CRYPTO_SR25519 = nativeCall(() => native.cryptoSr25519()) export const DEFAULT_SS58_FORMAT = nativeCall(() => native.defaultSs58Format()) export type SubstrateKeyType = 'sr25519' | 'ed25519' +export type KeypairKind = 'Ed25519' | 'Sr25519' | 'PublicOnly' export interface KeypairMetadata { address?: string @@ -219,6 +220,10 @@ export class Keypair implements PolkadotCompatibleKeypair { return this.handle.cryptoType } + get kind(): KeypairKind { + return this.handle.kind + } + get publicKey(): Buffer { return Buffer.from(this.handle.publicKey) } diff --git a/sdk/typescript-sdk/src/modules.ts b/sdk/typescript-sdk/src/modules.ts new file mode 100644 index 0000000000..d2d41d687b --- /dev/null +++ b/sdk/typescript-sdk/src/modules.ts @@ -0,0 +1,126 @@ +import * as crypto from './crypto' +import * as keys from './keys' +import { LedgerDevice } from './ledger' +import native from './native' +import * as runtime from './runtime' +import * as timelock from './timelock' +import * as value from './value' +import { toBuffer } from './wire' +import type { ByteLike } from './types' + +/** + * Rust-module-shaped namespace. It mirrors the public `bittensor_core` crate + * while retaining the idiomatic top-level TypeScript exports. + */ +export const rustCore = Object.freeze({ + keys: Object.freeze({ + Keypair: keys.Keypair, + CRYPTO_ED25519: keys.CRYPTO_ED25519, + CRYPTO_SR25519: keys.CRYPTO_SR25519, + DEFAULT_SS58_FORMAT: keys.DEFAULT_SS58_FORMAT, + generateMnemonic: keys.generateMnemonic, + verify: keys.verify, + publicKeyFromSs58: keys.publicKeyFromSs58, + ss58FromPublic: keys.ss58FromPublic, + encryptFor: keys.encryptFor, + }), + keyfiles: Object.freeze({ + serializeKeypair: keys.serializeKeypair, + serializedKeypairToKeyfileData: keys.serializedKeypairToKeyfileData, + deserializeKeypair: keys.deserializeKeypair, + deserializeKeypairFromKeyfileData: keys.deserializeKeypairFromKeyfileData, + encryptKeyfileData: keys.encryptKeyfileData, + decryptKeyfileData: keys.decryptKeyfileData, + keyfileDataIsEncrypted: keys.keyfileDataIsEncrypted, + keyfileDataIsEncryptedNacl: keys.keyfileDataIsEncryptedNacl, + keyfileDataIsEncryptedAnsible: keys.keyfileDataIsEncryptedAnsible, + keyfileDataIsEncryptedLegacy: keys.keyfileDataIsEncryptedLegacy, + keyfileDataEncryptionMethod: keys.keyfileDataEncryptionMethod, + getPasswordFromEnvironment: keys.getPasswordFromEnvironment, + savePasswordToEnvironment: keys.savePasswordToEnvironment, + }), + codec: Object.freeze({ + value: Object.freeze({ + Value: value.coreValue, + descriptor: value.coreValueDescriptor, + normalize: value.normalizeCoreValue, + toCorpusJson: value.coreValueDescriptorToCorpusJson, + u256Decimal(raw: ByteLike): string { + return native.u256LeToDecimal(toBuffer(raw, 'raw')) + }, + }), + decode: Object.freeze({ + Cursor: runtime.ScaleCursor, + compactU128: runtime.decodeCompactU128, + compactLength: runtime.decodeCompactLength, + convertTypeString: runtime.convertTypeString, + }), + encode: Object.freeze({ + compact: runtime.encodeCompact, + }), + storage: Object.freeze({ + hashParam: runtime.hashStorageParam, + concatHashLen: runtime.concatHashLength, + storagePrefix: runtime.storagePrefixFor, + }), + extrinsic: Object.freeze({ + eraBirth: runtime.eraBirth, + multisigAccountId: runtime.multisigAccountId, + multisigSs58: runtime.multisigSs58, + }), + batch: Object.freeze({ + PARALLEL_THRESHOLD: runtime.PARALLEL_THRESHOLD, + }), + }), + digest: Object.freeze({ + metadataDigest: crypto.metadataDigest, + generateExtrinsicProof: crypto.generateExtrinsicProof, + }), + mlkem: Object.freeze({ + seal: crypto.mlkemSeal, + twox128: crypto.mlkemTwox128, + MLKEM_NONCE_LEN: crypto.MLKEM_NONCE_LEN, + KDF_ID: crypto.KDF_ID, + }), + runtime: Object.freeze({ + Runtime: runtime.Runtime, + TypeSpec: runtime.typeSpec, + Primitive: runtime.primitiveFromName, + }), + timelock: Object.freeze({ + encryptAndCompress: timelock.encryptAndCompress, + decryptAndDecompress: timelock.decryptAndDecompress, + generateCommitV2: timelock.generateCommitV2, + encryptCommitment: timelock.encryptCommitment, + encryptNBlocks: timelock.encryptNBlocks, + encryptAtRound: timelock.encryptAtRound, + getRoundInfo: timelock.getRoundInfo, + getRevealRoundSignature: timelock.getRevealRoundSignature, + decrypt: timelock.decrypt, + decryptWithSignature: timelock.decryptWithSignature, + constants: Object.freeze({ + MAX_TEMPO: timelock.MAX_TEMPO, + MAX_TEMPO_U64: timelock.MAX_TEMPO_U64, + DRAND_PUBLIC_KEY: timelock.DRAND_PUBLIC_KEY, + GENESIS_TIME: timelock.GENESIS_TIME, + DRAND_PERIOD: timelock.DRAND_PERIOD, + QUICKNET_CHAIN_HASH: timelock.QUICKNET_CHAIN_HASH, + DRAND_ENDPOINTS: timelock.DRAND_ENDPOINTS, + SECURITY_BLOCK_OFFSET: timelock.SECURITY_BLOCK_OFFSET, + COMMIT_INCLUSION_BLOCK_OFFSET: timelock.COMMIT_INCLUSION_BLOCK_OFFSET, + maxSimulationBlocks: timelock.maxSimulationBlocks, + }), + epoch_schedule: Object.freeze({ + EpochScheduleError: timelock.EpochScheduleError, + shouldRunEpoch: timelock.shouldRunEpoch, + currentEpochPreRunCoinbase: timelock.currentEpochPreRunCoinbase, + simulateRunCoinbase: timelock.simulateRunCoinbase, + advanceBlocks: timelock.advanceBlocks, + predictFirstRevealBlock: timelock.predictFirstRevealBlock, + predictFirstRevealBlockResult: timelock.predictFirstRevealBlockResult, + }), + }), + signers: Object.freeze({ + ledger: Object.freeze({ LedgerDevice }), + }), +}) diff --git a/sdk/typescript-sdk/src/native.ts b/sdk/typescript-sdk/src/native.ts index 98f38367a7..9118207c84 100644 --- a/sdk/typescript-sdk/src/native.ts +++ b/sdk/typescript-sdk/src/native.ts @@ -2,6 +2,7 @@ export interface NativeKeypairHandle { readonly cryptoType: number + readonly kind: 'Ed25519' | 'Sr25519' | 'PublicOnly' readonly publicKey: Buffer readonly privateKey?: Buffer | null readonly ss58Address: string @@ -53,6 +54,30 @@ export interface NativeExtrinsicParams { metadataHashEnabled: boolean } +export interface NativePartialDecode { + value: unknown + offset: number + remaining: number +} + +export interface NativeCursorHandle { + readonly data: Buffer + readonly offset: number + readonly remaining: number + readonly strict: boolean + setStrict(strict: boolean): void + seek(offset: number): void + reset(data: Buffer, strict: boolean, offset: number): void + take(length: number): Buffer + byte(): number + decodeCompactU128(): bigint + decodeCompactLength(): bigint +} + +export interface NativeCursorConstructor { + fromBytes(data: Buffer, strict: boolean, offset: number): NativeCursorHandle +} + export interface NativeRuntimeHandle { readonly specVersion: number readonly transactionVersion: number @@ -62,15 +87,54 @@ export interface NativeRuntimeHandle { readonly outerEventType?: number | null readonly metadataBytes: Buffer decode(typeString: string, data: Buffer, strict: boolean): unknown - decodePartial(typeString: string, data: Buffer, offset: number, strict: boolean): { value: unknown; offset: number; remaining: number } + decodePartial( + typeString: string, + data: Buffer, + offset: number, + strict: boolean, + ): NativePartialDecode decodeTypeId(typeId: number, data: Buffer, strict: boolean): unknown - decodeTypeIdPartial(typeId: number, data: Buffer, offset: number, strict: boolean): { value: unknown; offset: number; remaining: number } + decodeTypeIdPartial( + typeId: number, + data: Buffer, + offset: number, + strict: boolean, + ): NativePartialDecode decodeBatch(typeStrings: string[], data: Buffer[]): unknown[] encode(typeString: string, value: unknown): Buffer encodeTypeId(typeId: number, value: unknown): Buffer typeIdOf(name: string): number | null | undefined typeNameOf(id: number): string | null | undefined typeSpec(typeString: string): unknown + decodeSpec(spec: unknown, data: Buffer, strict: boolean): unknown + decodeSpecDescriptor(spec: unknown, data: Buffer, strict: boolean): unknown + decodeValue( + spec: unknown, + data: Buffer, + offset: number, + strict: boolean, + ): NativePartialDecode + decodeValueDescriptor( + spec: unknown, + data: Buffer, + offset: number, + strict: boolean, + ): NativePartialDecode + decodeTypeIdDescriptor(typeId: number, data: Buffer, strict: boolean): unknown + decodeTypeIdDescriptorPartial( + typeId: number, + data: Buffer, + offset: number, + strict: boolean, + ): NativePartialDecode + encodeSpec(spec: unknown, value: unknown): Buffer + encodeSpecDescriptor(spec: unknown, value: unknown): Buffer + encodeValue(spec: unknown, value: unknown, prefix?: Buffer | null): Buffer + encodeValueDescriptor(spec: unknown, value: unknown, prefix?: Buffer | null): Buffer + encodeId(typeId: number, value: unknown, prefix?: Buffer | null): Buffer + encodeIdDescriptor(typeId: number, value: unknown, prefix?: Buffer | null): Buffer + coerceAccountId(value: unknown): Buffer + coerceAccountIdDescriptor(value: unknown): Buffer resolveType(id: number): unknown registryJson(): string registry(): unknown @@ -79,31 +143,76 @@ export interface NativeRuntimeHandle { pallets(): unknown[] extrinsicInfo(): unknown runtimeApis(): unknown + runtimeApiInfos(): unknown runtimeSnapshot(): unknown composeCall(pallet: string, fn: string, params: unknown): Buffer decodeCall(data: Buffer): unknown + decodeCallValue( + data: Buffer, + offset: number, + strict: boolean, + ): NativePartialDecode + decodeCallValueDescriptor( + data: Buffer, + offset: number, + strict: boolean, + ): NativePartialDecode storageEntry(pallet: string, storageFunction: string): NativeStorageEntry storagePrefix(pallet: string, storageFunction: string): Buffer storageKey(pallet: string, storageFunction: string, params: unknown): Buffer - storageKeyBatch(pallet: string, storageFunction: string, paramsList: unknown): Buffer[] - decodeStorageKeyParams(pallet: string, storageFunction: string, key: Buffer, fixed: number): unknown - decodeMapPairs(pallet: string, storageFunction: string, rawKeys: Buffer[], rawValues: Buffer[], fixed: number): NativeMapPair[] - decodeMapChanges(pallet: string, storageFunction: string, changes: NativeStorageChange[], fixed: number): NativeMapPair[] + storageKeyBatch( + pallet: string, + storageFunction: string, + paramsList: unknown, + ): Buffer[] + decodeStorageKeyParams( + pallet: string, + storageFunction: string, + key: Buffer, + fixed: number, + ): unknown + decodeMapPairs( + pallet: string, + storageFunction: string, + rawKeys: Buffer[], + rawValues: Buffer[], + fixed: number, + ): NativeMapPair[] + decodeMapChanges( + pallet: string, + storageFunction: string, + changes: NativeStorageChange[], + fixed: number, + ): NativeMapPair[] constant(pallet: string, name: string): { found: boolean; value: unknown } constantInfo(pallet: string, name: string): unknown | null | undefined moduleError(moduleIndex: number, errorIndex: number): { name: string; docs: string[] } signedExtensionIdentifiers(): string[] encodeEra(era: unknown): Buffer - signaturePayloadParts(params: NativeTxParams): { includedInExtrinsic: Buffer; includedInSignedData: Buffer } + signaturePayloadParts(params: NativeTxParams): { + includedInExtrinsic: Buffer + includedInSignedData: Buffer + } signaturePayload(callData: Buffer, params: NativeTxParams): Buffer - encodeSignedExtrinsic(callData: Buffer, publicKey: Buffer, signature: Buffer, signatureVersion: number, params: NativeExtrinsicParams): { bytes: Buffer; hash: Buffer } + encodeSignedExtrinsic( + callData: Buffer, + publicKey: Buffer, + signature: Buffer, + signatureVersion: number, + params: NativeExtrinsicParams, + ): { bytes: Buffer; hash: Buffer } decodeExtrinsic(data: Buffer, strict: boolean): unknown runtimeApiMap(): unknown metadataIr(): unknown } export interface NativeRuntimeConstructor { - fromMetadata(metadataBytes: Buffer, specVersion: number, transactionVersion: number, ss58Format: number): NativeRuntimeHandle + fromMetadata( + metadataBytes: Buffer, + specVersion: number, + transactionVersion: number, + ss58Format: number, + ): NativeRuntimeHandle } export interface NativeEpochScheduleState { @@ -117,7 +226,12 @@ export interface NativeEpochScheduleState { export interface NativeLedgerHandle { appVersion(): { major: number; minor: number; patch: number } - address(account: number, index: number, ss58Prefix: number, confirm: boolean): { publicKey: Buffer; ss58Address: string } + address( + account: number, + index: number, + ss58Prefix: number, + confirm: boolean, + ): { publicKey: Buffer; ss58Address: string } sign(account: number, index: number, payload: Buffer, proof: Buffer): Buffer } @@ -127,6 +241,7 @@ export interface NativeLedgerConstructor { export interface NativeBinding { NativeRuntime: NativeRuntimeConstructor + NativeCursor: NativeCursorConstructor NativeLedgerDevice: NativeLedgerConstructor bindingVersion(): string @@ -135,16 +250,47 @@ export interface NativeBinding { wireRoundtrip(value: unknown): unknown valueToCorpusJson(value: unknown): unknown u256LeToDecimal(raw: Buffer): string + coreValueDescriptorRoundtrip(value: unknown): unknown + coreValueDescriptorToWire(value: unknown): unknown + wireToCoreValueDescriptor(value: unknown): unknown + coreValueDescriptorToCorpusJson(value: unknown): unknown + coreValueDescriptorDisplay(value: unknown): string + coreValueString(value: string): unknown + coreValueHex(value: Buffer): unknown + coreValueRecord(fields: Array<{ name: string; value: unknown }>): unknown + coreValueNull(): unknown + coreValueBool(value: boolean): unknown + coreValueInt(value: bigint): unknown + coreValueUint(value: bigint): unknown + coreValueU256Le(raw: Buffer): unknown + coreValueBytes(value: Buffer): unknown + coreValueList(items: unknown[]): unknown + coreValueTuple(items: unknown[]): unknown + coreValueDict(entries: Array<{ key: unknown; value: unknown }>): unknown - keypairNew(ss58Address: string | null | undefined, publicKey: Buffer | null | undefined, cryptoType: number, ss58Format: number): NativeKeypairHandle - keypairFromMnemonic(mnemonic: string, cryptoType: number, password?: string | null): NativeKeypairHandle + keypairNew( + ss58Address: string | null | undefined, + publicKey: Buffer | null | undefined, + cryptoType: number, + ss58Format: number, + ): NativeKeypairHandle + keypairFromMnemonic( + mnemonic: string, + cryptoType: number, + password?: string | null, + ): NativeKeypairHandle keypairFromSeed(seed: Buffer, cryptoType: number): NativeKeypairHandle keypairFromUri(uri: string, cryptoType: number): NativeKeypairHandle keypairFromPrivateKey(privateKey: string, cryptoType: number): NativeKeypairHandle keypairFromEncryptedJson(jsonData: string, passphrase: string): NativeKeypairHandle generateMnemonic(nWords: number): string encryptFor(ss58Address: string, message: Buffer, cryptoType: number): Buffer - verifySignature(message: Buffer, signature: Buffer, ss58Address: string, cryptoType: number): boolean + verifySignature( + message: Buffer, + signature: Buffer, + ss58Address: string, + cryptoType: number, + ): boolean publicKeyFromSs58(ss58Address: string): Buffer ss58FromPublic(publicKey: Buffer, ss58Format: number): string serializeKeypair(keypair: NativeKeypairHandle): Buffer @@ -162,18 +308,37 @@ export interface NativeBinding { cryptoSr25519(): number defaultSs58Format(): number + convertTypeString(name: string): string + primitiveFromName(name: string): string | null | undefined + normalizeTypeSpec(spec: unknown): unknown eraBirth(period: bigint, current: bigint): bigint - multisigAccountId(signatories: Buffer[], threshold: number): { accountId: Buffer; sortedSignatories: Buffer[] } + multisigAccountId( + signatories: Buffer[], + threshold: number, + ): { accountId: Buffer; sortedSignatories: Buffer[] } multisigSs58(accountId: Buffer, ss58Format: number): string encodeCompact(value: bigint): Buffer - decodeCompactU128(data: Buffer, strict: boolean): { value: bigint; offset: number; remaining: number } - decodeCompactLength(data: Buffer, strict: boolean): { value: bigint; offset: number; remaining: number } + decodeCompactU128( + data: Buffer, + strict: boolean, + ): { value: bigint; offset: number; remaining: number } + decodeCompactLength( + data: Buffer, + strict: boolean, + ): { value: bigint; offset: number; remaining: number } hashStorageParam(hasher: string, data: Buffer): Buffer + storagePrefixFor(prefix: string, name: string): Buffer concatHashLength(hasher: string): number parallelDecodeThreshold(): number metadataDigest(metadataBytes: Buffer, info: Record): Buffer - generateExtrinsicProof(callData: Buffer, includedInExtrinsic: Buffer, includedInSignedData: Buffer, metadataBytes: Buffer, info: Record): Buffer + generateExtrinsicProof( + callData: Buffer, + includedInExtrinsic: Buffer, + includedInSignedData: Buffer, + metadataBytes: Buffer, + info: Record, + ): Buffer mlkemSeal(publicKey: Buffer, plaintext: Buffer, includeKeyHash: boolean): Buffer mlkemTwox128(data: Buffer): Buffer @@ -182,21 +347,65 @@ export interface NativeBinding { timelockEncryptAndCompress(data: Buffer, revealRound: bigint): Buffer timelockDecryptAndDecompress(encryptedData: Buffer, signatureBytes: Buffer): Buffer - timelockGenerateCommitV2(uids: number[], values: number[], versionKey: bigint, state: NativeEpochScheduleState, subnetRevealPeriodEpochs: bigint, blockTime: number, hotkey: Buffer): { ciphertext: Buffer; revealRound: bigint } - timelockEncryptCommitment(data: string, blocksUntilReveal: bigint, blockTime: number): { ciphertext: Buffer; revealRound: bigint } - timelockEncryptNBlocks(data: Buffer, nBlocks: bigint, blockTime: number): { ciphertext: Buffer; revealRound: bigint } - timelockEncryptAtRound(data: Buffer, revealRound: bigint): { ciphertext: Buffer; revealRound: bigint } + timelockGenerateCommitV2( + uids: number[], + values: number[], + versionKey: bigint, + state: NativeEpochScheduleState, + subnetRevealPeriodEpochs: bigint, + blockTime: number, + hotkey: Buffer, + ): { ciphertext: Buffer; revealRound: bigint } + timelockEncryptCommitment( + data: string, + blocksUntilReveal: bigint, + blockTime: number, + ): { ciphertext: Buffer; revealRound: bigint } + timelockEncryptNBlocks( + data: Buffer, + nBlocks: bigint, + blockTime: number, + ): { ciphertext: Buffer; revealRound: bigint } + timelockEncryptAtRound( + data: Buffer, + revealRound: bigint, + ): { ciphertext: Buffer; revealRound: bigint } timelockGetRoundInfo(round?: bigint | null): { round: bigint; signature: string } - timelockGetRevealRoundSignature(revealRound: bigint | null | undefined, noErrors: boolean): string | null | undefined - timelockDecrypt(encryptedData: Buffer, noErrors: boolean): Buffer | null | undefined + timelockGetRevealRoundSignature( + revealRound: bigint | null | undefined, + noErrors: boolean, + ): string | null | undefined + timelockDecrypt( + encryptedData: Buffer, + noErrors: boolean, + ): Buffer | null | undefined timelockDecryptWithSignature(encryptedData: Buffer, signatureHex: string): Buffer epochShouldRun(state: NativeEpochScheduleState, block: bigint): boolean epochCurrentPreRunCoinbase(state: NativeEpochScheduleState, block: bigint): bigint - epochSimulateRunCoinbase(state: NativeEpochScheduleState, block: bigint): NativeEpochScheduleState - epochAdvanceBlocks(state: NativeEpochScheduleState, start: bigint, end: bigint): NativeEpochScheduleState - epochPredictFirstRevealBlock(state: NativeEpochScheduleState, revealPeriodEpochs: bigint): bigint + epochSimulateRunCoinbase( + state: NativeEpochScheduleState, + block: bigint, + ): NativeEpochScheduleState + epochAdvanceBlocks( + state: NativeEpochScheduleState, + start: bigint, + end: bigint, + ): NativeEpochScheduleState + epochPredictFirstRevealBlock( + state: NativeEpochScheduleState, + revealPeriodEpochs: bigint, + ): bigint + epochPredictFirstRevealBlockResult( + state: NativeEpochScheduleState, + revealPeriodEpochs: bigint, + ): { ok: boolean; block?: bigint | null; error?: string | null } encodeWeightsTlockPayload(value: Record): Buffer - decodeWeightsTlockPayload(data: Buffer): { hotkey: Buffer; uids: number[]; values: number[]; versionKey: bigint } + decodeWeightsTlockPayload(data: Buffer): { + hotkey: Buffer + uids: number[] + values: number[] + versionKey: bigint + } encodeTimelockUserData(value: Record): Buffer decodeTimelockUserData(data: Buffer): { encryptedData: Buffer; revealRound: bigint } timelockMaxTempo(): number diff --git a/sdk/typescript-sdk/src/runtime.ts b/sdk/typescript-sdk/src/runtime.ts index 53f9fcbad8..6a2cf24c79 100644 --- a/sdk/typescript-sdk/src/runtime.ts +++ b/sdk/typescript-sdk/src/runtime.ts @@ -1,4 +1,5 @@ import native, { + type NativeCursorHandle, type NativeExtrinsicParams, type NativeRuntimeHandle, type NativeStorageChange, @@ -9,18 +10,29 @@ import { fromWire, toBigInt, toBuffer, toWire } from './wire' import type { ByteLike, CompactDecode, + CoreValueDescriptor, + ExtrinsicInfo, IntegerLike, MapPair, + MetadataIr, ModuleError, MultisigAccount, + PalletInfo, PartialDecode, PayloadParts, + PrimitiveName, + RuntimeApiInfo, + RuntimeApiMap, + RuntimeConstantInfo, + RuntimeSnapshot, ScaleValue, SignedExtrinsic, SignedExtrinsicParams, StorageChange, StorageEntry, + StorageEntryLike, TransactionParams, + TypeSpec, } from './types' function nativeTxParams(params: TransactionParams): NativeTxParams { @@ -55,6 +67,118 @@ function storageEntry(value: import('./native').NativeStorageEntry): StorageEntr } } +/** cyscale-compatible metadata type-name normalization from Rust. */ +export function convertTypeString(name: string): string { + return nativeCall(() => native.convertTypeString(name)) +} + +export function primitiveFromName(name: string): PrimitiveName | null { + return ( + (nativeCall(() => native.primitiveFromName(name)) as + | PrimitiveName + | null + | undefined) ?? null + ) +} + +export function normalizeTypeSpec(spec: TypeSpec): TypeSpec { + return nativeCall(() => native.normalizeTypeSpec(spec) as TypeSpec) +} + +export const typeSpec = Object.freeze({ + id(id: number): TypeSpec { + return normalizeTypeSpec({ kind: 'id', id }) + }, + primitive(name: PrimitiveName): TypeSpec { + return normalizeTypeSpec({ kind: 'primitive', name }) + }, + sequence(inner: TypeSpec): TypeSpec { + return normalizeTypeSpec({ kind: 'sequence', inner }) + }, + option(inner: TypeSpec): TypeSpec { + return normalizeTypeSpec({ kind: 'option', inner }) + }, + array(inner: TypeSpec, length: number): TypeSpec { + return normalizeTypeSpec({ kind: 'array', inner, length }) + }, + tuple(items: TypeSpec[]): TypeSpec { + return normalizeTypeSpec({ kind: 'tuple', items }) + }, + compact(inner: TypeSpec): TypeSpec { + return normalizeTypeSpec({ kind: 'compact', inner }) + }, + bytes(): TypeSpec { + return normalizeTypeSpec({ kind: 'bytes' }) + }, + accountId(): TypeSpec { + return normalizeTypeSpec({ kind: 'accountId' }) + }, + era(): TypeSpec { + return normalizeTypeSpec({ kind: 'era' }) + }, + call(): TypeSpec { + return normalizeTypeSpec({ kind: 'call' }) + }, + extrinsic(): TypeSpec { + return normalizeTypeSpec({ kind: 'extrinsic' }) + }, +}) + +/** Owned Node-API wrapper over Rust's public SCALE `Cursor`. */ +export class ScaleCursor { + private readonly handle: NativeCursorHandle + + constructor(data: ByteLike, strict = false, offset = 0) { + this.handle = nativeCall(() => + native.NativeCursor.fromBytes(toBuffer(data, 'data'), strict, offset), + ) + } + + get data(): Buffer { + return Buffer.from(this.handle.data) + } + + get offset(): number { + return this.handle.offset + } + + get remaining(): number { + return this.handle.remaining + } + + get strict(): boolean { + return this.handle.strict + } + + set strict(value: boolean) { + nativeCall(() => this.handle.setStrict(value)) + } + + seek(offset: number): void { + nativeCall(() => this.handle.seek(offset)) + } + + reset(data: ByteLike, strict = false, offset = 0): void { + nativeCall(() => this.handle.reset(toBuffer(data, 'data'), strict, offset)) + } + + take(length: number): Buffer { + return nativeCall(() => Buffer.from(this.handle.take(length))) + } + + byte(): number { + return nativeCall(() => this.handle.byte()) + } + + decodeCompactU128(): bigint { + return nativeCall(() => this.handle.decodeCompactU128()) + } + + decodeCompactLength(): bigint { + return nativeCall(() => this.handle.decodeCompactLength()) + } +} + export class Runtime { private readonly handle: NativeRuntimeHandle @@ -184,8 +308,176 @@ export class Runtime { return this.handle.typeNameOf(id) ?? null } - typeSpec(typeString: string): unknown { - return nativeCall(() => this.handle.typeSpec(typeString)) + typeSpec(typeString: string): TypeSpec { + return nativeCall(() => this.handle.typeSpec(typeString) as TypeSpec) + } + + decodeSpec( + spec: TypeSpec, + data: ByteLike, + strict = true, + ): T { + return nativeCall( + () => + fromWire( + this.handle.decodeSpec(spec, toBuffer(data, 'data'), strict), + ) as T, + ) + } + + decodeSpecDescriptor( + spec: TypeSpec, + data: ByteLike, + strict = true, + ): CoreValueDescriptor { + return nativeCall( + () => + this.handle.decodeSpecDescriptor( + spec, + toBuffer(data, 'data'), + strict, + ) as CoreValueDescriptor, + ) + } + + decodeValue( + spec: TypeSpec, + data: ByteLike, + offset = 0, + strict = false, + ): PartialDecode { + return nativeCall(() => { + const decoded = this.handle.decodeValue( + spec, + toBuffer(data, 'data'), + offset, + strict, + ) + return { ...decoded, value: fromWire(decoded.value) as T } + }) + } + + decodeValueDescriptor( + spec: TypeSpec, + data: ByteLike, + offset = 0, + strict = false, + ): PartialDecode { + return nativeCall( + () => + this.handle.decodeValueDescriptor( + spec, + toBuffer(data, 'data'), + offset, + strict, + ) as PartialDecode, + ) + } + + decodeTypeIdDescriptor( + typeId: number, + data: ByteLike, + strict = true, + ): CoreValueDescriptor { + return nativeCall( + () => + this.handle.decodeTypeIdDescriptor( + typeId, + toBuffer(data, 'data'), + strict, + ) as CoreValueDescriptor, + ) + } + + decodeTypeIdDescriptorPartial( + typeId: number, + data: ByteLike, + offset = 0, + strict = false, + ): PartialDecode { + return nativeCall( + () => + this.handle.decodeTypeIdDescriptorPartial( + typeId, + toBuffer(data, 'data'), + offset, + strict, + ) as PartialDecode, + ) + } + + encodeSpec(spec: TypeSpec, value: ScaleValue): Buffer { + return nativeCall(() => this.handle.encodeSpec(spec, toWire(value))) + } + + encodeSpecDescriptor(spec: TypeSpec, value: CoreValueDescriptor): Buffer { + return nativeCall(() => this.handle.encodeSpecDescriptor(spec, value)) + } + + encodeValue( + spec: TypeSpec, + value: ScaleValue, + prefix?: ByteLike | null, + ): Buffer { + return nativeCall(() => + this.handle.encodeValue( + spec, + toWire(value), + prefix == null ? undefined : toBuffer(prefix, 'prefix'), + ), + ) + } + + encodeValueDescriptor( + spec: TypeSpec, + value: CoreValueDescriptor, + prefix?: ByteLike | null, + ): Buffer { + return nativeCall(() => + this.handle.encodeValueDescriptor( + spec, + value, + prefix == null ? undefined : toBuffer(prefix, 'prefix'), + ), + ) + } + + encodeId( + typeId: number, + value: ScaleValue, + prefix?: ByteLike | null, + ): Buffer { + return nativeCall(() => + this.handle.encodeId( + typeId, + toWire(value), + prefix == null ? undefined : toBuffer(prefix, 'prefix'), + ), + ) + } + + encodeIdDescriptor( + typeId: number, + value: CoreValueDescriptor, + prefix?: ByteLike | null, + ): Buffer { + return nativeCall(() => + this.handle.encodeIdDescriptor( + typeId, + value, + prefix == null ? undefined : toBuffer(prefix, 'prefix'), + ), + ) + } + + coerceAccountId(value: ScaleValue): Buffer { + return nativeCall(() => Buffer.from(this.handle.coerceAccountId(toWire(value)))) + } + + coerceAccountIdDescriptor(value: CoreValueDescriptor): Buffer { + return nativeCall(() => + Buffer.from(this.handle.coerceAccountIdDescriptor(value)), + ) } resolveType(id: number): unknown { @@ -200,28 +492,32 @@ export class Runtime { return nativeCall(() => this.handle.registry()) } - pallet(name: string): unknown | null { - return this.handle.pallet(name) ?? null + pallet(name: string): PalletInfo | null { + return (this.handle.pallet(name) as PalletInfo | null | undefined) ?? null + } + + palletAt(index: number): PalletInfo | null { + return (this.handle.palletAt(index) as PalletInfo | null | undefined) ?? null } - palletAt(index: number): unknown | null { - return this.handle.palletAt(index) ?? null + pallets(): PalletInfo[] { + return this.handle.pallets() as PalletInfo[] } - pallets(): unknown[] { - return this.handle.pallets() + extrinsicInfo(): ExtrinsicInfo { + return this.handle.extrinsicInfo() as ExtrinsicInfo } - extrinsicInfo(): unknown { - return this.handle.extrinsicInfo() + runtimeApis(): RuntimeApiMap { + return this.handle.runtimeApis() as RuntimeApiMap } - runtimeApis(): unknown { - return this.handle.runtimeApis() + runtimeApiInfos(): RuntimeApiInfo[] { + return this.handle.runtimeApiInfos() as RuntimeApiInfo[] } - runtimeSnapshot(): unknown { - return this.handle.runtimeSnapshot() + runtimeSnapshot(): RuntimeSnapshot { + return this.handle.runtimeSnapshot() as RuntimeSnapshot } composeCall(pallet: string, fn: string, params: ScaleValue): Buffer { @@ -234,6 +530,36 @@ export class Runtime { ) } + decodeCallValue( + data: ByteLike, + offset = 0, + strict = false, + ): PartialDecode { + return nativeCall(() => { + const decoded = this.handle.decodeCallValue( + toBuffer(data, 'data'), + offset, + strict, + ) + return { ...decoded, value: fromWire(decoded.value) as T } + }) + } + + decodeCallValueDescriptor( + data: ByteLike, + offset = 0, + strict = false, + ): PartialDecode { + return nativeCall( + () => + this.handle.decodeCallValueDescriptor( + toBuffer(data, 'data'), + offset, + strict, + ) as PartialDecode, + ) + } + storageEntry(pallet: string, storageFunction: string): StorageEntry { return nativeCall(() => storageEntry(this.handle.storageEntry(pallet, storageFunction))) } @@ -325,8 +651,13 @@ export class Runtime { }) } - constantInfo(pallet: string, name: string): unknown | null { - return this.handle.constantInfo(pallet, name) ?? null + constantInfo(pallet: string, name: string): RuntimeConstantInfo | null { + return ( + (this.handle.constantInfo(pallet, name) as + | RuntimeConstantInfo + | null + | undefined) ?? null + ) } moduleError(moduleIndex: number, errorIndex: number): ModuleError { @@ -378,12 +709,12 @@ export class Runtime { ) } - runtimeApiMap(): unknown { - return this.handle.runtimeApiMap() + runtimeApiMap(): RuntimeApiMap { + return this.handle.runtimeApiMap() as RuntimeApiMap } - metadataIr(): unknown { - return nativeCall(() => this.handle.metadataIr()) + metadataIr(): MetadataIr { + return nativeCall(() => this.handle.metadataIr() as MetadataIr) } } @@ -426,8 +757,26 @@ export function hashStorageParam(hasher: string, data: ByteLike): Buffer { return nativeCall(() => native.hashStorageParam(hasher, toBuffer(data, 'data'))) } +export function storagePrefixFor(entry: StorageEntryLike): Buffer +export function storagePrefixFor(prefix: string, name: string): Buffer +export function storagePrefixFor( + entryOrPrefix: StorageEntryLike | string, + name?: string, +): Buffer { + const prefix = + typeof entryOrPrefix === 'string' ? entryOrPrefix : entryOrPrefix.prefix + const storageName = + typeof entryOrPrefix === 'string' ? name : entryOrPrefix.name + if (storageName == null) { + throw new TypeError('storage name is required') + } + return nativeCall(() => native.storagePrefixFor(prefix, storageName)) +} + export function concatHashLength(hasher: string): number { return nativeCall(() => native.concatHashLength(hasher)) } export const PARALLEL_DECODE_THRESHOLD = native.parallelDecodeThreshold() +/** Rust-name alias. */ +export const PARALLEL_THRESHOLD = PARALLEL_DECODE_THRESHOLD diff --git a/sdk/typescript-sdk/src/timelock.ts b/sdk/typescript-sdk/src/timelock.ts index 13750d0ef1..6875479d90 100644 --- a/sdk/typescript-sdk/src/timelock.ts +++ b/sdk/typescript-sdk/src/timelock.ts @@ -5,6 +5,7 @@ import type { ByteLike, CiphertextRound, DrandResponse, + EpochScheduleResult, EpochScheduleState, IntegerLike, TimelockUserData, @@ -37,6 +38,8 @@ export const MAX_TEMPO = native.timelockMaxTempo() export const MAX_TEMPO_U64 = native.timelockMaxTempoU64() export const DRAND_PUBLIC_KEY = native.timelockDrandPublicKey() export const DRAND_GENESIS_TIME = native.timelockGenesisTime() +/** Rust-name alias. */ +export const GENESIS_TIME = DRAND_GENESIS_TIME export const DRAND_PERIOD = native.timelockDrandPeriod() export const QUICKNET_CHAIN_HASH = native.timelockQuicknetChainHash() export const DRAND_ENDPOINTS = Object.freeze(native.timelockDrandEndpoints()) @@ -214,6 +217,28 @@ export function predictFirstRevealBlock( ) } +export const EpochScheduleError = Object.freeze([ + 'BoundExceeded', + 'TempoIsZero', +] as const) + +export function predictFirstRevealBlockResult( + state: EpochScheduleState, + revealPeriodEpochs: IntegerLike, +): EpochScheduleResult { + return nativeCall(() => { + const result = native.epochPredictFirstRevealBlockResult( + nativeState(state), + toBigInt(revealPeriodEpochs, 'revealPeriodEpochs'), + ) + return { + ok: result.ok, + block: result.block ?? null, + error: (result.error as EpochScheduleResult['error'] | undefined) ?? null, + } + }) +} + export function encodeWeightsTlockPayload(payload: WeightsTlockPayload): Buffer { return nativeCall(() => native.encodeWeightsTlockPayload({ @@ -258,6 +283,7 @@ export const timelock = Object.freeze({ simulateRunCoinbase, advanceBlocks, predictFirstRevealBlock, + predictFirstRevealBlockResult, encodeWeightsTlockPayload, decodeWeightsTlockPayload, encodeTimelockUserData, diff --git a/sdk/typescript-sdk/src/types.ts b/sdk/typescript-sdk/src/types.ts index 37669c95c1..f856c8df64 100644 --- a/sdk/typescript-sdk/src/types.ts +++ b/sdk/typescript-sdk/src/types.ts @@ -12,6 +12,56 @@ export type ScaleValue = | { [key: string]: ScaleValue } | Map +/** Exact, lossless representation of Rust's `bittensor_core::codec::Value`. */ +export type CoreValueDescriptor = + | { kind: 'null' } + | { kind: 'bool'; value: boolean } + | { kind: 'int'; value: string } + | { kind: 'uint'; value: string } + | { kind: 'u256'; littleEndianHex: `0x${string}` } + | { kind: 'str'; value: string } + | { kind: 'bytes'; hex: `0x${string}` } + | { kind: 'list'; items: CoreValueDescriptor[] } + | { kind: 'tuple'; items: CoreValueDescriptor[] } + | { kind: 'dict'; entries: CoreValueEntry[] } + +export interface CoreValueEntry { + key: CoreValueDescriptor + value: CoreValueDescriptor +} + +export type PrimitiveName = + | 'bool' + | 'char' + | 'str' + | 'u8' + | 'u16' + | 'u32' + | 'u64' + | 'u128' + | 'u256' + | 'i8' + | 'i16' + | 'i32' + | 'i64' + | 'i128' + | 'i256' + +/** Exact public shape of Rust's `runtime::type_string::TypeSpec`. */ +export type TypeSpec = + | { kind: 'id'; id: number } + | { kind: 'primitive'; name: PrimitiveName } + | { kind: 'sequence'; inner: TypeSpec } + | { kind: 'option'; inner: TypeSpec } + | { kind: 'array'; inner: TypeSpec; length: number } + | { kind: 'tuple'; items: TypeSpec[] } + | { kind: 'compact'; inner: TypeSpec } + | { kind: 'bytes' } + | { kind: 'accountId' } + | { kind: 'era' } + | { kind: 'call' } + | { kind: 'extrinsic' } + export interface PartialDecode { value: T offset: number @@ -24,10 +74,21 @@ export interface CompactDecode { remaining: number } -export interface StorageEntry { - pallet: string +export interface StorageEntryLike { + pallet?: string name: string prefix: string + modifier?: string + valueType?: string + valueTypeId?: number + paramTypes?: string[] + paramTypeIds?: number[] + paramHashers?: string[] + defaultBytes?: ByteLike +} + +export interface StorageEntry extends StorageEntryLike { + pallet: string modifier: string valueType: string valueTypeId: number @@ -52,6 +113,127 @@ export interface ModuleError { docs: string[] } +export interface RuntimeConstantInfo { + name: string + typeId: number + type: string + valueHex: string + docs: string[] +} + +export interface RuntimeStorageInfo { + name: string + prefix: string + modifier: string + hashers: string[] + keyTypeIds: number[] + keyTypes: string[] + valueTypeId: number + valueType: string + defaultHex: string +} + +export interface PalletInfo { + name: string + index: number + callsType: number | null + eventsType: number | null + errorsType: number | null + constants: RuntimeConstantInfo[] + storage: RuntimeStorageInfo[] +} + +export interface SignedExtensionInfo { + identifier: string + typeId: number + type: string + additionalSignedTypeId: number + additionalSignedType: string +} + +export interface ExtrinsicInfo { + version: number + addressType: number | null + callType: number | null + signatureType: number | null + signedExtensions: SignedExtensionInfo[] +} + +export interface RuntimeApiParamInfo { + name: string + typeId: number + type: string +} + +export interface RuntimeApiMethodInfo { + name: string + inputs: RuntimeApiParamInfo[] + output: number + outputType: string + docs: string[] +} + +export interface RuntimeApiInfo { + name: string + methods: RuntimeApiMethodInfo[] +} + +export type RuntimeApiMap = Record< + string, + Record< + string, + { + name: string + inputs: Array<[string, string]> + inputDetails?: RuntimeApiParamInfo[] + output: string + outputTypeId: number + docs: string[] + } + > +> + +export interface RuntimeSnapshot { + specVersion: number + transactionVersion: number + ss58Format: number + isV15: boolean + outerEventType: number | null + pallets: PalletInfo[] + extrinsic: ExtrinsicInfo + runtimeApis: RuntimeApiMap + runtimeApiInfos: RuntimeApiInfo[] +} + +export interface MetadataIrCall { + name: string + index: number + args: string[] + argTypes: string[] + docs: string +} + +export interface MetadataIrError { + index: number + name: string + docs: string +} + +export interface MetadataIrPallet { + name: string + index: number + calls: MetadataIrCall[] + errors: MetadataIrError[] + storage: string[] + constants: string[] +} + +export interface MetadataIr { + specVersion: number + pallets: MetadataIrPallet[] + runtimeApis: Array<{ name: string; methods: string[] }> +} + export interface TransactionParams { era: ScaleValue nonce: IntegerLike @@ -112,6 +294,14 @@ export interface EpochScheduleState { currentBlock: IntegerLike } +export type EpochScheduleErrorName = 'BoundExceeded' | 'TempoIsZero' + +export interface EpochScheduleResult { + ok: boolean + block: bigint | null + error: EpochScheduleErrorName | null +} + export interface WeightsTlockPayload { hotkey: ByteLike uids: number[] diff --git a/sdk/typescript-sdk/src/value.ts b/sdk/typescript-sdk/src/value.ts new file mode 100644 index 0000000000..f9399f512c --- /dev/null +++ b/sdk/typescript-sdk/src/value.ts @@ -0,0 +1,170 @@ +import native from './native' +import { nativeCall } from './errors' +import { fromWire, toBigInt, toBuffer, toWire } from './wire' +import type { + ByteLike, + CoreValueDescriptor, + CoreValueEntry, + IntegerLike, + ScaleValue, +} from './types' + +function roundtrip(value: CoreValueDescriptor): CoreValueDescriptor { + return nativeCall( + () => native.coreValueDescriptorRoundtrip(value) as CoreValueDescriptor, + ) +} + +function hex(value: ByteLike): `0x${string}` { + return `0x${toBuffer(value, 'value').toString('hex')}` +} + +export function coreValueDescriptorRoundtrip( + value: CoreValueDescriptor, +): CoreValueDescriptor { + return roundtrip(value) +} + +/** Alias emphasizing that the descriptor is normalized by Rust. */ +export const normalizeCoreValue = coreValueDescriptorRoundtrip + +export function coreValueDescriptorToWire(value: CoreValueDescriptor): ScaleValue { + return nativeCall(() => fromWire(native.coreValueDescriptorToWire(value))) +} + +export function wireToCoreValueDescriptor(value: ScaleValue): CoreValueDescriptor { + return nativeCall( + () => native.wireToCoreValueDescriptor(toWire(value)) as CoreValueDescriptor, + ) +} + +export function coreValueDescriptorToCorpusJson( + value: CoreValueDescriptor, +): unknown { + return nativeCall(() => native.coreValueDescriptorToCorpusJson(value)) +} + +export function coreValueDescriptorDisplay(value: CoreValueDescriptor): string { + return nativeCall(() => native.coreValueDescriptorDisplay(value)) +} + +export function coreValueNull(): CoreValueDescriptor { + return nativeCall(() => native.coreValueNull() as CoreValueDescriptor) +} + +export function coreValueBool(value: boolean): CoreValueDescriptor { + return nativeCall(() => native.coreValueBool(value) as CoreValueDescriptor) +} + +export function coreValueInt(value: IntegerLike): CoreValueDescriptor { + return nativeCall( + () => native.coreValueInt(toBigInt(value, 'value')) as CoreValueDescriptor, + ) +} + +export function coreValueUint(value: IntegerLike): CoreValueDescriptor { + return nativeCall( + () => native.coreValueUint(toBigInt(value, 'value')) as CoreValueDescriptor, + ) +} + +export function coreValueU256Le(value: ByteLike): CoreValueDescriptor { + return nativeCall( + () => native.coreValueU256Le(toBuffer(value, 'value')) as CoreValueDescriptor, + ) +} + +export function coreValueString(value: string): CoreValueDescriptor { + return nativeCall(() => native.coreValueString(value) as CoreValueDescriptor) +} + +export function coreValueBytes(value: ByteLike): CoreValueDescriptor { + return nativeCall( + () => native.coreValueBytes(toBuffer(value, 'value')) as CoreValueDescriptor, + ) +} + +export function coreValueList(items: CoreValueDescriptor[]): CoreValueDescriptor { + return nativeCall(() => native.coreValueList(items) as CoreValueDescriptor) +} + +export function coreValueTuple(items: CoreValueDescriptor[]): CoreValueDescriptor { + return nativeCall(() => native.coreValueTuple(items) as CoreValueDescriptor) +} + +export function coreValueDict(entries: CoreValueEntry[]): CoreValueDescriptor { + return nativeCall(() => native.coreValueDict(entries) as CoreValueDescriptor) +} + +export function coreValueRecord( + fields: Array<[name: string, value: CoreValueDescriptor]>, +): CoreValueDescriptor { + return nativeCall( + () => + native.coreValueRecord( + fields.map(([name, value]) => ({ name, value })), + ) as CoreValueDescriptor, + ) +} + +/** Rust `Value::hex`: construct a string descriptor containing `0x` hex. */ +export function coreValueHex(value: ByteLike): CoreValueDescriptor { + return nativeCall( + () => native.coreValueHex(toBuffer(value, 'value')) as CoreValueDescriptor, + ) +} + +/** Descriptor-only constructors that do not cross FFI until normalized. */ +export const coreValueDescriptor = Object.freeze({ + null(): CoreValueDescriptor { + return { kind: 'null' } + }, + bool(value: boolean): CoreValueDescriptor { + return { kind: 'bool', value } + }, + int(value: IntegerLike): CoreValueDescriptor { + return { kind: 'int', value: toBigInt(value, 'value').toString() } + }, + uint(value: IntegerLike): CoreValueDescriptor { + return { kind: 'uint', value: toBigInt(value, 'value').toString() } + }, + u256Le(value: ByteLike): CoreValueDescriptor { + return { kind: 'u256', littleEndianHex: hex(value) } + }, + str(value: string): CoreValueDescriptor { + return { kind: 'str', value } + }, + bytes(value: ByteLike): CoreValueDescriptor { + return { kind: 'bytes', hex: hex(value) } + }, + list(items: CoreValueDescriptor[]): CoreValueDescriptor { + return { kind: 'list', items } + }, + tuple(items: CoreValueDescriptor[]): CoreValueDescriptor { + return { kind: 'tuple', items } + }, + dict(entries: CoreValueEntry[]): CoreValueDescriptor { + return { kind: 'dict', entries } + }, +}) + +export const coreValue = Object.freeze({ + null: coreValueNull, + bool: coreValueBool, + int: coreValueInt, + uint: coreValueUint, + u256Le: coreValueU256Le, + str: coreValueString, + bytes: coreValueBytes, + list: coreValueList, + tuple: coreValueTuple, + dict: coreValueDict, + record: coreValueRecord, + hex: coreValueHex, + roundtrip: coreValueDescriptorRoundtrip, + normalize: normalizeCoreValue, + toWire: coreValueDescriptorToWire, + fromWire: wireToCoreValueDescriptor, + toCorpusJson: coreValueDescriptorToCorpusJson, + display: coreValueDescriptorDisplay, +}) diff --git a/sdk/typescript-sdk/src/wire.ts b/sdk/typescript-sdk/src/wire.ts index d030510987..a65fa191c0 100644 --- a/sdk/typescript-sdk/src/wire.ts +++ b/sdk/typescript-sdk/src/wire.ts @@ -100,7 +100,7 @@ function toWireAt(value: ScaleValue, depth: number): WireValue { } } - const output: Record = {} + const output: Record = Object.create(null) for (const [key, item] of entries) { if (item === undefined) { throw new TypeError(`SCALE object field ${JSON.stringify(key)} is undefined`) @@ -155,7 +155,7 @@ function fromWireAt(value: unknown, depth: number): ScaleValue { return output } - const output: Record = {} + const output: Record = Object.create(null) for (const [key, item] of Object.entries(object)) { output[key] = fromWireAt(item, depth + 1) } diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index e13ebbefe6..1f3de65a0e 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -120,3 +120,126 @@ test('ESM consumers receive the same named exports', async () => { assert.equal(esm.BINDING_VERSION, core.BINDING_VERSION) assert.equal(esm.Keypair, core.Keypair) }) + +test('exact codec::Value descriptors preserve every Rust enum variant', () => { + const descriptor = core.coreValueDict([ + { key: core.coreValueString('int'), value: core.coreValueInt(-(2n ** 127n)) }, + { key: core.coreValueString('uint'), value: core.coreValueUint(2n ** 128n - 1n) }, + { + key: core.coreValueString('u256'), + value: core.coreValueU256Le(Buffer.alloc(32, 0xff)), + }, + { + key: core.coreValueString('containers'), + value: core.coreValueTuple([ + core.coreValueList([core.coreValueBytes(Buffer.from([1, 2, 3]))]), + core.coreValueString('text'), + ]), + }, + ]) + + const normalized = core.normalizeCoreValue(descriptor) + assert.equal(normalized.kind, 'dict') + assert.equal(normalized.entries[0].value.kind, 'int') + assert.equal(normalized.entries[1].value.kind, 'uint') + assert.equal(normalized.entries[2].value.kind, 'u256') + assert.equal(normalized.entries[3].value.kind, 'tuple') + assert.equal( + core.u256LeToDecimal(Buffer.alloc(32, 0xff)), + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + ) +}) + +test('public Cursor and TypeSpec APIs are forwarded to Rust', () => { + const encoded = core.encodeCompact(16384n) + const cursor = new core.ScaleCursor(Buffer.concat([Buffer.from([9]), encoded]), true) + assert.equal(cursor.byte(), 9) + assert.equal(cursor.decodeCompactU128(), 16384n) + assert.equal(cursor.remaining, 0) + + assert.deepEqual(core.typeSpec.array(core.typeSpec.primitive('u8'), 32), { + kind: 'array', + inner: { kind: 'primitive', name: 'u8' }, + length: 32, + }) + assert.equal(core.primitiveFromName('String'), 'str') + assert.equal(core.convertTypeString('Vec'), 'Bytes') +}) + +test('epoch errors are available as exact Rust variants without throwing', () => { + const state = { + lastEpochBlock: 0n, + pendingEpochAt: 0n, + subnetEpochIndex: 0n, + tempo: 0, + blocksSinceLastStep: 0n, + currentBlock: 0n, + } + assert.deepEqual(core.predictFirstRevealBlockResult(state, 1n), { + ok: false, + block: null, + error: 'TempoIsZero', + }) +}) + +test('module-shaped export mirrors the public Rust crate', () => { + assert.equal(core.rustCore.keys.Keypair, core.Keypair) + assert.equal(core.rustCore.codec.decode.Cursor, core.ScaleCursor) + assert.equal(core.rustCore.codec.batch.PARALLEL_THRESHOLD, core.PARALLEL_THRESHOLD) + assert.equal(core.rustCore.mlkem.MLKEM_NONCE_LEN, 24) + assert.equal(core.rustCore.timelock.constants.GENESIS_TIME, core.GENESIS_TIME) + assert.deepEqual(core.rustCore.timelock.epoch_schedule.EpochScheduleError, [ + 'BoundExceeded', + 'TempoIsZero', + ]) +}) + +test('arbitrary StorageInfo helpers call Rust directly', () => { + const entry = { + pallet: 'System', + name: 'Account', + prefix: 'System', + modifier: 'Default', + valueType: 'scale_info::0', + valueTypeId: 0, + paramTypes: [], + paramTypeIds: [], + paramHashers: [], + defaultBytes: Buffer.alloc(0), + } + assert.equal( + core.storagePrefixFor(entry).toString('hex'), + '26aa394eea5630e07c48ae0c9558cef7b99d880ec681799c0cf30e8886371da9', + ) +}) + +test('prototype-sensitive decoded keys never become object prototypes', () => { + const dangerous = new Map([ + ['__proto__', { polluted: true }], + ['constructor', 7n], + ]) + const output = core.wireRoundtrip(dangerous) + assert.ok(output instanceof Map) + assert.equal(output.get('__proto__').polluted, true) + assert.equal(output.get('constructor'), 7n) + assert.equal({}.polluted, undefined) +}) + +test('native keypair exposes the exact Rust backing variant', () => { + assert.equal(core.Keypair.fromUri('//Alice').kind, 'Sr25519') + assert.equal(new core.Keypair(core.Keypair.fromUri('//Alice').ss58Address).kind, 'PublicOnly') +}) + +test('raw native escape hatch includes the complete low-level bridge', () => { + for (const name of [ + 'coreValueDescriptorRoundtrip', + 'convertTypeString', + 'normalizeTypeSpec', + 'storagePrefixFor', + 'epochPredictFirstRevealBlockResult', + ]) { + assert.equal(typeof core.native[name], 'function', `${name} is exported`) + } + assert.equal(typeof core.native.NativeCursor.fromBytes, 'function') + assert.equal(typeof core.native.NativeRuntime.fromMetadata, 'function') +}) From 69a636a1eb99e9ba9aae7c8ddb6ce46662315e82 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 10:01:19 -0700 Subject: [PATCH 05/72] Update basic.test.cjs --- sdk/typescript-sdk/test/basic.test.cjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 1f3de65a0e..432f29a402 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -30,7 +30,10 @@ test('Rust keypair is compatible with Polkadot.js and Moonwall signer expectatio assert.equal(raw.length, 64) assert.equal(typed.length, 65) assert.equal(typed[0], core.CRYPTO_SR25519) - assert.deepEqual(typed.subarray(1), raw) + + // sr25519 uses a randomized nonce, so two independently created signatures + // for the same payload are both valid but are not required to be identical. + assert.equal(alice.verify(payload, raw, alice.publicKey), true) assert.equal(alice.verify(payload, typed, alice.publicKey), true) }) From 90e41981b9a9a50d6c03f08f9cbebad404198739 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 10:45:37 -0700 Subject: [PATCH 06/72] fix review --- .github/workflows/typescript-e2e.yml | 2 +- sdk/typescript-sdk/src/keys.ts | 57 ++++++++++++++++++++++---- sdk/typescript-sdk/test/basic.test.cjs | 23 +++++++++++ 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index 50594bc524..b47b525c7d 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -43,7 +43,7 @@ jobs: run: | set -euo pipefail files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename') - pattern='^(ts-tests|common|node|pallets|precompiles|primitives|runtime|support|chain-extensions|src|vendor)/|^(Cargo\.(toml|lock)|build\.rs|rust-toolchain\.toml)$|^\.github/workflows/typescript-e2e\.yml$' + pattern='^sdk/typescript-sdk/|^(ts-tests|common|node|pallets|precompiles|primitives|runtime|support|chain-extensions|src|vendor)/|^(Cargo\.(toml|lock)|build\.rs|rust-toolchain\.toml)$|^\.github/workflows/typescript-e2e\.yml$' if grep -qE "$pattern" <<< "$files"; then echo "e2e=true" >> "$GITHUB_OUTPUT" else diff --git a/sdk/typescript-sdk/src/keys.ts b/sdk/typescript-sdk/src/keys.ts index 33edbd60c5..8e959a77e1 100644 --- a/sdk/typescript-sdk/src/keys.ts +++ b/sdk/typescript-sdk/src/keys.ts @@ -71,8 +71,35 @@ function unsupported(operation: string): never { * shape. TypeScript's `private` keyword is compile-time only and would leave * class fields enumerable at runtime. */ +interface DerivationSource { + baseUri: string + password?: string +} + const metadataStore = new WeakMap() -const sourceUriStore = new WeakMap() +const derivationSourceStore = new WeakMap() + +function derivationSourceFromMnemonic( + mnemonic: string, + password?: string | null, +): DerivationSource { + return password == null ? { baseUri: mnemonic } : { baseUri: mnemonic, password } +} + +function derivationSourceFromUri(uri: string): DerivationSource { + const passwordSeparator = uri.indexOf('///') + if (passwordSeparator === -1) return { baseUri: uri } + return { + baseUri: uri.slice(0, passwordSeparator), + password: uri.slice(passwordSeparator + 3), + } +} + +function derivationUri(source: DerivationSource): string { + return source.password === undefined + ? source.baseUri + : `${source.baseUri}///${source.password}` +} function sanitizeMetadata(metadata: KeypairMetadata): KeypairMetadata { const sanitized = { ...metadata } @@ -113,13 +140,15 @@ export class Keypair implements PolkadotCompatibleKeypair { private static wrap( handle: NativeKeypairHandle, - sourceUri?: string, + derivationSource?: DerivationSource, metadata: KeypairMetadata = {}, ): Keypair { const keypair = Object.create(Keypair.prototype) as Keypair keypair.handle = handle metadataStore.set(keypair, sanitizeMetadata(metadata)) - if (sourceUri != null) sourceUriStore.set(keypair, sourceUri) + if (derivationSource != null) { + derivationSourceStore.set(keypair, { ...derivationSource }) + } return keypair } @@ -130,7 +159,7 @@ export class Keypair implements PolkadotCompatibleKeypair { ): Keypair { return Keypair.wrap( nativeCall(() => native.keypairFromMnemonic(mnemonic, cryptoType, password ?? undefined)), - password == null ? mnemonic : undefined, + derivationSourceFromMnemonic(mnemonic, password), { type: keyTypeForCryptoType(cryptoType) }, ) } @@ -158,7 +187,7 @@ export class Keypair implements PolkadotCompatibleKeypair { static fromUri(uri: string, cryptoType = CRYPTO_SR25519): Keypair { return Keypair.wrap( nativeCall(() => native.keypairFromUri(uri, cryptoType)), - uri, + derivationSourceFromUri(uri), { type: keyTypeForCryptoType(cryptoType) }, ) } @@ -299,15 +328,25 @@ export class Keypair implements PolkadotCompatibleKeypair { } derive(suri: string, meta: KeypairMetadata = {}): Keypair { - const sourceUri = sourceUriStore.get(this) - if (sourceUri == null) { + const source = derivationSourceStore.get(this) + if (source == null) { throw new Error('derivation requires a keypair created from a mnemonic or secret URI') } - const derived = Keypair.fromUri(`${sourceUri}${suri}`, this.cryptoType) + + const childSource: DerivationSource = { + ...source, + baseUri: `${source.baseUri}${suri}`, + } + const derived = Keypair.wrap( + nativeCall(() => + native.keypairFromUri(derivationUri(childSource), this.cryptoType), + ), + childSource, + { type: keyTypeForCryptoType(this.cryptoType) }, + ) derived.setMeta(meta) return derived } - setMeta(meta: KeypairMetadata): void { metadataStore.set( this, diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 432f29a402..f2ef211184 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -246,3 +246,26 @@ test('raw native escape hatch includes the complete low-level bridge', () => { assert.equal(typeof core.native.NativeCursor.fromBytes, 'function') assert.equal(typeof core.native.NativeRuntime.fromMetadata, 'function') }) + +test('password-protected mnemonic keypairs derive children without exposing secrets', () => { + const mnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + const password = 'protected-derivation-password' + const parent = core.Keypair.fromMnemonic(mnemonic, core.CRYPTO_SR25519, password) + + const child = parent.derive('//child') + const expectedChild = core.Keypair.fromUri(`${mnemonic}//child///${password}`) + assert.deepEqual(child.publicKey, expectedChild.publicKey) + + const grandchild = child.derive('//grandchild') + const expectedGrandchild = core.Keypair.fromUri( + `${mnemonic}//child//grandchild///${password}`, + ) + assert.deepEqual(grandchild.publicKey, expectedGrandchild.publicKey) + + for (const pair of [parent, child, grandchild]) { + assert.equal(pair.meta.suri, undefined) + assert.equal(Object.prototype.hasOwnProperty.call(pair, 'sourceUri'), false) + assert.equal(Object.prototype.hasOwnProperty.call(pair, 'derivationSource'), false) + } +}) From 89cd213dd5a13b476123920e233f2780d7251eba Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 10:53:37 -0700 Subject: [PATCH 07/72] clippy --- sdk/typescript-sdk/native/src/ledger.rs | 2 +- sdk/typescript-sdk/native/src/runtime.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/typescript-sdk/native/src/ledger.rs b/sdk/typescript-sdk/native/src/ledger.rs index ce992ce0e8..54c7bf483a 100644 --- a/sdk/typescript-sdk/native/src/ledger.rs +++ b/sdk/typescript-sdk/native/src/ledger.rs @@ -25,7 +25,7 @@ pub struct NativeLedgerDevice { #[napi] impl NativeLedgerDevice { #[napi(factory)] - pub fn open() -> NapiResult { + pub fn open() -> napi::Result { LedgerDevice::open().napi().map(|inner| Self { inner }) } diff --git a/sdk/typescript-sdk/native/src/runtime.rs b/sdk/typescript-sdk/native/src/runtime.rs index 2f82a24c25..02bf0c2770 100644 --- a/sdk/typescript-sdk/native/src/runtime.rs +++ b/sdk/typescript-sdk/native/src/runtime.rs @@ -142,7 +142,7 @@ impl NativeCursor { #[napi] impl NativeCursor { #[napi(factory, js_name = "fromBytes")] - pub fn from_bytes(data: Buffer, strict: bool, offset: u32) -> NapiResult { + pub fn from_bytes(data: Buffer, strict: bool, offset: u32) -> napi::Result { let offset = usize::try_from(offset).map_err(|_| invalid_arg("cursor offset does not fit usize"))?; if offset > data.len() { @@ -215,7 +215,7 @@ impl NativeCursor { #[napi] pub fn byte(&mut self) -> NapiResult { - self.consume(Cursor::byte) + self.consume(|cursor| cursor.byte()) } #[napi(js_name = "decodeCompactU128")] @@ -353,7 +353,7 @@ impl NativeRuntime { spec_version: u32, transaction_version: u32, ss58_format: u16, - ) -> NapiResult { + ) -> napi::Result { let inner = Runtime::parse( metadata_bytes.as_ref(), spec_version, From d1ef7884410239ad2377f5b6df9636a6321f6aa6 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 11:26:21 -0700 Subject: [PATCH 08/72] fix warnings --- Cargo.lock | 1 + build.rs | 1 + sdk/bittensor-core/Cargo.toml | 1 + sdk/bittensor-core/src/codec/mod.rs | 47 +++++++++++++------ sdk/bittensor-core/src/digest/mod.rs | 10 +++- sdk/bittensor-core/src/keyfiles/mod.rs | 6 ++- sdk/bittensor-core/src/timelock/mod.rs | 3 ++ sdk/typescript-sdk/native/src/lib.rs | 2 + sdk/typescript-sdk/native/src/runtime.rs | 13 +++-- sdk/typescript-sdk/native/src/values.rs | 27 ++++++----- vendor/frontier/client/rpc/src/eth/pending.rs | 2 +- vendor/frontier/precompiles/src/evm/handle.rs | 4 +- .../precompiles/src/testing/execution.rs | 4 +- vendor/frontier/rustfmt.toml | 3 +- vendor/w3f-bls/src/double_pop.rs | 1 - vendor/w3f-bls/src/verifiers.rs | 2 +- 16 files changed, 84 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e6277dfc3..adf3a8d304 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1656,6 +1656,7 @@ dependencies = [ "sha2 0.10.9", "sodiumoxide", "sp-core", + "subtensor-macros", "tle", "twox-hash 2.1.2", "w3f-bls 0.1.3", diff --git a/build.rs b/build.rs index 5db280e249..7260e8a458 100644 --- a/build.rs +++ b/build.rs @@ -14,6 +14,7 @@ fn main() { println!("cargo:rerun-if-changed=pallets"); println!("cargo:rerun-if-changed=node"); println!("cargo:rerun-if-changed=runtime"); + println!("cargo:rerun-if-changed=sdk"); println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=src"); println!("cargo:rerun-if-changed=support"); diff --git a/sdk/bittensor-core/Cargo.toml b/sdk/bittensor-core/Cargo.toml index 78fb38541c..6aebc900a1 100644 --- a/sdk/bittensor-core/Cargo.toml +++ b/sdk/bittensor-core/Cargo.toml @@ -32,6 +32,7 @@ serde_json = { version = "1.0.132", features = ["arbitrary_precision"] } sha2 = "0.10.8" sodiumoxide = { version = "0.2", default-features = false, features = ["std"] } sp-core = { workspace = true, features = ["std", "full_crypto"] } +subtensor-macros.workspace = true zeroize = "1" # timelock (absorbed from bittensor-drand). tle and w3f-bls inherit the diff --git a/sdk/bittensor-core/src/codec/mod.rs b/sdk/bittensor-core/src/codec/mod.rs index c902c0fd88..c27a613c79 100644 --- a/sdk/bittensor-core/src/codec/mod.rs +++ b/sdk/bittensor-core/src/codec/mod.rs @@ -33,10 +33,8 @@ mod corpus_tests { serde_json::Value::Number(n) => { if let Some(i) = n.as_i64() { Value::Int(i128::from(i)) - } else if let Some(u) = n.as_u64() { - Value::Uint(u128::from(u)) } else { - panic!("fixture number {n} is not an integer") + Value::Uint(n.to_string().parse().unwrap()) } } serde_json::Value::String(s) => Value::Str(s.clone()), @@ -60,6 +58,25 @@ mod corpus_tests { serde_json::from_str(&raw).unwrap() } + fn json_u64(value: &serde_json::Value) -> u64 { + let serde_json::Value::Number(number) = value else { + panic!("fixture value {value:?} is not a number") + }; + number.to_string().parse().unwrap() + } + + fn json_u32(value: &serde_json::Value) -> u32 { + u32::try_from(json_u64(value)).unwrap() + } + + fn json_u16(value: &serde_json::Value) -> u16 { + u16::try_from(json_u64(value)).unwrap() + } + + fn json_u8(value: &serde_json::Value) -> u8 { + u8::try_from(json_u64(value)).unwrap() + } + fn golden_metadata_v15() -> Vec { let golden = golden(); let hex_str = golden["metadata"]["v15_hex"] @@ -75,9 +92,9 @@ mod corpus_tests { let g = golden(); Runtime::parse( &golden_metadata_v15(), - g["network"]["spec_version"].as_u64().unwrap() as u32, - g["network"]["transaction_version"].as_u64().unwrap() as u32, - g["network"]["ss58_format"].as_u64().unwrap() as u16, + json_u32(&g["network"]["spec_version"]), + json_u32(&g["network"]["transaction_version"]), + json_u16(&g["network"]["ss58_format"]), ) .expect("golden metadata parses") } @@ -94,7 +111,7 @@ mod corpus_tests { let corpus: serde_json::Value = serde_json::from_str(&raw).unwrap(); let rt = runtime(); assert_eq!( - corpus["spec_version"].as_u64().unwrap() as u32, + json_u32(&corpus["spec_version"]), rt.spec_version, "corpus and golden fixture must be recorded from the same runtime" ); @@ -102,7 +119,7 @@ mod corpus_tests { let mut failures: Vec = Vec::new(); let mut checked = 0usize; for ty in corpus["types"].as_array().unwrap() { - let id = ty["id"].as_u64().unwrap() as u32; + let id = json_u32(&ty["id"]); let name = ty["name"].as_str().unwrap(); for (i, sample) in ty["samples"].as_array().unwrap().iter().enumerate() { let scale_hex = sample["scale_hex"].as_str().unwrap(); @@ -352,7 +369,7 @@ mod corpus_tests { &call, &crate::codec::extrinsic::TxParams { era: Value::str("00"), - nonce: immortal["nonce"].as_u64().unwrap(), + nonce: json_u64(&immortal["nonce"]), tip: 0, tip_asset_id: None, genesis_hash: genesis, @@ -381,7 +398,7 @@ mod corpus_tests { Value::Int(i128::from(mortal["era"]["current"].as_i64().unwrap())), ), ]), - nonce: mortal["nonce"].as_u64().unwrap(), + nonce: json_u64(&mortal["nonce"]), tip: 0, tip_asset_id: None, genesis_hash: genesis, @@ -421,11 +438,11 @@ mod corpus_tests { &call, h256(case["public_key_hex"].as_str().unwrap()), &signature, - case["signature_version"].as_u64().unwrap() as u8, + json_u8(&case["signature_version"]), &crate::codec::extrinsic::TxParams { era, - nonce: case["nonce"].as_u64().unwrap(), - tip: case["tip"].as_u64().unwrap().into(), + nonce: json_u64(&case["nonce"]), + tip: json_u64(&case["tip"]).into(), tip_asset_id: None, genesis_hash: [0; 32], era_block_hash: [0; 32], @@ -464,7 +481,7 @@ mod corpus_tests { #[test] fn multisig_accounts_match_the_golden_vectors() { let g = golden(); - let ss58_format = g["network"]["ss58_format"].as_u64().unwrap() as u16; + let ss58_format = json_u16(&g["network"]["ss58_format"]); for case in g["multisig"].as_array().unwrap() { let signatories: Vec<[u8; 32]> = case["signatories"] .as_array() @@ -472,7 +489,7 @@ mod corpus_tests { .iter() .map(|s| crate::keys::public_key_from_ss58(s.as_str().unwrap()).unwrap()) .collect(); - let threshold = case["threshold"].as_u64().unwrap() as u16; + let threshold = json_u16(&case["threshold"]); let (account, sorted) = crate::codec::extrinsic::multisig_account_id(&signatories, threshold).unwrap(); assert_eq!( diff --git a/sdk/bittensor-core/src/digest/mod.rs b/sdk/bittensor-core/src/digest/mod.rs index efc2827f61..1604d73c96 100644 --- a/sdk/bittensor-core/src/digest/mod.rs +++ b/sdk/bittensor-core/src/digest/mod.rs @@ -20,6 +20,7 @@ use merkleized_metadata::{ generate_metadata_digest, generate_proof_for_extrinsic_parts, types::ExtrinsicMetadata, ExtraInfo, FrameMetadataPrepared, Proof, SignedExtrinsicData, }; +use subtensor_macros::freeze_struct; use crate::error::CoreError; @@ -55,6 +56,7 @@ impl ChainInfo { /// The upstream crate's `ExtraInfo` doesn't derive `Encode`; the field order /// here is pinned by the app's parser (spec_version, spec_name, base58_prefix, /// decimals, token_symbol — the RFC-0078 `MetadataDigest::V1` tail order). +#[freeze_struct("edb2a68250293f5")] #[derive(Encode)] struct ExtraInfoEncoded { spec_version: u32, @@ -79,6 +81,7 @@ impl From for ExtraInfoEncoded { /// The proof blob wire shape the Ledger generic app expects: the merkle proof /// of the types the extrinsic touches, the extrinsic metadata, and the chain /// constants — SCALE-encoded in this order. +#[freeze_struct("e1038cfe3b767760")] #[derive(Encode)] struct MetadataProof { proof: Proof, @@ -236,7 +239,12 @@ mod tests { let field = |name: &str| -> Vec { hex::decode(vector[name].as_str().unwrap()).unwrap() }; let metadata = golden_metadata_v15(); - let spec_version = vector["spec_version"].as_u64().unwrap() as u32; + let spec_version = { + let serde_json::Value::Number(number) = &vector["spec_version"] else { + panic!("ledger proof vector spec_version is not a number") + }; + number.to_string().parse::().unwrap() + }; let proof = generate_extrinsic_proof( &field("call_data_hex"), &field("included_in_extrinsic_hex"), diff --git a/sdk/bittensor-core/src/keyfiles/mod.rs b/sdk/bittensor-core/src/keyfiles/mod.rs index c4586b6dcf..b58c5db187 100644 --- a/sdk/bittensor-core/src/keyfiles/mod.rs +++ b/sdk/bittensor-core/src/keyfiles/mod.rs @@ -206,8 +206,10 @@ pub fn deserialize_keypair_from_keyfile_data(keyfile_data: &[u8]) -> Result number.to_string().parse::().ok(), + _ => None, + }) .unwrap_or(CRYPTO_SR25519); if let Some(secret_phrase) = keyfile_dict diff --git a/sdk/bittensor-core/src/timelock/mod.rs b/sdk/bittensor-core/src/timelock/mod.rs index 8bdd3d8d8c..5852e8ee09 100644 --- a/sdk/bittensor-core/src/timelock/mod.rs +++ b/sdk/bittensor-core/src/timelock/mod.rs @@ -19,6 +19,7 @@ use rand_core::{OsRng, RngCore}; use serde::Deserialize; use sha2::Digest; use std::time::{SystemTime, UNIX_EPOCH}; +use subtensor_macros::freeze_struct; use tle::{ curves::drand::TinyBLS381, ibe::fullident::Identity, @@ -43,6 +44,7 @@ fn now_unix() -> Result { .map_err(|e| tl_err(format!("SystemTime error: {e:?}"))) } +#[freeze_struct("705b0f6dde3ed6e")] #[derive(Encode, Decode, Debug, PartialEq)] pub struct WeightsTlockPayload { pub hotkey: Vec, @@ -51,6 +53,7 @@ pub struct WeightsTlockPayload { pub version_key: u64, } +#[freeze_struct("b96b617eb03c11a6")] #[derive(Encode, Decode)] pub struct UserData { pub encrypted_data: Vec, diff --git a/sdk/typescript-sdk/native/src/lib.rs b/sdk/typescript-sdk/native/src/lib.rs index bec8a0028b..c46a5beb3d 100644 --- a/sdk/typescript-sdk/native/src/lib.rs +++ b/sdk/typescript-sdk/native/src/lib.rs @@ -1,3 +1,5 @@ +#![cfg_attr(test, allow(dead_code))] + mod digest; mod errors; mod keys; diff --git a/sdk/typescript-sdk/native/src/runtime.rs b/sdk/typescript-sdk/native/src/runtime.rs index 02bf0c2770..b8add2146e 100644 --- a/sdk/typescript-sdk/native/src/runtime.rs +++ b/sdk/typescript-sdk/native/src/runtime.rs @@ -1164,6 +1164,13 @@ fn type_spec_from_json(value: JsonValue) -> NapiResult { type_spec_from_json_at(value, 0) } +fn json_u32(value: JsonValue) -> Option { + let JsonValue::Number(number) = value else { + return None; + }; + number.to_string().parse::().ok() +} + fn type_spec_from_json_at(value: JsonValue, depth: usize) -> NapiResult { if depth > 256 { return Err(invalid_arg("type spec nesting exceeds 256 levels")); @@ -1184,8 +1191,7 @@ fn type_spec_from_json_at(value: JsonValue, depth: usize) -> NapiResult map .remove("id") - .and_then(|value| value.as_u64()) - .and_then(|value| u32::try_from(value).ok()) + .and_then(json_u32) .map(TypeSpec::Id) .ok_or_else(|| invalid_arg("id type spec needs a u32 `id`")), "primitive" => { @@ -1203,8 +1209,7 @@ fn type_spec_from_json_at(value: JsonValue, depth: usize) -> NapiResult NapiResult { } fn number_to_value(number: Number) -> NapiResult { - if let Some(value) = number.as_i64() { - return Ok(Value::Int(i128::from(value))); - } - if let Some(value) = number.as_u64() { - let value = u128::from(value); - return Ok(if value <= i128::MAX as u128 { - Value::Int(value as i128) - } else { - Value::Uint(value) - }); + let text = number.to_string(); + if text.starts_with('-') { + return text + .parse::() + .map(Value::Int) + .map_err(|_| invalid_arg(format!("SCALE values must use integers; got {number}"))); } - Err(invalid_arg(format!( - "SCALE values must use integers; got {number}" - ))) + let value = text + .parse::() + .map_err(|_| invalid_arg(format!("SCALE values must use integers; got {number}")))?; + Ok(if value <= i128::MAX as u128 { + Value::Int(value as i128) + } else { + Value::Uint(value) + }) } fn object_from_wire(mut map: Map, depth: usize) -> NapiResult { diff --git a/vendor/frontier/client/rpc/src/eth/pending.rs b/vendor/frontier/client/rpc/src/eth/pending.rs index 9384b094d7..d30f138a24 100644 --- a/vendor/frontier/client/rpc/src/eth/pending.rs +++ b/vendor/frontier/client/rpc/src/eth/pending.rs @@ -61,7 +61,7 @@ where P: TransactionPool + 'static, { /// Creates a pending runtime API. - pub(crate) async fn pending_runtime_api(&self) -> Result<(B::Hash, ApiRef), Error> { + pub(crate) async fn pending_runtime_api(&self) -> Result<(B::Hash, ApiRef<'_, C::Api>), Error> { let api = self.client.runtime_api(); let info = self.client.info(); diff --git a/vendor/frontier/precompiles/src/evm/handle.rs b/vendor/frontier/precompiles/src/evm/handle.rs index 2d37d37dff..e1e54b7f0c 100644 --- a/vendor/frontier/precompiles/src/evm/handle.rs +++ b/vendor/frontier/precompiles/src/evm/handle.rs @@ -49,7 +49,7 @@ pub trait PrecompileHandleExt: PrecompileHandle { fn read_u32_selector(&self) -> MayRevert; /// Returns a reader of the input, skipping the selector. - fn read_after_selector(&self) -> MayRevert; + fn read_after_selector(&self) -> MayRevert>; } impl PrecompileHandleExt for T { @@ -96,7 +96,7 @@ impl PrecompileHandleExt for T { } /// Returns a reader of the input, skipping the selector. - fn read_after_selector(&self) -> MayRevert { + fn read_after_selector(&self) -> MayRevert> { Reader::new_skip_selector(self.input()) } } diff --git a/vendor/frontier/precompiles/src/testing/execution.rs b/vendor/frontier/precompiles/src/testing/execution.rs index e8279ee7dd..341d3a71ed 100644 --- a/vendor/frontier/precompiles/src/testing/execution.rs +++ b/vendor/frontier/precompiles/src/testing/execution.rs @@ -239,7 +239,7 @@ pub trait PrecompileTesterExt: PrecompileSet + Sized { from: impl Into, to: impl Into, data: impl Into>, - ) -> PrecompilesTester; + ) -> PrecompilesTester<'_, Self>; } impl PrecompileTesterExt for T { @@ -248,7 +248,7 @@ impl PrecompileTesterExt for T { from: impl Into, to: impl Into, data: impl Into>, - ) -> PrecompilesTester { + ) -> PrecompilesTester<'_, Self> { PrecompilesTester::new(self, from, to, data.into()) } } diff --git a/vendor/frontier/rustfmt.toml b/vendor/frontier/rustfmt.toml index 67966db319..6982b6a35f 100644 --- a/vendor/frontier/rustfmt.toml +++ b/vendor/frontier/rustfmt.toml @@ -1,4 +1,5 @@ hard_tabs = true -imports_granularity = "Crate" +# Unstable on stable rustfmt. +# imports_granularity = "Crate" use_field_init_shorthand = true merge_derives = false diff --git a/vendor/w3f-bls/src/double_pop.rs b/vendor/w3f-bls/src/double_pop.rs index 5a8c7b0712..8234802c55 100644 --- a/vendor/w3f-bls/src/double_pop.rs +++ b/vendor/w3f-bls/src/double_pop.rs @@ -17,7 +17,6 @@ use ark_ec::Group; use ark_ff::field_hashers::{DefaultFieldHasher, HashToField}; const PROOF_OF_POSSESSION_CONTEXT: &'static [u8] = b"POP_"; -const BLS_CONTEXT: &'static [u8] = b"BLS_"; /// Proof Of Possession of the secret key as the secret scaler genarting both public /// keys in G1 and G2 by generating a BLS Signature of public key (in G2) diff --git a/vendor/w3f-bls/src/verifiers.rs b/vendor/w3f-bls/src/verifiers.rs index 6c0a15cf2f..100748d17f 100644 --- a/vendor/w3f-bls/src/verifiers.rs +++ b/vendor/w3f-bls/src/verifiers.rs @@ -268,7 +268,7 @@ pub fn verify_using_aggregated_auxiliary_public_keys< //Simplify from here on. for (m, pk) in itr { - publickeys.push(pk.borrow().0.clone()); + publickeys.push(pk.0.clone()); messages.push( m.hash_to_signature_curve::() + E::SignatureGroupAffine::generator() * pseudo_random_scalar, From 8890aceb0ac66c202ce237a160db8fab717211e4 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 12:11:39 -0700 Subject: [PATCH 09/72] fix sdk build --- sdk/typescript-sdk/test/basic.test.cjs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index f2ef211184..424539e702 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -80,14 +80,15 @@ test('compact codec is the Rust implementation', () => { } }) -test('dynamic boundary preserves bigint, bytes, and non-string map keys', () => { +test('dynamic boundary preserves large bigint, bytes, and non-string map keys', () => { + const bigintKey = 2n ** 80n const input = new Map([ - [7n, Buffer.from([0, 1, 254, 255])], + [bigintKey, Buffer.from([0, 1, 254, 255])], ['nested', { value: 2n ** 200n, wideSafeNumber: Number.MAX_SAFE_INTEGER }], ]) const output = core.wireRoundtrip(input) assert.ok(output instanceof Map) - assert.deepEqual(output.get(7n), Buffer.from([0, 1, 254, 255])) + assert.deepEqual(output.get(bigintKey), Buffer.from([0, 1, 254, 255])) assert.equal(output.get('nested').value, 2n ** 200n) assert.equal(output.get('nested').wideSafeNumber, Number.MAX_SAFE_INTEGER) }) @@ -219,12 +220,13 @@ test('arbitrary StorageInfo helpers call Rust directly', () => { test('prototype-sensitive decoded keys never become object prototypes', () => { const dangerous = new Map([ ['__proto__', { polluted: true }], - ['constructor', 7n], + // Safe integers intentionally normalize to JavaScript numbers at the Rust boundary. + ['constructor', 7], ]) const output = core.wireRoundtrip(dangerous) assert.ok(output instanceof Map) assert.equal(output.get('__proto__').polluted, true) - assert.equal(output.get('constructor'), 7n) + assert.equal(output.get('constructor'), 7) assert.equal({}.polluted, undefined) }) From 4b6e0ed76cd47e46e56a7c6418e9731dfc2dccc2 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 12:15:10 -0700 Subject: [PATCH 10/72] fix build post-merge --- Cargo.lock | 11 +++++++++++ sdk/bittensor-core/Cargo.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 69eae0a6a9..4f0cda3e84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1676,6 +1676,17 @@ dependencies = [ "scale-info", ] +[[package]] +name = "bittensor-core-wasm" +version = "0.1.0" +dependencies = [ + "bittensor-core", + "hex", + "js-sys", + "scale-info", + "wasm-bindgen", +] + [[package]] name = "bittensor-typescript-sdk-native" version = "0.1.0" diff --git a/sdk/bittensor-core/Cargo.toml b/sdk/bittensor-core/Cargo.toml index 5461e56c33..be5cb3b916 100644 --- a/sdk/bittensor-core/Cargo.toml +++ b/sdk/bittensor-core/Cargo.toml @@ -32,7 +32,7 @@ serde = { version = "1.0.132", features = ["derive"] } # u128/u256 integers that must survive JSON round-trips digit-exact. serde_json = { version = "1.0.132", features = ["arbitrary_precision"] } sha2 = "0.10.8" -sodiumoxide = { version = "0.2", default-features = false, features = ["std"] } +sodiumoxide = { version = "0.2", default-features = false, features = ["std"], optional = true } sp-core = { workspace = true, features = ["std", "full_crypto"] } subtensor-macros.workspace = true zeroize = "1" From a630667f2d241528a5113f09912444b61cfe6792 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 12:15:59 -0700 Subject: [PATCH 11/72] Move key derivation into Rust & finish names --- sdk/bittensor-core/src/keys/mod.rs | 140 ++++++++++++++-- sdk/typescript-sdk/README.md | 9 ++ sdk/typescript-sdk/native/src/keys.rs | 5 + sdk/typescript-sdk/package.json | 5 +- .../scripts/check-native-parity.cjs | 151 ++++++++++++++++++ sdk/typescript-sdk/src/errors.ts | 2 + sdk/typescript-sdk/src/keys.ts | 56 +------ sdk/typescript-sdk/src/modules.ts | 12 ++ sdk/typescript-sdk/src/native.ts | 2 + sdk/typescript-sdk/test/basic.test.cjs | 14 +- 10 files changed, 326 insertions(+), 70 deletions(-) create mode 100755 sdk/typescript-sdk/scripts/check-native-parity.cjs diff --git a/sdk/bittensor-core/src/keys/mod.rs b/sdk/bittensor-core/src/keys/mod.rs index a33bfc49a7..6b4124692f 100644 --- a/sdk/bittensor-core/src/keys/mod.rs +++ b/sdk/bittensor-core/src/keys/mod.rs @@ -147,10 +147,67 @@ impl KeypairInner { } } +struct DerivationSource { + base_uri: Zeroizing, + password: Option>, +} + +impl DerivationSource { + fn from_mnemonic(mnemonic: &str, password: Option<&str>) -> Self { + Self { + base_uri: Zeroizing::new(mnemonic.to_owned()), + password: password.map(|value| Zeroizing::new(value.to_owned())), + } + } + + fn from_uri(uri: &str) -> Self { + let Some((base_uri, password)) = uri.split_once("///") else { + return Self { + base_uri: Zeroizing::new(uri.to_owned()), + password: None, + }; + }; + Self { + base_uri: Zeroizing::new(base_uri.to_owned()), + password: Some(Zeroizing::new(password.to_owned())), + } + } + + fn child(&self, path: &str) -> Result { + if path.is_empty() || !path.starts_with('/') || path.contains("///") { + return Err(crypto_err( + "derivation path must start with '/' and must not contain a password", + )); + } + let mut base_uri = String::with_capacity(self.base_uri.len() + path.len()); + base_uri.push_str(self.base_uri.as_str()); + base_uri.push_str(path); + Ok(Self { + base_uri: Zeroizing::new(base_uri), + password: self + .password + .as_ref() + .map(|value| Zeroizing::new(value.as_str().to_owned())), + }) + } + + fn secret_uri(&self) -> Zeroizing { + let password_len = self.password.as_ref().map_or(0, |value| value.len() + 3); + let mut uri = String::with_capacity(self.base_uri.len() + password_len); + uri.push_str(self.base_uri.as_str()); + if let Some(password) = &self.password { + uri.push_str("///"); + uri.push_str(password.as_str()); + } + Zeroizing::new(uri) + } +} + /// An sr25519 or ed25519 keypair backed by the workspace's sp-core. pub struct Keypair { inner: KeypairInner, ss58_format: u16, + derivation_source: Option, } impl Keypair { @@ -194,6 +251,7 @@ impl Keypair { crypto_type, }, ss58_format, + derivation_source: None, }) } @@ -219,6 +277,7 @@ impl Keypair { Ok(Self { inner, ss58_format: DEFAULT_SS58_FORMAT, + derivation_source: Some(DerivationSource::from_mnemonic(mnemonic, password)), }) } @@ -238,28 +297,54 @@ impl Keypair { Ok(Self { inner, ss58_format: DEFAULT_SS58_FORMAT, + derivation_source: None, }) } - /// Derive a keypair from a secret URI (e.g. "//Alice" or "//hard/soft"). - pub fn from_uri(uri: &str, crypto_type: u8) -> Result { - let inner = match crypto_type { - CRYPTO_SR25519 => KeypairInner::Sr25519( - sr25519::Pair::from_string(uri, None) - .map_err(|e| crypto_err(format!("invalid secret uri: {e:?}")))?, - ), - CRYPTO_ED25519 => KeypairInner::Ed25519( - ed25519::Pair::from_string(uri, None) - .map_err(|e| crypto_err(format!("invalid secret uri: {e:?}")))?, - ), - other => return Err(crypto_err(format!("unknown crypto type {other}"))), - }; + fn inner_from_uri(uri: &str, crypto_type: u8) -> Result { + match crypto_type { + CRYPTO_SR25519 => sr25519::Pair::from_string(uri, None) + .map(KeypairInner::Sr25519) + .map_err(|e| crypto_err(format!("invalid secret uri: {e:?}"))), + CRYPTO_ED25519 => ed25519::Pair::from_string(uri, None) + .map(KeypairInner::Ed25519) + .map_err(|e| crypto_err(format!("invalid secret uri: {e:?}"))), + other => Err(crypto_err(format!("unknown crypto type {other}"))), + } + } + + fn from_derivation_source( + source: DerivationSource, + crypto_type: u8, + ss58_format: u16, + ) -> Result { + let secret_uri = source.secret_uri(); + let inner = Self::inner_from_uri(secret_uri.as_str(), crypto_type)?; Ok(Self { inner, - ss58_format: DEFAULT_SS58_FORMAT, + ss58_format, + derivation_source: Some(source), }) } + /// Derive a keypair from a secret URI (e.g. "//Alice" or "//hard/soft"). + pub fn from_uri(uri: &str, crypto_type: u8) -> Result { + Self::from_derivation_source( + DerivationSource::from_uri(uri), + crypto_type, + DEFAULT_SS58_FORMAT, + ) + } + + /// Derive a child without exposing or reconstructing its secret URI outside Rust. + pub fn derive(&self, path: &str) -> Result { + let source = self + .derivation_source + .as_ref() + .ok_or_else(|| crypto_err("derivation requires a mnemonic or secret URI keypair"))?; + Self::from_derivation_source(source.child(path)?, self.crypto_type(), self.ss58_format) + } + /// Derive a keypair from a hex-encoded private key or seed bytes. pub fn from_private_key(private_key: &str, crypto_type: u8) -> Result { // Don't interpolate the hex error: it echoes offending secret characters. @@ -344,7 +429,11 @@ impl Keypair { #[cfg(feature = "host")] pub(crate) fn from_inner(inner: KeypairInner, ss58_format: u16) -> Self { - Self { inner, ss58_format } + Self { + inner, + ss58_format, + derivation_source: None, + } } /// Sign a message; returns the raw 64-byte signature. @@ -466,6 +555,27 @@ mod tests { ); } + #[test] + fn password_protected_mnemonic_derives_inside_keypair() { + let mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let password = "protected-derivation-password"; + let parent = Keypair::from_mnemonic(mnemonic, CRYPTO_SR25519, Some(password)).unwrap(); + let child = parent.derive("//child").unwrap(); + let expected = + Keypair::from_uri(&format!("{mnemonic}//child///{password}"), CRYPTO_SR25519).unwrap(); + assert_eq!(child.public_key_bytes(), expected.public_key_bytes()); + + let grandchild = child.derive("//grandchild").unwrap(); + let expected = Keypair::from_uri( + &format!("{mnemonic}//child//grandchild///{password}"), + CRYPTO_SR25519, + ) + .unwrap(); + assert_eq!(grandchild.public_key_bytes(), expected.public_key_bytes()); + assert!(parent.derive("///replacement-password").is_err()); + } + #[test] fn public_only_from_ss58_matches_full_key() { let full = Keypair::from_uri("//Alice", CRYPTO_SR25519).unwrap(); diff --git a/sdk/typescript-sdk/README.md b/sdk/typescript-sdk/README.md index df1848d2d9..0ad05032c1 100644 --- a/sdk/typescript-sdk/README.md +++ b/sdk/typescript-sdk/README.md @@ -69,3 +69,12 @@ const ciphertext = sealMevShieldTransaction(mlKemPublicKey, call) Large SCALE integers are returned as `bigint`; byte carriers are returned as `Buffer`. A decoded SCALE dictionary with non-string keys is returned as a `Map`, so no key information is lost. + +## Native parity and secret derivation + +Mnemonic, password, and secret-URI derivation state is retained only by the Rust +`Keypair`; TypeScript calls the native handle's `derive(path)` method and never +reconstructs a child secret URI. `npm run build` also compares the freshly +generated N-API declarations with `src/native.ts`, including every native class +method, so Rust additions cannot silently disappear from the documented +TypeScript boundary. diff --git a/sdk/typescript-sdk/native/src/keys.rs b/sdk/typescript-sdk/native/src/keys.rs index 1b83dd32d1..3e19df5aa0 100644 --- a/sdk/typescript-sdk/native/src/keys.rs +++ b/sdk/typescript-sdk/native/src/keys.rs @@ -55,6 +55,11 @@ impl NativeKeypair { self.inner.ss58_format() } + #[napi] + pub fn derive(&self, path: String) -> NapiResult { + self.inner.derive(&path).napi().map(NativeKeypair::new) + } + #[napi] pub fn sign(&self, message: Buffer) -> NapiResult { self.inner.sign(message.as_ref()).napi().map(Into::into) diff --git a/sdk/typescript-sdk/package.json b/sdk/typescript-sdk/package.json index fc747ff9e9..77da594bcb 100644 --- a/sdk/typescript-sdk/package.json +++ b/sdk/typescript-sdk/package.json @@ -33,12 +33,13 @@ "scripts": { "build:native": "napi build --manifest-path native/Cargo.toml --output-dir . --platform --release --all-features --js native.cjs --dts native.generated.d.ts", "build:ts": "tsc -p tsconfig.json && node scripts/generate-esm.cjs", - "build": "npm run build:native && npm run build:ts", + "build": "npm run build:native && npm run check:native-parity && npm run build:ts", "check": "npm run build && npm test", "test": "node --test test/*.test.cjs", "clean": "node scripts/clean.cjs", "prepack": "npm run build", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "check:native-parity": "node scripts/check-native-parity.cjs" }, "napi": { "binaryName": "bittensor_sdk" diff --git a/sdk/typescript-sdk/scripts/check-native-parity.cjs b/sdk/typescript-sdk/scripts/check-native-parity.cjs new file mode 100755 index 0000000000..c17ce8df3d --- /dev/null +++ b/sdk/typescript-sdk/scripts/check-native-parity.cjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const ts = require('typescript') + +const root = path.resolve(__dirname, '..') +const generatedPath = path.join(root, 'native.generated.d.ts') +const documentedPath = path.join(root, 'src', 'native.ts') + +function parse(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`missing ${path.relative(root, filePath)}; run npm run build:native first`) + } + return ts.createSourceFile( + filePath, + fs.readFileSync(filePath, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ) +} + +function hasModifier(node, kind) { + return node.modifiers?.some((modifier) => modifier.kind === kind) ?? false +} + +function memberName(member) { + const name = member.name + if (name == null) return null + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text + } + return null +} + +function declarationNames(statement) { + if (!ts.isVariableStatement(statement)) return [] + const names = [] + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) names.push(declaration.name.text) + } + return names +} + +function interfaceMembers(source) { + const interfaces = new Map() + for (const statement of source.statements) { + if (!ts.isInterfaceDeclaration(statement)) continue + interfaces.set( + statement.name.text, + new Set(statement.members.map(memberName).filter((name) => name != null)), + ) + } + return interfaces +} + +function generatedSurface(source) { + const values = new Set() + const classes = new Map() + for (const statement of source.statements) { + if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) continue + if (ts.isFunctionDeclaration(statement) && statement.name != null) { + values.add(statement.name.text) + } else if (ts.isClassDeclaration(statement) && statement.name != null) { + const name = statement.name.text + values.add(name) + const instance = new Set() + const statics = new Set() + for (const member of statement.members) { + if (ts.isConstructorDeclaration(member)) continue + const name = memberName(member) + if (name == null) continue + if (hasModifier(member, ts.SyntaxKind.StaticKeyword)) statics.add(name) + else instance.add(name) + } + classes.set(name, { instance, statics }) + } else if (ts.isEnumDeclaration(statement)) { + values.add(statement.name.text) + } else { + for (const name of declarationNames(statement)) values.add(name) + } + } + return { values, classes } +} + +function documentedBinding(source) { + const interfaces = interfaceMembers(source) + const binding = interfaces.get('NativeBinding') + if (binding == null) throw new Error('src/native.ts does not declare NativeBinding') + return { interfaces, binding } +} + +function sorted(values) { + return [...values].sort() +} + +function compareSet(label, actual, expected) { + const missing = sorted([...expected].filter((name) => !actual.has(name))) + const stale = sorted([...actual].filter((name) => !expected.has(name))) + if (missing.length === 0 && stale.length === 0) return [] + const lines = [`${label} differs:`] + if (missing.length > 0) lines.push(` missing documentation: ${missing.join(', ')}`) + if (stale.length > 0) lines.push(` stale documentation: ${stale.join(', ')}`) + return lines +} + +const classInterfaces = { + NativeKeypair: { instance: 'NativeKeypairHandle' }, + NativeRuntime: { instance: 'NativeRuntimeHandle', statics: 'NativeRuntimeConstructor' }, + NativeCursor: { instance: 'NativeCursorHandle', statics: 'NativeCursorConstructor' }, + NativeLedgerDevice: { instance: 'NativeLedgerHandle', statics: 'NativeLedgerConstructor' }, +} + +try { + const generated = generatedSurface(parse(generatedPath)) + const documented = documentedBinding(parse(documentedPath)) + const failures = compareSet('top-level native exports', documented.binding, generated.values) + + for (const [className, members] of generated.classes) { + const mapping = classInterfaces[className] + if (mapping == null) { + failures.push(`generated class ${className} has no documented interface mapping`) + continue + } + const instance = documented.interfaces.get(mapping.instance) + if (instance == null) { + failures.push(`missing interface ${mapping.instance} for ${className}`) + } else { + failures.push(...compareSet(`${className} instance members`, instance, members.instance)) + } + + const statics = mapping.statics == null ? new Set() : documented.interfaces.get(mapping.statics) + if (statics == null) { + failures.push(`missing interface ${mapping.statics} for ${className} statics`) + } else { + failures.push(...compareSet(`${className} static members`, statics, members.statics)) + } + } + + if (failures.length > 0) { + console.error('Native Rust/TypeScript parity check failed:') + for (const failure of failures) console.error(failure) + process.exit(1) + } + console.log(`Native parity OK: ${generated.values.size} exports and ${generated.classes.size} classes`) +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +} diff --git a/sdk/typescript-sdk/src/errors.ts b/sdk/typescript-sdk/src/errors.ts index eec9d5af40..984a65bf04 100644 --- a/sdk/typescript-sdk/src/errors.ts +++ b/sdk/typescript-sdk/src/errors.ts @@ -93,3 +93,5 @@ export function nativeCall(operation: () => T): T { throw mapNativeError(error) } } + +export { BittensorCoreError as CoreError } diff --git a/sdk/typescript-sdk/src/keys.ts b/sdk/typescript-sdk/src/keys.ts index 8e959a77e1..f569d5fe90 100644 --- a/sdk/typescript-sdk/src/keys.ts +++ b/sdk/typescript-sdk/src/keys.ts @@ -67,39 +67,10 @@ function unsupported(operation: string): never { } /** - * Keep derivation secrets and mutable metadata outside the JavaScript object - * shape. TypeScript's `private` keyword is compile-time only and would leave - * class fields enumerable at runtime. + * Keep mutable compatibility metadata outside the JavaScript object shape. + * Mnemonics, passwords, and secret URIs stay exclusively in NativeKeypair. */ -interface DerivationSource { - baseUri: string - password?: string -} - const metadataStore = new WeakMap() -const derivationSourceStore = new WeakMap() - -function derivationSourceFromMnemonic( - mnemonic: string, - password?: string | null, -): DerivationSource { - return password == null ? { baseUri: mnemonic } : { baseUri: mnemonic, password } -} - -function derivationSourceFromUri(uri: string): DerivationSource { - const passwordSeparator = uri.indexOf('///') - if (passwordSeparator === -1) return { baseUri: uri } - return { - baseUri: uri.slice(0, passwordSeparator), - password: uri.slice(passwordSeparator + 3), - } -} - -function derivationUri(source: DerivationSource): string { - return source.password === undefined - ? source.baseUri - : `${source.baseUri}///${source.password}` -} function sanitizeMetadata(metadata: KeypairMetadata): KeypairMetadata { const sanitized = { ...metadata } @@ -140,15 +111,11 @@ export class Keypair implements PolkadotCompatibleKeypair { private static wrap( handle: NativeKeypairHandle, - derivationSource?: DerivationSource, metadata: KeypairMetadata = {}, ): Keypair { const keypair = Object.create(Keypair.prototype) as Keypair keypair.handle = handle metadataStore.set(keypair, sanitizeMetadata(metadata)) - if (derivationSource != null) { - derivationSourceStore.set(keypair, { ...derivationSource }) - } return keypair } @@ -159,7 +126,6 @@ export class Keypair implements PolkadotCompatibleKeypair { ): Keypair { return Keypair.wrap( nativeCall(() => native.keypairFromMnemonic(mnemonic, cryptoType, password ?? undefined)), - derivationSourceFromMnemonic(mnemonic, password), { type: keyTypeForCryptoType(cryptoType) }, ) } @@ -175,7 +141,6 @@ export class Keypair implements PolkadotCompatibleKeypair { static fromSeed(seed: ByteLike, cryptoType = CRYPTO_SR25519): Keypair { return Keypair.wrap( nativeCall(() => native.keypairFromSeed(toBuffer(seed, 'seed'), cryptoType)), - undefined, { type: keyTypeForCryptoType(cryptoType) }, ) } @@ -187,7 +152,6 @@ export class Keypair implements PolkadotCompatibleKeypair { static fromUri(uri: string, cryptoType = CRYPTO_SR25519): Keypair { return Keypair.wrap( nativeCall(() => native.keypairFromUri(uri, cryptoType)), - derivationSourceFromUri(uri), { type: keyTypeForCryptoType(cryptoType) }, ) } @@ -199,7 +163,6 @@ export class Keypair implements PolkadotCompatibleKeypair { static fromPrivateKey(privateKey: string, cryptoType = CRYPTO_SR25519): Keypair { return Keypair.wrap( nativeCall(() => native.keypairFromPrivateKey(privateKey, cryptoType)), - undefined, { type: keyTypeForCryptoType(cryptoType) }, ) } @@ -328,25 +291,14 @@ export class Keypair implements PolkadotCompatibleKeypair { } derive(suri: string, meta: KeypairMetadata = {}): Keypair { - const source = derivationSourceStore.get(this) - if (source == null) { - throw new Error('derivation requires a keypair created from a mnemonic or secret URI') - } - - const childSource: DerivationSource = { - ...source, - baseUri: `${source.baseUri}${suri}`, - } const derived = Keypair.wrap( - nativeCall(() => - native.keypairFromUri(derivationUri(childSource), this.cryptoType), - ), - childSource, + nativeCall(() => this.handle.derive(suri)), { type: keyTypeForCryptoType(this.cryptoType) }, ) derived.setMeta(meta) return derived } + setMeta(meta: KeypairMetadata): void { metadataStore.set( this, diff --git a/sdk/typescript-sdk/src/modules.ts b/sdk/typescript-sdk/src/modules.ts index d2d41d687b..a434b5b25e 100644 --- a/sdk/typescript-sdk/src/modules.ts +++ b/sdk/typescript-sdk/src/modules.ts @@ -1,4 +1,5 @@ import * as crypto from './crypto' +import * as errors from './errors' import * as keys from './keys' import { LedgerDevice } from './ledger' import native from './native' @@ -13,6 +14,17 @@ import type { ByteLike } from './types' * while retaining the idiomatic top-level TypeScript exports. */ export const rustCore = Object.freeze({ + CoreError: errors.CoreError, + error: Object.freeze({ + CoreError: errors.CoreError, + BittensorCoreError: errors.BittensorCoreError, + KeyfileError: errors.KeyfileError, + WrongPasswordError: errors.WrongPasswordError, + NotInRuntimeError: errors.NotInRuntimeError, + CodecError: errors.CodecError, + CryptoError: errors.CryptoError, + DeviceError: errors.DeviceError, + }), keys: Object.freeze({ Keypair: keys.Keypair, CRYPTO_ED25519: keys.CRYPTO_ED25519, diff --git a/sdk/typescript-sdk/src/native.ts b/sdk/typescript-sdk/src/native.ts index 9118207c84..5763163d0a 100644 --- a/sdk/typescript-sdk/src/native.ts +++ b/sdk/typescript-sdk/src/native.ts @@ -7,6 +7,7 @@ export interface NativeKeypairHandle { readonly privateKey?: Buffer | null readonly ss58Address: string readonly ss58Format: number + derive(path: string): NativeKeypairHandle sign(message: Buffer): Buffer verify(message: Buffer, signature: Buffer): boolean encrypt(message: Buffer): Buffer @@ -240,6 +241,7 @@ export interface NativeLedgerConstructor { } export interface NativeBinding { + NativeKeypair: { readonly prototype: NativeKeypairHandle } NativeRuntime: NativeRuntimeConstructor NativeCursor: NativeCursorConstructor NativeLedgerDevice: NativeLedgerConstructor diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 424539e702..e180506bc6 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -249,7 +249,7 @@ test('raw native escape hatch includes the complete low-level bridge', () => { assert.equal(typeof core.native.NativeRuntime.fromMetadata, 'function') }) -test('password-protected mnemonic keypairs derive children without exposing secrets', () => { +test('password-protected mnemonic keypairs derive entirely through the native handle', () => { const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' const password = 'protected-derivation-password' @@ -264,6 +264,7 @@ test('password-protected mnemonic keypairs derive children without exposing secr `${mnemonic}//child//grandchild///${password}`, ) assert.deepEqual(grandchild.publicKey, expectedGrandchild.publicKey) + assert.equal(typeof core.native.NativeKeypair.prototype.derive, 'function') for (const pair of [parent, child, grandchild]) { assert.equal(pair.meta.suri, undefined) @@ -271,3 +272,14 @@ test('password-protected mnemonic keypairs derive children without exposing secr assert.equal(Object.prototype.hasOwnProperty.call(pair, 'derivationSource'), false) } }) + +test('rustCore mirrors the Rust CoreError module and root re-export', () => { + assert.equal(core.rustCore.CoreError, core.CoreError) + assert.equal(core.rustCore.error.CoreError, core.CoreError) + assert.equal(core.rustCore.error.KeyfileError, core.KeyfileError) + assert.equal(core.rustCore.error.WrongPasswordError, core.WrongPasswordError) + assert.equal(core.rustCore.error.NotInRuntimeError, core.NotInRuntimeError) + assert.equal(core.rustCore.error.CodecError, core.CodecError) + assert.equal(core.rustCore.error.CryptoError, core.CryptoError) + assert.equal(core.rustCore.error.DeviceError, core.DeviceError) +}) From 8b9f1904f0d0f7990bde880ace501e9c7e4ed430 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 12:25:58 -0700 Subject: [PATCH 12/72] fix keys --- sdk/bittensor-core/src/keys/mod.rs | 9 +++++++++ sdk/typescript-sdk/native/src/keys.rs | 7 +------ sdk/typescript-sdk/src/keys.ts | 5 ----- sdk/typescript-sdk/src/native.ts | 1 - sdk/typescript-sdk/test/basic.test.cjs | 10 ++++++++++ 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/sdk/bittensor-core/src/keys/mod.rs b/sdk/bittensor-core/src/keys/mod.rs index 6b4124692f..bd05e5cef4 100644 --- a/sdk/bittensor-core/src/keys/mod.rs +++ b/sdk/bittensor-core/src/keys/mod.rs @@ -138,6 +138,10 @@ pub enum KeypairInner { } impl KeypairInner { + fn has_private_key(&self) -> bool { + matches!(self, KeypairInner::Ed25519(_) | KeypairInner::Sr25519(_)) + } + fn private_key_bytes(&self) -> Option> { match self { KeypairInner::Ed25519(pair) => Some(pair.to_raw_vec()), @@ -375,6 +379,7 @@ impl Keypair { Ok(Self { inner, ss58_format: DEFAULT_SS58_FORMAT, + derivation_source: None, }) } @@ -423,6 +428,10 @@ impl Keypair { self.ss58_format } + pub fn has_private_key(&self) -> bool { + self.inner.has_private_key() + } + pub fn private_key_bytes(&self) -> Option> { self.inner.private_key_bytes() } diff --git a/sdk/typescript-sdk/native/src/keys.rs b/sdk/typescript-sdk/native/src/keys.rs index 3e19df5aa0..b86f03fb8e 100644 --- a/sdk/typescript-sdk/native/src/keys.rs +++ b/sdk/typescript-sdk/native/src/keys.rs @@ -25,7 +25,7 @@ impl NativeKeypair { #[napi(getter)] pub fn kind(&self) -> String { - if self.inner.private_key_bytes().is_none() { + if !self.inner.has_private_key() { return "PublicOnly".to_owned(); } match self.inner.crypto_type() { @@ -40,11 +40,6 @@ impl NativeKeypair { self.inner.public_key_bytes().to_vec().into() } - #[napi(getter)] - pub fn private_key(&self) -> Option { - self.inner.private_key_bytes().map(Into::into) - } - #[napi(getter)] pub fn ss58_address(&self) -> String { self.inner.ss58_address() diff --git a/sdk/typescript-sdk/src/keys.ts b/sdk/typescript-sdk/src/keys.ts index f569d5fe90..10f89f1886 100644 --- a/sdk/typescript-sdk/src/keys.ts +++ b/sdk/typescript-sdk/src/keys.ts @@ -224,11 +224,6 @@ export class Keypair implements PolkadotCompatibleKeypair { return this.publicKey } - get privateKey(): Buffer | null { - const value = this.handle.privateKey - return value == null ? null : Buffer.from(value) - } - get ss58Address(): string { return this.handle.ss58Address } diff --git a/sdk/typescript-sdk/src/native.ts b/sdk/typescript-sdk/src/native.ts index 5763163d0a..c8ca40447f 100644 --- a/sdk/typescript-sdk/src/native.ts +++ b/sdk/typescript-sdk/src/native.ts @@ -4,7 +4,6 @@ export interface NativeKeypairHandle { readonly cryptoType: number readonly kind: 'Ed25519' | 'Sr25519' | 'PublicOnly' readonly publicKey: Buffer - readonly privateKey?: Buffer | null readonly ss58Address: string readonly ss58Format: number derive(path: string): NativeKeypairHandle diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index e180506bc6..60f07567d9 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -63,6 +63,16 @@ test('mnemonics and secret URIs never appear in public keypair metadata', () => assert.equal(derived.meta.suri, undefined) }) +test('private key bytes are not exported to JavaScript', () => { + const alice = core.Keypair.fromUri('//Alice') + assert.equal(Object.prototype.hasOwnProperty.call(alice, 'privateKey'), false) + assert.equal('privateKey' in alice, false) + assert.equal( + Object.prototype.hasOwnProperty.call(core.native.NativeKeypair.prototype, 'privateKey'), + false, + ) +}) + test('fallible Runtime construction uses the native factory', () => { assert.throws( () => new core.Runtime(Buffer.from([0, 1, 2, 3]), 1, 1), From 4b40b22d4f9db6bd52b69ea7cd8d81627bfab5ea Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 12:31:27 -0700 Subject: [PATCH 13/72] add browser support --- sdk/bittensor-core-wasm/src/keys.rs | 21 + sdk/typescript-sdk/README.md | 37 +- sdk/typescript-sdk/package.json | 28 +- sdk/typescript-sdk/scripts/generate-esm.cjs | 51 +- sdk/typescript-sdk/src/browser.ts | 530 ++++++++++++++++++++ sdk/typescript-sdk/test/basic.test.cjs | 19 + 6 files changed, 661 insertions(+), 25 deletions(-) create mode 100644 sdk/typescript-sdk/src/browser.ts diff --git a/sdk/bittensor-core-wasm/src/keys.rs b/sdk/bittensor-core-wasm/src/keys.rs index 15e95f00e5..30cc98052b 100644 --- a/sdk/bittensor-core-wasm/src/keys.rs +++ b/sdk/bittensor-core-wasm/src/keys.rs @@ -104,6 +104,15 @@ impl Keypair { Ok(Self { inner }) } + /// Derive a child without exposing or reconstructing its secret URI + /// outside Rust/WASM. + pub fn derive(&self, path: &str) -> Result { + self.inner + .derive(path) + .map(|inner| Self { inner }) + .map_err(to_js_err) + } + /// Generate a fresh mnemonic with `nWords` (12/15/18/21/24; default 12). #[wasm_bindgen(js_name = generateMnemonic)] pub fn generate_mnemonic(n_words: Option) -> Result { @@ -115,6 +124,18 @@ impl Keypair { self.inner.crypto_type() } + #[wasm_bindgen(getter)] + pub fn kind(&self) -> String { + if !self.inner.has_private_key() { + return "PublicOnly".to_owned(); + } + match self.inner.crypto_type() { + keys::CRYPTO_ED25519 => "Ed25519".to_owned(), + keys::CRYPTO_SR25519 => "Sr25519".to_owned(), + _ => "PublicOnly".to_owned(), + } + } + #[wasm_bindgen(getter, js_name = publicKey)] pub fn public_key(&self) -> Vec { self.inner.public_key_bytes().to_vec() diff --git a/sdk/typescript-sdk/README.md b/sdk/typescript-sdk/README.md index 0ad05032c1..39512cab53 100644 --- a/sdk/typescript-sdk/README.md +++ b/sdk/typescript-sdk/README.md @@ -1,8 +1,9 @@ # `@bittensor/sdk` The monorepo's TypeScript SDK. It lives in its own `sdk/typescript-sdk` -package and is a deliberately thin Node-API wrapper around the sibling -`bittensor-core` Rust crate. +package. In Node.js it is a deliberately thin Node-API wrapper around the +sibling `bittensor-core` Rust crate. Browser builds resolve to a separate +WASM-compatible subset backed by `sdk/bittensor-core-wasm`. All chain-defined work runs in Rust: @@ -14,7 +15,7 @@ All chain-defined work runs in Rust: - ML-KEM/XChaCha20 MEV-shield envelopes; and - Ledger HID signing. -The TypeScript layer is limited to JavaScript-friendly names and defaults, +The Node TypeScript layer is limited to JavaScript-friendly names and defaults, lossless `Buffer`/`bigint`/`Map` boundary conversion, error classes, and compatibility adapters for the signer objects expected by Polkadot.js, Polkadot API, and Moonwall. @@ -24,6 +25,15 @@ raw generated Node-API module as `@bittensor/sdk/native`, so every native entry point is callable even when an ergonomic wrapper has not yet been added. +Browser bundlers should use the package's `browser` condition automatically, +or import the explicit `@bittensor/sdk/browser` subpath. That entrypoint does +not load `native.cjs`, `.node` binaries, Node `Buffer`, or native HID. It +returns `Uint8Array` values and exposes the portable subset: key generation, +SS58, signing and verification, RFC-0078 metadata proofs, ML-KEM sealing, and +timelock encryption/decryption when the caller fetches the drand signature. +Host-only features such as wallet keyfiles, encrypted JSON import, native +Ledger HID, and direct drand fetching remain Node-only. + ## Build locally From the repository root: @@ -39,6 +49,8 @@ The native crate is isolated under `sdk/typescript-sdk/native`; it links algorithm is reimplemented in TypeScript. Node.js 20.17 or newer is required. +Browser builds also require `wasm-pack` so `npm run build` can emit the +`dist/wasm/bittensor_core_wasm.js` bundle used by `@bittensor/sdk/browser`. ## Example @@ -70,6 +82,25 @@ Large SCALE integers are returned as `bigint`; byte carriers are returned as `Buffer`. A decoded SCALE dictionary with non-string keys is returned as a `Map`, so no key information is lost. +## Browser example + +```ts +import { Keypair, initBrowser } from '@bittensor/sdk/browser' + +await initBrowser() + +const alice = Keypair.fromUri('//Alice') +const message = new TextEncoder().encode('hello') +const signature = alice.sign(message) +console.log(alice.verify(message, signature)) +``` + +Extensions or strict-CSP applications can pass their own loader: + +```ts +await initBrowser(() => import('./vendor/bittensor_core_wasm.js')) +``` + ## Native parity and secret derivation Mnemonic, password, and secret-URI derivation state is retained only by the Rust diff --git a/sdk/typescript-sdk/package.json b/sdk/typescript-sdk/package.json index 77da594bcb..e9db6ccccc 100644 --- a/sdk/typescript-sdk/package.json +++ b/sdk/typescript-sdk/package.json @@ -6,14 +6,39 @@ "license": "Apache-2.0", "main": "dist/index.js", "types": "dist/index.d.ts", + "browser": "./dist/browser.mjs", "exports": { ".": { + "browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.mjs", + "require": "./dist/browser.js", + "default": "./dist/browser.mjs" + }, + "node": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "default": "./dist/index.js" + }, "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js", "default": "./dist/index.js" }, + "./browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.mjs", + "require": "./dist/browser.js", + "default": "./dist/browser.mjs" + }, "./native": { + "node": { + "types": "./native.generated.d.ts", + "import": "./native.cjs", + "require": "./native.cjs", + "default": "./native.cjs" + }, "types": "./native.generated.d.ts", "import": "./native.cjs", "require": "./native.cjs", @@ -32,8 +57,9 @@ }, "scripts": { "build:native": "napi build --manifest-path native/Cargo.toml --output-dir . --platform --release --all-features --js native.cjs --dts native.generated.d.ts", + "build:wasm": "wasm-pack build ../bittensor-core-wasm --target bundler --out-dir ../typescript-sdk/dist/wasm --out-name bittensor_core_wasm", "build:ts": "tsc -p tsconfig.json && node scripts/generate-esm.cjs", - "build": "npm run build:native && npm run check:native-parity && npm run build:ts", + "build": "npm run build:native && npm run check:native-parity && npm run build:ts && npm run build:wasm", "check": "npm run build && npm test", "test": "node --test test/*.test.cjs", "clean": "node scripts/clean.cjs", diff --git a/sdk/typescript-sdk/scripts/generate-esm.cjs b/sdk/typescript-sdk/scripts/generate-esm.cjs index da683b10bb..2503573293 100644 --- a/sdk/typescript-sdk/scripts/generate-esm.cjs +++ b/sdk/typescript-sdk/scripts/generate-esm.cjs @@ -4,13 +4,9 @@ const { readFileSync, writeFileSync } = require('node:fs') const { dirname, join, resolve } = require('node:path') const root = join(__dirname, '..') -const cjsPath = join(root, 'dist', 'index.js') -const esmPath = join(root, 'dist', 'index.mjs') const identifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/ -const seen = new Set() -const names = new Set() -function collectExports(filePath) { +function collectExports(filePath, seen, names) { const normalized = resolve(filePath) if (seen.has(normalized)) return seen.add(normalized) @@ -23,26 +19,39 @@ function collectExports(filePath) { if (match[1] !== '__esModule') names.add(match[1]) } for (const match of source.matchAll(/__exportStar\(require\(["'](.+?)["']\),\s*exports\)/g)) { - collectExports(resolve(dirname(normalized), `${match[1]}.js`)) + collectExports(resolve(dirname(normalized), `${match[1]}.js`), seen, names) } } -collectExports(cjsPath) -names.delete('default') +function generate(entry) { + const cjsPath = join(root, 'dist', `${entry}.js`) + const esmPath = join(root, 'dist', `${entry}.mjs`) + const seen = new Set() + const names = new Set() -const sorted = [...names].sort() -for (const name of sorted) { - if (!identifier.test(name)) { - throw new Error(`Cannot emit ESM named export for ${JSON.stringify(name)}`) + collectExports(cjsPath, seen, names) + names.delete('default') + + const sorted = [...names].sort() + for (const name of sorted) { + if (!identifier.test(name)) { + throw new Error(`Cannot emit ESM named export for ${JSON.stringify(name)}`) + } } + + const lines = [ + `import sdk from './${entry}.js'`, + '', + ...(entry === 'browser' + ? ["sdk.setDefaultBrowserWasmLoader(() => import('./wasm/bittensor_core_wasm.js'))", ''] + : []), + ...sorted.map((name) => `export const ${name} = sdk.${name}`), + '', + 'export default sdk', + '', + ] + writeFileSync(esmPath, lines.join('\n')) } -const lines = [ - "import sdk from './index.js'", - '', - ...sorted.map((name) => `export const ${name} = sdk.${name}`), - '', - 'export default sdk', - '', -] -writeFileSync(esmPath, lines.join('\n')) +generate('index') +generate('browser') diff --git a/sdk/typescript-sdk/src/browser.ts b/sdk/typescript-sdk/src/browser.ts new file mode 100644 index 0000000000..4741ce378c --- /dev/null +++ b/sdk/typescript-sdk/src/browser.ts @@ -0,0 +1,530 @@ +export const CRYPTO_ED25519 = 0 +export const CRYPTO_SR25519 = 1 +export const DEFAULT_SS58_FORMAT = 42 + +export type BrowserByteLike = Uint8Array | ArrayBuffer | ArrayBufferView +export type BrowserMessage = string | BrowserByteLike +export type BrowserIntegerLike = number | bigint +export type SubstrateKeyType = 'sr25519' | 'ed25519' +export type KeypairKind = 'Ed25519' | 'Sr25519' | 'PublicOnly' + +export interface KeypairMetadata { + address?: string + name?: string + type?: SubstrateKeyType + [key: string]: unknown +} + +export interface KeypairSignOptions { + /** Prefix the raw signature with its Substrate MultiSignature variant byte. */ + withType?: boolean +} + +export interface BrowserWasmModule { + default?: () => Promise | unknown + coreVersion(): string + Keypair: BrowserWasmKeypairConstructor + CryptoType: { + Ed25519: number + Sr25519: number + } + verifySignature( + message: BrowserMessage, + signature: Uint8Array, + ss58Address: string, + cryptoType?: number, + ): boolean + ss58Decode(ss58Address: string): Uint8Array + ss58Encode(publicKey: Uint8Array, ss58Format?: number): string + metadataDigest( + metadataBytes: Uint8Array, + specVersion: number, + specName: string, + base58Prefix?: number, + decimals?: number, + tokenSymbol?: string, + ): Uint8Array + generateExtrinsicProof( + callData: Uint8Array, + includedInExtrinsic: Uint8Array, + includedInSignedData: Uint8Array, + metadataBytes: Uint8Array, + specVersion: number, + specName: string, + base58Prefix?: number, + decimals?: number, + tokenSymbol?: string, + ): Uint8Array + getEncryptedCommitment( + data: string, + blocksUntilReveal: BrowserIntegerLike, + blockTime?: number, + ): [Uint8Array, number] + encrypt(data: Uint8Array, nBlocks: BrowserIntegerLike, blockTime?: number): [Uint8Array, number] + encryptAtRound(data: Uint8Array, revealRound: BrowserIntegerLike): [Uint8Array, number] + revealRound(encryptedData: Uint8Array): number + decryptWithSignature(encryptedData: Uint8Array, signatureHex: string): Uint8Array + encryptMlkem768( + publicKey: Uint8Array, + plaintext: Uint8Array, + includeKeyHash?: boolean, + ): Uint8Array + mlkemKdfId(): Uint8Array +} + +export interface BrowserWasmKeypair { + readonly cryptoType: number + readonly kind: KeypairKind + readonly publicKey: Uint8Array + readonly ss58Address: string + readonly ss58Format: number + derive(path: string): BrowserWasmKeypair + sign(message: BrowserMessage): Uint8Array + verify(message: BrowserMessage, signature: Uint8Array): boolean +} + +export interface BrowserWasmKeypairConstructor { + new ( + ss58Address?: string | null, + publicKey?: Uint8Array | null, + cryptoType?: number, + ss58Format?: number, + ): BrowserWasmKeypair + fromMnemonic( + mnemonic: string, + cryptoType?: number, + password?: string | null, + ): BrowserWasmKeypair + fromSeed(seed: Uint8Array, cryptoType?: number): BrowserWasmKeypair + fromUri(uri: string, cryptoType?: number): BrowserWasmKeypair + fromPrivateKey(privateKey: string, cryptoType?: number): BrowserWasmKeypair + generateMnemonic(nWords?: number): string +} + +export type BrowserWasmLoader = () => Promise + +let configuredLoader: BrowserWasmLoader | undefined +let defaultBrowserWasmLoader: BrowserWasmLoader | undefined +let wasmPromise: Promise | undefined + +const metadataStore = new WeakMap() + +function sanitizeMetadata(metadata: KeypairMetadata): KeypairMetadata { + const sanitized = { ...metadata } + delete sanitized.suri + return sanitized +} + +function defaultLoader(): Promise { + if (defaultBrowserWasmLoader == null) { + throw new Error( + 'No browser WASM loader is configured; import the ESM entry or call configureBrowserWasm()', + ) + } + return defaultBrowserWasmLoader() +} + +export function setDefaultBrowserWasmLoader(loader: BrowserWasmLoader): void { + defaultBrowserWasmLoader = loader + wasmPromise = undefined +} + +export function configureBrowserWasm(loader: BrowserWasmLoader): void { + configuredLoader = loader + wasmPromise = undefined +} + +export async function initBrowser( + loader: BrowserWasmLoader = configuredLoader ?? defaultLoader, +): Promise { + if (wasmPromise == null) { + wasmPromise = loader().then(async (module) => { + if (typeof module.default === 'function') { + await module.default() + } + wasmSync = () => module + return module + }) + } + return wasmPromise +} + +function wasmReady(): BrowserWasmModule { + throw new Error('Call await initBrowser() before using @bittensor/sdk/browser') +} + +let wasmSync: () => BrowserWasmModule = wasmReady + +export async function loadBrowser( + loader: BrowserWasmLoader = configuredLoader ?? defaultLoader, +): Promise { + return initBrowser(loader) +} + +function toBytes(value: BrowserByteLike, name = 'value'): Uint8Array { + if (value instanceof Uint8Array) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + if (value instanceof ArrayBuffer) return new Uint8Array(value) + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + } + throw new TypeError(`${name} must be a Uint8Array, ArrayBuffer, or ArrayBufferView`) +} + +function copyBytes(value: BrowserByteLike, name = 'value'): Uint8Array { + return new Uint8Array(toBytes(value, name)) +} + +function toInteger(value: BrowserIntegerLike, name = 'value'): number { + if (typeof value === 'bigint') { + if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RangeError(`${name} must fit in a JavaScript safe integer`) + } + return Number(value) + } + if (!Number.isSafeInteger(value)) { + throw new RangeError(`${name} must be a safe integer or bigint`) + } + return value +} + +function cryptoTypeForKeyType(type: SubstrateKeyType): number { + return type === 'ed25519' ? CRYPTO_ED25519 : CRYPTO_SR25519 +} + +function keyTypeForCryptoType(cryptoType: number): SubstrateKeyType { + if (cryptoType === CRYPTO_ED25519) return 'ed25519' + if (cryptoType === CRYPTO_SR25519) return 'sr25519' + throw new RangeError(`unsupported crypto type ${cryptoType}`) +} + +export class Keypair { + private handle!: BrowserWasmKeypair + + constructor( + ss58Address?: string | null, + publicKey?: BrowserByteLike | null, + cryptoType = CRYPTO_SR25519, + ss58Format = DEFAULT_SS58_FORMAT, + ) { + this.handle = new (wasmSync().Keypair)( + ss58Address ?? undefined, + publicKey == null ? undefined : toBytes(publicKey, 'publicKey'), + cryptoType, + ss58Format, + ) + metadataStore.set(this, {}) + } + + private static wrap(handle: BrowserWasmKeypair, metadata: KeypairMetadata = {}): Keypair { + const keypair = Object.create(Keypair.prototype) as Keypair + keypair.handle = handle + metadataStore.set(keypair, sanitizeMetadata(metadata)) + return keypair + } + + static fromMnemonic( + mnemonic: string, + cryptoType = CRYPTO_SR25519, + password?: string | null, + ): Keypair { + return Keypair.wrap( + wasmSync().Keypair.fromMnemonic(mnemonic, cryptoType, password ?? undefined), + { type: keyTypeForCryptoType(cryptoType) }, + ) + } + + static createFromMnemonic( + mnemonic: string, + cryptoType = CRYPTO_SR25519, + password?: string | null, + ): Keypair { + return Keypair.fromMnemonic(mnemonic, cryptoType, password) + } + + static fromSeed(seed: BrowserByteLike, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.wrap( + wasmSync().Keypair.fromSeed(toBytes(seed, 'seed'), cryptoType), + { type: keyTypeForCryptoType(cryptoType) }, + ) + } + + static createFromSeed(seed: BrowserByteLike, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.fromSeed(seed, cryptoType) + } + + static fromUri(uri: string, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.wrap( + wasmSync().Keypair.fromUri(uri, cryptoType), + { type: keyTypeForCryptoType(cryptoType) }, + ) + } + + static createFromUri(uri: string, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.fromUri(uri, cryptoType) + } + + static fromPrivateKey(privateKey: string, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.wrap( + wasmSync().Keypair.fromPrivateKey(privateKey, cryptoType), + { type: keyTypeForCryptoType(cryptoType) }, + ) + } + + static createFromPrivateKey(privateKey: string, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.fromPrivateKey(privateKey, cryptoType) + } + + static generateMnemonic(nWords = 12): string { + return wasmSync().Keypair.generateMnemonic(nWords) + } + + get cryptoType(): number { + return this.handle.cryptoType + } + + get kind(): KeypairKind { + return this.handle.kind + } + + get publicKey(): Uint8Array { + return copyBytes(this.handle.publicKey) + } + + get addressRaw(): Uint8Array { + return this.publicKey + } + + get ss58Address(): string { + return this.handle.ss58Address + } + + get address(): string { + return this.ss58Address + } + + get type(): SubstrateKeyType { + return keyTypeForCryptoType(this.cryptoType) + } + + get scheme(): 'Ed25519' | 'Sr25519' { + return this.cryptoType === CRYPTO_ED25519 ? 'Ed25519' : 'Sr25519' + } + + get ss58Format(): number { + return this.handle.ss58Format + } + + get meta(): KeypairMetadata { + return { ...(metadataStore.get(this) ?? {}) } + } + + get isLocked(): boolean { + return false + } + + sign(message: BrowserMessage, options: KeypairSignOptions = {}): Uint8Array { + const signature = this.handle.sign(typeof message === 'string' ? message : toBytes(message, 'message')) + if (!options.withType) return copyBytes(signature) + const typed = new Uint8Array(signature.length + 1) + typed[0] = this.cryptoType + typed.set(signature, 1) + return typed + } + + verify( + message: BrowserMessage, + signature: BrowserByteLike, + signerPublic?: string | BrowserByteLike, + ): boolean { + const suppliedSignature = toBytes(signature, 'signature') + const hasType = suppliedSignature.length === 65 && suppliedSignature[0] <= CRYPTO_SR25519 + const cryptoType = hasType ? suppliedSignature[0] : this.cryptoType + const rawSignature = hasType ? suppliedSignature.subarray(1) : suppliedSignature + + if (signerPublic == null) { + return this.handle.verify(typeof message === 'string' ? message : toBytes(message, 'message'), rawSignature) + } + if (typeof signerPublic === 'string' && !signerPublic.startsWith('0x')) { + return verifySignature(message, rawSignature, signerPublic, cryptoType) + } + const publicKey = + typeof signerPublic === 'string' + ? hexToBytes(signerPublic) + : toBytes(signerPublic, 'signerPublic') + return new Keypair(undefined, publicKey, cryptoType, this.ss58Format).verify( + message, + rawSignature, + ) + } + + derive(suri: string, meta: KeypairMetadata = {}): Keypair { + const derived = Keypair.wrap( + this.handle.derive(suri), + { type: keyTypeForCryptoType(this.cryptoType) }, + ) + derived.setMeta(meta) + return derived + } + + setMeta(meta: KeypairMetadata): void { + metadataStore.set( + this, + sanitizeMetadata({ ...(metadataStore.get(this) ?? {}), ...meta }), + ) + } +} + +export async function ready(): Promise { + return loadBrowser() +} + +export function coreVersion(): string { + return wasmSync().coreVersion() +} + +export function verifySignature( + message: BrowserMessage, + signature: BrowserByteLike, + ss58Address: string, + cryptoType = CRYPTO_SR25519, +): boolean { + return wasmSync().verifySignature( + typeof message === 'string' ? message : toBytes(message, 'message'), + toBytes(signature, 'signature'), + ss58Address, + cryptoType, + ) +} + +export function publicKeyFromSs58(ss58Address: string): Uint8Array { + return copyBytes(wasmSync().ss58Decode(ss58Address)) +} + +export function ss58FromPublic( + publicKey: BrowserByteLike, + ss58Format = DEFAULT_SS58_FORMAT, +): string { + return wasmSync().ss58Encode(toBytes(publicKey, 'publicKey'), ss58Format) +} + +export function metadataDigest( + metadataBytes: BrowserByteLike, + specVersion: number, + specName: string, + base58Prefix?: number, + decimals?: number, + tokenSymbol?: string, +): Uint8Array { + return copyBytes( + wasmSync().metadataDigest( + toBytes(metadataBytes, 'metadataBytes'), + specVersion, + specName, + base58Prefix, + decimals, + tokenSymbol, + ), + ) +} + +export function generateExtrinsicProof( + callData: BrowserByteLike, + includedInExtrinsic: BrowserByteLike, + includedInSignedData: BrowserByteLike, + metadataBytes: BrowserByteLike, + specVersion: number, + specName: string, + base58Prefix?: number, + decimals?: number, + tokenSymbol?: string, +): Uint8Array { + return copyBytes( + wasmSync().generateExtrinsicProof( + toBytes(callData, 'callData'), + toBytes(includedInExtrinsic, 'includedInExtrinsic'), + toBytes(includedInSignedData, 'includedInSignedData'), + toBytes(metadataBytes, 'metadataBytes'), + specVersion, + specName, + base58Prefix, + decimals, + tokenSymbol, + ), + ) +} + +export function getEncryptedCommitment( + data: string, + blocksUntilReveal: BrowserIntegerLike, + blockTime?: number, +): [Uint8Array, number] { + const [ciphertext, round] = wasmSync().getEncryptedCommitment( + data, + toInteger(blocksUntilReveal, 'blocksUntilReveal'), + blockTime, + ) + return [copyBytes(ciphertext), round] +} + +export function encrypt( + data: BrowserByteLike, + nBlocks: BrowserIntegerLike, + blockTime?: number, +): [Uint8Array, number] { + const [ciphertext, round] = wasmSync().encrypt(toBytes(data, 'data'), toInteger(nBlocks, 'nBlocks'), blockTime) + return [copyBytes(ciphertext), round] +} + +export function encryptAtRound( + data: BrowserByteLike, + revealRound: BrowserIntegerLike, +): [Uint8Array, number] { + const [ciphertext, round] = wasmSync().encryptAtRound( + toBytes(data, 'data'), + toInteger(revealRound, 'revealRound'), + ) + return [copyBytes(ciphertext), round] +} + +export function decryptWithSignature( + encryptedData: BrowserByteLike, + signatureHex: string, +): Uint8Array { + return copyBytes( + wasmSync().decryptWithSignature(toBytes(encryptedData, 'encryptedData'), signatureHex), + ) +} + +export function revealRound(encryptedData: BrowserByteLike): number { + return wasmSync().revealRound(toBytes(encryptedData, 'encryptedData')) +} + +export function sealMevShieldTransaction( + publicKey: BrowserByteLike, + plaintext: BrowserByteLike, + includeKeyHash = false, +): Uint8Array { + return copyBytes( + wasmSync().encryptMlkem768( + toBytes(publicKey, 'publicKey'), + toBytes(plaintext, 'plaintext'), + includeKeyHash, + ), + ) +} + +export function mlkemKdfId(): Uint8Array { + return copyBytes(wasmSync().mlkemKdfId()) +} + +function hexToBytes(hex: string): Uint8Array { + const value = hex.startsWith('0x') ? hex.slice(2) : hex + if (value.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(value)) { + throw new TypeError('hex string contains invalid bytes') + } + const bytes = new Uint8Array(value.length / 2) + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 60f07567d9..ba97da77c6 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -1,10 +1,29 @@ 'use strict' const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') const test = require('node:test') const core = require('../dist/index.js') +test('package exposes a WASM browser subset without the Node native addon', () => { + const root = path.join(__dirname, '..') + const packageJson = JSON.parse( + fs.readFileSync(path.join(root, 'package.json'), 'utf8'), + ) + + assert.equal(packageJson.browser, './dist/browser.mjs') + assert.equal(packageJson.exports['.'].browser.import, './dist/browser.mjs') + assert.equal(packageJson.exports['./browser'].import, './dist/browser.mjs') + assert.equal(packageJson.exports['./native'].node.import, './native.cjs') + + const browserSource = fs.readFileSync(path.join(root, 'dist', 'browser.js'), 'utf8') + assert.equal(browserSource.includes('./native'), false) + assert.equal(browserSource.includes('node:buffer'), false) + assert.equal(browserSource.includes('.node'), false) +}) + test('sr25519 keypair forwards signing and SS58 work to Rust', () => { const alice = core.Keypair.fromUri('//Alice') assert.equal( From 586a460845641449642f08899a1f107937c0b18b Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 13:02:21 -0700 Subject: [PATCH 14/72] add features from old bittensor --- sdk/typescript-sdk/README.md | 31 +- sdk/typescript-sdk/src/balance.ts | 131 ++ sdk/typescript-sdk/src/client.ts | 1674 +++++++++++++++++ sdk/typescript-sdk/src/index.ts | 3 + sdk/typescript-sdk/src/modules.ts | 9 + sdk/typescript-sdk/src/wallet.ts | 169 ++ sdk/typescript-sdk/test/basic.test.cjs | 29 + .../suites/dev/sdk/test-typescript-sdk.ts | 40 +- 8 files changed, 2073 insertions(+), 13 deletions(-) create mode 100644 sdk/typescript-sdk/src/balance.ts create mode 100644 sdk/typescript-sdk/src/client.ts create mode 100644 sdk/typescript-sdk/src/wallet.ts diff --git a/sdk/typescript-sdk/README.md b/sdk/typescript-sdk/README.md index 39512cab53..57c1af925e 100644 --- a/sdk/typescript-sdk/README.md +++ b/sdk/typescript-sdk/README.md @@ -5,7 +5,7 @@ package. In Node.js it is a deliberately thin Node-API wrapper around the sibling `bittensor-core` Rust crate. Browser builds resolve to a separate WASM-compatible subset backed by `sdk/bittensor-core-wasm`. -All chain-defined work runs in Rust: +Chain-defined work runs in Rust: - sr25519/ed25519 keys, signatures, SS58, and wallet keyfiles; - SCALE encoding and decoding, runtime metadata, storage keys, calls, and @@ -16,9 +16,15 @@ All chain-defined work runs in Rust: - Ledger HID signing. The Node TypeScript layer is limited to JavaScript-friendly names and defaults, -lossless `Buffer`/`bigint`/`Map` boundary conversion, error classes, and +lossless `Buffer`/`bigint`/`Map` boundary conversion, error classes, compatibility adapters for the signer objects expected by Polkadot.js, -Polkadot API, and Moonwall. +Polkadot API, and Moonwall, plus the client responsibilities that deliberately +do not live in Rust: WebSocket/HTTP JSON-RPC transport, reconnect/fallback +handling, subscriptions, storage queries, runtime API calls, nonce tracking, +extrinsic submission and inclusion/finalization outcome handling, wallet +filesystem management, generated-style descriptors, and high-level Bittensor +operations for balances, subnets, metagraphs, staking, registration, serving, +transfers, and weights. The package exposes both CommonJS and ESM entrypoints. It also exports the raw generated Node-API module as `@bittensor/sdk/native`, so every native @@ -82,6 +88,25 @@ Large SCALE integers are returned as `bigint`; byte carriers are returned as `Buffer`. A decoded SCALE dictionary with non-string keys is returned as a `Map`, so no key information is lost. +## Chain client example + +```ts +import { Balance, Client, Keypair, storage } from '@bittensor/sdk' + +const client = await new Client('finney').connect() +const alice = Keypair.fromUri('//Alice') + +const balance = await client.balances.get(alice.ss58Address) +const subnet = await client.subnets.info(1) +const metagraph = await client.subnets.metagraph(1) +const events = await client.query(storage.System.Events) + +await client.transfer(alice, '5F...', Balance.fromTao('0.01'), { + waitForFinalization: true, +}) +await client.close() +``` + ## Browser example ```ts diff --git a/sdk/typescript-sdk/src/balance.ts b/sdk/typescript-sdk/src/balance.ts new file mode 100644 index 0000000000..8606e70520 --- /dev/null +++ b/sdk/typescript-sdk/src/balance.ts @@ -0,0 +1,131 @@ +export const RAO_PER_TAO = 1_000_000_000n + +export type BalanceLike = Balance | bigint | number | string + +export class UnitMismatchError extends Error { + constructor(message: string) { + super(message) + this.name = 'UnitMismatchError' + } +} + +export class Balance { + readonly rao: bigint + readonly netuid: number + readonly symbol?: string + + constructor( + rao: BalanceLike, + netuid = rao instanceof Balance ? rao.netuid : 0, + symbol: string | null | undefined = rao instanceof Balance ? rao.symbol : undefined, + ) { + this.rao = balanceRao(rao) + this.netuid = netuid + this.symbol = symbol ?? undefined + } + + static fromRao(rao: BalanceLike, netuid = 0, symbol?: string | null): Balance { + return new Balance(rao, netuid, symbol) + } + + static fromTao(tao: number | string): Balance { + return Balance.fromAmount(tao, 0) + } + + static fromAlpha(alpha: number | string, netuid: number, symbol?: string | null): Balance { + if (netuid === 0) { + throw new UnitMismatchError('fromAlpha requires a non-zero netuid; use fromTao for TAO') + } + return Balance.fromAmount(alpha, netuid, symbol) + } + + static fromAmount(amount: number | string, netuid = 0, symbol?: string | null): Balance { + if (typeof amount === 'number' && !Number.isFinite(amount)) { + throw new RangeError('balance amount must be finite') + } + const text = String(amount).trim() + if (!/^-?\d+(\.\d+)?$/.test(text)) throw new RangeError(`invalid balance amount ${text}`) + const negative = text.startsWith('-') + const unsigned = negative ? text.slice(1) : text + const [whole, fraction = ''] = unsigned.split('.', 2) + const padded = `${fraction}000000000`.slice(0, 9) + const rao = BigInt(whole || '0') * RAO_PER_TAO + BigInt(padded) + return new Balance(negative ? -rao : rao, netuid, symbol) + } + + get tao(): number { + if (this.netuid !== 0) throw new UnitMismatchError(`This balance is subnet-${this.netuid} alpha, not TAO`) + return this.amount + } + + get alpha(): number { + if (this.netuid === 0) throw new UnitMismatchError('This balance is TAO, not alpha') + return this.amount + } + + get amount(): number { + return Number(this.rao) / Number(RAO_PER_TAO) + } + + get unit(): string { + if (this.netuid === 0) return this.symbol ?? 'TAO' + return this.symbol ?? `alpha${this.netuid}` + } + + withSymbol(symbol: string | null): Balance { + return new Balance(this.rao, this.netuid, symbol) + } + + add(other: BalanceLike): Balance { + return new Balance(this.rao + this.raoOf(other), this.netuid, this.symbol) + } + + sub(other: BalanceLike): Balance { + return new Balance(this.rao - this.raoOf(other), this.netuid, this.symbol) + } + + neg(): Balance { + return new Balance(-this.rao, this.netuid, this.symbol) + } + + eq(other: BalanceLike): boolean { + return this.rao === this.raoOf(other) + } + + toJSON(): string { + return this.rao.toString() + } + + toString(): string { + const sign = this.rao < 0n ? '-' : '' + const value = this.rao < 0n ? -this.rao : this.rao + const whole = value / RAO_PER_TAO + const fraction = (value % RAO_PER_TAO).toString().padStart(9, '0').replace(/0+$/, '') + return `${sign}${whole.toString()}${fraction ? `.${fraction}` : ''} ${this.unit}` + } + + private raoOf(other: BalanceLike): bigint { + if (other instanceof Balance) { + if (other.netuid !== this.netuid) { + throw new UnitMismatchError(`Cannot combine netuid ${this.netuid} with netuid ${other.netuid}`) + } + return other.rao + } + return balanceRao(other) + } +} + +export function balanceRao(value: BalanceLike): bigint { + if (value instanceof Balance) return value.rao + if (typeof value === 'bigint') return value + if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) throw new RangeError('balance rao must be a safe integer') + return BigInt(value) + } + if (/^-?\d+$/.test(value)) return BigInt(value) + return Balance.fromTao(value).rao +} + +export const tao = Balance.fromTao +export const alpha = Balance.fromAlpha +export const rao = Balance.fromRao diff --git a/sdk/typescript-sdk/src/client.ts b/sdk/typescript-sdk/src/client.ts new file mode 100644 index 0000000000..590a2d1fdb --- /dev/null +++ b/sdk/typescript-sdk/src/client.ts @@ -0,0 +1,1674 @@ +import { blake2_256 } from './crypto' +import { Keypair } from './keys' +import { Runtime, eraBirth } from './runtime' +import { toBuffer } from './wire' +import { Balance, type BalanceLike, balanceRao } from './balance' +import type { ByteLike, ScaleValue, SignedExtrinsic } from './types' + +export const SS58_FORMAT = 42 +export const DEFAULT_ERA_PERIOD = 128 +export const NETWORKS = Object.freeze({ + finney: 'wss://entrypoint-finney.opentensor.ai:443', + test: 'wss://test.finney.opentensor.ai:443', + archive: 'wss://archive.chain.opentensor.ai:443', + local: process.env.BT_CHAIN_ENDPOINT ?? 'ws://127.0.0.1:9944', +}) + +export type NetworkName = keyof typeof NETWORKS +export type Descriptor = readonly [string, string] +export type CallLike = + | readonly [string, string, ScaleValue?] + | { pallet?: string; module?: string; call?: string; function?: string; params?: ScaleValue } + | ByteLike + +export interface ClientOptions { + endpoint?: string + fallbackEndpoints?: string[] + retryForever?: boolean + autoConnect?: boolean +} + +export interface SubmitOptions { + nonce?: number + period?: number | null + tip?: BalanceLike + tipAssetId?: BalanceLike | null + metadataHash?: ByteLike | null + waitForInclusion?: boolean + waitForFinalization?: boolean +} + +export interface ExtrinsicResult { + success: boolean + message: string + extrinsicHash: string + blockHash?: string + blockNumber?: number + extrinsicIndex?: number + extrinsicId?: string + finalized?: boolean + fee?: Balance + events: unknown[] + error?: unknown +} + +export interface SignedExtrinsicResult extends SignedExtrinsic { + hex: string +} + +export interface ExtrinsicWatcher { + readonly extrinsicHash: string + readonly result: Promise + unsubscribe(): Promise +} + +export interface BlockHeader { + number: number + parentHash?: string + hash?: string + raw: unknown +} + +export interface BlockInfo { + number: number + hash: string + header: unknown + extrinsics: string[] + timestamp?: Date +} + +interface RpcRequest { + resolve(value: unknown): void + reject(error: Error): void +} + +interface SubscriptionState { + queue: unknown[] + waiters: Array<(value: IteratorResult) => void> + errors: Array<(error: Error) => void> + closed: boolean +} + +type WebSocketLike = { + readyState: number + send(data: string): void + close(): void + addEventListener(type: string, listener: (event: { data?: unknown }) => void): void +} + +type WebSocketConstructor = new (url: string) => WebSocketLike + +export class JsonRpcError extends Error { + readonly code?: number + readonly data?: unknown + + constructor(message: string, code?: number, data?: unknown) { + super(message) + this.name = 'JsonRpcError' + this.code = code + this.data = data + } +} + +export class ChainError extends Error { + readonly details?: unknown + + constructor(message: string, details?: unknown) { + super(message) + this.name = 'ChainError' + this.details = details + } +} + +export class JsonRpcTransport { + private readonly endpoints: string[] + private readonly retryForever: boolean + private endpointIndex = 0 + private id = 1 + private socket?: WebSocketLike + private connecting?: Promise + private pending = new Map() + private subscriptions = new Map() + + constructor(endpoint: string, fallbackEndpoints: string[] = [], retryForever = false) { + this.endpoints = [endpoint, ...fallbackEndpoints.filter((item) => item !== endpoint)] + this.retryForever = retryForever + } + + get endpoint(): string { + return this.endpoints[this.endpointIndex] + } + + async request(method: string, params: unknown[] = []): Promise { + for (;;) { + try { + return this.isHttpEndpoint() ? await this.httpRequest(method, params) : await this.wsRequest(method, params) + } catch (error) { + if (error instanceof JsonRpcError) throw error + this.rotateEndpoint() + if (!this.retryForever) throw error + await delay(1000) + } + } + } + + async subscribe( + subscribeMethod: string, + params: unknown[] = [], + unsubscribeMethod: string, + ): Promise & { unsubscribe(): Promise }> { + if (this.isHttpEndpoint()) throw new ChainError('subscriptions require a WebSocket endpoint') + const subscription = (await this.request(subscribeMethod, params)) as string + const state: SubscriptionState = { queue: [], waiters: [], errors: [], closed: false } + this.subscriptions.set(subscription, state) + const unsubscribe = async () => { + if (state.closed) return + state.closed = true + this.subscriptions.delete(subscription) + for (const waiter of state.waiters.splice(0)) waiter({ done: true, value: undefined }) + await this.request(unsubscribeMethod, [subscription]).catch(() => undefined) + } + return { + unsubscribe, + [Symbol.asyncIterator]: () => ({ + next: () => this.subscriptionNext(state), + return: async () => { + await unsubscribe() + return { done: true, value: undefined } + }, + }), + } + } + + close(): void { + this.socket?.close() + this.socket = undefined + this.connecting = undefined + } + + private isHttpEndpoint(): boolean { + return this.endpoint.startsWith('http://') || this.endpoint.startsWith('https://') + } + + private async httpRequest(method: string, params: unknown[]): Promise { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: this.id++, method, params }), + }) + if (!response.ok) throw new JsonRpcError(`HTTP ${response.status} from ${this.endpoint}`) + const payload = await response.json() + if (payload.error) throw new JsonRpcError(payload.error.message, payload.error.code, payload.error.data) + return payload.result + } + + private async wsRequest(method: string, params: unknown[]): Promise { + const socket = await this.connect() + const id = this.id++ + const promise = new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + }) + try { + socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })) + } catch (error) { + this.pending.delete(id) + throw error + } + return promise + } + + private async connect(): Promise { + if (this.socket?.readyState === 1) return this.socket + if (this.connecting != null) return this.connecting + + const WebSocketImpl = (globalThis as unknown as { WebSocket?: WebSocketConstructor }).WebSocket + if (WebSocketImpl == null) { + throw new ChainError('WebSocket is not available; use Node 20.17+ or pass an HTTP endpoint') + } + + this.connecting = new Promise((resolve, reject) => { + const socket = new WebSocketImpl(this.endpoint) + const fail = (error: Error) => { + this.socket = undefined + this.connecting = undefined + reject(error) + } + socket.addEventListener('open', () => { + this.socket = socket + this.connecting = undefined + resolve(socket) + }) + socket.addEventListener('error', () => fail(new ChainError(`could not connect to ${this.endpoint}`))) + socket.addEventListener('close', () => this.failPending(new ChainError(`connection closed: ${this.endpoint}`))) + socket.addEventListener('message', (event) => this.handleMessage(event.data)) + }) + return this.connecting + } + + private handleMessage(data: unknown): void { + const message = JSON.parse(String(data)) + if (typeof message.id === 'number') { + const pending = this.pending.get(message.id) + if (pending == null) return + this.pending.delete(message.id) + if (message.error) pending.reject(new JsonRpcError(message.error.message, message.error.code, message.error.data)) + else pending.resolve(message.result) + return + } + const subscription = message.params?.subscription + if (subscription == null) return + const state = this.subscriptions.get(subscription) + if (state == null || state.closed) return + const result = message.params?.result + const waiter = state.waiters.shift() + if (waiter != null) waiter({ done: false, value: result }) + else state.queue.push(result) + } + + private subscriptionNext(state: SubscriptionState): Promise> { + if (state.queue.length > 0) return Promise.resolve({ done: false, value: state.queue.shift() }) + if (state.closed) return Promise.resolve({ done: true, value: undefined }) + return new Promise((resolve, reject) => { + state.waiters.push(resolve) + state.errors.push(reject) + }) + } + + private failPending(error: Error): void { + this.socket = undefined + this.connecting = undefined + for (const pending of this.pending.values()) pending.reject(error) + this.pending.clear() + for (const state of this.subscriptions.values()) { + state.closed = true + for (const reject of state.errors.splice(0)) reject(error) + for (const waiter of state.waiters.splice(0)) waiter({ done: true, value: undefined }) + } + this.subscriptions.clear() + } + + private rotateEndpoint(): void { + this.socket = undefined + this.connecting = undefined + if (this.endpoints.length > 1) this.endpointIndex = (this.endpointIndex + 1) % this.endpoints.length + } +} + +export class Client { + readonly network: string + readonly endpoint: string + readonly transport: JsonRpcTransport + readonly balances: BalancesNamespace + readonly subnets: SubnetsNamespace + readonly neurons: NeuronsNamespace + readonly staking: StakingNamespace + + private runtime?: Runtime + private genesis?: string + private nonceCache = new Map() + + constructor(network: string = 'finney', options: ClientOptions = {}) { + const [label, endpoint] = resolveEndpoint(options.endpoint ?? network) + this.network = label + this.endpoint = endpoint + this.transport = new JsonRpcTransport(endpoint, options.fallbackEndpoints, options.retryForever) + this.balances = new BalancesNamespace(this) + this.subnets = new SubnetsNamespace(this) + this.neurons = new NeuronsNamespace(this) + this.staking = new StakingNamespace(this) + if (options.autoConnect) void this.connect() + } + + async connect(): Promise { + await this.runtimeAt() + return this + } + + async close(): Promise { + this.transport.close() + } + + async block(): Promise { + return this.blockNumber() + } + + getCurrentBlock(): Promise { + return this.blockNumber() + } + + get_current_block(): Promise { + return this.getCurrentBlock() + } + + async blockNumber(blockHash?: string | null): Promise { + const header = await this.rpc('chain_getHeader', blockHash == null ? [] : [blockHash]) + return headerNumber(header) + } + + async blockHash(block?: number | null): Promise { + return String(await this.rpc('chain_getBlockHash', block == null ? [] : [block])) + } + + getBlockHash(block?: number | null): Promise { + return this.blockHash(block) + } + + get_block_hash(block?: number | null): Promise { + return this.blockHash(block) + } + + async finalizedHead(): Promise { + return String(await this.rpc('chain_getFinalizedHead')) + } + + async genesisHash(): Promise { + this.genesis ??= await this.blockHash(0) + return this.genesis + } + + async runtimeAt(block?: number | string | null): Promise { + const blockHash = await this.resolveBlockHash(block) + if (this.runtime != null && blockHash == null) return this.runtime + const [metadataHex, version, properties] = await Promise.all([ + this.rpc('state_getMetadata', blockHash == null ? [] : [blockHash]), + this.rpc('state_getRuntimeVersion', blockHash == null ? [] : [blockHash]), + this.rpc('system_properties').catch(() => ({})), + ]) + const ss58Format = Number((properties as { ss58Format?: number }).ss58Format ?? SS58_FORMAT) + const runtime = new Runtime( + hexToBuffer(String(metadataHex)), + Number((version as { specVersion: number }).specVersion), + Number((version as { transactionVersion: number }).transactionVersion), + ss58Format, + ) + if (blockHash == null) this.runtime = runtime + return runtime + } + + rpc(method: string, params: unknown[] = []): Promise { + return this.transport.request(method, params) + } + + async query( + pallet: string | Descriptor, + storageFunction?: string | ScaleValue[], + paramsOrBlock?: ScaleValue[] | number | string | null, + block?: number | string | null, + ): Promise { + const [moduleName, itemName, itemParams, blockRef] = + normalizeStorageArgs(pallet, storageFunction, paramsOrBlock, block) + const blockHash = await this.resolveBlockHash(blockRef) + const runtime = await this.runtimeAt(blockHash) + const key = runtime.storageKey(moduleName, itemName, itemParams) + const raw = await this.rpc('state_getStorage', [hex(key), ...(blockHash == null ? [] : [blockHash])]) + const entry = runtime.storageEntry(moduleName, itemName) + const bytes = raw == null ? entry.defaultBytes : hexToBuffer(String(raw)) + if (bytes == null || bytes.length === 0) return undefined + return runtime.decode(entry.valueType, bytes, false) + } + + async queryBatch( + pallet: string | Descriptor, + storageFunction: string | ScaleValue[][], + paramSetsOrBlock?: ScaleValue[][] | number | string | null, + block?: number | string | null, + ): Promise> { + const [moduleName, itemName, sets, blockRef] = + normalizeBatchArgs(pallet, storageFunction, paramSetsOrBlock, block) + if (sets.length === 0) return [] + const blockHash = await this.resolveBlockHash(blockRef) + const runtime = await this.runtimeAt(blockHash) + const keys = runtime.storageKeyBatch(moduleName, itemName, sets) + const raw = await this.rpc('state_queryStorageAt', [keys.map(hex), ...(blockHash == null ? [] : [blockHash])]) + const changes = ((raw as Array<{ changes?: Array<[string, string | null]> }>)[0]?.changes ?? []) + const valueByKey = new Map(changes.map(([key, value]) => [key.toLowerCase(), value])) + const entry = runtime.storageEntry(moduleName, itemName) + return keys.map((key) => { + const value = valueByKey.get(hex(key).toLowerCase()) + if (value == null) return undefined + return runtime.decode(entry.valueType, hexToBuffer(value), false) + }) + } + + async queryMap( + pallet: string | Descriptor, + storageFunction?: string | ScaleValue[], + paramsOrBlock?: ScaleValue[] | number | string | null, + block?: number | string | null, + pageSize = 512, + ): Promise> { + const [moduleName, itemName, itemParams, blockRef] = + normalizeStorageArgs(pallet, storageFunction, paramsOrBlock, block) + const blockHash = await this.resolveBlockHash(blockRef) + const runtime = await this.runtimeAt(blockHash) + const prefix = runtime.storageKey(moduleName, itemName, itemParams) + const entry = runtime.storageEntry(moduleName, itemName) + const out: Array<[K, V]> = [] + let startKey: string | null = null + for (;;) { + const keys = (await this.rpc('state_getKeysPaged', [ + hex(prefix), + pageSize, + startKey, + ...(blockHash == null ? [] : [blockHash]), + ])) as string[] + if (keys.length === 0) break + const raw = await this.rpc('state_queryStorageAt', [keys, ...(blockHash == null ? [] : [blockHash])]) + const changes = ((raw as Array<{ changes?: Array<[string, string | null]> }>)[0]?.changes ?? []) + const valueByKey = new Map(changes.map(([key, value]) => [key.toLowerCase(), value])) + for (const key of keys) { + const value = valueByKey.get(key.toLowerCase()) + if (value == null) continue + const decodedKey = runtime.decodeStorageKeyParams(moduleName, itemName, hexToBuffer(key), itemParams.length) + const normalizedKey = (decodedKey.length === 1 ? decodedKey[0] : decodedKey) as K + out.push([normalizedKey, runtime.decode(entry.valueType, hexToBuffer(value), false)]) + } + startKey = keys[keys.length - 1] + } + return out + } + + queryModule( + moduleName: string, + name: string, + params: ScaleValue[] = [], + block?: number | string | null, + ): Promise { + return this.query(moduleName, name, params, block) + } + + query_module( + moduleName: string, + name: string, + params: ScaleValue[] = [], + block?: number | string | null, + ): Promise { + return this.queryModule(moduleName, name, params, block) + } + + querySubtensor( + name: string, + params: ScaleValue[] = [], + block?: number | string | null, + ): Promise { + return this.query('SubtensorModule', name, params, block) + } + + query_subtensor( + name: string, + params: ScaleValue[] = [], + block?: number | string | null, + ): Promise { + return this.querySubtensor(name, params, block) + } + + async runtimeCall( + api: string | Descriptor, + method?: string | ScaleValue[], + paramsOrBlock: ScaleValue[] | number | string | null = [], + block?: number | string | null, + ): Promise { + const [apiName, methodName, callParams, blockRef] = normalizeRuntimeArgs(api, method, paramsOrBlock, block) + const blockHash = await this.resolveBlockHash(blockRef) + const runtime = await this.runtimeAt(blockHash) + const info = runtime.runtimeApis()[apiName]?.[methodName] + if (info == null) throw new ChainError(`runtime API ${apiName}.${methodName} not found`) + const inputDetails = info.inputDetails ?? [] + if (inputDetails.length !== callParams.length) { + throw new ChainError(`${apiName}.${methodName} expects ${inputDetails.length} params`) + } + const encoded = Buffer.concat(callParams.map((value, index) => runtime.encodeId(inputDetails[index].typeId, value))) + const raw = await this.rpc('state_call', [`${apiName}_${methodName}`, hex(encoded), blockHash ?? null]) + return runtime.decodeTypeId(info.outputTypeId, hexToBuffer(String(raw)), false) + } + + runtime( + method: Descriptor, + params: ScaleValue[] = [], + block?: number | string | null, + ): Promise { + return this.runtimeCall(method, params, block) + } + + queryRuntimeApi( + api: string, + method: string, + params: ScaleValue[] = [], + block?: number | string | null, + ): Promise { + return this.runtimeCall(api, method, params, block) + } + + query_runtime_api( + api: string, + method: string, + params: ScaleValue[] = [], + block?: number | string | null, + ): Promise { + return this.queryRuntimeApi(api, method, params, block) + } + + async stateCall(method: string, data: ByteLike | string, block?: number | string | null): Promise { + const blockHash = await this.resolveBlockHash(block) + return (await this.rpc('state_call', [method, typeof data === 'string' ? data : hex(data), blockHash ?? null])) as T + } + + state_call(method: string, data: ByteLike | string, block?: number | string | null): Promise { + return this.stateCall(method, data, block) + } + + async constant( + pallet: string | Descriptor, + name?: string, + block?: number | string | null, + ): Promise { + const [moduleName, constantName] = typeof pallet === 'string' ? [pallet, name as string] : pallet + return (await this.runtimeAt(block)).constant(moduleName, constantName) + } + + decodeScale( + typeString: string, + data: ByteLike | string, + block?: number | string | null, + ): Promise { + return this.runtimeAt(block).then((runtime) => + runtime.decode(typeString, typeof data === 'string' ? hexToBuffer(data) : data, false), + ) + } + + decode_scale( + typeString: string, + data: ByteLike | string, + block?: number | string | null, + ): Promise { + return this.decodeScale(typeString, data, block) + } + + composeCall(pallet: string, fn: string, params: ScaleValue = {}, block?: number | string | null): Promise { + return this.runtimeAt(block).then((runtime) => runtime.composeCall(pallet, fn, params)) + } + + compose(call: CallLike, block?: number | string | null): Promise { + return this.callData(call, block) + } + + async *blocks(options: { finalized?: boolean } = {}): AsyncIterable { + const subscription = await this.transport.subscribe( + options.finalized ? 'chain_subscribeFinalizedHeads' : 'chain_subscribeNewHeads', + [], + options.finalized ? 'chain_unsubscribeFinalizedHeads' : 'chain_unsubscribeNewHeads', + ) + try { + for await (const raw of subscription) yield normalizeHeader(raw) + } finally { + await subscription.unsubscribe() + } + } + + async waitForBlock(block?: number | null, options: { timeoutMs?: number } = {}): Promise { + const target = block ?? (await this.blockNumber()) + 1 + const wait = async () => { + for await (const header of this.blocks()) { + if (header.number >= target) return header + } + throw new ChainError('block subscription ended before the target block') + } + return options.timeoutMs == null ? wait() : withTimeout(wait(), options.timeoutMs) + } + + wait_for_block(block?: number | null, options: { timeoutMs?: number } = {}): Promise { + return this.waitForBlock(block, options) + } + + async timestamp(block?: number | string | null): Promise { + const ms = await this.query(storage.Timestamp.Now, [], block) + return new Date(Number(ms ?? 0)) + } + + async blockInfo(block?: number | null): Promise { + const raw = await this.rpc('chain_getBlock', block == null ? [] : [await this.blockHash(block)]) + const value = raw as { block?: { header?: { number?: string; hash?: string }; extrinsics?: string[] } } + if (value.block?.header == null) return null + const number = headerNumber(value.block.header) + return { + number, + hash: await this.blockHash(number), + header: value.block.header, + extrinsics: value.block.extrinsics ?? [], + timestamp: await this.timestamp(number).catch(() => undefined), + } + } + + block_info(block?: number | null): Promise { + return this.blockInfo(block) + } + + async at(block?: number | null): Promise { + const number = block ?? await this.blockNumber() + return new Snapshot(this, number, await this.blockHash(number)) + } + + balance(rao: BalanceLike, netuid = 0, symbol?: string | null): Balance { + return Balance.fromRao(rao, netuid, symbol) + } + + read(name: string, params: Record = {}): Promise { + return read(this, name, params) + } + + reads(): Array<{ name: string; category: string; params: string[] }> { + return READS.slice() + } + + async signExtrinsic(call: CallLike, keypair: Keypair, options: SubmitOptions = {}): Promise { + const runtime = await this.runtimeAt() + const callData = await this.callData(call) + const nonce = options.nonce ?? (await this.accountNextIndex(keypair.ss58Address)) + const period = options.period === undefined ? DEFAULT_ERA_PERIOD : options.period + const { era, eraBlockHash } = await this.normalizeEra(period) + const tip = balanceRao(options.tip ?? 0) + const tipAssetId = options.tipAssetId == null ? null : balanceRao(options.tipAssetId) + const metadataHash = options.metadataHash == null ? null : toBuffer(options.metadataHash, 'metadataHash') + const txParams = { + era, + nonce, + tip, + tipAssetId, + genesisHash: hexToBuffer(await this.genesisHash()), + eraBlockHash: hexToBuffer(eraBlockHash), + metadataHash, + } + const payload = runtime.signaturePayload(callData, txParams) + const signature = keypair.sign(payload) + const signed = runtime.encodeSignedExtrinsic(callData, keypair.publicKey, signature, keypair.cryptoType, { + era, + nonce, + tip, + tipAssetId, + metadataHashEnabled: metadataHash != null, + }) + return { ...signed, hex: hex(signed.bytes) } + } + + async submit(call: CallLike, keypair: Keypair, options: SubmitOptions = {}): Promise { + try { + const signed = await this.signExtrinsic(call, keypair, options) + return await this.submitSigned(signed, keypair.ss58Address, options) + } catch (error) { + this.clearNonce(keypair.ss58Address) + throw error + } + } + + async submitSigned( + extrinsic: SignedExtrinsicResult | SignedExtrinsic | ByteLike, + signerAddress?: string, + options: SubmitOptions = {}, + ): Promise { + const bytes = Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array + ? toBuffer(extrinsic, 'extrinsic') + : toBuffer(extrinsic.bytes, 'extrinsic.bytes') + const extrinsicHash = hex(blake2_256(bytes)) + try { + if (!options.waitForInclusion && !options.waitForFinalization) { + const submitted = String(await this.rpc('author_submitExtrinsic', [hex(bytes)])) + return { success: true, message: 'Submitted', extrinsicHash: submitted, events: [] } + } + const watcher = await this.watchSigned(bytes, { + waitForFinalization: options.waitForFinalization ?? false, + }) + return await watcher.result + } catch (error) { + if (signerAddress != null) this.clearNonce(signerAddress) + throw error + } + } + + async watchSigned( + extrinsic: SignedExtrinsicResult | SignedExtrinsic | ByteLike, + options: Pick = {}, + ): Promise { + const bytes = Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array + ? toBuffer(extrinsic, 'extrinsic') + : toBuffer(extrinsic.bytes, 'extrinsic.bytes') + const extrinsicHash = hex(blake2_256(bytes)) + const subscription = await this.transport.subscribe( + 'author_submitAndWatchExtrinsic', + [hex(bytes)], + 'author_unwatchExtrinsic', + ) + return { + extrinsicHash, + result: this.resolveWatchedExtrinsic( + subscription, + extrinsicHash, + options.waitForFinalization ?? false, + ), + unsubscribe: () => subscription.unsubscribe(), + } + } + + async accountNextIndex(address: string, useCache = true): Promise { + if (useCache && this.nonceCache.has(address)) { + const nonce = this.nonceCache.get(address)! + this.nonceCache.set(address, nonce + 1) + return nonce + } + const nonce = Number(await this.rpc('system_accountNextIndex', [address])) + this.nonceCache.set(address, nonce + 1) + return nonce + } + + clearNonce(address: string): void { + this.nonceCache.delete(address) + } + + async estimateFee(call: CallLike, keypair: Keypair): Promise { + const signed = await this.signExtrinsic(call, keypair, { + nonce: await this.accountNextIndex(keypair.ss58Address, false), + period: null, + }) + const runtime = await this.runtimeAt() + const length = Buffer.alloc(4) + length.writeUInt32LE(signed.bytes.length, 0) + const raw = await this.rpc('state_call', ['TransactionPaymentApi_query_info', hex(Buffer.concat([signed.bytes, length])), null]) + const info = runtime.decodeTypeId>( + runtime.runtimeApis().TransactionPaymentApi.query_info.outputTypeId, + hexToBuffer(String(raw)), + false, + ) + return Balance.fromRao(String(info.partial_fee ?? info.partialFee ?? 0)) + } + + submitCall(pallet: string, fn: string, params: ScaleValue, keypair: Keypair, options: SubmitOptions = {}): Promise { + return this.submit([pallet, fn, params], keypair, options) + } + + submit_call(pallet: string, fn: string, params: ScaleValue, keypair: Keypair, options: SubmitOptions = {}): Promise { + return this.submitCall(pallet, fn, params, keypair, options) + } + + transfer(keypair: Keypair, dest: string, amount: BalanceLike, options: SubmitOptions & { keepAlive?: boolean } = {}): Promise { + return this.submit(calls.balances[options.keepAlive === false ? 'transferAllowDeath' : 'transferKeepAlive'](dest, amount), keypair, { + waitForInclusion: true, + ...options, + }) + } + + transfer_keep_alive(keypair: Keypair, dest: string, amount: BalanceLike, options: SubmitOptions = {}): Promise { + return this.transfer(keypair, dest, amount, { keepAlive: true, ...options }) + } + + transfer_allow_death(keypair: Keypair, dest: string, amount: BalanceLike, options: SubmitOptions = {}): Promise { + return this.transfer(keypair, dest, amount, { keepAlive: false, ...options }) + } + + setWeights(keypair: Keypair, netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string, options: SubmitOptions = {}): Promise { + return this.submit(calls.subtensor.setWeights(netuid, dests, weights, versionKey), keypair, { waitForInclusion: true, ...options }) + } + + set_weights(keypair: Keypair, netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string, options: SubmitOptions = {}): Promise { + return this.setWeights(keypair, netuid, dests, weights, versionKey, options) + } + + burnedRegister(keypair: Keypair, netuid: number, hotkey: string, options: SubmitOptions = {}): Promise { + return this.submit(calls.subtensor.burnedRegister(netuid, hotkey), keypair, { waitForInclusion: true, ...options }) + } + + burned_register(keypair: Keypair, netuid: number, hotkey: string, options: SubmitOptions = {}): Promise { + return this.burnedRegister(keypair, netuid, hotkey, options) + } + + rootRegister(keypair: Keypair, hotkey: string, options: SubmitOptions = {}): Promise { + return this.submit(calls.subtensor.rootRegister(hotkey), keypair, { waitForInclusion: true, ...options }) + } + + root_register(keypair: Keypair, hotkey: string, options: SubmitOptions = {}): Promise { + return this.rootRegister(keypair, hotkey, options) + } + + registerNetwork(keypair: Keypair, hotkey: string, options: SubmitOptions = {}): Promise { + return this.submit(calls.subtensor.registerNetwork(hotkey), keypair, { waitForInclusion: true, ...options }) + } + + register_network(keypair: Keypair, hotkey: string, options: SubmitOptions = {}): Promise { + return this.registerNetwork(keypair, hotkey, options) + } + + registerSubnet(keypair: Keypair, hotkey: string, options: SubmitOptions = {}): Promise { + return this.registerNetwork(keypair, hotkey, options) + } + + register_subnet(keypair: Keypair, hotkey: string, options: SubmitOptions = {}): Promise { + return this.registerSubnet(keypair, hotkey, options) + } + + serveAxon( + keypair: Keypair, + args: { netuid: number; ip: number; port: number; version?: number; ipType?: number; protocol?: number }, + options: SubmitOptions = {}, + ): Promise { + return this.submit(calls.subtensor.serveAxon(args.netuid, args.ip, args.port, args.version, args.ipType, args.protocol), keypair, { + waitForInclusion: true, + ...options, + }) + } + + serve_axon( + keypair: Keypair, + args: { netuid: number; ip: number; port: number; version?: number; ipType?: number; protocol?: number }, + options: SubmitOptions = {}, + ): Promise { + return this.serveAxon(keypair, args, options) + } + + getBalance(address: string | Keypair, block?: number | string | null): Promise { + return this.balances.get(typeof address === 'string' ? address : address.ss58Address, block) + } + + get_balance(address: string | Keypair, block?: number | string | null): Promise { + return this.getBalance(address, block) + } + + getBalances(addresses: Array, block?: number | string | null): Promise> { + return this.balances.getMany(addresses.map((address) => typeof address === 'string' ? address : address.ss58Address), block) + } + + get_balances(addresses: Array, block?: number | string | null): Promise> { + return this.getBalances(addresses, block) + } + + getStake(coldkey: string | Keypair, hotkey: string | Keypair, netuid: number, block?: number | string | null): Promise { + return this.staking.get( + typeof coldkey === 'string' ? coldkey : coldkey.ss58Address, + typeof hotkey === 'string' ? hotkey : hotkey.ss58Address, + netuid, + block, + ) + } + + get_stake(coldkey: string | Keypair, hotkey: string | Keypair, netuid: number, block?: number | string | null): Promise { + return this.getStake(coldkey, hotkey, netuid, block) + } + + metagraph(netuid: number, block?: number | string | null): Promise { + return this.subnets.metagraph(netuid, block) + } + + subnet(netuid: number, block?: number | string | null): Promise { + return this.subnets.subnet(netuid, block) + } + + allSubnets(block?: number | string | null): Promise { + return this.subnets.all(block) + } + + all_subnets(block?: number | string | null): Promise { + return this.allSubnets(block) + } + + subnetExists(netuid: number, block?: number | string | null): Promise { + return this.subnets.exists(netuid, block) + } + + subnet_exists(netuid: number, block?: number | string | null): Promise { + return this.subnetExists(netuid, block) + } + + getSubnetHyperparameters(netuid: number, block?: number | string | null): Promise { + return this.subnets.hyperparameters(netuid, block) + } + + get_subnet_hyperparameters(netuid: number, block?: number | string | null): Promise { + return this.getSubnetHyperparameters(netuid, block) + } + + async callData(call: CallLike, block?: number | string | null): Promise { + if (Buffer.isBuffer(call) || call instanceof Uint8Array) return toBuffer(call, 'call') + const [pallet, fn, params] = normalizeCall(call) + return this.composeCall(pallet, fn, params, block) + } + + async resolveBlockHash(block?: number | string | null): Promise { + if (block == null) return null + if (typeof block === 'string') return block + return this.blockHash(block) + } + + private async normalizeEra(period: number | null): Promise<{ era: ScaleValue; eraBlockHash: string }> { + if (period == null) return { era: '00', eraBlockHash: await this.genesisHash() } + const finalized = await this.finalizedHead() + const current = await this.blockNumber(finalized) + const birth = Number(eraBirth(period, current)) + return { era: { period, current }, eraBlockHash: await this.blockHash(birth) } + } + + private async resolveWatchedExtrinsic( + subscription: AsyncIterable & { unsubscribe(): Promise }, + extrinsicHash: string, + waitForFinalization: boolean, + ): Promise { + try { + for await (const status of subscription) { + const normalized = normalizeStatus(status) + const fatal = ['usurped', 'retracted', 'finalitytimeout', 'dropped', 'invalid'].find((name) => normalized[name] != null) + if (fatal != null) throw new ChainError(`Extrinsic ${fatal}`, status) + if (waitForFinalization && normalized.finalized != null) { + return this.resolveInclusion(extrinsicHash, String(normalized.finalized), true) + } + if (!waitForFinalization && normalized.inblock != null) { + return this.resolveInclusion(extrinsicHash, String(normalized.inblock), false) + } + } + throw new ChainError('extrinsic watch ended before inclusion') + } finally { + await subscription.unsubscribe() + } + } + + private async resolveInclusion(extrinsicHash: string, blockHash: string, finalized: boolean): Promise { + const block = (await this.rpc('chain_getBlock', [blockHash])) as { block: { header: unknown; extrinsics: string[] } } + const blockNumber = headerNumber(block.block.header) + const extrinsicIndex = block.block.extrinsics.findIndex((item) => hex(blake2_256(hexToBuffer(item))) === extrinsicHash) + if (extrinsicIndex < 0) throw new ChainError(`extrinsic ${extrinsicHash} was not found in block ${blockHash}`) + const events = ((await this.query(storage.System.Events, [], blockHash)) ?? []) as unknown[] + const triggered = events.filter((event) => eventExtrinsicIndex(event) === extrinsicIndex) + const failed = triggered.find((event) => eventName(event) === 'System.ExtrinsicFailed') + const success = triggered.some((event) => eventName(event) === 'System.ExtrinsicSuccess') + const feeEvent = triggered.find((event) => eventName(event) === 'TransactionPayment.TransactionFeePaid') + return { + success: success && failed == null, + message: failed == null ? 'Success' : 'Extrinsic failed', + extrinsicHash, + blockHash, + blockNumber, + extrinsicIndex, + extrinsicId: `${blockNumber}-${String(extrinsicIndex).padStart(4, '0')}`, + finalized, + fee: feeEvent == null ? undefined : feeFromEvent(feeEvent), + events: triggered, + error: failed, + } + } +} + +export class Snapshot { + readonly balances: SnapshotBalancesNamespace + readonly subnets: SnapshotSubnetsNamespace + readonly staking: SnapshotStakingNamespace + readonly neurons: SnapshotNeuronsNamespace + + constructor(readonly client: Client, readonly block: number, readonly blockHash: string) { + this.balances = new SnapshotBalancesNamespace(this) + this.subnets = new SnapshotSubnetsNamespace(this) + this.staking = new SnapshotStakingNamespace(this) + this.neurons = new SnapshotNeuronsNamespace(this) + } + + query(item: string | Descriptor, nameOrParams?: string | ScaleValue[], params?: ScaleValue[]): Promise { + return typeof item === 'string' + ? this.client.query(item, nameOrParams as string, params ?? [], this.blockHash) + : this.client.query(item, (nameOrParams as ScaleValue[] | undefined) ?? [], this.blockHash) + } + + queryMap(item: Descriptor, params: ScaleValue[] = []): Promise> { + return this.client.queryMap(item, params, this.blockHash) + } + + queryBatch(item: Descriptor, paramSets: ScaleValue[][]): Promise> { + return this.client.queryBatch(item, paramSets, this.blockHash) + } + + runtime(method: Descriptor, params: ScaleValue[] = []): Promise { + return this.client.runtime(method, params, this.blockHash) + } + + constant(item: Descriptor): Promise { + return this.client.constant(item, undefined, this.blockHash) + } + + read(name: string, params: Record = {}): Promise { + return read(this.client, name, params, this.blockHash) + } +} + +export class BalancesNamespace { + constructor(private readonly client: Client) {} + + async free(address: string, block?: number | string | null): Promise { + const account = await this.client.query>(storage.System.Account, [address], block) + const data = account?.data as Record | undefined + return Balance.fromRao(String(data?.free ?? 0)) + } + + get(address: string, block?: number | string | null): Promise { + return this.free(address, block) + } + + async getMany(addresses: string[], block?: number | string | null): Promise> { + const accounts = await this.client.queryBatch>(storage.System.Account, addresses.map((address) => [address]), block) + const out: Record = {} + addresses.forEach((address, index) => { + const data = accounts[index]?.data as Record | undefined + out[address] = Balance.fromRao(String(data?.free ?? 0)) + }) + return out + } + + get_many(addresses: string[], block?: number | string | null): Promise> { + return this.getMany(addresses, block) + } + + async existentialDeposit(block?: number | string | null): Promise { + return Balance.fromRao(String((await this.client.constant(constants.Balances.ExistentialDeposit, undefined, block)) ?? 0)) + } + + existential_deposit(block?: number | string | null): Promise { + return this.existentialDeposit(block) + } +} + +export interface SubnetInfo { + netuid: number + tempo: number + burn: Balance + neuronCount: number +} + +export class SubnetsNamespace { + constructor(private readonly client: Client) {} + + async subnet(netuid: number, block?: number | string | null): Promise { + const [tempo, burn, count] = await Promise.all([ + this.client.query(storage.SubtensorModule.Tempo, [netuid], block), + this.client.query(storage.SubtensorModule.Burn, [netuid], block), + this.client.query(storage.SubtensorModule.SubnetworkN, [netuid], block), + ]) + return { netuid, tempo: Number(tempo ?? 0), burn: Balance.fromRao(String(burn ?? 0)), neuronCount: Number(count ?? 0) } + } + + info(netuid: number, block?: number | string | null): Promise { + return this.subnet(netuid, block) + } + + async all(block?: number | string | null): Promise { + const [added, tempos, burns, counts] = await Promise.all([ + this.client.queryMap(storage.SubtensorModule.NetworksAdded, [], block), + this.client.queryMap(storage.SubtensorModule.Tempo, [], block), + this.client.queryMap(storage.SubtensorModule.Burn, [], block), + this.client.queryMap(storage.SubtensorModule.SubnetworkN, [], block), + ]) + const tempoByNetuid = new Map(tempos.map(([key, value]) => [Number(key), Number(value)])) + const burnByNetuid = new Map(burns.map(([key, value]) => [Number(key), String(value)])) + const countByNetuid = new Map(counts.map(([key, value]) => [Number(key), Number(value)])) + return added + .filter(([, value]) => value) + .map(([netuid]) => Number(netuid)) + .sort((a, b) => a - b) + .map((netuid) => ({ + netuid, + tempo: tempoByNetuid.get(netuid) ?? 0, + burn: Balance.fromRao(burnByNetuid.get(netuid) ?? 0), + neuronCount: countByNetuid.get(netuid) ?? 0, + })) + } + + async exists(netuid: number, block?: number | string | null): Promise { + return Boolean(await this.client.query(storage.SubtensorModule.NetworksAdded, [netuid], block)) + } + + subnetExists(netuid: number, block?: number | string | null): Promise { + return this.exists(netuid, block) + } + + subnet_exists(netuid: number, block?: number | string | null): Promise { + return this.exists(netuid, block) + } + + metagraph(netuid: number, block?: number | string | null): Promise { + return this.client.runtime(runtimeApi.SubnetInfoRuntimeApi.get_metagraph, [netuid], block) + } + + hyperparameters(netuid: number, block?: number | string | null): Promise { + return this.client.runtime(runtimeApi.SubnetInfoRuntimeApi.get_subnet_hyperparams, [netuid], block) + } + + subnetHyperparameters(netuid: number, block?: number | string | null): Promise { + return this.hyperparameters(netuid, block) + } + + subnet_hyperparameters(netuid: number, block?: number | string | null): Promise { + return this.hyperparameters(netuid, block) + } + + async commitRevealEnabled(netuid: number, block?: number | string | null): Promise { + return Boolean(await this.client.query(storage.SubtensorModule.CommitRevealWeightsEnabled, [netuid], block)) + } + + commit_reveal_enabled(netuid: number, block?: number | string | null): Promise { + return this.commitRevealEnabled(netuid, block) + } + + burn(netuid: number, block?: number | string | null): Promise { + return this.subnet(netuid, block).then((info) => info.burn) + } +} + +export class NeuronsNamespace { + constructor(private readonly client: Client) {} + + all(netuid: number, lite = true, block?: number | string | null): Promise { + return this.client.runtime( + lite ? runtimeApi.NeuronInfoRuntimeApi.get_neurons_lite : runtimeApi.NeuronInfoRuntimeApi.get_neurons, + [netuid], + block, + ) + } + + get(netuid: number, uid: number, lite = true, block?: number | string | null): Promise { + return this.client.runtime( + lite ? runtimeApi.NeuronInfoRuntimeApi.get_neuron_lite : runtimeApi.NeuronInfoRuntimeApi.get_neuron, + [netuid, uid], + block, + ) + } +} + +export interface StakePosition { + hotkey: string + coldkey: string + netuid: number + stake: Balance + isRegistered: boolean + raw: Record +} + +export class StakingNamespace { + constructor(private readonly client: Client) {} + + async get(coldkey: string, hotkey: string, netuid: number, block?: number | string | null): Promise { + const info = await this.client.runtime | null>( + runtimeApi.StakeInfoRuntimeApi.get_stake_info_for_hotkey_coldkey_netuid, + [hotkey, coldkey, netuid], + block, + ) + return Balance.fromRao(String(info?.stake ?? 0), netuid) + } + + async positions(coldkey: string, block?: number | string | null): Promise { + const records = await this.client.runtime>>( + runtimeApi.StakeInfoRuntimeApi.get_stake_info_for_coldkey, + [coldkey], + block, + ) + return (records ?? []).map((record) => ({ + hotkey: String(record.hotkey), + coldkey: String(record.coldkey), + netuid: Number(record.netuid ?? 0), + stake: Balance.fromRao(String(record.stake ?? 0), Number(record.netuid ?? 0)), + isRegistered: Boolean(record.is_registered ?? record.isRegistered ?? false), + raw: record, + })) + } + + stake(coldkey: string, hotkey: string, netuid: number, block?: number | string | null): Promise { + return this.get(coldkey, hotkey, netuid, block) + } + + stakeForColdkey(coldkey: string, block?: number | string | null): Promise { + return this.positions(coldkey, block) + } + + stake_for_coldkey(coldkey: string, block?: number | string | null): Promise { + return this.positions(coldkey, block) + } + + addStake(keypair: Keypair, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { + return this.client.submit(calls.subtensor.addStake(hotkey, netuid, amount), keypair, { waitForInclusion: true, ...options }) + } + + add_stake(keypair: Keypair, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { + return this.addStake(keypair, hotkey, netuid, amount, options) + } + + removeStake(keypair: Keypair, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { + return this.client.submit(calls.subtensor.removeStake(hotkey, netuid, amount), keypair, { waitForInclusion: true, ...options }) + } + + remove_stake(keypair: Keypair, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { + return this.removeStake(keypair, hotkey, netuid, amount, options) + } +} + +export class SnapshotBalancesNamespace { + constructor(private readonly snapshot: Snapshot) {} + get(address: string): Promise { return this.snapshot.client.balances.get(address, this.snapshot.blockHash) } + getMany(addresses: string[]): Promise> { return this.snapshot.client.balances.getMany(addresses, this.snapshot.blockHash) } +} + +export class SnapshotSubnetsNamespace { + constructor(private readonly snapshot: Snapshot) {} + info(netuid: number): Promise { return this.snapshot.client.subnets.info(netuid, this.snapshot.blockHash) } + all(): Promise { return this.snapshot.client.subnets.all(this.snapshot.blockHash) } + metagraph(netuid: number): Promise { return this.snapshot.client.subnets.metagraph(netuid, this.snapshot.blockHash) } +} + +export class SnapshotStakingNamespace { + constructor(private readonly snapshot: Snapshot) {} + get(coldkey: string, hotkey: string, netuid: number): Promise { return this.snapshot.client.staking.get(coldkey, hotkey, netuid, this.snapshot.blockHash) } + positions(coldkey: string): Promise { return this.snapshot.client.staking.positions(coldkey, this.snapshot.blockHash) } +} + +export class SnapshotNeuronsNamespace { + constructor(private readonly snapshot: Snapshot) {} + all(netuid: number, lite = true): Promise { return this.snapshot.client.neurons.all(netuid, lite, this.snapshot.blockHash) } +} + +export class SubtensorClient extends Client {} +export const Subtensor = SubtensorClient + +export function subtensor(network: string = 'finney', options: ClientOptions = {}): Client { + return new Client(network, options) +} + +export function call(pallet: string, fn: string, params: ScaleValue = {}): [string, string, ScaleValue] { + return [pallet, fn, params] +} + +export function descriptor(pallet: string, item: string): Descriptor { + return [pallet, item] +} + +export const storage = Object.freeze({ + Balances: Object.freeze({ + Account: descriptor('Balances', 'Account'), + TotalIssuance: descriptor('Balances', 'TotalIssuance'), + Locks: descriptor('Balances', 'Locks'), + Holds: descriptor('Balances', 'Holds'), + }), + Multisig: Object.freeze({ Multisigs: descriptor('Multisig', 'Multisigs') }), + Proxy: Object.freeze({ Proxies: descriptor('Proxy', 'Proxies') }), + SubtensorModule: Object.freeze({ + NetworksAdded: descriptor('SubtensorModule', 'NetworksAdded'), + Tempo: descriptor('SubtensorModule', 'Tempo'), + Burn: descriptor('SubtensorModule', 'Burn'), + SubnetworkN: descriptor('SubtensorModule', 'SubnetworkN'), + CommitRevealWeightsEnabled: descriptor('SubtensorModule', 'CommitRevealWeightsEnabled'), + TokenSymbol: descriptor('SubtensorModule', 'TokenSymbol'), + SubnetIdentitiesV3: descriptor('SubtensorModule', 'SubnetIdentitiesV3'), + StakingHotkeys: descriptor('SubtensorModule', 'StakingHotkeys'), + OwnedHotkeys: descriptor('SubtensorModule', 'OwnedHotkeys'), + AutoStakeDestination: descriptor('SubtensorModule', 'AutoStakeDestination'), + AutoStakeDestinationColdkeys: descriptor('SubtensorModule', 'AutoStakeDestinationColdkeys'), + TotalStake: descriptor('SubtensorModule', 'TotalStake'), + StakeThreshold: descriptor('SubtensorModule', 'StakeThreshold'), + LastEpochBlock: descriptor('SubtensorModule', 'LastEpochBlock'), + }), + System: Object.freeze({ + Account: descriptor('System', 'Account'), + Events: descriptor('System', 'Events'), + }), + Timestamp: Object.freeze({ Now: descriptor('Timestamp', 'Now') }), +}) + +export const constants = Object.freeze({ + Balances: Object.freeze({ ExistentialDeposit: descriptor('Balances', 'ExistentialDeposit') }), + SubtensorModule: Object.freeze({ + InitialMinStake: descriptor('SubtensorModule', 'InitialMinStake'), + InitialStartCallDelay: descriptor('SubtensorModule', 'InitialStartCallDelay'), + InitialWeightsVersionKey: descriptor('SubtensorModule', 'InitialWeightsVersionKey'), + }), +}) + +export const runtimeApi = Object.freeze({ + DelegateInfoRuntimeApi: Object.freeze({ + get_delegates: descriptor('DelegateInfoRuntimeApi', 'get_delegates'), + get_delegate: descriptor('DelegateInfoRuntimeApi', 'get_delegate'), + get_delegated: descriptor('DelegateInfoRuntimeApi', 'get_delegated'), + }), + NeuronInfoRuntimeApi: Object.freeze({ + get_neurons: descriptor('NeuronInfoRuntimeApi', 'get_neurons'), + get_neuron: descriptor('NeuronInfoRuntimeApi', 'get_neuron'), + get_neurons_lite: descriptor('NeuronInfoRuntimeApi', 'get_neurons_lite'), + get_neuron_lite: descriptor('NeuronInfoRuntimeApi', 'get_neuron_lite'), + }), + StakeInfoRuntimeApi: Object.freeze({ + get_stake_info_for_coldkey: descriptor('StakeInfoRuntimeApi', 'get_stake_info_for_coldkey'), + get_stake_info_for_coldkeys: descriptor('StakeInfoRuntimeApi', 'get_stake_info_for_coldkeys'), + get_stake_info_for_hotkey_coldkey_netuid: descriptor('StakeInfoRuntimeApi', 'get_stake_info_for_hotkey_coldkey_netuid'), + get_stake_availability_for_coldkeys: descriptor('StakeInfoRuntimeApi', 'get_stake_availability_for_coldkeys'), + get_stake_fee: descriptor('StakeInfoRuntimeApi', 'get_stake_fee'), + }), + SubnetInfoRuntimeApi: Object.freeze({ + get_subnet_info: descriptor('SubnetInfoRuntimeApi', 'get_subnet_info'), + get_subnets_info: descriptor('SubnetInfoRuntimeApi', 'get_subnets_info'), + get_subnet_hyperparams: descriptor('SubnetInfoRuntimeApi', 'get_subnet_hyperparams'), + get_all_dynamic_info: descriptor('SubnetInfoRuntimeApi', 'get_all_dynamic_info'), + get_all_metagraphs: descriptor('SubnetInfoRuntimeApi', 'get_all_metagraphs'), + get_metagraph: descriptor('SubnetInfoRuntimeApi', 'get_metagraph'), + get_dynamic_info: descriptor('SubnetInfoRuntimeApi', 'get_dynamic_info'), + get_subnet_state: descriptor('SubnetInfoRuntimeApi', 'get_subnet_state'), + get_selective_metagraph: descriptor('SubnetInfoRuntimeApi', 'get_selective_metagraph'), + get_next_epoch_start_block: descriptor('SubnetInfoRuntimeApi', 'get_next_epoch_start_block'), + }), + SubnetRegistrationRuntimeApi: Object.freeze({ + get_network_registration_cost: descriptor('SubnetRegistrationRuntimeApi', 'get_network_registration_cost'), + }), + TransactionPaymentApi: Object.freeze({ + query_info: descriptor('TransactionPaymentApi', 'query_info'), + query_fee_details: descriptor('TransactionPaymentApi', 'query_fee_details'), + }), +}) + +export const runtimeApis = runtimeApi + +export const calls = Object.freeze({ + balances: Object.freeze({ + transferKeepAlive(dest: string, value: BalanceLike) { + return call('Balances', 'transfer_keep_alive', { dest, value: balanceRao(value) }) + }, + transferAllowDeath(dest: string, value: BalanceLike) { + return call('Balances', 'transfer_allow_death', { dest, value: balanceRao(value) }) + }, + }), + subtensor: Object.freeze({ + addStake(hotkey: string, netuid: number, amount: BalanceLike) { + return call('SubtensorModule', 'add_stake', { hotkey, netuid, amount_staked: balanceRao(amount) }) + }, + burnedRegister(netuid: number, hotkey: string) { + return call('SubtensorModule', 'burned_register', { netuid, hotkey }) + }, + commitWeights(netuid: number, commitHash: ByteLike | string) { + return call('SubtensorModule', 'commit_weights', { netuid, commit_hash: commitHash }) + }, + moveStake(originHotkey: string, destinationHotkey: string, originNetuid: number, destinationNetuid: number, amount: BalanceLike) { + return call('SubtensorModule', 'move_stake', { + origin_hotkey: originHotkey, + destination_hotkey: destinationHotkey, + origin_netuid: originNetuid, + destination_netuid: destinationNetuid, + alpha_amount: balanceRao(amount), + }) + }, + register(netuid: number, blockNumber: bigint | number | string, nonce: bigint | number | string, work: ByteLike, hotkey: string, coldkey: string) { + return call('SubtensorModule', 'register', { netuid, block_number: BigInt(blockNumber), nonce: BigInt(nonce), work, hotkey, coldkey }) + }, + registerNetwork(hotkey: string) { + return call('SubtensorModule', 'register_network', { hotkey }) + }, + removeStake(hotkey: string, netuid: number, amount: BalanceLike) { + return call('SubtensorModule', 'remove_stake', { hotkey, netuid, amount_unstaked: balanceRao(amount) }) + }, + revealWeights(netuid: number, uids: number[], values: number[], salt: number[], versionKey: bigint | number | string) { + return call('SubtensorModule', 'reveal_weights', { netuid, uids, values, salt, version_key: BigInt(versionKey) }) + }, + rootRegister(hotkey: string) { + return call('SubtensorModule', 'root_register', { hotkey }) + }, + serveAxon(netuid: number, ip: number, port: number, version = 0, ipType = 4, protocol = 4) { + return call('SubtensorModule', 'serve_axon', { netuid, version, ip, port, ip_type: ipType, protocol, placeholder1: 0, placeholder2: 0 }) + }, + servePrometheus(netuid: number, ip: number, port: number, version = 0, ipType = 4) { + return call('SubtensorModule', 'serve_prometheus', { netuid, version, ip, port, ip_type: ipType }) + }, + setChildren(hotkey: string, netuid: number, children: ScaleValue) { + return call('SubtensorModule', 'set_children', { hotkey, netuid, children }) + }, + setWeights(netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string) { + return call('SubtensorModule', 'set_weights', { netuid, dests, weights, version_key: BigInt(versionKey) }) + }, + startCall(netuid: number) { + return call('SubtensorModule', 'start_call', { netuid }) + }, + transferStake(destinationColdkey: string, hotkey: string, originNetuid: number, destinationNetuid: number, amount: BalanceLike) { + return call('SubtensorModule', 'transfer_stake', { + destination_coldkey: destinationColdkey, + hotkey, + origin_netuid: originNetuid, + destination_netuid: destinationNetuid, + alpha_amount: balanceRao(amount), + }) + }, + unstakeAll(hotkey: string) { + return call('SubtensorModule', 'unstake_all', { hotkey }) + }, + }), + Balances: Object.freeze({ + transfer_keep_alive(dest: string, value: BalanceLike) { + return call('Balances', 'transfer_keep_alive', { dest, value: balanceRao(value) }) + }, + transfer_allow_death(dest: string, value: BalanceLike) { + return call('Balances', 'transfer_allow_death', { dest, value: balanceRao(value) }) + }, + }), + SubtensorModule: Object.freeze({ + add_stake(hotkey: string, netuid: number, amountStaked: BalanceLike) { + return call('SubtensorModule', 'add_stake', { hotkey, netuid, amount_staked: balanceRao(amountStaked) }) + }, + burned_register(netuid: number, hotkey: string) { + return call('SubtensorModule', 'burned_register', { netuid, hotkey }) + }, + commit_weights(netuid: number, commitHash: ByteLike | string) { + return call('SubtensorModule', 'commit_weights', { netuid, commit_hash: commitHash }) + }, + move_stake(originHotkey: string, destinationHotkey: string, originNetuid: number, destinationNetuid: number, alphaAmount: BalanceLike) { + return call('SubtensorModule', 'move_stake', { + origin_hotkey: originHotkey, + destination_hotkey: destinationHotkey, + origin_netuid: originNetuid, + destination_netuid: destinationNetuid, + alpha_amount: balanceRao(alphaAmount), + }) + }, + register(netuid: number, blockNumber: bigint | number | string, nonce: bigint | number | string, work: ByteLike, hotkey: string, coldkey: string) { + return call('SubtensorModule', 'register', { netuid, block_number: BigInt(blockNumber), nonce: BigInt(nonce), work, hotkey, coldkey }) + }, + register_network(hotkey: string) { + return call('SubtensorModule', 'register_network', { hotkey }) + }, + remove_stake(hotkey: string, netuid: number, amountUnstaked: BalanceLike) { + return call('SubtensorModule', 'remove_stake', { hotkey, netuid, amount_unstaked: balanceRao(amountUnstaked) }) + }, + reveal_weights(netuid: number, uids: number[], values: number[], salt: number[], versionKey: bigint | number | string) { + return call('SubtensorModule', 'reveal_weights', { netuid, uids, values, salt, version_key: BigInt(versionKey) }) + }, + root_register(hotkey: string) { + return call('SubtensorModule', 'root_register', { hotkey }) + }, + serve_axon(netuid: number, ip: number, port: number, version = 0, ipType = 4, protocol = 4) { + return call('SubtensorModule', 'serve_axon', { netuid, version, ip, port, ip_type: ipType, protocol, placeholder1: 0, placeholder2: 0 }) + }, + serve_prometheus(netuid: number, ip: number, port: number, version = 0, ipType = 4) { + return call('SubtensorModule', 'serve_prometheus', { netuid, version, ip, port, ip_type: ipType }) + }, + set_children(hotkey: string, netuid: number, children: ScaleValue) { + return call('SubtensorModule', 'set_children', { hotkey, netuid, children }) + }, + set_weights(netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string) { + return call('SubtensorModule', 'set_weights', { netuid, dests, weights, version_key: BigInt(versionKey) }) + }, + start_call(netuid: number) { + return call('SubtensorModule', 'start_call', { netuid }) + }, + transfer_stake(destinationColdkey: string, hotkey: string, originNetuid: number, destinationNetuid: number, alphaAmount: BalanceLike) { + return call('SubtensorModule', 'transfer_stake', { + destination_coldkey: destinationColdkey, + hotkey, + origin_netuid: originNetuid, + destination_netuid: destinationNetuid, + alpha_amount: balanceRao(alphaAmount), + }) + }, + unstake_all(hotkey: string) { + return call('SubtensorModule', 'unstake_all', { hotkey }) + }, + }), +}) + +const READS = [ + { name: 'balance', category: 'Accounts & keys', params: ['coldkey_ss58'] }, + { name: 'balances', category: 'Accounts & keys', params: ['coldkey_ss58s'] }, + { name: 'subnet', category: 'Subnets', params: ['netuid'] }, + { name: 'subnets', category: 'Subnets', params: [] }, + { name: 'burn', category: 'Subnets', params: ['netuid'] }, + { name: 'commit_reveal_enabled', category: 'Subnets', params: ['netuid'] }, + { name: 'subnet_hyperparameters', category: 'Subnets', params: ['netuid'] }, + { name: 'metagraph', category: 'Subnets', params: ['netuid'] }, + { name: 'stake', category: 'Staking', params: ['coldkey_ss58', 'hotkey_ss58', 'netuid'] }, + { name: 'stake_for_coldkey', category: 'Staking', params: ['coldkey_ss58'] }, +] + +async function read(client: Client, name: string, params: Record, block?: number | string | null): Promise { + switch (name) { + case 'balance': + return client.balances.get(String(params.coldkey_ss58), block) + case 'balances': + return client.balances.getMany(Array.isArray(params.coldkey_ss58s) ? params.coldkey_ss58s.map(String) : [], block) + case 'subnet': + return client.subnets.info(Number(params.netuid), block) + case 'subnets': + return client.subnets.all(block) + case 'burn': + return client.subnets.burn(Number(params.netuid), block) + case 'commit_reveal_enabled': + return client.subnets.commitRevealEnabled(Number(params.netuid), block) + case 'subnet_hyperparameters': + return client.subnets.hyperparameters(Number(params.netuid), block) + case 'metagraph': + return client.subnets.metagraph(Number(params.netuid), block) + case 'stake': + return client.staking.get(String(params.coldkey_ss58), String(params.hotkey_ss58), Number(params.netuid), block) + case 'stake_for_coldkey': + return client.staking.positions(String(params.coldkey_ss58), block) + default: + throw new ChainError(`unknown read ${name}`) + } +} + +function resolveEndpoint(network: string): [string, string] { + if (network.startsWith('ws://') || network.startsWith('wss://') || network.startsWith('http')) return [network, network] + if (Object.prototype.hasOwnProperty.call(NETWORKS, network)) return [network, NETWORKS[network as NetworkName]] + throw new Error(`Unknown network ${network}`) +} + +function normalizeStorageArgs( + pallet: string | Descriptor, + storageFunction?: string | ScaleValue[], + paramsOrBlock?: ScaleValue[] | number | string | null, + block?: number | string | null, +): [string, string, ScaleValue[], number | string | null | undefined] { + if (typeof pallet !== 'string') { + const itemParams = Array.isArray(storageFunction) ? storageFunction : [] + const blockRef = Array.isArray(storageFunction) + ? blockFrom(paramsOrBlock) + : blockFrom(storageFunction) + return [pallet[0], pallet[1], itemParams, blockRef] + } + return [pallet, storageFunction as string, Array.isArray(paramsOrBlock) ? paramsOrBlock : [], block ?? (Array.isArray(paramsOrBlock) ? undefined : paramsOrBlock)] +} + +function normalizeBatchArgs( + pallet: string | Descriptor, + storageFunction: string | ScaleValue[][], + paramSetsOrBlock?: ScaleValue[][] | number | string | null, + block?: number | string | null, +): [string, string, ScaleValue[][], number | string | null | undefined] { + if (typeof pallet !== 'string') { + return [pallet[0], pallet[1], Array.isArray(storageFunction) ? storageFunction : [], blockFrom(paramSetsOrBlock)] + } + return [pallet, storageFunction as string, Array.isArray(paramSetsOrBlock) ? paramSetsOrBlock : [], block ?? (Array.isArray(paramSetsOrBlock) ? undefined : paramSetsOrBlock)] +} + +function normalizeRuntimeArgs( + api: string | Descriptor, + method?: string | ScaleValue[], + paramsOrBlock: ScaleValue[] | number | string | null = [], + block?: number | string | null, +): [string, string, ScaleValue[], number | string | null | undefined] { + if (typeof api !== 'string') { + const callParams = Array.isArray(method) ? method : [] + const blockRef = Array.isArray(method) ? blockFrom(paramsOrBlock) : blockFrom(method) + return [api[0], api[1], callParams, blockRef] + } + return [api, method as string, Array.isArray(paramsOrBlock) ? paramsOrBlock : [], block ?? (Array.isArray(paramsOrBlock) ? undefined : paramsOrBlock)] +} + +function blockFrom(value: unknown): number | string | null | undefined { + return typeof value === 'number' || typeof value === 'string' || value == null ? value : undefined +} + +function normalizeCall(callLike: Exclude): [string, string, ScaleValue] { + if (Array.isArray(callLike)) return [callLike[0], callLike[1], callLike[2] ?? {}] + return [callLike.pallet ?? callLike.module ?? '', callLike.call ?? callLike.function ?? '', callLike.params ?? {}] +} + +function hex(bytes: ByteLike): string { + return `0x${toBuffer(bytes, 'bytes').toString('hex')}` +} + +function hexToBuffer(value: string): Buffer { + const text = value.startsWith('0x') ? value.slice(2) : value + return Buffer.from(text, 'hex') +} + +function hexNumber(value: string): number { + return Number.parseInt(value, 16) +} + +function headerNumber(header: unknown): number { + const value = (header as { number?: string | number }).number + return typeof value === 'number' ? value : hexNumber(String(value ?? '0x0')) +} + +function normalizeHeader(raw: unknown): BlockHeader { + const value = raw as { number?: string | number; parentHash?: string; hash?: string } + return { number: headerNumber(raw), parentHash: value.parentHash, hash: value.hash, raw } +} + +function normalizeStatus(status: unknown): Record { + if (typeof status === 'string') return { [status.toLowerCase()]: null } + const out: Record = {} + for (const [key, value] of Object.entries(status as Record)) out[key.toLowerCase()] = value + return out +} + +function eventName(event: unknown): string { + const value = event as { module_id?: string; event_id?: string; event?: { module_id?: string; event_id?: string; section?: string; method?: string } } + const nested = value.event + return `${value.module_id ?? nested?.module_id ?? nested?.section ?? ''}.${value.event_id ?? nested?.event_id ?? nested?.method ?? ''}` +} + +function eventExtrinsicIndex(event: unknown): number | null { + const value = event as { extrinsic_idx?: unknown; phase?: unknown } + if (value.extrinsic_idx != null) return Number(value.extrinsic_idx) + const phase = value.phase as Record | undefined + const apply = phase?.ApplyExtrinsic ?? phase?.applyExtrinsic + return apply == null ? null : Number(apply) +} + +function feeFromEvent(event: unknown): Balance | undefined { + const attrs = (event as { attributes?: unknown }).attributes + if (attrs == null || typeof attrs !== 'object') return undefined + const amount = (attrs as Record).actual_fee ?? (attrs as Record).actualFee ?? (attrs as Record).fee + return amount == null ? undefined : Balance.fromRao(String(amount)) +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new ChainError(`timed out after ${timeoutMs}ms`)), timeoutMs) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error) => { + clearTimeout(timer) + reject(error) + }, + ) + }) +} diff --git a/sdk/typescript-sdk/src/index.ts b/sdk/typescript-sdk/src/index.ts index 3c10204a78..d44baef9b5 100644 --- a/sdk/typescript-sdk/src/index.ts +++ b/sdk/typescript-sdk/src/index.ts @@ -4,6 +4,8 @@ import type { ByteLike, ScaleValue } from './types' export * from './crypto' export * from './errors' +export * from './balance' +export * from './client' export * from './keys' export * from './ledger' export * from './modules' @@ -11,6 +13,7 @@ export * from './runtime' export * from './timelock' export * from './types' export * from './value' +export * from './wallet' export { fromWire, toWire, WIRE_TAG } /** diff --git a/sdk/typescript-sdk/src/modules.ts b/sdk/typescript-sdk/src/modules.ts index a434b5b25e..5c861f6772 100644 --- a/sdk/typescript-sdk/src/modules.ts +++ b/sdk/typescript-sdk/src/modules.ts @@ -1,6 +1,7 @@ import * as crypto from './crypto' import * as errors from './errors' import * as keys from './keys' +import * as client from './client' import { LedgerDevice } from './ledger' import native from './native' import * as runtime from './runtime' @@ -99,6 +100,14 @@ export const rustCore = Object.freeze({ TypeSpec: runtime.typeSpec, Primitive: runtime.primitiveFromName, }), + client: Object.freeze({ + Client: client.Client, + Subtensor: client.Subtensor, + storage: client.storage, + constants: client.constants, + runtimeApi: client.runtimeApi, + calls: client.calls, + }), timelock: Object.freeze({ encryptAndCompress: timelock.encryptAndCompress, decryptAndDecompress: timelock.decryptAndDecompress, diff --git a/sdk/typescript-sdk/src/wallet.ts b/sdk/typescript-sdk/src/wallet.ts new file mode 100644 index 0000000000..f54e5838b0 --- /dev/null +++ b/sdk/typescript-sdk/src/wallet.ts @@ -0,0 +1,169 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' + +import { + CRYPTO_SR25519, + Keypair, + decryptKeyfileData, + deserializeKeypair, + encryptKeyfileData, + keyfileDataIsEncrypted, + serializeKeypair, +} from './keys' + +export const DEFAULT_WALLET_PATH = join(homedir(), '.bittensor', 'wallets') + +export interface WalletOptions { + name?: string + hotkey?: string + path?: string +} + +export interface SaveKeyOptions { + encrypt?: boolean + overwrite?: boolean + password?: string | null +} + +export class Keyfile { + readonly path: string + readonly name: string + + constructor(path: string, name: string) { + this.path = path + this.name = name + } + + exists(): boolean { + return existsSync(this.path) + } + + getKeypair(password?: string | null): Keypair { + const data = readFileSync(this.path) + const decoded = keyfileDataIsEncrypted(data) + ? decryptKeyfileData(data, password ?? undefined) + : data + return deserializeKeypair(decoded) + } + + setKeypair(keypair: Keypair, options: SaveKeyOptions = {}): void { + const { encrypt = false, overwrite = false, password } = options + if (encrypt && password == null) { + throw new Error(`Password is required to encrypt ${this.path}`) + } + if (this.exists() && !overwrite) { + throw new Error(`Keyfile ${this.path} already exists`) + } + mkdirSync(dirname(this.path), { recursive: true }) + const serialized = serializeKeypair(keypair) + const data = encrypt ? encryptKeyfileData(serialized, password as string) : serialized + writeFileSync(this.path, data, { mode: 0o600 }) + } +} + +export class Wallet { + readonly name: string + readonly hotkeyName: string + readonly path: string + readonly coldkeyFile: Keyfile + readonly coldkeypubFile: Keyfile + readonly hotkeyFile: Keyfile + readonly hotkeypubFile: Keyfile + + private coldkeyCache?: Keypair + private coldkeypubCache?: Keypair + private hotkeyCache?: Keypair + private hotkeypubCache?: Keypair + + constructor(options: WalletOptions = {}) { + this.name = options.name ?? 'default' + this.hotkeyName = options.hotkey ?? 'default' + this.path = options.path ?? DEFAULT_WALLET_PATH + const walletDir = join(this.path, this.name) + this.coldkeyFile = new Keyfile(join(walletDir, 'coldkey'), 'coldkey') + this.coldkeypubFile = new Keyfile(join(walletDir, 'coldkeypub.txt'), 'coldkeypub.txt') + this.hotkeyFile = new Keyfile(join(walletDir, 'hotkeys', this.hotkeyName), this.hotkeyName) + this.hotkeypubFile = new Keyfile( + join(walletDir, 'hotkeys', `${this.hotkeyName}pub.txt`), + `${this.hotkeyName}pub.txt`, + ) + } + + get coldkey(): Keypair { + this.coldkeyCache ??= this.coldkeyFile.getKeypair() + return this.coldkeyCache + } + + get coldkeypub(): Keypair { + this.coldkeypubCache ??= this.coldkeypubFile.getKeypair() + return this.coldkeypubCache + } + + get hotkey(): Keypair { + this.hotkeyCache ??= this.hotkeyFile.getKeypair() + return this.hotkeyCache + } + + get hotkeypub(): Keypair { + this.hotkeypubCache ??= this.hotkeypubFile.getKeypair() + return this.hotkeypubCache + } + + getColdkey(password?: string | null): Keypair { + this.coldkeyCache = this.coldkeyFile.getKeypair(password) + return this.coldkeyCache + } + + getHotkey(password?: string | null): Keypair { + this.hotkeyCache = this.hotkeyFile.getKeypair(password) + return this.hotkeyCache + } + + setColdkey(keypair: Keypair, options: SaveKeyOptions = {}): this { + this.coldkeyCache = keypair + this.coldkeyFile.setKeypair(keypair, options) + this.coldkeypubCache = publicOnly(keypair) + this.coldkeypubFile.setKeypair(this.coldkeypubCache, { overwrite: options.overwrite ?? true }) + return this + } + + setHotkey(keypair: Keypair, options: SaveKeyOptions = {}): this { + this.hotkeyCache = keypair + this.hotkeyFile.setKeypair(keypair, options) + this.hotkeypubCache = publicOnly(keypair) + this.hotkeypubFile.setKeypair(this.hotkeypubCache, { overwrite: options.overwrite ?? true }) + return this + } + + createNewColdkey(options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}): this { + const keypair = Keypair.generate(options.cryptoType ?? CRYPTO_SR25519, options.nWords ?? 12) + return this.setColdkey(keypair, { ...options, encrypt: options.encrypt ?? options.password != null }) + } + + createNewHotkey(options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}): this { + const keypair = Keypair.generate(options.cryptoType ?? CRYPTO_SR25519, options.nWords ?? 12) + return this.setHotkey(keypair, options) + } + + regenerateColdkey( + mnemonic: string, + options: SaveKeyOptions & { cryptoType?: number; password?: string | null } = {}, + ): this { + return this.setColdkey( + Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519), + { ...options, encrypt: options.encrypt ?? options.password != null }, + ) + } + + regenerateHotkey( + mnemonic: string, + options: SaveKeyOptions & { cryptoType?: number; password?: string | null } = {}, + ): this { + return this.setHotkey(Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519), options) + } +} + +function publicOnly(keypair: Keypair): Keypair { + return new Keypair(keypair.ss58Address, keypair.publicKey, keypair.cryptoType, keypair.ss58Format) +} diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index ba97da77c6..1b1b3f5277 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -219,6 +219,8 @@ test('module-shaped export mirrors the public Rust crate', () => { assert.equal(core.rustCore.keys.Keypair, core.Keypair) assert.equal(core.rustCore.codec.decode.Cursor, core.ScaleCursor) assert.equal(core.rustCore.codec.batch.PARALLEL_THRESHOLD, core.PARALLEL_THRESHOLD) + assert.equal(core.rustCore.client.Client, core.Client) + assert.equal(core.rustCore.client.storage.System.Events[0], 'System') assert.equal(core.rustCore.mlkem.MLKEM_NONCE_LEN, 24) assert.equal(core.rustCore.timelock.constants.GENESIS_TIME, core.GENESIS_TIME) assert.deepEqual(core.rustCore.timelock.epoch_schedule.EpochScheduleError, [ @@ -227,6 +229,33 @@ test('module-shaped export mirrors the public Rust crate', () => { ]) }) +test('chain client surface is exported without Polkadot.js glue', () => { + assert.equal(typeof core.Client, 'function') + assert.equal(typeof core.Client.prototype.watchSigned, 'function') + assert.equal(core.Subtensor, core.SubtensorClient) + assert.equal(typeof core.subtensor, 'function') + assert.equal(typeof core.Wallet, 'function') + assert.equal(typeof core.Balance.fromTao, 'function') + assert.deepEqual(core.storage.SubtensorModule.NetworksAdded, [ + 'SubtensorModule', + 'NetworksAdded', + ]) + assert.deepEqual(core.runtimeApi.SubnetInfoRuntimeApi.get_metagraph, [ + 'SubnetInfoRuntimeApi', + 'get_metagraph', + ]) + assert.deepEqual(core.calls.subtensor.rootRegister('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'), [ + 'SubtensorModule', + 'root_register', + { hotkey: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY' }, + ]) + assert.deepEqual(core.calls.SubtensorModule.root_register('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'), [ + 'SubtensorModule', + 'root_register', + { hotkey: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY' }, + ]) +}) + test('arbitrary StorageInfo helpers call Rust directly', () => { const entry = { pallet: 'System', diff --git a/ts-tests/suites/dev/sdk/test-typescript-sdk.ts b/ts-tests/suites/dev/sdk/test-typescript-sdk.ts index dd5eeb5ee3..5db2d29c0c 100644 --- a/ts-tests/suites/dev/sdk/test-typescript-sdk.ts +++ b/ts-tests/suites/dev/sdk/test-typescript-sdk.ts @@ -1,7 +1,5 @@ -import { BINDING_VERSION, blake2_256 } from "@bittensor/sdk"; -import type { ApiPromise } from "@polkadot/api"; +import { BINDING_VERSION, Client, Keypair, blake2_256, storage } from "@bittensor/sdk"; import { describeSuite, expect } from "@moonwall/cli"; -import { keyringPairFromUri } from "../../../utils/account.ts"; describeSuite({ id: "DEV_TYPESCRIPT_SDK_01", @@ -10,17 +8,39 @@ describeSuite({ testCases: ({ it, context }) => { it({ id: "T01", - title: "signs and submits a transaction with the Rust keypair", + title: "connects, constructs, submits, and reads with the SDK chain client", test: async () => { - const polkadotJs: ApiPromise = context.polkadotJs(); - const alice = keyringPairFromUri("//Alice"); + const client = await new Client("ws://127.0.0.1:9947").connect(); + const alice = Keypair.fromUri("//Alice"); const remark = blake2_256(Buffer.from(`typescript-sdk:${BINDING_VERSION}`)); - const tx = polkadotJs.tx.system.remark(remark); + const call = await client.composeCall("System", "remark", { remark }); - await context.createBlock([await tx.signAsync(alice)]); + try { + const signed = await client.signExtrinsic(call, alice); + const watcher = await client.watchSigned(signed); - const events = await polkadotJs.query.system.events(); - expect(events.some(({ event }) => event.method === "ExtrinsicSuccess")).to.be.true; + await context.createBlock(); + + const included = await watcher.result; + expect(included.success).to.be.true; + expect(included.blockHash).to.not.be.undefined; + expect(included.extrinsicIndex).to.be.a("number"); + + const events = (await client.query(storage.System.Events, [], included.blockHash)) as + | Array<{ module_id?: string; event_id?: string }> + | undefined; + expect( + events?.some( + (event) => + (event as { module_id?: string; event_id?: string }).module_id === + "System" && + (event as { module_id?: string; event_id?: string }).event_id === + "ExtrinsicSuccess", + ), + ).to.be.true; + } finally { + await client.close(); + } }, }); }, From 3a381614db719e3129d2ac150445f6b3b1e98e53 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 13:07:09 -0700 Subject: [PATCH 15/72] fix ts sdk job --- sdk/typescript-sdk/src/client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/typescript-sdk/src/client.ts b/sdk/typescript-sdk/src/client.ts index 590a2d1fdb..37c6d8cb87 100644 --- a/sdk/typescript-sdk/src/client.ts +++ b/sdk/typescript-sdk/src/client.ts @@ -303,7 +303,7 @@ export class Client { readonly neurons: NeuronsNamespace readonly staking: StakingNamespace - private runtime?: Runtime + private runtimeCache?: Runtime private genesis?: string private nonceCache = new Map() @@ -368,7 +368,7 @@ export class Client { async runtimeAt(block?: number | string | null): Promise { const blockHash = await this.resolveBlockHash(block) - if (this.runtime != null && blockHash == null) return this.runtime + if (this.runtimeCache != null && blockHash == null) return this.runtimeCache const [metadataHex, version, properties] = await Promise.all([ this.rpc('state_getMetadata', blockHash == null ? [] : [blockHash]), this.rpc('state_getRuntimeVersion', blockHash == null ? [] : [blockHash]), @@ -381,7 +381,7 @@ export class Client { Number((version as { transactionVersion: number }).transactionVersion), ss58Format, ) - if (blockHash == null) this.runtime = runtime + if (blockHash == null) this.runtimeCache = runtime return runtime } From d80e20ecb77c5d7d586b6d2ee00e7d29985ada62 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 13:09:50 -0700 Subject: [PATCH 16/72] fix keys --- sdk/typescript-sdk/src/wallet.ts | 7 +++++-- sdk/typescript-sdk/test/basic.test.cjs | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/sdk/typescript-sdk/src/wallet.ts b/sdk/typescript-sdk/src/wallet.ts index f54e5838b0..1f6d4aa8aa 100644 --- a/sdk/typescript-sdk/src/wallet.ts +++ b/sdk/typescript-sdk/src/wallet.ts @@ -143,7 +143,7 @@ export class Wallet { createNewHotkey(options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}): this { const keypair = Keypair.generate(options.cryptoType ?? CRYPTO_SR25519, options.nWords ?? 12) - return this.setHotkey(keypair, options) + return this.setHotkey(keypair, { ...options, encrypt: options.encrypt ?? options.password != null }) } regenerateColdkey( @@ -160,7 +160,10 @@ export class Wallet { mnemonic: string, options: SaveKeyOptions & { cryptoType?: number; password?: string | null } = {}, ): this { - return this.setHotkey(Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519), options) + return this.setHotkey( + Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519), + { ...options, encrypt: options.encrypt ?? options.password != null }, + ) } } diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 1b1b3f5277..fefcaa0dc4 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const fs = require('node:fs') +const os = require('node:os') const path = require('node:path') const test = require('node:test') @@ -256,6 +257,28 @@ test('chain client surface is exported without Polkadot.js glue', () => { ]) }) +test('hotkey helpers encrypt keyfiles when a password is provided', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bittensor-wallet-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const password = 'review-password' + + const created = new core.Wallet({ name: 'created', hotkey: 'default', path: root }) + created.createNewHotkey({ password }) + assert.equal( + core.keyfileDataIsEncrypted(fs.readFileSync(created.hotkeyFile.path)), + true, + ) + + const mnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + const regenerated = new core.Wallet({ name: 'regenerated', hotkey: 'default', path: root }) + regenerated.regenerateHotkey(mnemonic, { password }) + assert.equal( + core.keyfileDataIsEncrypted(fs.readFileSync(regenerated.hotkeyFile.path)), + true, + ) +}) + test('arbitrary StorageInfo helpers call Rust directly', () => { const entry = { pallet: 'System', From 8d34effc763b2afc903914a45c7fc5feb05ba6eb Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 13:30:19 -0700 Subject: [PATCH 17/72] try fix typescript sdk again --- sdk/typescript-sdk/native/src/runtime.rs | 4 ++-- sdk/typescript-sdk/src/client.ts | 6 +++++- ts-tests/suites/dev/sdk/test-typescript-sdk.ts | 8 +++----- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/sdk/typescript-sdk/native/src/runtime.rs b/sdk/typescript-sdk/native/src/runtime.rs index b8add2146e..2eafd41ccc 100644 --- a/sdk/typescript-sdk/native/src/runtime.rs +++ b/sdk/typescript-sdk/native/src/runtime.rs @@ -778,11 +778,11 @@ impl NativeRuntime { pub fn compose_call( &self, pallet: String, - function: String, + call_function: String, params: JsonValue, ) -> NapiResult { self.inner - .compose_call(&pallet, &function, &from_wire(params)?) + .compose_call(&pallet, &call_function, &from_wire(params)?) .napi() .map(Into::into) } diff --git a/sdk/typescript-sdk/src/client.ts b/sdk/typescript-sdk/src/client.ts index 37c6d8cb87..1f83172043 100644 --- a/sdk/typescript-sdk/src/client.ts +++ b/sdk/typescript-sdk/src/client.ts @@ -1598,10 +1598,14 @@ function blockFrom(value: unknown): number | string | null | undefined { } function normalizeCall(callLike: Exclude): [string, string, ScaleValue] { - if (Array.isArray(callLike)) return [callLike[0], callLike[1], callLike[2] ?? {}] + if (isCallTuple(callLike)) return [callLike[0], callLike[1], callLike[2] ?? {}] return [callLike.pallet ?? callLike.module ?? '', callLike.call ?? callLike.function ?? '', callLike.params ?? {}] } +function isCallTuple(callLike: Exclude): callLike is readonly [string, string, ScaleValue?] { + return Array.isArray(callLike) +} + function hex(bytes: ByteLike): string { return `0x${toBuffer(bytes, 'bytes').toString('hex')}` } diff --git a/ts-tests/suites/dev/sdk/test-typescript-sdk.ts b/ts-tests/suites/dev/sdk/test-typescript-sdk.ts index 5db2d29c0c..2990ea74fe 100644 --- a/ts-tests/suites/dev/sdk/test-typescript-sdk.ts +++ b/ts-tests/suites/dev/sdk/test-typescript-sdk.ts @@ -32,11 +32,9 @@ describeSuite({ expect( events?.some( (event) => - (event as { module_id?: string; event_id?: string }).module_id === - "System" && - (event as { module_id?: string; event_id?: string }).event_id === - "ExtrinsicSuccess", - ), + (event as { module_id?: string; event_id?: string }).module_id === "System" && + (event as { module_id?: string; event_id?: string }).event_id === "ExtrinsicSuccess" + ) ).to.be.true; } finally { await client.close(); From 86af620ff002fc60af202cc4db651c35f14b64d2 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 13:40:41 -0700 Subject: [PATCH 18/72] fix browser gap --- sdk/typescript-sdk/src/browser.ts | 463 +++++++++++++++++++++++++ sdk/typescript-sdk/test/basic.test.cjs | 201 +++++++++++ 2 files changed, 664 insertions(+) diff --git a/sdk/typescript-sdk/src/browser.ts b/sdk/typescript-sdk/src/browser.ts index 4741ce378c..3902ed5591 100644 --- a/sdk/typescript-sdk/src/browser.ts +++ b/sdk/typescript-sdk/src/browser.ts @@ -1,3 +1,8 @@ +import type { + ModuleError, + ScaleValue, +} from './types' + export const CRYPTO_ED25519 = 0 export const CRYPTO_SR25519 = 1 export const DEFAULT_SS58_FORMAT = 42 @@ -20,10 +25,105 @@ export interface KeypairSignOptions { withType?: boolean } +export interface BrowserStorageEntry { + pallet: string + name: string + prefix: string + modifier: string + valueType: string + paramTypes: string[] + paramHashers: string[] + defaultBytes: Uint8Array +} + +export interface BrowserStorageChange { + key: string + value?: string | null +} + +export interface BrowserMapPair { + key: K + value: V +} + +export interface BrowserPayloadParts { + includedInExtrinsic: Uint8Array + includedInSignedData: Uint8Array +} + +export interface BrowserTransactionParams { + era: ScaleValue + nonce: BrowserIntegerLike + tip?: BrowserIntegerLike + tipAssetId?: BrowserIntegerLike | null + genesisHash: BrowserByteLike + eraBlockHash: BrowserByteLike + metadataHash?: BrowserByteLike | null +} + +export interface BrowserSignedExtrinsicParams { + era: ScaleValue + nonce: BrowserIntegerLike + tip?: BrowserIntegerLike + tipAssetId?: BrowserIntegerLike | null + metadataHashEnabled?: boolean +} + +export interface BrowserSignedExtrinsic { + bytes: Uint8Array + hash: Uint8Array +} + +export interface BrowserMultisigAccount { + accountId: Uint8Array + sortedSignatories: Uint8Array[] +} + +export type BrowserRuntimeApiMap = Record< + string, + Record< + string, + { + name: string + inputs: Array<[string, string]> + output: string + docs: string[] + } + > +> + +export interface BrowserMetadataIrCall { + name: string + args: string[] + docs: string +} + +export interface BrowserMetadataIrError { + index: number + name: string + docs: string +} + +export interface BrowserMetadataIrPallet { + name: string + index: number + calls: BrowserMetadataIrCall[] + errors: BrowserMetadataIrError[] + storage: string[] + constants: string[] +} + +export interface BrowserMetadataIr { + specVersion: number + pallets: BrowserMetadataIrPallet[] + runtimeApis: Array<{ name: string; methods: string[] }> +} + export interface BrowserWasmModule { default?: () => Promise | unknown coreVersion(): string Keypair: BrowserWasmKeypairConstructor + Runtime: BrowserWasmRuntimeConstructor CryptoType: { Ed25519: number Sr25519: number @@ -70,6 +170,8 @@ export interface BrowserWasmModule { includeKeyHash?: boolean, ): Uint8Array mlkemKdfId(): Uint8Array + eraBirth(period: BrowserIntegerLike, current: BrowserIntegerLike): number + multisigAccountId(signatories: Uint8Array[], threshold: number): [Uint8Array, Uint8Array[]] } export interface BrowserWasmKeypair { @@ -101,6 +203,91 @@ export interface BrowserWasmKeypairConstructor { generateMnemonic(nWords?: number): string } +export interface BrowserWasmRuntime { + readonly specVersion: number + readonly transactionVersion: number + readonly ss58Format: number + readonly isV15: boolean + readonly extrinsicVersion: number + decode(typeString: string, data: Uint8Array, strict?: boolean): unknown + batchDecode(typeStrings: string[], data: Uint8Array[]): unknown[] + encode(typeString: string, value: ScaleValue): Uint8Array + typeIdOf(name: string): number | undefined + typeNameOf(id: number): string | undefined + registryJson(): string + composeCall(pallet: string, fn: string, params: ScaleValue): Uint8Array + decodeCall(data: Uint8Array): unknown + storageEntry(pallet: string, storageFunction: string): BrowserStorageEntry + storagePrefix(pallet: string, storageFunction: string): Uint8Array + storageKey(pallet: string, storageFunction: string, params: ScaleValue[]): Uint8Array + storageKeyBatch(pallet: string, storageFunction: string, paramsList: ScaleValue[][]): Uint8Array[] + decodeStorageKeyParams( + pallet: string, + storageFunction: string, + key: Uint8Array, + fixed?: number, + ): unknown[] + decodeMapPairs( + pallet: string, + storageFunction: string, + rawKeys: Uint8Array[], + rawValues: Uint8Array[], + fixed?: number, + ): Array<[unknown, unknown]> + decodeMapChanges( + pallet: string, + storageFunction: string, + changes: Array<[string, string | null]>, + fixed?: number, + ): Array<[unknown, unknown]> + constant(pallet: string, name: string): unknown + moduleError(moduleIndex: number, errorIndex: number): [string, string[]] + signedExtensionIdentifiers(): string[] + encodeEra(era: ScaleValue): Uint8Array + signaturePayloadParts( + era: ScaleValue, + nonce: BrowserIntegerLike, + tip: BrowserIntegerLike, + tipAssetId: BrowserIntegerLike | null, + genesisHash: Uint8Array, + eraBlockHash: Uint8Array, + metadataHash?: Uint8Array, + ): [Uint8Array, Uint8Array] + signaturePayload( + callData: Uint8Array, + era: ScaleValue, + nonce: BrowserIntegerLike, + tip: BrowserIntegerLike, + tipAssetId: BrowserIntegerLike | null, + genesisHash: Uint8Array, + eraBlockHash: Uint8Array, + metadataHash?: Uint8Array, + ): Uint8Array + encodeSignedExtrinsic( + callData: Uint8Array, + publicKey: Uint8Array, + signature: Uint8Array, + signatureVersion: number, + era: ScaleValue, + nonce: BrowserIntegerLike, + tip: BrowserIntegerLike, + tipAssetId: BrowserIntegerLike | null, + metadataHashEnabled?: boolean, + ): [Uint8Array, Uint8Array] + decodeExtrinsic(data: Uint8Array, strict?: boolean): unknown + runtimeApiMap(): BrowserRuntimeApiMap + metadataIr(): BrowserMetadataIr +} + +export interface BrowserWasmRuntimeConstructor { + new ( + metadataBytes: Uint8Array, + specVersion: number, + transactionVersion: number, + ss58Format?: number, + ): BrowserWasmRuntime +} + export type BrowserWasmLoader = () => Promise let configuredLoader: BrowserWasmLoader | undefined @@ -197,6 +384,264 @@ function keyTypeForCryptoType(cryptoType: number): SubstrateKeyType { throw new RangeError(`unsupported crypto type ${cryptoType}`) } +function copyStorageEntry(entry: BrowserStorageEntry): BrowserStorageEntry { + return { + ...entry, + paramTypes: entry.paramTypes.slice(), + paramHashers: entry.paramHashers.slice(), + defaultBytes: copyBytes(entry.defaultBytes, 'defaultBytes'), + } +} + +function copyByteList(values: Uint8Array[]): Uint8Array[] { + return values.map((value) => copyBytes(value)) +} + +function mapPairs(pairs: Array<[unknown, unknown]>): Array> { + return pairs.map(([key, value]) => ({ key: key as K, value: value as V })) +} + +function metadataHashArg(value?: BrowserByteLike | null): Uint8Array | undefined { + return value == null ? undefined : toBytes(value, 'metadataHash') +} + +export class Runtime { + private readonly handle: BrowserWasmRuntime + + constructor( + metadataBytes: BrowserByteLike, + specVersion: number, + transactionVersion: number, + ss58Format = DEFAULT_SS58_FORMAT, + ) { + this.handle = new (wasmSync().Runtime)( + toBytes(metadataBytes, 'metadataBytes'), + specVersion, + transactionVersion, + ss58Format, + ) + } + + get specVersion(): number { + return this.handle.specVersion + } + + get transactionVersion(): number { + return this.handle.transactionVersion + } + + get ss58Format(): number { + return this.handle.ss58Format + } + + get isV15(): boolean { + return this.handle.isV15 + } + + get extrinsicVersion(): number { + return this.handle.extrinsicVersion + } + + decode( + typeString: string, + data: BrowserByteLike, + strict = true, + ): T { + return this.handle.decode(typeString, toBytes(data, 'data'), strict) as T + } + + decodeBatch( + typeStrings: string[], + data: BrowserByteLike[], + ): T[] { + return this.handle.batchDecode(typeStrings, data.map((value) => toBytes(value, 'data'))) as T[] + } + + encode(typeString: string, value: ScaleValue): Uint8Array { + return copyBytes(this.handle.encode(typeString, value)) + } + + typeIdOf(name: string): number | null { + return this.handle.typeIdOf(name) ?? null + } + + typeNameOf(id: number): string | null { + return this.handle.typeNameOf(id) ?? null + } + + registryJson(): string { + return this.handle.registryJson() + } + + composeCall(pallet: string, fn: string, params: ScaleValue): Uint8Array { + return copyBytes(this.handle.composeCall(pallet, fn, params)) + } + + decodeCall(data: BrowserByteLike): T { + return this.handle.decodeCall(toBytes(data, 'data')) as T + } + + storageEntry(pallet: string, storageFunction: string): BrowserStorageEntry { + return copyStorageEntry(this.handle.storageEntry(pallet, storageFunction)) + } + + storagePrefix(pallet: string, storageFunction: string): Uint8Array { + return copyBytes(this.handle.storagePrefix(pallet, storageFunction)) + } + + storageKey( + pallet: string, + storageFunction: string, + params: ScaleValue[] = [], + ): Uint8Array { + return copyBytes(this.handle.storageKey(pallet, storageFunction, params)) + } + + storageKeyBatch( + pallet: string, + storageFunction: string, + paramsList: ScaleValue[][], + ): Uint8Array[] { + return copyByteList(this.handle.storageKeyBatch(pallet, storageFunction, paramsList)) + } + + decodeStorageKeyParams( + pallet: string, + storageFunction: string, + key: BrowserByteLike, + fixed = 0, + ): T[] { + return this.handle.decodeStorageKeyParams( + pallet, + storageFunction, + toBytes(key, 'key'), + fixed, + ) as T[] + } + + decodeMapPairs( + pallet: string, + storageFunction: string, + rawKeys: BrowserByteLike[], + rawValues: BrowserByteLike[], + fixed = 0, + ): Array> { + return mapPairs( + this.handle.decodeMapPairs( + pallet, + storageFunction, + rawKeys.map((value) => toBytes(value, 'rawKey')), + rawValues.map((value) => toBytes(value, 'rawValue')), + fixed, + ), + ) + } + + decodeMapChanges( + pallet: string, + storageFunction: string, + changes: BrowserStorageChange[], + fixed = 0, + ): Array> { + return mapPairs( + this.handle.decodeMapChanges( + pallet, + storageFunction, + changes.map((change) => [change.key, change.value ?? null]), + fixed, + ), + ) + } + + constant(pallet: string, name: string): T | undefined { + return this.handle.constant(pallet, name) as T | undefined + } + + moduleError(moduleIndex: number, errorIndex: number): ModuleError { + const [name, docs] = this.handle.moduleError(moduleIndex, errorIndex) + return { name, docs: docs.slice() } + } + + signedExtensionIdentifiers(): string[] { + return this.handle.signedExtensionIdentifiers().slice() + } + + encodeEra(era: ScaleValue): Uint8Array { + return copyBytes(this.handle.encodeEra(era)) + } + + signaturePayloadParts(params: BrowserTransactionParams): BrowserPayloadParts { + const [includedInExtrinsic, includedInSignedData] = this.handle.signaturePayloadParts( + params.era, + params.nonce, + params.tip ?? 0, + params.tipAssetId ?? null, + toBytes(params.genesisHash, 'genesisHash'), + toBytes(params.eraBlockHash, 'eraBlockHash'), + metadataHashArg(params.metadataHash), + ) + return { + includedInExtrinsic: copyBytes(includedInExtrinsic), + includedInSignedData: copyBytes(includedInSignedData), + } + } + + signaturePayload(callData: BrowserByteLike, params: BrowserTransactionParams): Uint8Array { + return copyBytes( + this.handle.signaturePayload( + toBytes(callData, 'callData'), + params.era, + params.nonce, + params.tip ?? 0, + params.tipAssetId ?? null, + toBytes(params.genesisHash, 'genesisHash'), + toBytes(params.eraBlockHash, 'eraBlockHash'), + metadataHashArg(params.metadataHash), + ), + ) + } + + encodeSignedExtrinsic( + callData: BrowserByteLike, + publicKey: BrowserByteLike, + signature: BrowserByteLike, + signatureVersion: number, + params: BrowserSignedExtrinsicParams, + ): BrowserSignedExtrinsic { + const [bytes, hash] = this.handle.encodeSignedExtrinsic( + toBytes(callData, 'callData'), + toBytes(publicKey, 'publicKey'), + toBytes(signature, 'signature'), + signatureVersion, + params.era, + params.nonce, + params.tip ?? 0, + params.tipAssetId ?? null, + params.metadataHashEnabled ?? false, + ) + return { bytes: copyBytes(bytes), hash: copyBytes(hash) } + } + + decodeExtrinsic( + data: BrowserByteLike, + strict = true, + ): T { + return this.handle.decodeExtrinsic(toBytes(data, 'data'), strict) as T + } + + runtimeApiMap(): BrowserRuntimeApiMap { + return this.handle.runtimeApiMap() + } + + runtimeApis(): BrowserRuntimeApiMap { + return this.runtimeApiMap() + } + + metadataIr(): BrowserMetadataIr { + return this.handle.metadataIr() + } +} + export class Keypair { private handle!: BrowserWasmKeypair @@ -382,6 +827,24 @@ export function coreVersion(): string { return wasmSync().coreVersion() } +export function eraBirth(period: BrowserIntegerLike, current: BrowserIntegerLike): number { + return wasmSync().eraBirth(period, current) +} + +export function multisigAccountId( + signatories: BrowserByteLike[], + threshold: number, +): BrowserMultisigAccount { + const [accountId, sortedSignatories] = wasmSync().multisigAccountId( + signatories.map((value) => toBytes(value, 'signatory')), + threshold, + ) + return { + accountId: copyBytes(accountId), + sortedSignatories: copyByteList(sortedSignatories), + } +} + export function verifySignature( message: BrowserMessage, signature: BrowserByteLike, diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index fefcaa0dc4..7bcb380550 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -25,6 +25,207 @@ test('package exposes a WASM browser subset without the Node native addon', () = assert.equal(browserSource.includes('.node'), false) }) +test('browser Runtime exposes WASM codec, call, storage, and extrinsic helpers', async () => { + const browser = require('../dist/browser.js') + + class FakeRuntime { + constructor(metadataBytes, specVersion, transactionVersion, ss58Format) { + this.metadataBytes = metadataBytes + this.specVersion = specVersion + this.transactionVersion = transactionVersion + this.ss58Format = ss58Format + this.isV15 = true + this.extrinsicVersion = 4 + } + + decode(typeString, data, strict = true) { + return { typeString, first: data[0], strict } + } + + batchDecode(typeStrings, data) { + return data.map((item, index) => ({ typeString: typeStrings[index], first: item[0] })) + } + + encode(_typeString, value) { + return Uint8Array.of(value.value) + } + + typeIdOf(name) { + return name === 'Known' ? 7 : undefined + } + + typeNameOf(id) { + return id === 7 ? 'Known' : undefined + } + + registryJson() { + return '{"ok":true}' + } + + composeCall(pallet, fn) { + return Uint8Array.of(pallet.length, fn.length) + } + + decodeCall(data) { + return { len: data.length } + } + + storageEntry(pallet, storageFunction) { + return { + pallet, + name: storageFunction, + prefix: pallet, + modifier: 'Default', + valueType: 'scale_info::1', + paramTypes: ['scale_info::0'], + paramHashers: ['Blake2_128Concat'], + defaultBytes: Uint8Array.of(9), + } + } + + storagePrefix() { + return Uint8Array.of(1) + } + + storageKey(_pallet, _storageFunction, params) { + return Uint8Array.of(params.length) + } + + storageKeyBatch(_pallet, _storageFunction, paramsList) { + return paramsList.map((params) => Uint8Array.of(params.length)) + } + + decodeStorageKeyParams(_pallet, _storageFunction, key, fixed = 0) { + return [fixed, key[0]] + } + + decodeMapPairs(_pallet, _storageFunction, rawKeys, rawValues) { + return rawKeys.map((key, index) => [key[0], rawValues[index][0]]) + } + + decodeMapChanges(_pallet, _storageFunction, changes) { + return changes.map(([key, value]) => [key, value]) + } + + constant() { + return 55 + } + + moduleError() { + return ['BadOrigin', ['doc']] + } + + signedExtensionIdentifiers() { + return ['CheckNonce'] + } + + encodeEra(era) { + return Uint8Array.of(era.period) + } + + signaturePayloadParts() { + return [Uint8Array.of(1), Uint8Array.of(2)] + } + + signaturePayload(callData, _era, nonce) { + return Uint8Array.of(callData[0], Number(nonce)) + } + + encodeSignedExtrinsic(callData, _publicKey, _signature, signatureVersion) { + return [Uint8Array.of(callData[0], signatureVersion), Uint8Array.of(3, 4)] + } + + decodeExtrinsic(data, strict = true) { + return { len: data.length, strict } + } + + runtimeApiMap() { + return { Api: { method: { name: 'method', inputs: [], output: 'scale_info::0', docs: [] } } } + } + + metadataIr() { + return { specVersion: 1, pallets: [], runtimeApis: [] } + } + } + + await browser.initBrowser(async () => ({ + Runtime: FakeRuntime, + eraBirth: (period, current) => Number(current) - (Number(current) % Number(period)), + multisigAccountId: (signatories, threshold) => [ + Uint8Array.of(threshold), + signatories.slice().reverse(), + ], + })) + + const runtime = new browser.Runtime(Uint8Array.of(1), 10, 20, 42) + assert.equal(runtime.specVersion, 10) + assert.equal(runtime.transactionVersion, 20) + assert.equal(runtime.ss58Format, 42) + assert.equal(runtime.isV15, true) + assert.equal(runtime.extrinsicVersion, 4) + assert.deepEqual(runtime.decode('u8', Uint8Array.of(5)), { typeString: 'u8', first: 5, strict: true }) + assert.deepEqual(runtime.decodeBatch(['u8'], [Uint8Array.of(6)]), [{ typeString: 'u8', first: 6 }]) + assert.deepEqual(runtime.encode('u8', { value: 7 }), Uint8Array.of(7)) + assert.equal(runtime.typeIdOf('Known'), 7) + assert.equal(runtime.typeNameOf(7), 'Known') + assert.equal(runtime.registryJson(), '{"ok":true}') + assert.deepEqual(runtime.composeCall('System', 'remark', {}), Uint8Array.of(6, 6)) + assert.deepEqual(runtime.decodeCall(Uint8Array.of(1, 2, 3)), { len: 3 }) + assert.deepEqual(runtime.storageEntry('System', 'Account').defaultBytes, Uint8Array.of(9)) + assert.deepEqual(runtime.storagePrefix('System', 'Account'), Uint8Array.of(1)) + assert.deepEqual(runtime.storageKey('System', 'Account', ['Alice']), Uint8Array.of(1)) + assert.deepEqual(runtime.storageKeyBatch('System', 'Account', [['Alice'], ['Alice', 'Bob']]), [ + Uint8Array.of(1), + Uint8Array.of(2), + ]) + assert.deepEqual(runtime.decodeStorageKeyParams('System', 'Account', Uint8Array.of(8), 1), [1, 8]) + assert.deepEqual( + runtime.decodeMapPairs('System', 'Account', [Uint8Array.of(1)], [Uint8Array.of(2)]), + [{ key: 1, value: 2 }], + ) + assert.deepEqual( + runtime.decodeMapChanges('System', 'Account', [{ key: '0x01', value: '0x02' }]), + [{ key: '0x01', value: '0x02' }], + ) + assert.equal(runtime.constant('Balances', 'ExistentialDeposit'), 55) + assert.deepEqual(runtime.moduleError(0, 0), { name: 'BadOrigin', docs: ['doc'] }) + assert.deepEqual(runtime.signedExtensionIdentifiers(), ['CheckNonce']) + assert.deepEqual(runtime.encodeEra({ period: 64, current: 128 }), Uint8Array.of(64)) + assert.deepEqual( + runtime.signaturePayloadParts({ + era: '00', + nonce: 2, + genesisHash: Uint8Array.of(0), + eraBlockHash: Uint8Array.of(0), + }), + { includedInExtrinsic: Uint8Array.of(1), includedInSignedData: Uint8Array.of(2) }, + ) + assert.deepEqual( + runtime.signaturePayload(Uint8Array.of(9), { + era: '00', + nonce: 2, + genesisHash: Uint8Array.of(0), + eraBlockHash: Uint8Array.of(0), + }), + Uint8Array.of(9, 2), + ) + assert.deepEqual( + runtime.encodeSignedExtrinsic(Uint8Array.of(9), Uint8Array.of(1), Uint8Array.of(2), 1, { + era: '00', + nonce: 2, + }), + { bytes: Uint8Array.of(9, 1), hash: Uint8Array.of(3, 4) }, + ) + assert.deepEqual(runtime.decodeExtrinsic(Uint8Array.of(1, 2), false), { len: 2, strict: false }) + assert.deepEqual(runtime.runtimeApis(), runtime.runtimeApiMap()) + assert.deepEqual(runtime.metadataIr(), { specVersion: 1, pallets: [], runtimeApis: [] }) + assert.equal(browser.eraBirth(64, 130), 128) + assert.deepEqual( + browser.multisigAccountId([Uint8Array.of(1), Uint8Array.of(2)], 2), + { accountId: Uint8Array.of(2), sortedSignatories: [Uint8Array.of(2), Uint8Array.of(1)] }, + ) +}) + test('sr25519 keypair forwards signing and SS58 work to Rust', () => { const alice = core.Keypair.fromUri('//Alice') assert.equal( From 19793445837f626beecca0c204963db3ddcb1d7c Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 13:47:07 -0700 Subject: [PATCH 19/72] Update typescript-e2e.yml --- .github/workflows/typescript-e2e.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index b47b525c7d..0f8e08bc5e 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -118,6 +118,12 @@ jobs: with: node-version-file: ts-tests/.nvmrc + - name: Install wasm-pack + run: | + curl -LsSf https://github.com/rustwasm/wasm-pack/releases/download/v0.13.1/wasm-pack-v0.13.1-x86_64-unknown-linux-musl.tar.gz \ + | tar zxf - --strip-components=1 -C "$HOME/.cargo/bin" --wildcards '*/wasm-pack' + wasm-pack --version + - name: Build and test TypeScript SDK run: | npm --prefix sdk/typescript-sdk ci From f0183d88c8c494cb3870f338e05555b6d6e18025 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 13:58:55 -0700 Subject: [PATCH 20/72] fix parity check --- sdk/bittensor-core/binding-manifest.json | 381 ++++++++++++++++++ sdk/typescript-sdk/README.md | 11 +- sdk/typescript-sdk/package.json | 5 +- .../scripts/check-native-parity.cjs | 277 +++++++++++-- sdk/typescript-sdk/src/browser.ts | 61 +++ sdk/typescript-sdk/test/basic.test.cjs | 49 +++ 6 files changed, 734 insertions(+), 50 deletions(-) create mode 100644 sdk/bittensor-core/binding-manifest.json diff --git a/sdk/bittensor-core/binding-manifest.json b/sdk/bittensor-core/binding-manifest.json new file mode 100644 index 0000000000..36ef339289 --- /dev/null +++ b/sdk/bittensor-core/binding-manifest.json @@ -0,0 +1,381 @@ +{ + "native": { + "values": [ + "bindingVersion", + "concatHashLength", + "convertTypeString", + "coreValueBool", + "coreValueBytes", + "coreValueDescriptorDisplay", + "coreValueDescriptorRoundtrip", + "coreValueDescriptorToCorpusJson", + "coreValueDescriptorToWire", + "coreValueDict", + "coreValueHex", + "coreValueInt", + "coreValueList", + "coreValueNull", + "coreValueRecord", + "coreValueString", + "coreValueTuple", + "coreValueU256Le", + "coreValueUint", + "cryptoEd25519", + "cryptoSr25519", + "decodeCompactLength", + "decodeCompactU128", + "decodeTimelockUserData", + "decodeWeightsTlockPayload", + "decryptKeyfileData", + "defaultSs58Format", + "deserializeKeypair", + "encodeCompact", + "encodeTimelockUserData", + "encodeWeightsTlockPayload", + "encryptFor", + "encryptKeyfileData", + "epochAdvanceBlocks", + "epochCurrentPreRunCoinbase", + "epochPredictFirstRevealBlock", + "epochPredictFirstRevealBlockResult", + "epochShouldRun", + "epochSimulateRunCoinbase", + "eraBirth", + "generateExtrinsicProof", + "generateMnemonic", + "getPasswordFromEnvironment", + "hashStorageParam", + "keyfileDataEncryptionMethod", + "keyfileDataIsEncrypted", + "keyfileDataIsEncryptedAnsible", + "keyfileDataIsEncryptedLegacy", + "keyfileDataIsEncryptedNacl", + "keypairFromEncryptedJson", + "keypairFromMnemonic", + "keypairFromPrivateKey", + "keypairFromSeed", + "keypairFromUri", + "keypairNew", + "ledgerEnabled", + "metadataDigest", + "mlkemKdfId", + "mlkemNonceLength", + "mlkemSeal", + "mlkemTwox128", + "multisigAccountId", + "multisigSs58", + "normalizeTypeSpec", + "parallelDecodeThreshold", + "primitiveFromName", + "publicKeyFromSs58", + "savePasswordToEnvironment", + "serializeKeypair", + "ss58FromPublic", + "storagePrefixFor", + "timelockCommitInclusionBlockOffset", + "timelockDecrypt", + "timelockDecryptAndDecompress", + "timelockDecryptWithSignature", + "timelockDrandEndpoints", + "timelockDrandPeriod", + "timelockDrandPublicKey", + "timelockEncryptAndCompress", + "timelockEncryptAtRound", + "timelockEncryptCommitment", + "timelockEncryptNBlocks", + "timelockGenerateCommitV2", + "timelockGenesisTime", + "timelockGetRevealRoundSignature", + "timelockGetRoundInfo", + "timelockMaxSimulationBlocks", + "timelockMaxTempo", + "timelockMaxTempoU64", + "timelockQuicknetChainHash", + "timelockSecurityBlockOffset", + "u256LeToDecimal", + "valueToCorpusJson", + "verifySignature", + "wireRoundtrip", + "wireTag", + "wireToCoreValueDescriptor" + ], + "classes": { + "NativeCursor": { + "statics": ["fromBytes"], + "instance": [ + "byte", + "data", + "decodeCompactLength", + "decodeCompactU128", + "offset", + "remaining", + "reset", + "seek", + "setStrict", + "strict", + "take" + ] + }, + "NativeKeypair": { + "statics": [], + "instance": [ + "cryptoType", + "decrypt", + "derive", + "encrypt", + "kind", + "publicKey", + "sign", + "ss58Address", + "ss58Format", + "verify" + ] + }, + "NativeLedgerDevice": { + "statics": ["open"], + "instance": ["address", "appVersion", "sign"] + }, + "NativeRuntime": { + "statics": ["fromMetadata"], + "instance": [ + "coerceAccountId", + "coerceAccountIdDescriptor", + "composeCall", + "constant", + "constantInfo", + "decode", + "decodeBatch", + "decodeCall", + "decodeCallValue", + "decodeCallValueDescriptor", + "decodeExtrinsic", + "decodeMapChanges", + "decodeMapPairs", + "decodePartial", + "decodeSpec", + "decodeSpecDescriptor", + "decodeStorageKeyParams", + "decodeTypeId", + "decodeTypeIdDescriptor", + "decodeTypeIdDescriptorPartial", + "decodeTypeIdPartial", + "decodeValue", + "decodeValueDescriptor", + "encode", + "encodeEra", + "encodeId", + "encodeIdDescriptor", + "encodeSignedExtrinsic", + "encodeSpec", + "encodeSpecDescriptor", + "encodeTypeId", + "encodeValue", + "encodeValueDescriptor", + "extrinsicInfo", + "extrinsicVersion", + "isV15", + "metadataBytes", + "metadataIr", + "moduleError", + "outerEventType", + "pallet", + "palletAt", + "pallets", + "registry", + "registryJson", + "resolveType", + "runtimeApiInfos", + "runtimeApiMap", + "runtimeApis", + "runtimeSnapshot", + "signaturePayload", + "signaturePayloadParts", + "signedExtensionIdentifiers", + "specVersion", + "ss58Format", + "storageEntry", + "storageKey", + "storageKeyBatch", + "storagePrefix", + "transactionVersion", + "typeIdOf", + "typeNameOf", + "typeSpec" + ] + } + } + }, + "wasm": { + "values": [ + "CryptoType", + "coreVersion", + "decryptWithSignature", + "encrypt", + "encryptAtRound", + "encryptMlkem768", + "eraBirth", + "generateExtrinsicProof", + "getEncryptedCommitV2", + "getEncryptedCommitment", + "metadataDigest", + "mlkemKdfId", + "multisigAccountId", + "revealRound", + "ss58Decode", + "ss58Encode", + "verifySignature" + ], + "classes": { + "Keypair": { + "statics": [ + "fromMnemonic", + "fromPrivateKey", + "fromSeed", + "fromUri", + "generateMnemonic" + ], + "instance": [ + "cryptoType", + "derive", + "kind", + "publicKey", + "sign", + "ss58Address", + "ss58Format", + "verify" + ] + }, + "Runtime": { + "statics": [], + "instance": [ + "batchDecode", + "composeCall", + "constant", + "decode", + "decodeCall", + "decodeExtrinsic", + "decodeMapChanges", + "decodeMapPairs", + "decodeStorageKeyParams", + "encode", + "encodeEra", + "encodeSignedExtrinsic", + "extrinsicVersion", + "isV15", + "metadataIr", + "moduleError", + "registryJson", + "runtimeApiMap", + "signaturePayload", + "signaturePayloadParts", + "signedExtensionIdentifiers", + "specVersion", + "ss58Format", + "storageEntry", + "storageKey", + "storageKeyBatch", + "storagePrefix", + "transactionVersion", + "typeIdOf", + "typeNameOf" + ] + } + } + }, + "browser": { + "values": [ + "CRYPTO_ED25519", + "CRYPTO_SR25519", + "DEFAULT_SS58_FORMAT", + "configureBrowserWasm", + "coreVersion", + "decryptWithSignature", + "encrypt", + "encryptAtRound", + "eraBirth", + "generateCommitV2", + "generateExtrinsicProof", + "getEncryptedCommitment", + "initBrowser", + "loadBrowser", + "metadataDigest", + "mlkemKdfId", + "multisigAccountId", + "publicKeyFromSs58", + "ready", + "revealRound", + "sealMevShieldTransaction", + "setDefaultBrowserWasmLoader", + "ss58FromPublic", + "verifySignature" + ], + "classes": { + "Keypair": { + "statics": [ + "createFromMnemonic", + "createFromPrivateKey", + "createFromSeed", + "createFromUri", + "fromMnemonic", + "fromPrivateKey", + "fromSeed", + "fromUri", + "generateMnemonic" + ], + "instance": [ + "address", + "addressRaw", + "cryptoType", + "derive", + "isLocked", + "kind", + "meta", + "publicKey", + "scheme", + "setMeta", + "sign", + "ss58Address", + "ss58Format", + "type", + "verify" + ] + }, + "Runtime": { + "statics": [], + "instance": [ + "composeCall", + "constant", + "decode", + "decodeBatch", + "decodeCall", + "decodeExtrinsic", + "decodeMapChanges", + "decodeMapPairs", + "decodeStorageKeyParams", + "encode", + "encodeEra", + "encodeSignedExtrinsic", + "extrinsicVersion", + "isV15", + "metadataIr", + "moduleError", + "registryJson", + "runtimeApiMap", + "runtimeApis", + "signaturePayload", + "signaturePayloadParts", + "signedExtensionIdentifiers", + "specVersion", + "ss58Format", + "storageEntry", + "storageKey", + "storageKeyBatch", + "storagePrefix", + "transactionVersion", + "typeIdOf", + "typeNameOf" + ] + } + } + } +} diff --git a/sdk/typescript-sdk/README.md b/sdk/typescript-sdk/README.md index 57c1af925e..906b71c6ff 100644 --- a/sdk/typescript-sdk/README.md +++ b/sdk/typescript-sdk/README.md @@ -126,11 +126,12 @@ Extensions or strict-CSP applications can pass their own loader: await initBrowser(() => import('./vendor/bittensor_core_wasm.js')) ``` -## Native parity and secret derivation +## Binding parity and secret derivation Mnemonic, password, and secret-URI derivation state is retained only by the Rust `Keypair`; TypeScript calls the native handle's `derive(path)` method and never -reconstructs a child secret URI. `npm run build` also compares the freshly -generated N-API declarations with `src/native.ts`, including every native class -method, so Rust additions cannot silently disappear from the documented -TypeScript boundary. +reconstructs a child secret URI. `npm run build` also checks the Rust-side +binding manifest against the generated N-API declarations, `src/native.ts`, the +generated WASM declarations, `BrowserWasmModule`, and the public browser +wrapper, so Rust additions cannot silently disappear from either TypeScript +boundary. diff --git a/sdk/typescript-sdk/package.json b/sdk/typescript-sdk/package.json index e9db6ccccc..09c0187253 100644 --- a/sdk/typescript-sdk/package.json +++ b/sdk/typescript-sdk/package.json @@ -59,13 +59,14 @@ "build:native": "napi build --manifest-path native/Cargo.toml --output-dir . --platform --release --all-features --js native.cjs --dts native.generated.d.ts", "build:wasm": "wasm-pack build ../bittensor-core-wasm --target bundler --out-dir ../typescript-sdk/dist/wasm --out-name bittensor_core_wasm", "build:ts": "tsc -p tsconfig.json && node scripts/generate-esm.cjs", - "build": "npm run build:native && npm run check:native-parity && npm run build:ts && npm run build:wasm", + "build": "npm run build:native && npm run build:ts && npm run build:wasm && npm run check:binding-parity", "check": "npm run build && npm test", "test": "node --test test/*.test.cjs", "clean": "node scripts/clean.cjs", "prepack": "npm run build", "typecheck": "tsc -p tsconfig.json --noEmit", - "check:native-parity": "node scripts/check-native-parity.cjs" + "check:native-parity": "npm run check:binding-parity", + "check:binding-parity": "node scripts/check-native-parity.cjs" }, "napi": { "binaryName": "bittensor_sdk" diff --git a/sdk/typescript-sdk/scripts/check-native-parity.cjs b/sdk/typescript-sdk/scripts/check-native-parity.cjs index c17ce8df3d..495e222345 100755 --- a/sdk/typescript-sdk/scripts/check-native-parity.cjs +++ b/sdk/typescript-sdk/scripts/check-native-parity.cjs @@ -6,12 +6,21 @@ const path = require('node:path') const ts = require('typescript') const root = path.resolve(__dirname, '..') -const generatedPath = path.join(root, 'native.generated.d.ts') -const documentedPath = path.join(root, 'src', 'native.ts') +const sdkRoot = path.resolve(root, '..') +const repoRoot = path.resolve(sdkRoot, '..') +const manifestPath = path.join(sdkRoot, 'bittensor-core', 'binding-manifest.json') +const nativeGeneratedPath = path.join(root, 'native.generated.d.ts') +const nativeDocumentedPath = path.join(root, 'src', 'native.ts') +const browserPath = path.join(root, 'src', 'browser.ts') +const wasmGeneratedPath = path.join(root, 'dist', 'wasm', 'bittensor_core_wasm.d.ts') -function parse(filePath) { +function repoRelative(filePath) { + return path.relative(repoRoot, filePath) +} + +function parse(filePath, hint) { if (!fs.existsSync(filePath)) { - throw new Error(`missing ${path.relative(root, filePath)}; run npm run build:native first`) + throw new Error(`missing ${repoRelative(filePath)}; ${hint}`) } return ts.createSourceFile( filePath, @@ -56,95 +65,277 @@ function interfaceMembers(source) { return interfaces } -function generatedSurface(source) { +function exportedSurface(source, options = {}) { const values = new Set() const classes = new Map() + const ignoredClassMembers = options.ignoredClassMembers ?? new Set() + const publicOnly = options.publicOnly ?? false + for (const statement of source.statements) { if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) continue if (ts.isFunctionDeclaration(statement) && statement.name != null) { values.add(statement.name.text) } else if (ts.isClassDeclaration(statement) && statement.name != null) { - const name = statement.name.text - values.add(name) + const className = statement.name.text + values.add(className) const instance = new Set() const statics = new Set() for (const member of statement.members) { if (ts.isConstructorDeclaration(member)) continue + if ( + publicOnly && + (hasModifier(member, ts.SyntaxKind.PrivateKeyword) || + hasModifier(member, ts.SyntaxKind.ProtectedKeyword)) + ) { + continue + } const name = memberName(member) - if (name == null) continue + if (name == null || ignoredClassMembers.has(name)) continue if (hasModifier(member, ts.SyntaxKind.StaticKeyword)) statics.add(name) else instance.add(name) } - classes.set(name, { instance, statics }) + classes.set(className, { instance, statics }) } else if (ts.isEnumDeclaration(statement)) { values.add(statement.name.text) } else { for (const name of declarationNames(statement)) values.add(name) } } - return { values, classes } -} -function documentedBinding(source) { - const interfaces = interfaceMembers(source) - const binding = interfaces.get('NativeBinding') - if (binding == null) throw new Error('src/native.ts does not declare NativeBinding') - return { interfaces, binding } + return { values, classes } } function sorted(values) { return [...values].sort() } -function compareSet(label, actual, expected) { +function uniqueSet(values, label) { + if (!Array.isArray(values)) throw new Error(`${label} must be an array`) + const out = new Set() + for (const value of values) { + if (typeof value !== 'string') throw new Error(`${label} entries must be strings`) + if (out.has(value)) throw new Error(`${label} contains duplicate entry ${value}`) + out.add(value) + } + return out +} + +function manifestSurface(manifest, sectionName) { + const section = manifest[sectionName] + if (section == null || typeof section !== 'object') { + throw new Error(`binding manifest is missing ${sectionName}`) + } + + const values = uniqueSet(section.values ?? [], `${sectionName}.values`) + const classes = new Map() + const manifestClasses = section.classes ?? {} + if (manifestClasses == null || typeof manifestClasses !== 'object' || Array.isArray(manifestClasses)) { + throw new Error(`${sectionName}.classes must be an object`) + } + + for (const [className, members] of Object.entries(manifestClasses)) { + if (members == null || typeof members !== 'object' || Array.isArray(members)) { + throw new Error(`${sectionName}.classes.${className} must be an object`) + } + const instance = uniqueSet(members.instance ?? [], `${sectionName}.classes.${className}.instance`) + const statics = uniqueSet(members.statics ?? [], `${sectionName}.classes.${className}.statics`) + values.add(className) + classes.set(className, { instance, statics }) + } + + return { values, classes } +} + +function readManifest() { + if (!fs.existsSync(manifestPath)) { + throw new Error(`missing ${repoRelative(manifestPath)}`) + } + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + return { + native: manifestSurface(raw, 'native'), + wasm: manifestSurface(raw, 'wasm'), + browser: manifestSurface(raw, 'browser'), + } +} + +function compareSet(label, actual, expected, actualLabel, expectedLabel) { const missing = sorted([...expected].filter((name) => !actual.has(name))) const stale = sorted([...actual].filter((name) => !expected.has(name))) if (missing.length === 0 && stale.length === 0) return [] const lines = [`${label} differs:`] - if (missing.length > 0) lines.push(` missing documentation: ${missing.join(', ')}`) - if (stale.length > 0) lines.push(` stale documentation: ${stale.join(', ')}`) + if (missing.length > 0) lines.push(` missing from ${actualLabel}: ${missing.join(', ')}`) + if (stale.length > 0) lines.push(` not in ${expectedLabel}: ${stale.join(', ')}`) return lines } -const classInterfaces = { - NativeKeypair: { instance: 'NativeKeypairHandle' }, - NativeRuntime: { instance: 'NativeRuntimeHandle', statics: 'NativeRuntimeConstructor' }, - NativeCursor: { instance: 'NativeCursorHandle', statics: 'NativeCursorConstructor' }, - NativeLedgerDevice: { instance: 'NativeLedgerHandle', statics: 'NativeLedgerConstructor' }, +function compareSurface(label, actual, expected, actualLabel = 'binding', expectedLabel = 'manifest') { + const failures = compareSet( + `${label} top-level exports`, + actual.values, + expected.values, + actualLabel, + expectedLabel, + ) + + for (const [className, expectedMembers] of expected.classes) { + const actualMembers = actual.classes.get(className) + if (actualMembers == null) { + failures.push(`${label} is missing class ${className}`) + continue + } + failures.push( + ...compareSet( + `${label}.${className} instance members`, + actualMembers.instance, + expectedMembers.instance, + actualLabel, + expectedLabel, + ), + ...compareSet( + `${label}.${className} static members`, + actualMembers.statics, + expectedMembers.statics, + actualLabel, + expectedLabel, + ), + ) + } + + return failures } -try { - const generated = generatedSurface(parse(generatedPath)) - const documented = documentedBinding(parse(documentedPath)) - const failures = compareSet('top-level native exports', documented.binding, generated.values) - - for (const [className, members] of generated.classes) { - const mapping = classInterfaces[className] - if (mapping == null) { - failures.push(`generated class ${className} has no documented interface mapping`) +function compareClassInterfaces(label, interfaces, expected, mapping) { + const failures = [] + for (const [className, expectedMembers] of expected.classes) { + const interfaceNames = mapping[className] + if (interfaceNames == null) { + failures.push(`${label} has no interface mapping for ${className}`) continue } - const instance = documented.interfaces.get(mapping.instance) + + const instance = interfaces.get(interfaceNames.instance) if (instance == null) { - failures.push(`missing interface ${mapping.instance} for ${className}`) + failures.push(`${label} is missing interface ${interfaceNames.instance} for ${className}`) } else { - failures.push(...compareSet(`${className} instance members`, instance, members.instance)) + failures.push( + ...compareSet( + `${label}.${className} instance members`, + instance, + expectedMembers.instance, + 'interface', + 'manifest', + ), + ) } - const statics = mapping.statics == null ? new Set() : documented.interfaces.get(mapping.statics) - if (statics == null) { - failures.push(`missing interface ${mapping.statics} for ${className} statics`) - } else { - failures.push(...compareSet(`${className} static members`, statics, members.statics)) + if (interfaceNames.statics == null && expectedMembers.statics.size > 0) { + failures.push(`${label} has no static interface mapping for ${className}`) + } else if (interfaceNames.statics != null) { + const statics = interfaces.get(interfaceNames.statics) + if (statics == null) { + failures.push(`${label} is missing interface ${interfaceNames.statics} for ${className} statics`) + continue + } + failures.push( + ...compareSet( + `${label}.${className} static members`, + statics, + expectedMembers.statics, + 'interface', + 'manifest', + ), + ) } } + return failures +} + +function cloneSurface(surface) { + return { + values: new Set(surface.values), + classes: new Map( + [...surface.classes].map(([className, members]) => [ + className, + { + instance: new Set(members.instance), + statics: new Set(members.statics), + }, + ]), + ), + } +} + +const nativeClassInterfaces = { + NativeKeypair: { instance: 'NativeKeypairHandle' }, + NativeRuntime: { instance: 'NativeRuntimeHandle', statics: 'NativeRuntimeConstructor' }, + NativeCursor: { instance: 'NativeCursorHandle', statics: 'NativeCursorConstructor' }, + NativeLedgerDevice: { instance: 'NativeLedgerHandle', statics: 'NativeLedgerConstructor' }, +} + +const wasmClassInterfaces = { + Keypair: { instance: 'BrowserWasmKeypair', statics: 'BrowserWasmKeypairConstructor' }, + Runtime: { instance: 'BrowserWasmRuntime', statics: 'BrowserWasmRuntimeConstructor' }, +} + +try { + const manifest = readManifest() + const nativeGenerated = exportedSurface( + parse(nativeGeneratedPath, 'run npm run build:native first'), + ) + const nativeSource = parse(nativeDocumentedPath, 'restore src/native.ts') + const nativeDocumented = interfaceMembers(nativeSource) + const nativeBinding = nativeDocumented.get('NativeBinding') + if (nativeBinding == null) throw new Error('src/native.ts does not declare NativeBinding') + + const wasmGenerated = exportedSurface( + parse(wasmGeneratedPath, 'run npm run build:wasm first'), + { ignoredClassMembers: new Set(['free']) }, + ) + const browserSource = parse(browserPath, 'restore src/browser.ts') + const browserInterfaces = interfaceMembers(browserSource) + const browserPublic = exportedSurface(browserSource, { publicOnly: true }) + const browserModule = browserInterfaces.get('BrowserWasmModule') + if (browserModule == null) throw new Error('src/browser.ts does not declare BrowserWasmModule') + const expectedBrowserModule = cloneSurface(manifest.wasm) + expectedBrowserModule.values.add('default') + + const failures = [ + ...compareSurface('native N-API generated declarations', nativeGenerated, manifest.native), + ...compareSet( + 'src/native.ts NativeBinding', + nativeBinding, + manifest.native.values, + 'interface', + 'manifest', + ), + ...compareClassInterfaces('src/native.ts class handles', nativeDocumented, manifest.native, nativeClassInterfaces), + ...compareSurface('browser WASM generated declarations', wasmGenerated, manifest.wasm), + ...compareSet( + 'src/browser.ts BrowserWasmModule', + browserModule, + expectedBrowserModule.values, + 'interface', + 'manifest', + ), + ...compareClassInterfaces('src/browser.ts WASM class handles', browserInterfaces, manifest.wasm, wasmClassInterfaces), + ...compareSurface( + 'src/browser.ts public browser wrapper', + browserPublic, + manifest.browser, + 'wrapper', + 'manifest', + ), + ] if (failures.length > 0) { - console.error('Native Rust/TypeScript parity check failed:') + console.error('Bittensor core binding parity check failed:') for (const failure of failures) console.error(failure) process.exit(1) } - console.log(`Native parity OK: ${generated.values.size} exports and ${generated.classes.size} classes`) + console.log( + `Binding parity OK: native ${manifest.native.values.size} exports, ` + + `WASM ${manifest.wasm.values.size} exports, browser ${manifest.browser.values.size} exports`, + ) } catch (error) { console.error(error instanceof Error ? error.message : String(error)) process.exit(1) diff --git a/sdk/typescript-sdk/src/browser.ts b/sdk/typescript-sdk/src/browser.ts index 3902ed5591..e567a9e1d6 100644 --- a/sdk/typescript-sdk/src/browser.ts +++ b/sdk/typescript-sdk/src/browser.ts @@ -79,6 +79,15 @@ export interface BrowserMultisigAccount { sortedSignatories: Uint8Array[] } +export interface BrowserEpochScheduleState { + lastEpochBlock: BrowserIntegerLike + pendingEpochAt: BrowserIntegerLike + subnetEpochIndex: BrowserIntegerLike + tempo: number + blocksSinceLastStep: BrowserIntegerLike + currentBlock: BrowserIntegerLike +} + export type BrowserRuntimeApiMap = Record< string, Record< @@ -160,6 +169,20 @@ export interface BrowserWasmModule { blocksUntilReveal: BrowserIntegerLike, blockTime?: number, ): [Uint8Array, number] + getEncryptedCommitV2( + uids: Uint16Array, + weights: Uint16Array, + versionKey: BrowserIntegerLike, + lastEpochBlock: BrowserIntegerLike, + pendingEpochAt: BrowserIntegerLike, + subnetEpochIndex: BrowserIntegerLike, + tempo: number, + blocksSinceLastStep: BrowserIntegerLike, + currentBlock: BrowserIntegerLike, + subnetRevealPeriodEpochs: BrowserIntegerLike, + blockTime: number, + hotkey: Uint8Array, + ): [Uint8Array, number] encrypt(data: Uint8Array, nBlocks: BrowserIntegerLike, blockTime?: number): [Uint8Array, number] encryptAtRound(data: Uint8Array, revealRound: BrowserIntegerLike): [Uint8Array, number] revealRound(encryptedData: Uint8Array): number @@ -397,6 +420,18 @@ function copyByteList(values: Uint8Array[]): Uint8Array[] { return values.map((value) => copyBytes(value)) } +function toUint16Array(values: number[], name: string): Uint16Array { + const output = new Uint16Array(values.length) + for (let i = 0; i < values.length; i += 1) { + const value = values[i] + if (!Number.isInteger(value) || value < 0 || value > 0xffff) { + throw new RangeError(`${name}[${i}] must be an unsigned 16-bit integer`) + } + output[i] = value + } + return output +} + function mapPairs(pairs: Array<[unknown, unknown]>): Array> { return pairs.map(([key, value]) => ({ key: key as K, value: value as V })) } @@ -929,6 +964,32 @@ export function getEncryptedCommitment( return [copyBytes(ciphertext), round] } +export function generateCommitV2( + uids: number[], + values: number[], + versionKey: BrowserIntegerLike, + state: BrowserEpochScheduleState, + subnetRevealPeriodEpochs: BrowserIntegerLike, + blockTime: number, + hotkey: BrowserByteLike, +): [Uint8Array, number] { + const [ciphertext, round] = wasmSync().getEncryptedCommitV2( + toUint16Array(uids, 'uids'), + toUint16Array(values, 'values'), + versionKey, + state.lastEpochBlock, + state.pendingEpochAt, + state.subnetEpochIndex, + state.tempo, + state.blocksSinceLastStep, + state.currentBlock, + subnetRevealPeriodEpochs, + blockTime, + toBytes(hotkey, 'hotkey'), + ) + return [copyBytes(ciphertext), round] +} + export function encrypt( data: BrowserByteLike, nBlocks: BrowserIntegerLike, diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 7bcb380550..23351fe06b 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -155,6 +155,36 @@ test('browser Runtime exposes WASM codec, call, storage, and extrinsic helpers', Uint8Array.of(threshold), signatories.slice().reverse(), ], + getEncryptedCommitV2: ( + uids, + weights, + versionKey, + lastEpochBlock, + pendingEpochAt, + subnetEpochIndex, + tempo, + blocksSinceLastStep, + currentBlock, + subnetRevealPeriodEpochs, + blockTime, + hotkey, + ) => [ + Uint8Array.of( + uids[0], + weights[0], + Number(versionKey), + Number(lastEpochBlock), + Number(pendingEpochAt), + Number(subnetEpochIndex), + tempo, + Number(blocksSinceLastStep), + Number(currentBlock), + Number(subnetRevealPeriodEpochs), + blockTime, + hotkey[0], + ), + 777, + ], })) const runtime = new browser.Runtime(Uint8Array.of(1), 10, 20, 42) @@ -224,6 +254,25 @@ test('browser Runtime exposes WASM codec, call, storage, and extrinsic helpers', browser.multisigAccountId([Uint8Array.of(1), Uint8Array.of(2)], 2), { accountId: Uint8Array.of(2), sortedSignatories: [Uint8Array.of(2), Uint8Array.of(1)] }, ) + assert.deepEqual( + browser.generateCommitV2( + [1], + [2], + 3, + { + lastEpochBlock: 4, + pendingEpochAt: 5, + subnetEpochIndex: 6, + tempo: 7, + blocksSinceLastStep: 8, + currentBlock: 9, + }, + 10, + 11, + Uint8Array.of(12), + ), + [Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12), 777], + ) }) test('sr25519 keypair forwards signing and SS58 work to Rust', () => { From 17fe74e2a50609a8fb5b140deaee9dc4bec4e242 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 14:03:05 -0700 Subject: [PATCH 21/72] fix --- .github/workflows/check-rust.yml | 6 +++--- .github/workflows/typescript-e2e.yml | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/check-rust.yml b/.github/workflows/check-rust.yml index 656a479c11..42927a0e9e 100644 --- a/.github/workflows/check-rust.yml +++ b/.github/workflows/check-rust.yml @@ -195,9 +195,9 @@ jobs: node-version: 22 - name: Install wasm-pack run: | - curl -LsSf https://github.com/rustwasm/wasm-pack/releases/download/v0.13.1/wasm-pack-v0.13.1-x86_64-unknown-linux-musl.tar.gz \ - | tar zxf - --strip-components=1 -C "$HOME/.cargo/bin" --wildcards '*/wasm-pack' - wasm-pack --version + cargo install --locked --version 0.13.1 --root "$RUNNER_TEMP/wasm-pack" --force wasm-pack + echo "$RUNNER_TEMP/wasm-pack/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/wasm-pack/bin/wasm-pack" --version - name: wasm smoke test (node) run: | wasm-pack build sdk/bittensor-core-wasm --dev --target nodejs --out-dir pkg-node diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index 0f8e08bc5e..730fb17234 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -91,7 +91,8 @@ jobs: build-typescript-sdk: runs-on: [self-hosted, fireactions-heavy] - needs: [trusted-pr, typescript-formatting] + needs: [trusted-pr, typescript-formatting, changes] + if: needs.changes.outputs.e2e == 'true' timeout-minutes: 60 steps: - name: Check-out repository @@ -120,9 +121,9 @@ jobs: - name: Install wasm-pack run: | - curl -LsSf https://github.com/rustwasm/wasm-pack/releases/download/v0.13.1/wasm-pack-v0.13.1-x86_64-unknown-linux-musl.tar.gz \ - | tar zxf - --strip-components=1 -C "$HOME/.cargo/bin" --wildcards '*/wasm-pack' - wasm-pack --version + cargo install --locked --version 0.13.1 --root "$RUNNER_TEMP/wasm-pack" --force wasm-pack + echo "$RUNNER_TEMP/wasm-pack/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/wasm-pack/bin/wasm-pack" --version - name: Build and test TypeScript SDK run: | From 3ce5325b3b2ce789ff0e88155a6457bd7be22610 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 14:14:23 -0700 Subject: [PATCH 22/72] fix ledger --- sdk/typescript-sdk/src/client.ts | 435 ++++++++++++++++++++----- sdk/typescript-sdk/src/ledger.ts | 45 +++ sdk/typescript-sdk/src/modules.ts | 4 +- sdk/typescript-sdk/test/basic.test.cjs | 215 ++++++++++++ 4 files changed, 617 insertions(+), 82 deletions(-) diff --git a/sdk/typescript-sdk/src/client.ts b/sdk/typescript-sdk/src/client.ts index 1f83172043..b6185b5c74 100644 --- a/sdk/typescript-sdk/src/client.ts +++ b/sdk/typescript-sdk/src/client.ts @@ -1,9 +1,10 @@ -import { blake2_256 } from './crypto' -import { Keypair } from './keys' +import { blake2_256, generateExtrinsicProof, metadataDigest } from './crypto' +import { CRYPTO_SR25519, Keypair, publicKeyFromSs58 } from './keys' +import { LedgerDevice } from './ledger' import { Runtime, eraBirth } from './runtime' import { toBuffer } from './wire' import { Balance, type BalanceLike, balanceRao } from './balance' -import type { ByteLike, ScaleValue, SignedExtrinsic } from './types' +import type { ByteLike, ChainInfo, ScaleValue, SignedExtrinsic, TransactionParams } from './types' export const SS58_FORMAT = 42 export const DEFAULT_ERA_PERIOD = 128 @@ -38,6 +39,77 @@ export interface SubmitOptions { waitForFinalization?: boolean } +export interface SignerAccountContext { + client: Client + runtime: Runtime + ss58Format: number +} + +export interface SignerAccount { + ss58Address?: string + address?: string + publicKey?: ByteLike + cryptoType?: number + requiresMetadataProof?: boolean + requiresMetadataHash?: boolean +} + +export interface SignerPayloadContext { + client: Client + runtime: Runtime + address: string + publicKey: Buffer + cryptoType: number + callData: Buffer + payload: Buffer + txParams: TransactionParams + metadataHash: Buffer | null + metadataProof?: Buffer + proof?: Buffer + includedInExtrinsic?: Buffer + includedInSignedData?: Buffer + chainInfo?: ChainInfo +} + +export interface ExtensionSignRawRequest { + address: string + data: string + type: 'bytes' + payload: Buffer + metadataHash?: string + metadataProof?: Buffer + proof?: Buffer + chainInfo?: ChainInfo +} + +export type SignerSignature = + | ByteLike + | string + | { signature: ByteLike | string } + +export interface ChainSigner { + readonly ss58Address?: string + readonly address?: string + readonly publicKey?: ByteLike + readonly cryptoType?: number + readonly requiresMetadataProof?: boolean + readonly requiresMetadataHash?: boolean + getAccount?(context: SignerAccountContext): SignerAccount | Promise + sign?( + payload: ByteLike, + context?: SignerPayloadContext, + ): SignerSignature | Promise + signPayload?( + payload: ByteLike, + context: SignerPayloadContext, + ): SignerSignature | Promise + signRaw?( + request: ExtensionSignRawRequest, + ): SignerSignature | Promise +} + +export type SignerLike = Keypair | ChainSigner | LedgerDevice + export interface ExtrinsicResult { success: boolean message: string @@ -54,6 +126,7 @@ export interface ExtrinsicResult { export interface SignedExtrinsicResult extends SignedExtrinsic { hex: string + signerAddress: string } export interface ExtrinsicWatcher { @@ -89,6 +162,19 @@ interface SubscriptionState { closed: boolean } +interface ResolvedSigner { + signer: Keypair | ChainSigner + ss58Address: string + publicKey: Buffer + cryptoType: number + requiresMetadataProof: boolean +} + +interface NormalizedSignature { + signature: Buffer + cryptoType: number +} + type WebSocketLike = { readyState: number send(data: string): void @@ -385,6 +471,32 @@ export class Client { return runtime } + async chainInfo(runtime?: Runtime): Promise { + const resolvedRuntime = runtime ?? await this.runtimeAt() + const [version, properties] = await Promise.all([ + this.rpc('state_getRuntimeVersion'), + this.rpc('system_properties').catch(() => ({})), + ]) + const runtimeVersion = version as { specName?: unknown; specVersion?: unknown } + const chainProperties = properties as Record + return { + specVersion: Number(runtimeVersion.specVersion ?? resolvedRuntime.specVersion), + specName: String(runtimeVersion.specName ?? 'node-subtensor'), + base58Prefix: propertyNumber( + chainProperties.ss58Format ?? chainProperties.ss58Prefix, + resolvedRuntime.ss58Format, + ), + decimals: propertyNumber( + chainProperties.tokenDecimals ?? chainProperties.decimals, + 9, + ), + tokenSymbol: propertyString( + chainProperties.tokenSymbol ?? chainProperties.symbol, + 'TAO', + ), + } + } + rpc(method: string, params: unknown[] = []): Promise { return this.transport.request(method, params) } @@ -660,42 +772,87 @@ export class Client { return READS.slice() } - async signExtrinsic(call: CallLike, keypair: Keypair, options: SubmitOptions = {}): Promise { - const runtime = await this.runtimeAt() - const callData = await this.callData(call) - const nonce = options.nonce ?? (await this.accountNextIndex(keypair.ss58Address)) - const period = options.period === undefined ? DEFAULT_ERA_PERIOD : options.period - const { era, eraBlockHash } = await this.normalizeEra(period) - const tip = balanceRao(options.tip ?? 0) - const tipAssetId = options.tipAssetId == null ? null : balanceRao(options.tipAssetId) - const metadataHash = options.metadataHash == null ? null : toBuffer(options.metadataHash, 'metadataHash') - const txParams = { - era, - nonce, - tip, - tipAssetId, - genesisHash: hexToBuffer(await this.genesisHash()), - eraBlockHash: hexToBuffer(eraBlockHash), - metadataHash, + async signExtrinsic(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { + let resolved: ResolvedSigner | undefined + let shouldClearNonce = false + try { + const runtime = await this.runtimeAt() + const callData = await this.callData(call) + resolved = await this.resolveSigner(signer, runtime) + const nonce = options.nonce ?? (await this.accountNextIndex(resolved.ss58Address)) + shouldClearNonce = options.nonce == null + const period = options.period === undefined ? DEFAULT_ERA_PERIOD : options.period + const { era, eraBlockHash } = await this.normalizeEra(period) + const tip = balanceRao(options.tip ?? 0) + const tipAssetId = options.tipAssetId == null ? null : balanceRao(options.tipAssetId) + const chainInfo = resolved.requiresMetadataProof ? await this.chainInfo(runtime) : undefined + const metadataHash = + options.metadataHash == null + ? resolved.requiresMetadataProof + ? metadataDigest(runtime.metadataBytes, chainInfo!) + : null + : toBuffer(options.metadataHash, 'metadataHash') + const txParams = { + era, + nonce, + tip, + tipAssetId, + genesisHash: hexToBuffer(await this.genesisHash()), + eraBlockHash: hexToBuffer(eraBlockHash), + metadataHash, + } + const proofParts = resolved.requiresMetadataProof + ? runtime.signaturePayloadParts(txParams) + : undefined + const metadataProof = proofParts == null + ? undefined + : generateExtrinsicProof( + callData, + proofParts.includedInExtrinsic, + proofParts.includedInSignedData, + runtime.metadataBytes, + chainInfo!, + ) + const payload = runtime.signaturePayload(callData, txParams) + const context: SignerPayloadContext = { + client: this, + runtime, + address: resolved.ss58Address, + publicKey: resolved.publicKey, + cryptoType: resolved.cryptoType, + callData, + payload, + txParams, + metadataHash, + metadataProof, + proof: metadataProof, + includedInExtrinsic: proofParts?.includedInExtrinsic, + includedInSignedData: proofParts?.includedInSignedData, + chainInfo, + } + const signedBySigner = await this.signWithSigner(resolved.signer, payload, context) + const signed = runtime.encodeSignedExtrinsic(callData, resolved.publicKey, signedBySigner.signature, signedBySigner.cryptoType, { + era, + nonce, + tip, + tipAssetId, + metadataHashEnabled: metadataHash != null, + }) + return { ...signed, hex: hex(signed.bytes), signerAddress: resolved.ss58Address } + } catch (error) { + if (shouldClearNonce && resolved != null) this.clearNonce(resolved.ss58Address) + throw error } - const payload = runtime.signaturePayload(callData, txParams) - const signature = keypair.sign(payload) - const signed = runtime.encodeSignedExtrinsic(callData, keypair.publicKey, signature, keypair.cryptoType, { - era, - nonce, - tip, - tipAssetId, - metadataHashEnabled: metadataHash != null, - }) - return { ...signed, hex: hex(signed.bytes) } } - async submit(call: CallLike, keypair: Keypair, options: SubmitOptions = {}): Promise { + async submit(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { + let signerAddress = staticSignerAddress(signer) try { - const signed = await this.signExtrinsic(call, keypair, options) - return await this.submitSigned(signed, keypair.ss58Address, options) + const signed = await this.signExtrinsic(call, signer, options) + signerAddress = signed.signerAddress + return await this.submitSigned(signed, signed.signerAddress, options) } catch (error) { - this.clearNonce(keypair.ss58Address) + if (signerAddress != null) this.clearNonce(signerAddress) throw error } } @@ -763,12 +920,13 @@ export class Client { this.nonceCache.delete(address) } - async estimateFee(call: CallLike, keypair: Keypair): Promise { - const signed = await this.signExtrinsic(call, keypair, { - nonce: await this.accountNextIndex(keypair.ss58Address, false), + async estimateFee(call: CallLike, signer: SignerLike): Promise { + const runtime = await this.runtimeAt() + const account = await this.resolveSigner(signer, runtime) + const signed = await this.signExtrinsic(call, signer, { + nonce: await this.accountNextIndex(account.ss58Address, false), period: null, }) - const runtime = await this.runtimeAt() const length = Buffer.alloc(4) length.writeUInt32LE(signed.bytes.length, 0) const raw = await this.rpc('state_call', ['TransactionPaymentApi_query_info', hex(Buffer.concat([signed.bytes, length])), null]) @@ -780,86 +938,86 @@ export class Client { return Balance.fromRao(String(info.partial_fee ?? info.partialFee ?? 0)) } - submitCall(pallet: string, fn: string, params: ScaleValue, keypair: Keypair, options: SubmitOptions = {}): Promise { - return this.submit([pallet, fn, params], keypair, options) + submitCall(pallet: string, fn: string, params: ScaleValue, signer: SignerLike, options: SubmitOptions = {}): Promise { + return this.submit([pallet, fn, params], signer, options) } - submit_call(pallet: string, fn: string, params: ScaleValue, keypair: Keypair, options: SubmitOptions = {}): Promise { - return this.submitCall(pallet, fn, params, keypair, options) + submit_call(pallet: string, fn: string, params: ScaleValue, signer: SignerLike, options: SubmitOptions = {}): Promise { + return this.submitCall(pallet, fn, params, signer, options) } - transfer(keypair: Keypair, dest: string, amount: BalanceLike, options: SubmitOptions & { keepAlive?: boolean } = {}): Promise { - return this.submit(calls.balances[options.keepAlive === false ? 'transferAllowDeath' : 'transferKeepAlive'](dest, amount), keypair, { + transfer(signer: SignerLike, dest: string, amount: BalanceLike, options: SubmitOptions & { keepAlive?: boolean } = {}): Promise { + return this.submit(calls.balances[options.keepAlive === false ? 'transferAllowDeath' : 'transferKeepAlive'](dest, amount), signer, { waitForInclusion: true, ...options, }) } - transfer_keep_alive(keypair: Keypair, dest: string, amount: BalanceLike, options: SubmitOptions = {}): Promise { - return this.transfer(keypair, dest, amount, { keepAlive: true, ...options }) + transfer_keep_alive(signer: SignerLike, dest: string, amount: BalanceLike, options: SubmitOptions = {}): Promise { + return this.transfer(signer, dest, amount, { keepAlive: true, ...options }) } - transfer_allow_death(keypair: Keypair, dest: string, amount: BalanceLike, options: SubmitOptions = {}): Promise { - return this.transfer(keypair, dest, amount, { keepAlive: false, ...options }) + transfer_allow_death(signer: SignerLike, dest: string, amount: BalanceLike, options: SubmitOptions = {}): Promise { + return this.transfer(signer, dest, amount, { keepAlive: false, ...options }) } - setWeights(keypair: Keypair, netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string, options: SubmitOptions = {}): Promise { - return this.submit(calls.subtensor.setWeights(netuid, dests, weights, versionKey), keypair, { waitForInclusion: true, ...options }) + setWeights(signer: SignerLike, netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string, options: SubmitOptions = {}): Promise { + return this.submit(calls.subtensor.setWeights(netuid, dests, weights, versionKey), signer, { waitForInclusion: true, ...options }) } - set_weights(keypair: Keypair, netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string, options: SubmitOptions = {}): Promise { - return this.setWeights(keypair, netuid, dests, weights, versionKey, options) + set_weights(signer: SignerLike, netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string, options: SubmitOptions = {}): Promise { + return this.setWeights(signer, netuid, dests, weights, versionKey, options) } - burnedRegister(keypair: Keypair, netuid: number, hotkey: string, options: SubmitOptions = {}): Promise { - return this.submit(calls.subtensor.burnedRegister(netuid, hotkey), keypair, { waitForInclusion: true, ...options }) + burnedRegister(signer: SignerLike, netuid: number, hotkey: string, options: SubmitOptions = {}): Promise { + return this.submit(calls.subtensor.burnedRegister(netuid, hotkey), signer, { waitForInclusion: true, ...options }) } - burned_register(keypair: Keypair, netuid: number, hotkey: string, options: SubmitOptions = {}): Promise { - return this.burnedRegister(keypair, netuid, hotkey, options) + burned_register(signer: SignerLike, netuid: number, hotkey: string, options: SubmitOptions = {}): Promise { + return this.burnedRegister(signer, netuid, hotkey, options) } - rootRegister(keypair: Keypair, hotkey: string, options: SubmitOptions = {}): Promise { - return this.submit(calls.subtensor.rootRegister(hotkey), keypair, { waitForInclusion: true, ...options }) + rootRegister(signer: SignerLike, hotkey: string, options: SubmitOptions = {}): Promise { + return this.submit(calls.subtensor.rootRegister(hotkey), signer, { waitForInclusion: true, ...options }) } - root_register(keypair: Keypair, hotkey: string, options: SubmitOptions = {}): Promise { - return this.rootRegister(keypair, hotkey, options) + root_register(signer: SignerLike, hotkey: string, options: SubmitOptions = {}): Promise { + return this.rootRegister(signer, hotkey, options) } - registerNetwork(keypair: Keypair, hotkey: string, options: SubmitOptions = {}): Promise { - return this.submit(calls.subtensor.registerNetwork(hotkey), keypair, { waitForInclusion: true, ...options }) + registerNetwork(signer: SignerLike, hotkey: string, options: SubmitOptions = {}): Promise { + return this.submit(calls.subtensor.registerNetwork(hotkey), signer, { waitForInclusion: true, ...options }) } - register_network(keypair: Keypair, hotkey: string, options: SubmitOptions = {}): Promise { - return this.registerNetwork(keypair, hotkey, options) + register_network(signer: SignerLike, hotkey: string, options: SubmitOptions = {}): Promise { + return this.registerNetwork(signer, hotkey, options) } - registerSubnet(keypair: Keypair, hotkey: string, options: SubmitOptions = {}): Promise { - return this.registerNetwork(keypair, hotkey, options) + registerSubnet(signer: SignerLike, hotkey: string, options: SubmitOptions = {}): Promise { + return this.registerNetwork(signer, hotkey, options) } - register_subnet(keypair: Keypair, hotkey: string, options: SubmitOptions = {}): Promise { - return this.registerSubnet(keypair, hotkey, options) + register_subnet(signer: SignerLike, hotkey: string, options: SubmitOptions = {}): Promise { + return this.registerSubnet(signer, hotkey, options) } serveAxon( - keypair: Keypair, + signer: SignerLike, args: { netuid: number; ip: number; port: number; version?: number; ipType?: number; protocol?: number }, options: SubmitOptions = {}, ): Promise { - return this.submit(calls.subtensor.serveAxon(args.netuid, args.ip, args.port, args.version, args.ipType, args.protocol), keypair, { + return this.submit(calls.subtensor.serveAxon(args.netuid, args.ip, args.port, args.version, args.ipType, args.protocol), signer, { waitForInclusion: true, ...options, }) } serve_axon( - keypair: Keypair, + signer: SignerLike, args: { netuid: number; ip: number; port: number; version?: number; ipType?: number; protocol?: number }, options: SubmitOptions = {}, ): Promise { - return this.serveAxon(keypair, args, options) + return this.serveAxon(signer, args, options) } getBalance(address: string | Keypair, block?: number | string | null): Promise { @@ -935,6 +1093,69 @@ export class Client { return this.blockHash(block) } + private async resolveSigner(signer: SignerLike, runtime: Runtime): Promise { + const normalizedSigner: Keypair | ChainSigner = + signer instanceof LedgerDevice ? signer.signer() : signer + const signerShape = normalizedSigner as ChainSigner + const account = await signerShape.getAccount?.({ + client: this, + runtime, + ss58Format: runtime.ss58Format, + }) + const ss58Address = + accountAddress(account) ?? staticSignerAddress(normalizedSigner) + if (ss58Address == null) { + throw new ChainError('signer must expose an address, ss58Address, or getAccount()') + } + const publicKey = signerPublicKey(account?.publicKey ?? signerShape.publicKey, ss58Address) + const cryptoType = account?.cryptoType ?? signerShape.cryptoType ?? CRYPTO_SR25519 + return { + signer: normalizedSigner, + ss58Address, + publicKey, + cryptoType, + requiresMetadataProof: Boolean( + account?.requiresMetadataProof ?? + account?.requiresMetadataHash ?? + signerShape.requiresMetadataProof ?? + signerShape.requiresMetadataHash, + ), + } + } + + private async signWithSigner( + signer: Keypair | ChainSigner, + payload: Buffer, + context: SignerPayloadContext, + ): Promise { + const signerShape = signer as ChainSigner + if (signerShape.signPayload != null) { + return normalizeSignature( + await signerShape.signPayload(payload, context), + context.cryptoType, + ) + } + if (signerShape.signRaw != null) { + return normalizeSignature( + await signerShape.signRaw({ + address: context.address, + data: hex(payload), + type: 'bytes', + payload, + metadataHash: context.metadataHash == null ? undefined : hex(context.metadataHash), + metadataProof: context.metadataProof, + proof: context.proof, + chainInfo: context.chainInfo, + }), + context.cryptoType, + ) + } + if (signerShape.sign != null) { + return normalizeSignature(await signerShape.sign(payload, context), context.cryptoType) + } + throw new ChainError('signer must implement sign(), signPayload(), or signRaw()') + } + private async normalizeEra(period: number | null): Promise<{ era: ScaleValue; eraBlockHash: string }> { if (period == null) return { era: '00', eraBlockHash: await this.genesisHash() } const finalized = await this.finalizedHead() @@ -1223,20 +1444,20 @@ export class StakingNamespace { return this.positions(coldkey, block) } - addStake(keypair: Keypair, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { - return this.client.submit(calls.subtensor.addStake(hotkey, netuid, amount), keypair, { waitForInclusion: true, ...options }) + addStake(signer: SignerLike, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { + return this.client.submit(calls.subtensor.addStake(hotkey, netuid, amount), signer, { waitForInclusion: true, ...options }) } - add_stake(keypair: Keypair, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { - return this.addStake(keypair, hotkey, netuid, amount, options) + add_stake(signer: SignerLike, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { + return this.addStake(signer, hotkey, netuid, amount, options) } - removeStake(keypair: Keypair, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { - return this.client.submit(calls.subtensor.removeStake(hotkey, netuid, amount), keypair, { waitForInclusion: true, ...options }) + removeStake(signer: SignerLike, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { + return this.client.submit(calls.subtensor.removeStake(hotkey, netuid, amount), signer, { waitForInclusion: true, ...options }) } - remove_stake(keypair: Keypair, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { - return this.removeStake(keypair, hotkey, netuid, amount, options) + remove_stake(signer: SignerLike, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { + return this.removeStake(signer, hotkey, netuid, amount, options) } } @@ -1545,6 +1766,60 @@ async function read(client: Client, name: string, params: Record 0 ? value : undefined +} + +function signerPublicKey(value: ByteLike | undefined, ss58Address: string): Buffer { + return value == null + ? publicKeyFromSs58(ss58Address) + : toBuffer(value, 'signer.publicKey') +} + +function normalizeSignature( + result: SignerSignature, + fallbackCryptoType: number, +): NormalizedSignature { + const raw = signatureBytes(result) + if (raw.length === 65 && raw[0] <= CRYPTO_SR25519) { + return { signature: Buffer.from(raw.subarray(1)), cryptoType: raw[0] } + } + return { signature: raw, cryptoType: fallbackCryptoType } +} + +function signatureBytes(result: SignerSignature): Buffer { + if (typeof result === 'string') return hexToBuffer(result) + if (Buffer.isBuffer(result) || result instanceof Uint8Array) { + return toBuffer(result, 'signature') + } + return signatureBytes(result.signature) +} + function resolveEndpoint(network: string): [string, string] { if (network.startsWith('ws://') || network.startsWith('wss://') || network.startsWith('http')) return [network, network] if (Object.prototype.hasOwnProperty.call(NETWORKS, network)) return [network, NETWORKS[network as NetworkName]] diff --git a/sdk/typescript-sdk/src/ledger.ts b/sdk/typescript-sdk/src/ledger.ts index bdd5b1bb18..4108fee3f0 100644 --- a/sdk/typescript-sdk/src/ledger.ts +++ b/sdk/typescript-sdk/src/ledger.ts @@ -3,6 +3,47 @@ import { nativeCall } from './errors' import { toBuffer } from './wire' import type { ByteLike, LedgerAddress, LedgerVersion } from './types' +export interface LedgerSignerOptions { + account?: number + index?: number + ss58Prefix?: number + confirmAddress?: boolean +} + +export class LedgerSigner { + readonly requiresMetadataProof = true + + constructor( + private readonly device: LedgerDevice, + private readonly options: LedgerSignerOptions = {}, + ) {} + + getAccount(context: { ss58Format?: number } = {}): LedgerAddress { + return this.device.address( + this.options.account ?? 0, + this.options.index ?? 0, + this.options.ss58Prefix ?? context.ss58Format ?? 42, + this.options.confirmAddress ?? false, + ) + } + + signPayload( + payload: ByteLike, + context: { proof?: ByteLike; metadataProof?: ByteLike } = {}, + ): Buffer { + const proof = context.metadataProof ?? context.proof + if (proof == null) { + throw new Error('Ledger signing requires an RFC-0078 metadata proof') + } + return this.device.sign( + this.options.account ?? 0, + this.options.index ?? 0, + payload, + proof, + ) + } +} + export class LedgerDevice { private readonly handle: NativeLedgerHandle @@ -33,6 +74,10 @@ export class LedgerDevice { return nativeCall(() => this.handle.address(account, index, ss58Prefix, confirm)) } + signer(options: LedgerSignerOptions = {}): LedgerSigner { + return new LedgerSigner(this, options) + } + sign( account: number, index: number, diff --git a/sdk/typescript-sdk/src/modules.ts b/sdk/typescript-sdk/src/modules.ts index 5c861f6772..4514dd0c65 100644 --- a/sdk/typescript-sdk/src/modules.ts +++ b/sdk/typescript-sdk/src/modules.ts @@ -2,7 +2,7 @@ import * as crypto from './crypto' import * as errors from './errors' import * as keys from './keys' import * as client from './client' -import { LedgerDevice } from './ledger' +import { LedgerDevice, LedgerSigner } from './ledger' import native from './native' import * as runtime from './runtime' import * as timelock from './timelock' @@ -142,6 +142,6 @@ export const rustCore = Object.freeze({ }), }), signers: Object.freeze({ - ledger: Object.freeze({ LedgerDevice }), + ledger: Object.freeze({ LedgerDevice, LedgerSigner }), }), }) diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 23351fe06b..69becc2a41 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -8,6 +8,101 @@ const test = require('node:test') const core = require('../dist/index.js') +function compactLength(buffer, offset) { + const first = buffer[offset] + const mode = first & 0b11 + if (mode === 0) return { length: first >> 2, offset: offset + 1 } + if (mode === 1) return { length: buffer.readUInt16LE(offset) >> 2, offset: offset + 2 } + if (mode === 2) return { length: buffer.readUInt32LE(offset) >>> 2, offset: offset + 4 } + const bytes = (first >> 2) + 4 + let length = 0 + for (let i = 0; i < bytes; i += 1) length += buffer[offset + 1 + i] * (256 ** i) + return { length, offset: offset + 1 + bytes } +} + +function goldenMetadataBytes() { + const golden = JSON.parse( + fs.readFileSync( + path.join(__dirname, '..', '..', 'python', 'tests', 'fixtures', 'golden.json'), + 'utf8', + ), + ) + const raw = Buffer.from(golden.metadata.v15_hex.slice(2), 'hex') + assert.equal(raw[0], 1) + const decoded = compactLength(raw, 1) + return raw.subarray(decoded.offset, decoded.offset + decoded.length) +} + +function ledgerProofVector() { + const vector = JSON.parse( + fs.readFileSync( + path.join(__dirname, '..', '..', 'bittensor-core', 'fixtures', 'ledger_proof_vector.json'), + 'utf8', + ), + ) + return { + specVersion: vector.spec_version, + callData: Buffer.from(vector.call_data_hex, 'hex'), + includedInExtrinsic: Buffer.from(vector.included_in_extrinsic_hex, 'hex'), + includedInSignedData: Buffer.from(vector.included_in_signed_data_hex, 'hex'), + } +} + +function fakeSigningRuntime(overrides = {}) { + const captures = {} + const runtime = { + specVersion: 419, + transactionVersion: 1, + ss58Format: 42, + metadataBytes: Buffer.alloc(0), + signaturePayloadParts(params) { + captures.partsParams = params + return { + includedInExtrinsic: Buffer.from([1]), + includedInSignedData: Buffer.from([2]), + } + }, + signaturePayload(_callData, params) { + captures.payloadParams = params + return Buffer.from([9, 9, 9]) + }, + encodeSignedExtrinsic(callData, publicKey, signature, signatureVersion, params) { + captures.encoded = { callData, publicKey, signature, signatureVersion, params } + return { + bytes: Buffer.concat([Buffer.from([signatureVersion]), signature.subarray(0, 2)]), + hash: Buffer.alloc(32, 7), + } + }, + ...overrides, + } + return { runtime, captures } +} + +function fakeSigningClient(runtime, callData) { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + client.runtimeAt = async () => runtime + client.callData = async () => Buffer.from(callData) + client.accountNextIndex = async (address) => { + client.lastNonceAddress = address + return 12 + } + client.genesisHash = async () => `0x${'41'.repeat(32)}` + client.rpc = async (method) => { + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + throw new Error(`unexpected RPC ${method}`) + } + return client +} + test('package exposes a WASM browser subset without the Node native addon', () => { const root = path.join(__dirname, '..') const packageJson = JSON.parse( @@ -472,6 +567,7 @@ test('module-shaped export mirrors the public Rust crate', () => { assert.equal(core.rustCore.codec.batch.PARALLEL_THRESHOLD, core.PARALLEL_THRESHOLD) assert.equal(core.rustCore.client.Client, core.Client) assert.equal(core.rustCore.client.storage.System.Events[0], 'System') + assert.equal(core.rustCore.signers.ledger.LedgerSigner, core.LedgerSigner) assert.equal(core.rustCore.mlkem.MLKEM_NONCE_LEN, 24) assert.equal(core.rustCore.timelock.constants.GENESIS_TIME, core.GENESIS_TIME) assert.deepEqual(core.rustCore.timelock.epoch_schedule.EpochScheduleError, [ @@ -507,6 +603,125 @@ test('chain client surface is exported without Polkadot.js glue', () => { ]) }) +test('Client signs extrinsics with extension-style signRaw signers', async () => { + const callData = Buffer.from([5, 6, 7]) + const { runtime, captures } = fakeSigningRuntime() + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 6) + const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 9), + ]) + let request + const signer = { + address, + publicKey, + signRaw(req) { + request = req + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } + + const signed = await client.signExtrinsic(callData, signer, { period: null }) + + assert.equal(signed.signerAddress, address) + assert.equal(client.lastNonceAddress, address) + assert.equal(request.address, address) + assert.equal(request.type, 'bytes') + assert.equal(request.data, '0x090909') + assert.equal(request.metadataProof, undefined) + assert.deepEqual(captures.encoded.callData, callData) + assert.deepEqual(captures.encoded.publicKey, publicKey) + assert.deepEqual(captures.encoded.signature, Buffer.alloc(64, 9)) + assert.equal(captures.encoded.signatureVersion, core.CRYPTO_SR25519) + assert.equal(captures.encoded.params.metadataHashEnabled, false) +}) + +test('Client generates RFC-0078 proof for Ledger metadata-verifying signers', async () => { + const vector = ledgerProofVector() + const metadataBytes = goldenMetadataBytes() + const captures = {} + const { runtime } = fakeSigningRuntime({ + specVersion: vector.specVersion, + metadataBytes, + signaturePayloadParts(params) { + captures.partsParams = params + return { + includedInExtrinsic: vector.includedInExtrinsic, + includedInSignedData: vector.includedInSignedData, + } + }, + signaturePayload(_callData, params) { + captures.payloadParams = params + return Buffer.from([9, 9, 9]) + }, + encodeSignedExtrinsic(callData, publicKey, signature, signatureVersion, params) { + captures.encoded = { callData, publicKey, signature, signatureVersion, params } + return { + bytes: Buffer.concat([Buffer.from([signatureVersion]), signature.subarray(0, 2)]), + hash: Buffer.alloc(32, 7), + } + }, + }) + const client = fakeSigningClient(runtime, vector.callData) + const publicKey = Buffer.alloc(32, 5) + const address = core.ss58FromPublic(publicKey, 42) + const ledgerCalls = {} + const fakeDevice = { + address(account, index, ss58Prefix, confirm) { + ledgerCalls.address = { account, index, ss58Prefix, confirm } + return { publicKey, ss58Address: address } + }, + sign(account, index, payload, proof) { + ledgerCalls.sign = { + account, + index, + payload: Buffer.from(payload), + proof: Buffer.from(proof), + } + return Buffer.concat([Buffer.from([core.CRYPTO_ED25519]), Buffer.alloc(64, 7)]) + }, + } + const signer = new core.LedgerSigner(fakeDevice, { + account: 1, + index: 2, + confirmAddress: true, + }) + const chainInfo = { + specVersion: vector.specVersion, + specName: 'node-subtensor', + base58Prefix: 42, + decimals: 9, + tokenSymbol: 'TAO', + } + + const signed = await client.signExtrinsic(vector.callData, signer, { period: null }) + const expectedMetadataHash = core.metadataDigest(metadataBytes, chainInfo) + const expectedProof = core.generateExtrinsicProof( + vector.callData, + vector.includedInExtrinsic, + vector.includedInSignedData, + metadataBytes, + chainInfo, + ) + + assert.equal(signed.signerAddress, address) + assert.deepEqual(ledgerCalls.address, { + account: 1, + index: 2, + ss58Prefix: 42, + confirm: true, + }) + assert.deepEqual(captures.partsParams.metadataHash, expectedMetadataHash) + assert.deepEqual(ledgerCalls.sign.payload, Buffer.from([9, 9, 9])) + assert.deepEqual(ledgerCalls.sign.proof, expectedProof) + assert.deepEqual(captures.encoded.callData, vector.callData) + assert.deepEqual(captures.encoded.signature, Buffer.alloc(64, 7)) + assert.equal(captures.encoded.signatureVersion, core.CRYPTO_ED25519) + assert.equal(captures.encoded.params.metadataHashEnabled, true) +}) + test('hotkey helpers encrypt keyfiles when a password is provided', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bittensor-wallet-')) t.after(() => fs.rmSync(root, { recursive: true, force: true })) From 437b717beace35a4dbf47b24d1181099268de777 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 15:52:37 -0700 Subject: [PATCH 23/72] fix ts e2e --- sdk/typescript-sdk/README.md | 19 +++++++++--------- sdk/typescript-sdk/package.json | 7 ------- sdk/typescript-sdk/scripts/generate-esm.cjs | 2 +- sdk/typescript-sdk/test/basic.test.cjs | 13 ++++++++++-- .../suites/dev/sdk/test-typescript-sdk.ts | 20 ++++++++++++++++++- ts-tests/utils/address.ts | 5 +++-- ts-tests/utils/transactions.ts | 3 ++- 7 files changed, 45 insertions(+), 24 deletions(-) diff --git a/sdk/typescript-sdk/README.md b/sdk/typescript-sdk/README.md index 906b71c6ff..a463f06a42 100644 --- a/sdk/typescript-sdk/README.md +++ b/sdk/typescript-sdk/README.md @@ -2,8 +2,8 @@ The monorepo's TypeScript SDK. It lives in its own `sdk/typescript-sdk` package. In Node.js it is a deliberately thin Node-API wrapper around the -sibling `bittensor-core` Rust crate. Browser builds resolve to a separate -WASM-compatible subset backed by `sdk/bittensor-core-wasm`. +sibling `bittensor-core` Rust crate. Browser applications use the explicit +`@bittensor/sdk/browser` entrypoint backed by `sdk/bittensor-core-wasm`. Chain-defined work runs in Rust: @@ -31,14 +31,13 @@ raw generated Node-API module as `@bittensor/sdk/native`, so every native entry point is callable even when an ergonomic wrapper has not yet been added. -Browser bundlers should use the package's `browser` condition automatically, -or import the explicit `@bittensor/sdk/browser` subpath. That entrypoint does -not load `native.cjs`, `.node` binaries, Node `Buffer`, or native HID. It -returns `Uint8Array` values and exposes the portable subset: key generation, -SS58, signing and verification, RFC-0078 metadata proofs, ML-KEM sealing, and -timelock encryption/decryption when the caller fetches the drand signature. -Host-only features such as wallet keyfiles, encrypted JSON import, native -Ledger HID, and direct drand fetching remain Node-only. +Browser bundlers should import the explicit `@bittensor/sdk/browser` subpath. +That entrypoint does not load `native.cjs`, `.node` binaries, Node `Buffer`, +or native HID. It returns `Uint8Array` values and exposes the portable subset: +key generation, SS58, signing and verification, RFC-0078 metadata proofs, +ML-KEM sealing, and timelock encryption/decryption when the caller fetches the +drand signature. Host-only features such as wallet keyfiles, encrypted JSON +import, native Ledger HID, and direct drand fetching remain Node-only. ## Build locally diff --git a/sdk/typescript-sdk/package.json b/sdk/typescript-sdk/package.json index 09c0187253..e491fc1d10 100644 --- a/sdk/typescript-sdk/package.json +++ b/sdk/typescript-sdk/package.json @@ -6,15 +6,8 @@ "license": "Apache-2.0", "main": "dist/index.js", "types": "dist/index.d.ts", - "browser": "./dist/browser.mjs", "exports": { ".": { - "browser": { - "types": "./dist/browser.d.ts", - "import": "./dist/browser.mjs", - "require": "./dist/browser.js", - "default": "./dist/browser.mjs" - }, "node": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", diff --git a/sdk/typescript-sdk/scripts/generate-esm.cjs b/sdk/typescript-sdk/scripts/generate-esm.cjs index 2503573293..39c3efee70 100644 --- a/sdk/typescript-sdk/scripts/generate-esm.cjs +++ b/sdk/typescript-sdk/scripts/generate-esm.cjs @@ -40,7 +40,7 @@ function generate(entry) { } const lines = [ - `import sdk from './${entry}.js'`, + `import * as sdk from './${entry}.js'`, '', ...(entry === 'browser' ? ["sdk.setDefaultBrowserWasmLoader(() => import('./wasm/bittensor_core_wasm.js'))", ''] diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 69becc2a41..067d687690 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -109,8 +109,9 @@ test('package exposes a WASM browser subset without the Node native addon', () = fs.readFileSync(path.join(root, 'package.json'), 'utf8'), ) - assert.equal(packageJson.browser, './dist/browser.mjs') - assert.equal(packageJson.exports['.'].browser.import, './dist/browser.mjs') + assert.equal(Object.prototype.hasOwnProperty.call(packageJson, 'browser'), false) + assert.equal(Object.prototype.hasOwnProperty.call(packageJson.exports['.'], 'browser'), false) + assert.equal(packageJson.exports['.'].node.import, './dist/index.mjs') assert.equal(packageJson.exports['./browser'].import, './dist/browser.mjs') assert.equal(packageJson.exports['./native'].node.import, './native.cjs') @@ -498,6 +499,14 @@ test('ESM consumers receive the same named exports', async () => { const esm = await import('../dist/index.mjs') assert.equal(esm.BINDING_VERSION, core.BINDING_VERSION) assert.equal(esm.Keypair, core.Keypair) + assert.equal(esm.Client, core.Client) + assert.equal(esm.generateKeyringPair, core.generateKeyringPair) + assert.equal(esm.bytesToHex, core.bytesToHex) + + const root = path.join(__dirname, '..') + const source = fs.readFileSync(path.join(root, 'dist', 'index.mjs'), 'utf8') + assert.equal(source.includes("import * as sdk from './index.js'"), true) + assert.equal(source.includes("import sdk from './index.js'"), false) }) test('exact codec::Value descriptors preserve every Rust enum variant', () => { diff --git a/ts-tests/suites/dev/sdk/test-typescript-sdk.ts b/ts-tests/suites/dev/sdk/test-typescript-sdk.ts index 2990ea74fe..6b3144b1cb 100644 --- a/ts-tests/suites/dev/sdk/test-typescript-sdk.ts +++ b/ts-tests/suites/dev/sdk/test-typescript-sdk.ts @@ -1,6 +1,18 @@ import { BINDING_VERSION, Client, Keypair, blake2_256, storage } from "@bittensor/sdk"; import { describeSuite, expect } from "@moonwall/cli"; +function getSdkEndpoint(): string | undefined { + if (process.env.BT_CHAIN_ENDPOINT) { + return process.env.BT_CHAIN_ENDPOINT; + } + if (process.env.WSS_URL) { + return process.env.WSS_URL; + } + if (process.env.MOONWALL_RPC_PORT) { + return `ws://127.0.0.1:${process.env.MOONWALL_RPC_PORT}`; + } +} + describeSuite({ id: "DEV_TYPESCRIPT_SDK_01", title: "Rust-backed TypeScript SDK integration", @@ -10,7 +22,13 @@ describeSuite({ id: "T01", title: "connects, constructs, submits, and reads with the SDK chain client", test: async () => { - const client = await new Client("ws://127.0.0.1:9947").connect(); + const endpoint = getSdkEndpoint(); + expect(endpoint).to.be.a("string"); + if (!endpoint) { + throw new Error("Moonwall did not expose an SDK websocket endpoint"); + } + + const client = await new Client(endpoint).connect(); const alice = Keypair.fromUri("//Alice"); const remark = blake2_256(Buffer.from(`typescript-sdk:${BINDING_VERSION}`)); const call = await client.composeCall("System", "remark", { remark }); diff --git a/ts-tests/utils/address.ts b/ts-tests/utils/address.ts index cb0dedbe15..1f5bff4356 100644 --- a/ts-tests/utils/address.ts +++ b/ts-tests/utils/address.ts @@ -18,8 +18,9 @@ export function convertH160ToSS58(ethAddress: string): string { return ss58FromPublic(convertH160ToPublicKey(ethAddress), SS58_PREFIX); } -export function convertPublicKeyToSs58(publicKey: Uint8Array): string { - return ss58FromPublic(publicKey, SS58_PREFIX); +export function convertPublicKeyToSs58(publicKey: Uint8Array | string): string { + const publicKeyBytes = typeof publicKey === "string" ? hexToBytes(publicKey, "publicKey") : publicKey; + return ss58FromPublic(publicKeyBytes, SS58_PREFIX); } export function ss58ToEthAddress(ss58Address: string): string { diff --git a/ts-tests/utils/transactions.ts b/ts-tests/utils/transactions.ts index 53b9185828..7a524bf841 100644 --- a/ts-tests/utils/transactions.ts +++ b/ts-tests/utils/transactions.ts @@ -66,7 +66,8 @@ export async function sendTransaction( timeout: number = 3 * 60 * 1000 ): Promise { const callerStack = new Error().stack; - const polkadotSigner = getPolkadotSigner(signer.publicKey, "Sr25519", signer.sign); + const scheme = signer.type === "ed25519" ? "Ed25519" : "Sr25519"; + const polkadotSigner = getPolkadotSigner(signer.publicKey, scheme, (payload) => signer.sign(payload)); return new Promise((resolve) => { const timer = setTimeout(() => { From 800a45e2aafe33b747dc1c6532155ff43c80c829 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 16:03:41 -0700 Subject: [PATCH 24/72] fix runtime metadata --- sdk/typescript-sdk/src/client.ts | 156 +++++++++++++++++++++++-- sdk/typescript-sdk/test/basic.test.cjs | 107 +++++++++++++++++ 2 files changed, 254 insertions(+), 9 deletions(-) diff --git a/sdk/typescript-sdk/src/client.ts b/sdk/typescript-sdk/src/client.ts index b6185b5c74..2b6d37075e 100644 --- a/sdk/typescript-sdk/src/client.ts +++ b/sdk/typescript-sdk/src/client.ts @@ -8,6 +8,8 @@ import type { ByteLike, ChainInfo, ScaleValue, SignedExtrinsic, TransactionParam export const SS58_FORMAT = 42 export const DEFAULT_ERA_PERIOD = 128 +export const DEFAULT_HEAD_RUNTIME_TTL_MS = 12_000 +export const DEFAULT_HISTORICAL_RUNTIME_CACHE_SIZE = 64 export const NETWORKS = Object.freeze({ finney: 'wss://entrypoint-finney.opentensor.ai:443', test: 'wss://test.finney.opentensor.ai:443', @@ -27,6 +29,8 @@ export interface ClientOptions { fallbackEndpoints?: string[] retryForever?: boolean autoConnect?: boolean + headRuntimeTtlMs?: number + historicalRuntimeCacheSize?: number } export interface SubmitOptions { @@ -170,6 +174,20 @@ interface ResolvedSigner { requiresMetadataProof: boolean } +interface RuntimeVersionInfo { + specVersion: number + transactionVersion: number +} + +interface RuntimeCacheEntry extends RuntimeVersionInfo { + runtime: Runtime + ss58Format: number +} + +interface HeadRuntimeCacheEntry extends RuntimeCacheEntry { + expiresAtMs: number +} + interface NormalizedSignature { signature: Buffer cryptoType: number @@ -389,7 +407,11 @@ export class Client { readonly neurons: NeuronsNamespace readonly staking: StakingNamespace - private runtimeCache?: Runtime + private readonly headRuntimeTtlMs: number + private readonly historicalRuntimeCacheSize: number + private headRuntimeCache?: HeadRuntimeCacheEntry + private runtimesBySpecVersion = new Map() + private historicalRuntimeCache = new Map() private genesis?: string private nonceCache = new Map() @@ -402,6 +424,11 @@ export class Client { this.subnets = new SubnetsNamespace(this) this.neurons = new NeuronsNamespace(this) this.staking = new StakingNamespace(this) + this.headRuntimeTtlMs = nonNegativeNumber(options.headRuntimeTtlMs, DEFAULT_HEAD_RUNTIME_TTL_MS) + this.historicalRuntimeCacheSize = nonNegativeInteger( + options.historicalRuntimeCacheSize, + DEFAULT_HISTORICAL_RUNTIME_CACHE_SIZE, + ) if (options.autoConnect) void this.connect() } @@ -454,21 +481,99 @@ export class Client { async runtimeAt(block?: number | string | null): Promise { const blockHash = await this.resolveBlockHash(block) - if (this.runtimeCache != null && blockHash == null) return this.runtimeCache - const [metadataHex, version, properties] = await Promise.all([ - this.rpc('state_getMetadata', blockHash == null ? [] : [blockHash]), + return blockHash == null ? this.headRuntime() : this.historicalRuntimeAt(blockHash) + } + + invalidateRuntimeCache(): void { + this.headRuntimeCache = undefined + } + + private async headRuntime(): Promise { + const now = Date.now() + if (this.headRuntimeCache != null && this.headRuntimeCache.expiresAtMs > now) { + return this.headRuntimeCache.runtime + } + + const [version, ss58Format] = await this.runtimeVersionAndSs58(null) + if (this.headRuntimeCache != null && sameRuntimeVersion(this.headRuntimeCache, version, ss58Format)) { + this.headRuntimeCache.expiresAtMs = Date.now() + this.headRuntimeTtlMs + this.cacheRuntimeBySpecVersion(this.headRuntimeCache) + return this.headRuntimeCache.runtime + } + + this.invalidateRuntimeCache() + const entry = await this.runtimeForVersion(version, ss58Format, null) + this.headRuntimeCache = { ...entry, expiresAtMs: Date.now() + this.headRuntimeTtlMs } + return entry.runtime + } + + private async historicalRuntimeAt(blockHash: string): Promise { + const cached = this.historicalRuntime(blockHash) + if (cached != null) return cached.runtime + + const [version, ss58Format] = await this.runtimeVersionAndSs58(blockHash) + const entry = await this.runtimeForVersion(version, ss58Format, blockHash) + this.cacheHistoricalRuntime(blockHash, entry) + return entry.runtime + } + + private async runtimeVersionAndSs58(blockHash: string | null): Promise<[RuntimeVersionInfo, number]> { + const [version, properties] = await Promise.all([ this.rpc('state_getRuntimeVersion', blockHash == null ? [] : [blockHash]), this.rpc('system_properties').catch(() => ({})), ]) - const ss58Format = Number((properties as { ss58Format?: number }).ss58Format ?? SS58_FORMAT) + const chainProperties = properties as Record + return [ + runtimeVersionInfo(version), + propertyNumber(chainProperties.ss58Format ?? chainProperties.ss58Prefix, SS58_FORMAT), + ] + } + + private async runtimeForVersion( + version: RuntimeVersionInfo, + ss58Format: number, + blockHash: string | null, + ): Promise { + const cached = this.runtimesBySpecVersion.get(version.specVersion) + if (cached != null && sameRuntimeVersion(cached, version, ss58Format)) { + this.cacheRuntimeBySpecVersion(cached) + return cached + } + + const metadataHex = await this.rpc('state_getMetadata', blockHash == null ? [] : [blockHash]) const runtime = new Runtime( hexToBuffer(String(metadataHex)), - Number((version as { specVersion: number }).specVersion), - Number((version as { transactionVersion: number }).transactionVersion), + version.specVersion, + version.transactionVersion, ss58Format, ) - if (blockHash == null) this.runtimeCache = runtime - return runtime + const entry = { runtime, ss58Format, ...version } + this.cacheRuntimeBySpecVersion(entry) + return entry + } + + private cacheRuntimeBySpecVersion(entry: RuntimeCacheEntry): void { + this.runtimesBySpecVersion.delete(entry.specVersion) + this.runtimesBySpecVersion.set(entry.specVersion, entry) + } + + private historicalRuntime(blockHash: string): RuntimeCacheEntry | undefined { + const entry = this.historicalRuntimeCache.get(blockHash) + if (entry == null) return undefined + this.historicalRuntimeCache.delete(blockHash) + this.historicalRuntimeCache.set(blockHash, entry) + return entry + } + + private cacheHistoricalRuntime(blockHash: string, entry: RuntimeCacheEntry): void { + if (this.historicalRuntimeCacheSize <= 0) return + this.historicalRuntimeCache.delete(blockHash) + this.historicalRuntimeCache.set(blockHash, entry) + while (this.historicalRuntimeCache.size > this.historicalRuntimeCacheSize) { + const oldest = this.historicalRuntimeCache.keys().next().value + if (oldest == null) break + this.historicalRuntimeCache.delete(oldest) + } } async chainInfo(runtime?: Runtime): Promise { @@ -1193,6 +1298,7 @@ export class Client { const extrinsicIndex = block.block.extrinsics.findIndex((item) => hex(blake2_256(hexToBuffer(item))) === extrinsicHash) if (extrinsicIndex < 0) throw new ChainError(`extrinsic ${extrinsicHash} was not found in block ${blockHash}`) const events = ((await this.query(storage.System.Events, [], blockHash)) ?? []) as unknown[] + if (events.some((event) => eventName(event) === 'System.CodeUpdated')) this.invalidateRuntimeCache() const triggered = events.filter((event) => eventExtrinsicIndex(event) === extrinsicIndex) const failed = triggered.find((event) => eventName(event) === 'System.ExtrinsicFailed') const success = triggered.some((event) => eventName(event) === 'System.ExtrinsicSuccess') @@ -1782,6 +1888,38 @@ function propertyString(value: unknown, fallback: string): string { return item == null ? fallback : String(item) } +function runtimeVersionInfo(value: unknown): RuntimeVersionInfo { + const version = value as { specVersion?: unknown; transactionVersion?: unknown } + const specVersion = Number(version.specVersion) + const transactionVersion = Number(version.transactionVersion) + if (!Number.isFinite(specVersion) || !Number.isFinite(transactionVersion)) { + throw new ChainError('runtime version response is missing specVersion or transactionVersion', value) + } + return { specVersion, transactionVersion } +} + +function sameRuntimeVersion( + entry: RuntimeVersionInfo & { ss58Format: number }, + version: RuntimeVersionInfo, + ss58Format: number, +): boolean { + return ( + entry.specVersion === version.specVersion && + entry.transactionVersion === version.transactionVersion && + entry.ss58Format === ss58Format + ) +} + +function nonNegativeNumber(value: unknown, fallback: number): number { + if (value == null) return fallback + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback +} + +function nonNegativeInteger(value: unknown, fallback: number): number { + return Math.floor(nonNegativeNumber(value, fallback)) +} + function accountAddress(account?: SignerAccount | null): string | undefined { return stringValue(account?.ss58Address) ?? stringValue(account?.address) } diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 067d687690..5ec86dc1de 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -103,6 +103,59 @@ function fakeSigningClient(runtime, callData) { return client } +function fakeRuntimeCacheClient(options = {}) { + const metadataHex = `0x${goldenMetadataBytes().toString('hex')}` + const calls = { + metadata: [], + version: [], + properties: [], + } + let headVersion = { + specVersion: 419, + transactionVersion: 1, + } + const blockVersions = new Map() + const client = new core.Client('local', { + endpoint: 'http://127.0.0.1:9944', + headRuntimeTtlMs: options.headRuntimeTtlMs ?? 1_000, + historicalRuntimeCacheSize: options.historicalRuntimeCacheSize ?? 2, + }) + + client.rpc = async (method, params = []) => { + const blockHash = params[0] ?? null + if (method === 'state_getRuntimeVersion') { + calls.version.push(blockHash) + const version = blockHash == null ? headVersion : blockVersions.get(blockHash) + if (version == null) throw new Error(`missing version for ${blockHash}`) + return { + specName: 'node-subtensor', + specVersion: version.specVersion, + transactionVersion: version.transactionVersion, + } + } + if (method === 'state_getMetadata') { + calls.metadata.push(blockHash) + return metadataHex + } + if (method === 'system_properties') { + calls.properties.push(blockHash) + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + throw new Error(`unexpected RPC ${method}`) + } + + return { + client, + calls, + setHeadVersion(specVersion, transactionVersion = 1) { + headVersion = { specVersion, transactionVersion } + }, + setBlockVersion(blockHash, specVersion, transactionVersion = 1) { + blockVersions.set(blockHash, { specVersion, transactionVersion }) + }, + } +} + test('package exposes a WASM browser subset without the Node native addon', () => { const root = path.join(__dirname, '..') const packageJson = JSON.parse( @@ -612,6 +665,60 @@ test('chain client surface is exported without Polkadot.js glue', () => { ]) }) +test('Client expires head runtime metadata and invalidates it on runtime upgrade', async () => { + const { client, calls, setHeadVersion } = fakeRuntimeCacheClient({ + headRuntimeTtlMs: 1_000, + }) + + const first = await client.runtimeAt() + const cached = await client.runtimeAt() + assert.equal(cached, first) + assert.equal(calls.version.length, 1) + assert.equal(calls.metadata.length, 1) + + client.headRuntimeCache.expiresAtMs = 0 + const sameVersion = await client.runtimeAt() + assert.equal(sameVersion, first) + assert.equal(calls.version.length, 2) + assert.equal(calls.metadata.length, 1) + + setHeadVersion(420, 2) + client.headRuntimeCache.expiresAtMs = 0 + const upgraded = await client.runtimeAt() + assert.notEqual(upgraded, first) + assert.equal(upgraded.specVersion, 420) + assert.equal(upgraded.transactionVersion, 2) + assert.equal(calls.version.length, 3) + assert.deepEqual(calls.metadata, [null, null]) +}) + +test('Client caches historical runtimes by block hash with LRU eviction', async () => { + const { client, calls, setBlockVersion } = fakeRuntimeCacheClient({ + historicalRuntimeCacheSize: 2, + }) + const blockA = `0x${'aa'.repeat(32)}` + const blockB = `0x${'bb'.repeat(32)}` + const blockC = `0x${'cc'.repeat(32)}` + setBlockVersion(blockA, 419, 1) + setBlockVersion(blockB, 420, 1) + setBlockVersion(blockC, 421, 1) + + const runtimeA = await client.runtimeAt(blockA) + assert.equal(await client.runtimeAt(blockA), runtimeA) + assert.deepEqual(calls.version, [blockA]) + assert.deepEqual(calls.metadata, [blockA]) + + await client.runtimeAt(blockB) + await client.runtimeAt(blockC) + assert.equal(client.historicalRuntimeCache.has(blockA), false) + assert.equal(client.historicalRuntimeCache.has(blockB), true) + assert.equal(client.historicalRuntimeCache.has(blockC), true) + + assert.equal(await client.runtimeAt(blockA), runtimeA) + assert.deepEqual(calls.version, [blockA, blockB, blockC, blockA]) + assert.deepEqual(calls.metadata, [blockA, blockB, blockC]) +}) + test('Client signs extrinsics with extension-style signRaw signers', async () => { const callData = Buffer.from([5, 6, 7]) const { runtime, captures } = fakeSigningRuntime() From d7153bc0778d2baa3b6eff622cd92575695603a2 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 16:23:36 -0700 Subject: [PATCH 25/72] fix --- .github/workflows/typescript-e2e.yml | 4 +- sdk/typescript-sdk/package-lock.json | 485 +++++++++++++++++++ sdk/typescript-sdk/package.json | 3 +- sdk/typescript-sdk/scripts/browser-smoke.cjs | 363 ++++++++++++++ sdk/typescript-sdk/src/balance.ts | 42 +- sdk/typescript-sdk/src/client.ts | 372 ++++++++++++-- sdk/typescript-sdk/src/wallet.ts | 30 +- sdk/typescript-sdk/test/basic.test.cjs | 237 ++++++++- 8 files changed, 1464 insertions(+), 72 deletions(-) create mode 100644 sdk/typescript-sdk/scripts/browser-smoke.cjs diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index 730fb17234..c484a26ff4 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -43,7 +43,7 @@ jobs: run: | set -euo pipefail files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename') - pattern='^sdk/typescript-sdk/|^(ts-tests|common|node|pallets|precompiles|primitives|runtime|support|chain-extensions|src|vendor)/|^(Cargo\.(toml|lock)|build\.rs|rust-toolchain\.toml)$|^\.github/workflows/typescript-e2e\.yml$' + pattern='^sdk/(typescript-sdk|bittensor-core|bittensor-core-wasm)/|^(ts-tests|common|node|pallets|precompiles|primitives|runtime|support|chain-extensions|src|vendor)/|^(Cargo\.(toml|lock)|build\.rs|rust-toolchain\.toml)$|^\.github/workflows/typescript-e2e\.yml$' if grep -qE "$pattern" <<< "$files"; then echo "e2e=true" >> "$GITHUB_OUTPUT" else @@ -103,7 +103,7 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ - build-essential clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config + build-essential chromium clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config - name: Install Rust uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable diff --git a/sdk/typescript-sdk/package-lock.json b/sdk/typescript-sdk/package-lock.json index a80aa8ded7..045863f995 100644 --- a/sdk/typescript-sdk/package-lock.json +++ b/sdk/typescript-sdk/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@napi-rs/cli": "3.7.2", "@types/node": "22.15.0", + "esbuild": "0.28.1", "typescript": "5.8.3" }, "engines": { @@ -54,6 +55,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", @@ -1686,6 +2129,48 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", diff --git a/sdk/typescript-sdk/package.json b/sdk/typescript-sdk/package.json index e491fc1d10..8932271a90 100644 --- a/sdk/typescript-sdk/package.json +++ b/sdk/typescript-sdk/package.json @@ -54,7 +54,7 @@ "build:ts": "tsc -p tsconfig.json && node scripts/generate-esm.cjs", "build": "npm run build:native && npm run build:ts && npm run build:wasm && npm run check:binding-parity", "check": "npm run build && npm test", - "test": "node --test test/*.test.cjs", + "test": "node --test test/*.test.cjs && node scripts/browser-smoke.cjs", "clean": "node scripts/clean.cjs", "prepack": "npm run build", "typecheck": "tsc -p tsconfig.json --noEmit", @@ -67,6 +67,7 @@ "devDependencies": { "@napi-rs/cli": "3.7.2", "@types/node": "22.15.0", + "esbuild": "0.28.1", "typescript": "5.8.3" }, "repository": { diff --git a/sdk/typescript-sdk/scripts/browser-smoke.cjs b/sdk/typescript-sdk/scripts/browser-smoke.cjs new file mode 100644 index 0000000000..f3a1c7c727 --- /dev/null +++ b/sdk/typescript-sdk/scripts/browser-smoke.cjs @@ -0,0 +1,363 @@ +'use strict' + +const assert = require('node:assert/strict') +const { spawn, spawnSync } = require('node:child_process') +const fs = require('node:fs') +const http = require('node:http') +const os = require('node:os') +const path = require('node:path') + +const esbuild = require('esbuild') + +const root = path.join(__dirname, '..') +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bittensor-sdk-browser-')) +const isCi = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true' + +function compactLength(buffer, offset) { + const first = buffer[offset] + const mode = first & 0b11 + if (mode === 0) return { length: first >> 2, offset: offset + 1 } + if (mode === 1) return { length: buffer.readUInt16LE(offset) >> 2, offset: offset + 2 } + if (mode === 2) return { length: buffer.readUInt32LE(offset) >>> 2, offset: offset + 4 } + const bytes = (first >> 2) + 4 + let length = 0 + for (let i = 0; i < bytes; i += 1) length += buffer[offset + 1 + i] * (256 ** i) + return { length, offset: offset + 1 + bytes } +} + +function goldenMetadataHex() { + const golden = JSON.parse( + fs.readFileSync( + path.join(root, '..', 'python', 'tests', 'fixtures', 'golden.json'), + 'utf8', + ), + ) + const raw = Buffer.from(golden.metadata.v15_hex.slice(2), 'hex') + assert.equal(raw[0], 1) + const decoded = compactLength(raw, 1) + return raw.subarray(decoded.offset, decoded.offset + decoded.length).toString('hex') +} + +function modulePath(filePath) { + const relative = path.relative(tmp, filePath).replace(/\\/g, '/') + return relative.startsWith('.') ? relative : `./${relative}` +} + +function browserEntry(metadataHex) { + return ` +import * as sdk from ${JSON.stringify(modulePath(path.join(root, 'dist', 'browser.mjs')))} +import * as wasmBindings from ${JSON.stringify(modulePath(path.join(root, 'dist', 'wasm', 'bittensor_core_wasm_bg.js')))} +import wasmUrl from ${JSON.stringify(modulePath(path.join(root, 'dist', 'wasm', 'bittensor_core_wasm_bg.wasm')))} + +const metadataHex = ${JSON.stringify(metadataHex)} +const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function hexToBytes(hex) { + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i += 1) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + return out +} + +async function loadWasm() { + const response = await fetch(wasmUrl) + if (!response.ok) throw new Error('failed to fetch WASM: ' + response.status) + const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), { + './bittensor_core_wasm_bg.js': wasmBindings, + }) + wasmBindings.__wbg_set_wasm(instance.exports) + instance.exports.__wbindgen_start() + const module = { ...wasmBindings } + delete module.default + return module +} + +try { + sdk.configureBrowserWasm(() => loadWasm()) + const wasm = await sdk.initBrowser() + assert(typeof wasm.Runtime === 'function', 'WASM Runtime constructor is available') + assert(typeof sdk.coreVersion() === 'string' && sdk.coreVersion().length > 0, 'WASM initialized') + + const keypair = sdk.Keypair.fromMnemonic(mnemonic) + const message = new TextEncoder().encode('browser signing smoke') + const signature = keypair.sign(message) + assert(signature.length === 64, 'sr25519 signature length') + assert(keypair.verify(message, signature), 'keypair verifies its own signature') + assert(sdk.verifySignature(message, signature, keypair.ss58Address), 'browser verifySignature works') + assert(sdk.ss58FromPublic(keypair.publicKey, 42) === keypair.ss58Address, 'SS58 roundtrip works') + + const metadata = hexToBytes(metadataHex) + const runtime = new sdk.Runtime(metadata, 419, 1, 42) + assert(runtime.specVersion === 419, 'runtime spec version') + assert(runtime.transactionVersion === 1, 'runtime transaction version') + assert(runtime.metadataIr().pallets.some((pallet) => pallet.name === 'System'), 'runtime metadata parsed') + + const call = runtime.composeCall('System', 'remark', { remark: new Uint8Array([1, 2, 3]) }) + const decodedCall = runtime.decodeCall(call) + assert(decodedCall.call_module === 'System', 'call module decoded') + assert(decodedCall.call_function === 'remark', 'call function decoded') + + const genesisHash = new Uint8Array(32) + const eraBlockHash = new Uint8Array(32) + genesisHash.fill(1) + eraBlockHash.fill(2) + const txParams = { + era: '00', + nonce: 0, + tip: 0, + tipAssetId: null, + genesisHash, + eraBlockHash, + } + const payloadParts = runtime.signaturePayloadParts(txParams) + assert(payloadParts.includedInExtrinsic.length > 0, 'signature payload extrinsic parts') + assert(payloadParts.includedInSignedData.length > 0, 'signature payload signed parts') + const payload = runtime.signaturePayload(call, txParams) + const extrinsicSignature = keypair.sign(payload) + const signed = runtime.encodeSignedExtrinsic( + call, + keypair.publicKey, + extrinsicSignature, + keypair.cryptoType, + { era: '00', nonce: 0, tip: 0, tipAssetId: null, metadataHashEnabled: false }, + ) + assert(signed.bytes.length > call.length, 'signed extrinsic bytes') + assert(signed.hash.length === 32, 'signed extrinsic hash') + const decodedExtrinsic = runtime.decodeExtrinsic(signed.bytes, false) + assert(decodedExtrinsic.call.call_module === 'System', 'extrinsic call module decoded') + assert(decodedExtrinsic.call.call_function === 'remark', 'extrinsic call function decoded') + + window.__SDK_BROWSER_RESULT__ = { + ok: true, + address: keypair.ss58Address, + callLength: call.length, + extrinsicLength: signed.bytes.length, + } +} catch (error) { + window.__SDK_BROWSER_ERROR__ = { + message: String(error?.message ?? error), + stack: String(error?.stack ?? ''), + } +} +` +} + +async function bundleBrowserEntry() { + const entry = path.join(tmp, 'entry.js') + fs.writeFileSync(entry, browserEntry(goldenMetadataHex())) + const outdir = path.join(tmp, 'site') + fs.mkdirSync(outdir, { recursive: true }) + await esbuild.build({ + entryPoints: [entry], + bundle: true, + format: 'esm', + platform: 'browser', + target: ['chrome110'], + outdir, + entryNames: 'bundle', + assetNames: 'assets/[name]-[hash]', + loader: { '.wasm': 'file' }, + logLevel: 'silent', + }) + fs.writeFileSync( + path.join(outdir, 'index.html'), + 'Bittensor SDK Browser Smoke', + ) + return outdir +} + +function serve(directory) { + const server = http.createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + const pathname = url.pathname === '/' ? '/index.html' : url.pathname + const filePath = path.join(directory, pathname) + if (!filePath.startsWith(directory) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + response.writeHead(404) + response.end('not found') + return + } + const extension = path.extname(filePath) + const contentType = extension === '.html' + ? 'text/html; charset=utf-8' + : extension === '.js' + ? 'text/javascript; charset=utf-8' + : extension === '.wasm' + ? 'application/wasm' + : 'application/octet-stream' + response.writeHead(200, { + 'content-type': contentType, + 'content-security-policy': "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'; base-uri 'none'; object-src 'none'", + }) + response.end(fs.readFileSync(filePath)) + }) + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve({ + server, + url: `http://127.0.0.1:${server.address().port}/`, + }) + }) + }) +} + +function findChrome() { + const candidates = [ + process.env.CHROME_BIN, + process.env.CHROMIUM_BIN, + 'chromium', + 'chromium-browser', + 'google-chrome', + 'google-chrome-stable', + ].filter(Boolean) + for (const candidate of candidates) { + const result = spawnSync(candidate, ['--version'], { stdio: 'ignore' }) + if (result.status === 0) return candidate + } + if (!isCi) return undefined + throw new Error('Chromium is required for browser smoke tests in CI; install chromium or set CHROME_BIN') +} + +function launchChrome(chrome, url) { + const profile = path.join(tmp, 'chrome-profile') + fs.mkdirSync(profile, { recursive: true }) + const args = [ + '--headless=new', + '--disable-gpu', + '--no-sandbox', + '--disable-dev-shm-usage', + '--remote-debugging-port=0', + `--user-data-dir=${profile}`, + url, + ] + const child = spawn(chrome, args, { stdio: ['ignore', 'ignore', 'pipe'] }) + let stderr = '' + const devtools = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`timed out waiting for Chromium DevTools\n${stderr}`)), 15_000) + child.once('exit', (code, signal) => { + clearTimeout(timeout) + reject(new Error(`Chromium exited before DevTools was ready: ${code ?? signal}\n${stderr}`)) + }) + child.stderr.on('data', (chunk) => { + stderr += String(chunk) + const match = stderr.match(/DevTools listening on (ws:\/\/[^\s]+)/) + if (match == null) return + clearTimeout(timeout) + resolve(match[1]) + }) + }) + return { child, devtools } +} + +function cdp(wsUrl) { + const socket = new WebSocket(wsUrl) + let nextId = 1 + const pending = new Map() + const events = [] + + const ready = new Promise((resolve, reject) => { + socket.addEventListener('open', resolve, { once: true }) + socket.addEventListener('error', () => reject(new Error('failed to connect to Chromium DevTools')), { once: true }) + }) + + socket.addEventListener('message', (event) => { + const message = JSON.parse(String(event.data)) + if (message.id != null) { + const entry = pending.get(message.id) + if (entry == null) return + pending.delete(message.id) + if (message.error) entry.reject(new Error(message.error.message)) + else entry.resolve(message.result) + return + } + events.push(message) + }) + + return { + ready, + events, + send(method, params = {}) { + const id = nextId++ + const payload = JSON.stringify({ id, method, params }) + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }) + socket.send(payload) + }) + }, + close() { + socket.close() + }, + } +} + +async function pageWebSocketUrl(browserWsUrl) { + const httpUrl = new URL(browserWsUrl.replace(/^ws:/, 'http:').replace(/^wss:/, 'https:')) + const response = await fetch(`${httpUrl.origin}/json/list`) + const targets = await response.json() + const page = targets.find((target) => target.type === 'page') + if (page?.webSocketDebuggerUrl == null) { + throw new Error(`could not find Chromium page target: ${JSON.stringify(targets)}`) + } + return page.webSocketDebuggerUrl +} + +async function waitForBrowserResult(client) { + const deadline = Date.now() + 30_000 + for (;;) { + const result = await client.send('Runtime.evaluate', { + expression: 'window.__SDK_BROWSER_RESULT__ || window.__SDK_BROWSER_ERROR__ || null', + returnByValue: true, + awaitPromise: false, + }) + const value = result.result?.value + if (value?.ok) return value + if (value?.message) { + throw new Error(`${value.message}\n${value.stack ?? ''}`) + } + const exception = client.events.find((event) => event.method === 'Runtime.exceptionThrown') + if (exception != null) throw new Error(JSON.stringify(exception.params?.exceptionDetails ?? exception.params)) + if (Date.now() > deadline) throw new Error('timed out waiting for browser smoke result') + await new Promise((resolve) => setTimeout(resolve, 100)) + } +} + +async function main() { + let server + let chrome + let client + try { + const site = await bundleBrowserEntry() + const chromePath = findChrome() + if (chromePath == null) { + console.warn('Skipping browser smoke tests because Chromium is not installed; CI requires Chromium.') + return + } + const served = await serve(site) + server = served.server + chrome = launchChrome(chromePath, served.url) + const browserWsUrl = await chrome.devtools + client = cdp(await pageWebSocketUrl(browserWsUrl)) + await client.ready + await client.send('Runtime.enable') + await client.send('Page.enable') + const result = await waitForBrowserResult(client) + assert.equal(result.ok, true) + assert.equal(typeof result.address, 'string') + assert.ok(result.callLength > 0) + assert.ok(result.extrinsicLength > result.callLength) + } finally { + client?.close() + server?.close() + if (chrome?.child.exitCode == null) chrome?.child.kill() + fs.rmSync(tmp, { recursive: true, force: true }) + } +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/sdk/typescript-sdk/src/balance.ts b/sdk/typescript-sdk/src/balance.ts index 8606e70520..ac6c8450a5 100644 --- a/sdk/typescript-sdk/src/balance.ts +++ b/sdk/typescript-sdk/src/balance.ts @@ -1,4 +1,5 @@ export const RAO_PER_TAO = 1_000_000_000n +const MAX_SAFE_RAO = BigInt(Number.MAX_SAFE_INTEGER) export type BalanceLike = Balance | bigint | number | string @@ -58,13 +59,27 @@ export class Balance { return this.amount } + get taoString(): string { + if (this.netuid !== 0) throw new UnitMismatchError(`This balance is subnet-${this.netuid} alpha, not TAO`) + return this.amountString + } + get alpha(): number { if (this.netuid === 0) throw new UnitMismatchError('This balance is TAO, not alpha') return this.amount } + get alphaString(): string { + if (this.netuid === 0) throw new UnitMismatchError('This balance is TAO, not alpha') + return this.amountString + } + get amount(): number { - return Number(this.rao) / Number(RAO_PER_TAO) + return this.toNumber() + } + + get amountString(): string { + return formatRao(this.rao) } get unit(): string { @@ -97,11 +112,14 @@ export class Balance { } toString(): string { - const sign = this.rao < 0n ? '-' : '' - const value = this.rao < 0n ? -this.rao : this.rao - const whole = value / RAO_PER_TAO - const fraction = (value % RAO_PER_TAO).toString().padStart(9, '0').replace(/0+$/, '') - return `${sign}${whole.toString()}${fraction ? `.${fraction}` : ''} ${this.unit}` + return `${this.amountString} ${this.unit}` + } + + toNumber(): number { + if (abs(this.rao) > MAX_SAFE_RAO) { + throw new RangeError('balance exceeds JavaScript safe integer precision; use rao or amountString') + } + return Number(this.rao) / Number(RAO_PER_TAO) } private raoOf(other: BalanceLike): bigint { @@ -115,6 +133,18 @@ export class Balance { } } +function abs(value: bigint): bigint { + return value < 0n ? -value : value +} + +function formatRao(rao: bigint): string { + const sign = rao < 0n ? '-' : '' + const value = rao < 0n ? -rao : rao + const whole = value / RAO_PER_TAO + const fraction = (value % RAO_PER_TAO).toString().padStart(9, '0').replace(/0+$/, '') + return `${sign}${whole.toString()}${fraction ? `.${fraction}` : ''}` +} + export function balanceRao(value: BalanceLike): bigint { if (value instanceof Balance) return value.rao if (typeof value === 'bigint') return value diff --git a/sdk/typescript-sdk/src/client.ts b/sdk/typescript-sdk/src/client.ts index 2b6d37075e..ec696c897f 100644 --- a/sdk/typescript-sdk/src/client.ts +++ b/sdk/typescript-sdk/src/client.ts @@ -4,12 +4,16 @@ import { LedgerDevice } from './ledger' import { Runtime, eraBirth } from './runtime' import { toBuffer } from './wire' import { Balance, type BalanceLike, balanceRao } from './balance' -import type { ByteLike, ChainInfo, ScaleValue, SignedExtrinsic, TransactionParams } from './types' +import type { ByteLike, ChainInfo, ScaleValue, SignedExtrinsic, StorageEntry, TransactionParams } from './types' export const SS58_FORMAT = 42 export const DEFAULT_ERA_PERIOD = 128 export const DEFAULT_HEAD_RUNTIME_TTL_MS = 12_000 export const DEFAULT_HISTORICAL_RUNTIME_CACHE_SIZE = 64 +export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 +export const DEFAULT_MAX_REQUEST_RETRIES = 2 +export const DEFAULT_RETRY_BACKOFF_MS = 250 +export const DEFAULT_MAX_RETRY_BACKOFF_MS = 5_000 export const NETWORKS = Object.freeze({ finney: 'wss://entrypoint-finney.opentensor.ai:443', test: 'wss://test.finney.opentensor.ai:443', @@ -31,6 +35,25 @@ export interface ClientOptions { autoConnect?: boolean headRuntimeTtlMs?: number historicalRuntimeCacheSize?: number + requestTimeoutMs?: number + maxRequestRetries?: number + retryBackoffMs?: number + maxRetryBackoffMs?: number +} + +export interface RpcRequestOptions { + signal?: AbortSignal + timeoutMs?: number + maxRetries?: number + retryBackoffMs?: number + maxRetryBackoffMs?: number +} + +export interface JsonRpcTransportOptions { + requestTimeoutMs?: number + maxRequestRetries?: number + retryBackoffMs?: number + maxRetryBackoffMs?: number } export interface SubmitOptions { @@ -157,13 +180,23 @@ export interface BlockInfo { interface RpcRequest { resolve(value: unknown): void reject(error: Error): void + cleanup(): void +} + +interface SubscriptionWaiter { + resolve(value: IteratorResult): void + reject(error: Error): void } interface SubscriptionState { queue: unknown[] - waiters: Array<(value: IteratorResult) => void> - errors: Array<(error: Error) => void> + waiters: SubscriptionWaiter[] closed: boolean + subscribeMethod: string + params: unknown[] + unsubscribeMethod: string + subscription?: string + resubscribing?: Promise } interface ResolvedSigner { @@ -224,34 +257,76 @@ export class ChainError extends Error { } } +export class RequestTimeoutError extends ChainError { + constructor(message: string) { + super(message) + this.name = 'RequestTimeoutError' + } +} + +export class RequestAbortedError extends ChainError { + constructor(message = 'request aborted') { + super(message) + this.name = 'RequestAbortedError' + } +} + export class JsonRpcTransport { private readonly endpoints: string[] private readonly retryForever: boolean + private readonly requestTimeoutMs: number + private readonly maxRequestRetries: number + private readonly retryBackoffMs: number + private readonly maxRetryBackoffMs: number private endpointIndex = 0 private id = 1 private socket?: WebSocketLike private connecting?: Promise private pending = new Map() - private subscriptions = new Map() - - constructor(endpoint: string, fallbackEndpoints: string[] = [], retryForever = false) { + private subscriptions = new Set() + private subscriptionsById = new Map() + private closed = false + + constructor( + endpoint: string, + fallbackEndpoints: string[] = [], + retryForever = false, + options: JsonRpcTransportOptions = {}, + ) { this.endpoints = [endpoint, ...fallbackEndpoints.filter((item) => item !== endpoint)] this.retryForever = retryForever + this.requestTimeoutMs = nonNegativeNumber(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS) + this.maxRequestRetries = nonNegativeInteger(options.maxRequestRetries, DEFAULT_MAX_REQUEST_RETRIES) + this.retryBackoffMs = nonNegativeNumber(options.retryBackoffMs, DEFAULT_RETRY_BACKOFF_MS) + this.maxRetryBackoffMs = nonNegativeNumber(options.maxRetryBackoffMs, DEFAULT_MAX_RETRY_BACKOFF_MS) } get endpoint(): string { return this.endpoints[this.endpointIndex] } - async request(method: string, params: unknown[] = []): Promise { + async request(method: string, params: unknown[] = [], options: RpcRequestOptions = {}): Promise { + const requestOptions = { + ...options, + timeoutMs: options.timeoutMs ?? this.requestTimeoutMs, + } + throwIfAborted(requestOptions.signal) + const maxRetries = requestOptions.maxRetries ?? this.maxRequestRetries + const retryBackoffMs = requestOptions.retryBackoffMs ?? this.retryBackoffMs + const maxRetryBackoffMs = requestOptions.maxRetryBackoffMs ?? this.maxRetryBackoffMs + let attempt = 0 for (;;) { try { - return this.isHttpEndpoint() ? await this.httpRequest(method, params) : await this.wsRequest(method, params) + return this.isHttpEndpoint() + ? await this.httpRequest(method, params, requestOptions) + : await this.wsRequest(method, params, requestOptions) } catch (error) { - if (error instanceof JsonRpcError) throw error + if (error instanceof JsonRpcError || error instanceof RequestAbortedError) throw error + if (!this.retryForever && attempt >= maxRetries) throw error + attempt += 1 this.rotateEndpoint() - if (!this.retryForever) throw error - await delay(1000) + const capped = Math.min(retryBackoffMs * (2 ** Math.max(0, attempt - 1)), maxRetryBackoffMs) + await delay(capped, requestOptions.signal) } } } @@ -262,15 +337,30 @@ export class JsonRpcTransport { unsubscribeMethod: string, ): Promise & { unsubscribe(): Promise }> { if (this.isHttpEndpoint()) throw new ChainError('subscriptions require a WebSocket endpoint') - const subscription = (await this.request(subscribeMethod, params)) as string - const state: SubscriptionState = { queue: [], waiters: [], errors: [], closed: false } - this.subscriptions.set(subscription, state) + const state: SubscriptionState = { + queue: [], + waiters: [], + closed: false, + subscribeMethod, + params, + unsubscribeMethod, + } + this.subscriptions.add(state) + try { + await this.activateSubscription(state) + } catch (error) { + this.subscriptions.delete(state) + throw error + } const unsubscribe = async () => { if (state.closed) return state.closed = true - this.subscriptions.delete(subscription) - for (const waiter of state.waiters.splice(0)) waiter({ done: true, value: undefined }) - await this.request(unsubscribeMethod, [subscription]).catch(() => undefined) + this.subscriptions.delete(state) + if (state.subscription != null) this.subscriptionsById.delete(state.subscription) + for (const waiter of state.waiters.splice(0)) waiter.resolve({ done: true, value: undefined }) + const subscription = state.subscription + state.subscription = undefined + if (subscription != null) await this.request(unsubscribeMethod, [subscription]).catch(() => undefined) } return { unsubscribe, @@ -285,43 +375,75 @@ export class JsonRpcTransport { } close(): void { + this.closed = true this.socket?.close() this.socket = undefined this.connecting = undefined + this.failPending(new ChainError('transport closed')) + for (const state of [...this.subscriptions]) this.closeSubscription(state) } private isHttpEndpoint(): boolean { return this.endpoint.startsWith('http://') || this.endpoint.startsWith('https://') } - private async httpRequest(method: string, params: unknown[]): Promise { - const response = await fetch(this.endpoint, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: this.id++, method, params }), - }) - if (!response.ok) throw new JsonRpcError(`HTTP ${response.status} from ${this.endpoint}`) - const payload = await response.json() - if (payload.error) throw new JsonRpcError(payload.error.message, payload.error.code, payload.error.data) - return payload.result + private async httpRequest(method: string, params: unknown[], options: RpcRequestOptions): Promise { + const request = withRequestSignal(options) + try { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: this.id++, method, params }), + signal: request.signal, + }) + if (!response.ok) throw new JsonRpcError(`HTTP ${response.status} from ${this.endpoint}`) + const payload = await response.json() + if (payload.error) throw new JsonRpcError(payload.error.message, payload.error.code, payload.error.data) + return payload.result + } catch (error) { + throw normalizeAbortError(error, request) + } finally { + request.cleanup() + } } - private async wsRequest(method: string, params: unknown[]): Promise { - const socket = await this.connect() + private async wsRequest(method: string, params: unknown[], options: RpcRequestOptions): Promise { + const socket = await this.connect(options) const id = this.id++ const promise = new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }) + const request = withRequestSignal(options) + const cleanup = () => request.cleanup() + const fail = (error: Error) => { + if (!this.pending.delete(id)) return + cleanup() + reject(error) + } + this.pending.set(id, { + resolve(value) { + cleanup() + resolve(value) + }, + reject(error) { + cleanup() + reject(error) + }, + cleanup, + }) + request.onAbort((error) => fail(error)) }) try { socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })) } catch (error) { + const pending = this.pending.get(id) this.pending.delete(id) + pending?.cleanup() throw error } return promise } - private async connect(): Promise { + private async connect(options: RpcRequestOptions = {}): Promise { + if (this.closed) throw new ChainError('transport closed') if (this.socket?.readyState === 1) return this.socket if (this.connecting != null) return this.connecting @@ -331,19 +453,34 @@ export class JsonRpcTransport { } this.connecting = new Promise((resolve, reject) => { + const request = withRequestSignal(options) const socket = new WebSocketImpl(this.endpoint) + let settled = false + const cleanup = () => request.cleanup() const fail = (error: Error) => { + if (settled) return + settled = true + cleanup() this.socket = undefined this.connecting = undefined + try { + socket.close() + } catch { + // Ignore close errors while unwinding a failed connection. + } reject(error) } + request.onAbort((error) => fail(error)) socket.addEventListener('open', () => { + if (settled) return + settled = true + cleanup() this.socket = socket this.connecting = undefined resolve(socket) }) socket.addEventListener('error', () => fail(new ChainError(`could not connect to ${this.endpoint}`))) - socket.addEventListener('close', () => this.failPending(new ChainError(`connection closed: ${this.endpoint}`))) + socket.addEventListener('close', () => this.handleSocketClose(new ChainError(`connection closed: ${this.endpoint}`))) socket.addEventListener('message', (event) => this.handleMessage(event.data)) }) return this.connecting @@ -361,11 +498,11 @@ export class JsonRpcTransport { } const subscription = message.params?.subscription if (subscription == null) return - const state = this.subscriptions.get(subscription) + const state = this.subscriptionsById.get(subscription) if (state == null || state.closed) return const result = message.params?.result const waiter = state.waiters.shift() - if (waiter != null) waiter({ done: false, value: result }) + if (waiter != null) waiter.resolve({ done: false, value: result }) else state.queue.push(result) } @@ -373,27 +510,73 @@ export class JsonRpcTransport { if (state.queue.length > 0) return Promise.resolve({ done: false, value: state.queue.shift() }) if (state.closed) return Promise.resolve({ done: true, value: undefined }) return new Promise((resolve, reject) => { - state.waiters.push(resolve) - state.errors.push(reject) + state.waiters.push({ resolve, reject }) }) } private failPending(error: Error): void { this.socket = undefined this.connecting = undefined - for (const pending of this.pending.values()) pending.reject(error) + for (const pending of this.pending.values()) { + pending.cleanup() + pending.reject(error) + } this.pending.clear() - for (const state of this.subscriptions.values()) { - state.closed = true - for (const reject of state.errors.splice(0)) reject(error) - for (const waiter of state.waiters.splice(0)) waiter({ done: true, value: undefined }) + } + + private handleSocketClose(error: Error): void { + this.failPending(error) + this.subscriptionsById.clear() + if (this.closed) return + for (const state of this.subscriptions) { + state.subscription = undefined + void this.resubscribe(state) + } + } + + private async activateSubscription(state: SubscriptionState): Promise { + const subscription = String(await this.request(state.subscribeMethod, state.params)) + if (state.closed) { + await this.request(state.unsubscribeMethod, [subscription]).catch(() => undefined) + return + } + state.subscription = subscription + this.subscriptionsById.set(subscription, state) + } + + private resubscribe(state: SubscriptionState): Promise { + if (state.closed) return Promise.resolve() + state.resubscribing ??= this.activateSubscription(state) + .catch((error) => { + this.closeSubscription(state, error instanceof Error ? error : new ChainError(String(error))) + }) + .finally(() => { + state.resubscribing = undefined + }) + return state.resubscribing + } + + private closeSubscription(state: SubscriptionState, error?: Error): void { + if (state.closed) return + state.closed = true + this.subscriptions.delete(state) + if (state.subscription != null) this.subscriptionsById.delete(state.subscription) + state.subscription = undefined + for (const waiter of state.waiters.splice(0)) { + if (error == null) waiter.resolve({ done: true, value: undefined }) + else waiter.reject(error) } - this.subscriptions.clear() } private rotateEndpoint(): void { + const socket = this.socket this.socket = undefined this.connecting = undefined + try { + socket?.close() + } catch { + // Ignore close errors while rotating to another endpoint. + } if (this.endpoints.length > 1) this.endpointIndex = (this.endpointIndex + 1) % this.endpoints.length } } @@ -419,7 +602,12 @@ export class Client { const [label, endpoint] = resolveEndpoint(options.endpoint ?? network) this.network = label this.endpoint = endpoint - this.transport = new JsonRpcTransport(endpoint, options.fallbackEndpoints, options.retryForever) + this.transport = new JsonRpcTransport(endpoint, options.fallbackEndpoints, options.retryForever, { + requestTimeoutMs: options.requestTimeoutMs, + maxRequestRetries: options.maxRequestRetries, + retryBackoffMs: options.retryBackoffMs, + maxRetryBackoffMs: options.maxRetryBackoffMs, + }) this.balances = new BalancesNamespace(this) this.subnets = new SubnetsNamespace(this) this.neurons = new NeuronsNamespace(this) @@ -619,9 +807,7 @@ export class Client { const key = runtime.storageKey(moduleName, itemName, itemParams) const raw = await this.rpc('state_getStorage', [hex(key), ...(blockHash == null ? [] : [blockHash])]) const entry = runtime.storageEntry(moduleName, itemName) - const bytes = raw == null ? entry.defaultBytes : hexToBuffer(String(raw)) - if (bytes == null || bytes.length === 0) return undefined - return runtime.decode(entry.valueType, bytes, false) + return decodeStorageValue(runtime, entry, raw) } async queryBatch( @@ -642,8 +828,7 @@ export class Client { const entry = runtime.storageEntry(moduleName, itemName) return keys.map((key) => { const value = valueByKey.get(hex(key).toLowerCase()) - if (value == null) return undefined - return runtime.decode(entry.valueType, hexToBuffer(value), false) + return decodeStorageValue(runtime, entry, value) }) } @@ -1872,6 +2057,16 @@ async function read(client: Client, name: string, params: Record( + runtime: Runtime, + entry: StorageEntry, + value: unknown, +): T | undefined { + const bytes = value == null ? entry.defaultBytes : hexToBuffer(String(value)) + if (bytes == null || bytes.length === 0) return undefined + return runtime.decode(entry.valueType, bytes, false) +} + function firstProperty(value: unknown): unknown { return Array.isArray(value) ? value[0] : value } @@ -2070,8 +2265,85 @@ function feeFromEvent(event: unknown): Balance | undefined { return amount == null ? undefined : Balance.fromRao(String(amount)) } -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) +interface RequestSignal { + signal?: AbortSignal + cleanup(): void + onAbort(handler: (error: Error) => void): void + error(): Error +} + +function delay(ms: number, signal?: AbortSignal): Promise { + throwIfAborted(signal) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup() + resolve() + }, ms) + const abort = () => { + cleanup() + reject(new RequestAbortedError()) + } + const cleanup = () => { + clearTimeout(timer) + signal?.removeEventListener('abort', abort) + } + signal?.addEventListener('abort', abort, { once: true }) + }) +} + +function withRequestSignal(options: RpcRequestOptions): RequestSignal { + const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS + const controller = new AbortController() + let timedOut = false + let abortError: Error | undefined + let cleaned = false + const listeners = new Set<(error: Error) => void>() + const notify = (error: Error) => { + abortError = error + for (const listener of [...listeners]) listener(error) + } + const timeout = timeoutMs === 0 + ? undefined + : setTimeout(() => { + timedOut = true + controller.abort() + notify(new RequestTimeoutError(`request timed out after ${timeoutMs}ms`)) + }, timeoutMs) + const abort = () => { + controller.abort() + notify(new RequestAbortedError()) + } + if (options.signal?.aborted) abort() + else options.signal?.addEventListener('abort', abort, { once: true }) + + return { + signal: controller.signal, + cleanup() { + if (cleaned) return + cleaned = true + if (timeout != null) clearTimeout(timeout) + options.signal?.removeEventListener('abort', abort) + listeners.clear() + }, + onAbort(handler: (error: Error) => void) { + if (abortError != null) handler(abortError) + else listeners.add(handler) + }, + error() { + return abortError ?? (timedOut + ? new RequestTimeoutError(`request timed out after ${timeoutMs}ms`) + : new RequestAbortedError()) + }, + } +} + +function normalizeAbortError(error: unknown, request: RequestSignal): Error { + if (request.signal?.aborted) return request.error() + return error instanceof Error ? error : new Error(String(error)) +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw new RequestAbortedError() } function withTimeout(promise: Promise, timeoutMs: number): Promise { diff --git a/sdk/typescript-sdk/src/wallet.ts b/sdk/typescript-sdk/src/wallet.ts index 1f6d4aa8aa..554be4fdbe 100644 --- a/sdk/typescript-sdk/src/wallet.ts +++ b/sdk/typescript-sdk/src/wallet.ts @@ -23,9 +23,16 @@ export interface WalletOptions { export interface SaveKeyOptions { encrypt?: boolean overwrite?: boolean + keyfilePassword?: string | null + /** @deprecated Use keyfilePassword. */ password?: string | null } +export interface RegenerateKeyOptions extends SaveKeyOptions { + cryptoType?: number + mnemonicPassword?: string | null +} + export class Keyfile { readonly path: string readonly name: string @@ -48,7 +55,8 @@ export class Keyfile { } setKeypair(keypair: Keypair, options: SaveKeyOptions = {}): void { - const { encrypt = false, overwrite = false, password } = options + const { encrypt = false, overwrite = false } = options + const password = keyfilePassword(options) if (encrypt && password == null) { throw new Error(`Password is required to encrypt ${this.path}`) } @@ -138,35 +146,39 @@ export class Wallet { createNewColdkey(options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}): this { const keypair = Keypair.generate(options.cryptoType ?? CRYPTO_SR25519, options.nWords ?? 12) - return this.setColdkey(keypair, { ...options, encrypt: options.encrypt ?? options.password != null }) + return this.setColdkey(keypair, { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null }) } createNewHotkey(options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}): this { const keypair = Keypair.generate(options.cryptoType ?? CRYPTO_SR25519, options.nWords ?? 12) - return this.setHotkey(keypair, { ...options, encrypt: options.encrypt ?? options.password != null }) + return this.setHotkey(keypair, { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null }) } regenerateColdkey( mnemonic: string, - options: SaveKeyOptions & { cryptoType?: number; password?: string | null } = {}, + options: RegenerateKeyOptions = {}, ): this { return this.setColdkey( - Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519), - { ...options, encrypt: options.encrypt ?? options.password != null }, + Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519, options.mnemonicPassword), + { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null }, ) } regenerateHotkey( mnemonic: string, - options: SaveKeyOptions & { cryptoType?: number; password?: string | null } = {}, + options: RegenerateKeyOptions = {}, ): this { return this.setHotkey( - Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519), - { ...options, encrypt: options.encrypt ?? options.password != null }, + Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519, options.mnemonicPassword), + { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null }, ) } } +function keyfilePassword(options: SaveKeyOptions): string | null | undefined { + return options.keyfilePassword ?? options.password +} + function publicOnly(keypair: Keypair): Keypair { return new Keypair(keypair.ss58Address, keypair.publicKey, keypair.cryptoType, keypair.ss58Format) } diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 5ec86dc1de..497da22719 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -156,6 +156,82 @@ function fakeRuntimeCacheClient(options = {}) { } } +function waitFor(predicate, label = 'condition') { + return new Promise((resolve, reject) => { + let attempts = 0 + const tick = () => { + if (predicate()) { + resolve() + return + } + attempts += 1 + if (attempts > 200) { + reject(new Error(`timed out waiting for ${label}`)) + return + } + setTimeout(tick, 5) + } + tick() + }) +} + +function installFakeWebSocket() { + const original = globalThis.WebSocket + class FakeWebSocket { + static sockets = [] + static onSend = () => undefined + + readyState = 0 + sent = [] + listeners = new Map() + + constructor(url) { + this.url = url + FakeWebSocket.sockets.push(this) + queueMicrotask(() => this.open()) + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) ?? [] + listeners.push(listener) + this.listeners.set(type, listeners) + } + + send(data) { + const message = JSON.parse(String(data)) + this.sent.push(message) + FakeWebSocket.onSend(this, message) + } + + close() { + if (this.readyState === 3) return + this.readyState = 3 + this.emit('close', {}) + } + + open() { + if (this.readyState !== 0) return + this.readyState = 1 + this.emit('open', {}) + } + + serverMessage(message) { + this.emit('message', { data: JSON.stringify(message) }) + } + + emit(type, event) { + for (const listener of this.listeners.get(type) ?? []) listener(event) + } + } + globalThis.WebSocket = FakeWebSocket + return { + FakeWebSocket, + restore() { + globalThis.WebSocket = original + }, + } +} + test('package exposes a WASM browser subset without the Node native addon', () => { const root = path.join(__dirname, '..') const packageJson = JSON.parse( @@ -665,6 +741,104 @@ test('chain client surface is exported without Polkadot.js glue', () => { ]) }) +test('Balance numeric getters throw before losing precision', () => { + const small = core.Balance.fromTao('1.25') + assert.equal(small.amount, 1.25) + assert.equal(small.tao, 1.25) + assert.equal(small.amountString, '1.25') + assert.equal(small.taoString, '1.25') + + const alpha = core.Balance.fromRao('123000000000', 7, 'ALPHA') + assert.equal(alpha.alphaString, '123') + assert.equal(alpha.toString(), '123 ALPHA') + + const unsafe = core.Balance.fromRao((BigInt(Number.MAX_SAFE_INTEGER) + 1n).toString()) + assert.equal(unsafe.amountString, '9007199.254740992') + assert.throws(() => unsafe.amount, /safe integer precision/) + assert.throws(() => unsafe.tao, /safe integer precision/) +}) + +test('JsonRpcTransport restores websocket subscriptions after reconnect', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + let nextSubscription = 1 + FakeWebSocket.onSend = (socket, message) => { + if (message.method === 'chain_subscribeNewHeads') { + const subscription = `sub-${nextSubscription++}` + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: subscription })) + return + } + if (message.method === 'chain_unsubscribeNewHeads') { + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: true })) + } + } + + const transport = new core.JsonRpcTransport('ws://node-a', [], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + }) + const subscription = await transport.subscribe( + 'chain_subscribeNewHeads', + [], + 'chain_unsubscribeNewHeads', + ) + const iterator = subscription[Symbol.asyncIterator]() + FakeWebSocket.sockets[0].serverMessage({ + jsonrpc: '2.0', + method: 'chain_subscription', + params: { subscription: 'sub-1', result: { number: 1 } }, + }) + assert.deepEqual(await iterator.next(), { done: false, value: { number: 1 } }) + + FakeWebSocket.sockets[0].close() + await waitFor( + () => FakeWebSocket.sockets.length === 2 && + FakeWebSocket.sockets[1].sent.some((message) => message.method === 'chain_subscribeNewHeads'), + 'resubscribe after reconnect', + ) + FakeWebSocket.sockets[1].serverMessage({ + jsonrpc: '2.0', + method: 'chain_subscription', + params: { subscription: 'sub-2', result: { number: 2 } }, + }) + assert.deepEqual(await iterator.next(), { done: false, value: { number: 2 } }) + + await subscription.unsubscribe() +}) + +test('JsonRpcTransport bounds retries and supports request cancellation', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + let requests = 0 + FakeWebSocket.onSend = () => { + requests += 1 + } + const transport = new core.JsonRpcTransport('ws://node-a', [], false, { + requestTimeoutMs: 5, + maxRequestRetries: 1, + retryBackoffMs: 1, + maxRetryBackoffMs: 1, + }) + + await assert.rejects( + () => transport.request('state_getMetadata'), + (error) => error.name === 'RequestTimeoutError', + ) + assert.equal(requests, 2) + + const controller = new AbortController() + const pending = transport.request('state_getMetadata', [], { + signal: controller.signal, + timeoutMs: 1_000, + maxRetries: 0, + }) + controller.abort() + await assert.rejects( + pending, + (error) => error.name === 'RequestAbortedError', + ) +}) + test('Client expires head runtime metadata and invalidates it on runtime upgrade', async () => { const { client, calls, setHeadVersion } = fakeRuntimeCacheClient({ headRuntimeTtlMs: 1_000, @@ -719,6 +893,41 @@ test('Client caches historical runtimes by block hash with LRU eviction', async assert.deepEqual(calls.metadata, [blockA, blockB, blockC]) }) +test('Client queryBatch decodes metadata defaults for missing storage values', async () => { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const keys = [Buffer.from([1]), Buffer.from([2])] + const runtime = { + storageKeyBatch() { + return keys + }, + storageEntry() { + return { + pallet: 'Example', + name: 'Value', + prefix: 'Example', + modifier: 'Default', + valueType: 'u8', + valueTypeId: 0, + paramTypes: [], + paramTypeIds: [], + paramHashers: [], + defaultBytes: Buffer.from([9]), + } + }, + decode(_type, bytes) { + return bytes[0] + }, + } + client.runtimeAt = async () => runtime + client.resolveBlockHash = async () => null + client.rpc = async (method) => { + assert.equal(method, 'state_queryStorageAt') + return [{ changes: [['0x01', '0x05']] }] + } + + assert.deepEqual(await client.queryBatch('Example', 'Value', [[], []]), [5, 9]) +}) + test('Client signs extrinsics with extension-style signRaw signers', async () => { const callData = Buffer.from([5, 6, 7]) const { runtime, captures } = fakeSigningRuntime() @@ -838,13 +1047,14 @@ test('Client generates RFC-0078 proof for Ledger metadata-verifying signers', as assert.equal(captures.encoded.params.metadataHashEnabled, true) }) -test('hotkey helpers encrypt keyfiles when a password is provided', (t) => { +test('wallet helpers keep mnemonic and keyfile passwords separate', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bittensor-wallet-')) t.after(() => fs.rmSync(root, { recursive: true, force: true })) - const password = 'review-password' + const keyfilePassword = 'review-keyfile-password' + const mnemonicPassword = 'review-mnemonic-password' const created = new core.Wallet({ name: 'created', hotkey: 'default', path: root }) - created.createNewHotkey({ password }) + created.createNewHotkey({ keyfilePassword }) assert.equal( core.keyfileDataIsEncrypted(fs.readFileSync(created.hotkeyFile.path)), true, @@ -853,11 +1063,30 @@ test('hotkey helpers encrypt keyfiles when a password is provided', (t) => { const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' const regenerated = new core.Wallet({ name: 'regenerated', hotkey: 'default', path: root }) - regenerated.regenerateHotkey(mnemonic, { password }) + regenerated.regenerateHotkey(mnemonic, { mnemonicPassword, keyfilePassword }) assert.equal( core.keyfileDataIsEncrypted(fs.readFileSync(regenerated.hotkeyFile.path)), true, ) + assert.deepEqual( + regenerated.getHotkey(keyfilePassword).publicKey, + core.Keypair.fromMnemonic(mnemonic, core.CRYPTO_SR25519, mnemonicPassword).publicKey, + ) + assert.notDeepEqual( + regenerated.getHotkey(keyfilePassword).publicKey, + core.Keypair.fromMnemonic(mnemonic, core.CRYPTO_SR25519).publicKey, + ) + + const cold = new core.Wallet({ name: 'regenerated-cold', hotkey: 'default', path: root }) + cold.regenerateColdkey(mnemonic, { mnemonicPassword, keyfilePassword }) + assert.equal( + core.keyfileDataIsEncrypted(fs.readFileSync(cold.coldkeyFile.path)), + true, + ) + assert.deepEqual( + cold.getColdkey(keyfilePassword).publicKey, + core.Keypair.fromMnemonic(mnemonic, core.CRYPTO_SR25519, mnemonicPassword).publicKey, + ) }) test('arbitrary StorageInfo helpers call Rust directly', () => { From 423b73be1ff96f68c18af29020076fb3b1afc152 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 16:35:56 -0700 Subject: [PATCH 26/72] try fix e2e ts again --- .github/workflows/typescript-e2e.yml | 52 +++++++++++++++++++- sdk/typescript-sdk/scripts/browser-smoke.cjs | 4 ++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index c484a26ff4..f9537c4bf5 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -103,7 +103,7 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ - build-essential chromium clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config + build-essential clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config - name: Install Rust uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable @@ -119,6 +119,56 @@ jobs: with: node-version-file: ts-tests/.nvmrc + - name: Install Chromium + run: | + set -euo pipefail + + resolve_chrome() { + for candidate in "${CHROME_BIN:-}" "${CHROMIUM_BIN:-}" chromium chromium-browser google-chrome google-chrome-stable /usr/bin/chromium /usr/bin/chromium-browser /snap/bin/chromium /opt/google/chrome/chrome; do + [ -n "$candidate" ] || continue + resolved="" + if [ -x "$candidate" ]; then + resolved="$candidate" + elif resolved="$(command -v "$candidate" 2>/dev/null)"; then + : + fi + if [ -n "$resolved" ] && "$resolved" --version >/dev/null 2>&1; then + printf '%s\n' "$resolved" + return 0 + fi + done + return 1 + } + + install_chromium_package() { + sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update + for package in chromium chromium-browser; do + if apt-cache show "$package" >/dev/null 2>&1; then + sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ + -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ + "$package" || true + if resolve_chrome >/dev/null; then + return 0 + fi + fi + done + return 1 + } + + if ! chrome_bin="$(resolve_chrome)"; then + if ! install_chromium_package; then + echo "::error::Unable to install a runnable Chromium browser for TypeScript SDK browser tests" + exit 1 + fi + if ! chrome_bin="$(resolve_chrome)"; then + echo "::error::Chromium install completed but no runnable browser executable was found" + exit 1 + fi + fi + + "$chrome_bin" --version + echo "CHROME_BIN=$chrome_bin" >> "$GITHUB_ENV" + - name: Install wasm-pack run: | cargo install --locked --version 0.13.1 --root "$RUNNER_TEMP/wasm-pack" --force wasm-pack diff --git a/sdk/typescript-sdk/scripts/browser-smoke.cjs b/sdk/typescript-sdk/scripts/browser-smoke.cjs index f3a1c7c727..2d8396355a 100644 --- a/sdk/typescript-sdk/scripts/browser-smoke.cjs +++ b/sdk/typescript-sdk/scripts/browser-smoke.cjs @@ -213,6 +213,10 @@ function findChrome() { 'chromium-browser', 'google-chrome', 'google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', + '/opt/google/chrome/chrome', ].filter(Boolean) for (const candidate of candidates) { const result = spawnSync(candidate, ['--version'], { stdio: 'ignore' }) From cca254e141f77b2fde24d16760b8f389d94b514e Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 16:46:08 -0700 Subject: [PATCH 27/72] fix nonce --- sdk/typescript-sdk/package.json | 4 +- sdk/typescript-sdk/src/client.ts | 209 ++++++++++++++++++++----- sdk/typescript-sdk/test/basic.test.cjs | 196 ++++++++++++++++++++++- 3 files changed, 365 insertions(+), 44 deletions(-) diff --git a/sdk/typescript-sdk/package.json b/sdk/typescript-sdk/package.json index 8932271a90..762aa19b8d 100644 --- a/sdk/typescript-sdk/package.json +++ b/sdk/typescript-sdk/package.json @@ -21,9 +21,7 @@ }, "./browser": { "types": "./dist/browser.d.ts", - "import": "./dist/browser.mjs", - "require": "./dist/browser.js", - "default": "./dist/browser.mjs" + "import": "./dist/browser.mjs" }, "./native": { "node": { diff --git a/sdk/typescript-sdk/src/client.ts b/sdk/typescript-sdk/src/client.ts index ec696c897f..f30639c67e 100644 --- a/sdk/typescript-sdk/src/client.ts +++ b/sdk/typescript-sdk/src/client.ts @@ -33,6 +33,9 @@ export interface ClientOptions { fallbackEndpoints?: string[] retryForever?: boolean autoConnect?: boolean + webSocket?: WebSocketConstructor + webSocketConstructor?: WebSocketConstructor + webSocketFactory?: WebSocketFactory headRuntimeTtlMs?: number historicalRuntimeCacheSize?: number requestTimeoutMs?: number @@ -50,6 +53,9 @@ export interface RpcRequestOptions { } export interface JsonRpcTransportOptions { + webSocket?: WebSocketConstructor + webSocketConstructor?: WebSocketConstructor + webSocketFactory?: WebSocketFactory requestTimeoutMs?: number maxRequestRetries?: number retryBackoffMs?: number @@ -154,6 +160,7 @@ export interface ExtrinsicResult { export interface SignedExtrinsicResult extends SignedExtrinsic { hex: string signerAddress: string + nonce: number } export interface ExtrinsicWatcher { @@ -221,19 +228,40 @@ interface HeadRuntimeCacheEntry extends RuntimeCacheEntry { expiresAtMs: number } +type NonceStatus = 'reserved' | 'submitted' | 'confirmed' | 'failed' | 'reusable' + +interface NonceAccountState { + next?: number + reusable: number[] + statuses: Map + queue: Promise +} + +interface NonceReservation { + address: string + nonce: number +} + +const MANAGED_NONCE = Symbol('managedNonce') + +type ManagedSignedExtrinsicResult = SignedExtrinsicResult & { + [MANAGED_NONCE]?: NonceReservation +} + interface NormalizedSignature { signature: Buffer cryptoType: number } -type WebSocketLike = { +export type WebSocketLike = { readyState: number send(data: string): void close(): void addEventListener(type: string, listener: (event: { data?: unknown }) => void): void } -type WebSocketConstructor = new (url: string) => WebSocketLike +export type WebSocketConstructor = new (url: string) => WebSocketLike +export type WebSocketFactory = (url: string) => WebSocketLike export class JsonRpcError extends Error { readonly code?: number @@ -278,6 +306,8 @@ export class JsonRpcTransport { private readonly maxRequestRetries: number private readonly retryBackoffMs: number private readonly maxRetryBackoffMs: number + private readonly webSocketConstructor?: WebSocketConstructor + private readonly webSocketFactory?: WebSocketFactory private endpointIndex = 0 private id = 1 private socket?: WebSocketLike @@ -299,6 +329,8 @@ export class JsonRpcTransport { this.maxRequestRetries = nonNegativeInteger(options.maxRequestRetries, DEFAULT_MAX_REQUEST_RETRIES) this.retryBackoffMs = nonNegativeNumber(options.retryBackoffMs, DEFAULT_RETRY_BACKOFF_MS) this.maxRetryBackoffMs = nonNegativeNumber(options.maxRetryBackoffMs, DEFAULT_MAX_RETRY_BACKOFF_MS) + this.webSocketConstructor = options.webSocketConstructor ?? options.webSocket + this.webSocketFactory = options.webSocketFactory } get endpoint(): string { @@ -447,14 +479,17 @@ export class JsonRpcTransport { if (this.socket?.readyState === 1) return this.socket if (this.connecting != null) return this.connecting - const WebSocketImpl = (globalThis as unknown as { WebSocket?: WebSocketConstructor }).WebSocket - if (WebSocketImpl == null) { - throw new ChainError('WebSocket is not available; use Node 20.17+ or pass an HTTP endpoint') - } - this.connecting = new Promise((resolve, reject) => { const request = withRequestSignal(options) - const socket = new WebSocketImpl(this.endpoint) + let socket: WebSocketLike + try { + socket = this.createWebSocket(this.endpoint) + } catch (error) { + request.cleanup() + this.connecting = undefined + reject(error) + return + } let settled = false const cleanup = () => request.cleanup() const fail = (error: Error) => { @@ -486,6 +521,19 @@ export class JsonRpcTransport { return this.connecting } + private createWebSocket(url: string): WebSocketLike { + if (this.webSocketFactory != null) return this.webSocketFactory(url) + const WebSocketImpl = + this.webSocketConstructor ?? + (globalThis as unknown as { WebSocket?: WebSocketConstructor }).WebSocket + if (WebSocketImpl == null) { + throw new ChainError( + 'WebSocket is not available; pass webSocketFactory or webSocketConstructor, or use an HTTP endpoint', + ) + } + return new WebSocketImpl(url) + } + private handleMessage(data: unknown): void { const message = JSON.parse(String(data)) if (typeof message.id === 'number') { @@ -596,13 +644,16 @@ export class Client { private runtimesBySpecVersion = new Map() private historicalRuntimeCache = new Map() private genesis?: string - private nonceCache = new Map() + private nonceAccounts = new Map() constructor(network: string = 'finney', options: ClientOptions = {}) { const [label, endpoint] = resolveEndpoint(options.endpoint ?? network) this.network = label this.endpoint = endpoint this.transport = new JsonRpcTransport(endpoint, options.fallbackEndpoints, options.retryForever, { + webSocket: options.webSocket, + webSocketConstructor: options.webSocketConstructor, + webSocketFactory: options.webSocketFactory, requestTimeoutMs: options.requestTimeoutMs, maxRequestRetries: options.maxRequestRetries, retryBackoffMs: options.retryBackoffMs, @@ -1064,13 +1115,13 @@ export class Client { async signExtrinsic(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { let resolved: ResolvedSigner | undefined - let shouldClearNonce = false + let reservation: NonceReservation | undefined try { const runtime = await this.runtimeAt() const callData = await this.callData(call) resolved = await this.resolveSigner(signer, runtime) - const nonce = options.nonce ?? (await this.accountNextIndex(resolved.ss58Address)) - shouldClearNonce = options.nonce == null + reservation = options.nonce == null ? await this.reserveNonce(resolved.ss58Address) : undefined + const nonce = options.nonce ?? reservation!.nonce const period = options.period === undefined ? DEFAULT_ERA_PERIOD : options.period const { era, eraBlockHash } = await this.normalizeEra(period) const tip = balanceRao(options.tip ?? 0) @@ -1128,23 +1179,23 @@ export class Client { tipAssetId, metadataHashEnabled: metadataHash != null, }) - return { ...signed, hex: hex(signed.bytes), signerAddress: resolved.ss58Address } + const result: ManagedSignedExtrinsicResult = { + ...signed, + hex: hex(signed.bytes), + signerAddress: resolved.ss58Address, + nonce, + } + if (reservation != null) result[MANAGED_NONCE] = reservation + return result } catch (error) { - if (shouldClearNonce && resolved != null) this.clearNonce(resolved.ss58Address) + if (reservation != null) await this.failNonce(reservation, true) throw error } } async submit(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { - let signerAddress = staticSignerAddress(signer) - try { - const signed = await this.signExtrinsic(call, signer, options) - signerAddress = signed.signerAddress - return await this.submitSigned(signed, signed.signerAddress, options) - } catch (error) { - if (signerAddress != null) this.clearNonce(signerAddress) - throw error - } + const signed = await this.signExtrinsic(call, signer, options) + return this.submitSigned(signed, signed.signerAddress, options) } async submitSigned( @@ -1156,17 +1207,25 @@ export class Client { ? toBuffer(extrinsic, 'extrinsic') : toBuffer(extrinsic.bytes, 'extrinsic.bytes') const extrinsicHash = hex(blake2_256(bytes)) + const reservation = managedNonceReservation(extrinsic) + let submitted = false try { if (!options.waitForInclusion && !options.waitForFinalization) { - const submitted = String(await this.rpc('author_submitExtrinsic', [hex(bytes)])) - return { success: true, message: 'Submitted', extrinsicHash: submitted, events: [] } + const hash = String(await this.rpc('author_submitExtrinsic', [hex(bytes)])) + submitted = true + if (reservation != null) await this.submitNonce(reservation) + return { success: true, message: 'Submitted', extrinsicHash: hash, events: [] } } const watcher = await this.watchSigned(bytes, { waitForFinalization: options.waitForFinalization ?? false, }) - return await watcher.result + submitted = true + if (reservation != null) await this.submitNonce(reservation) + const result = await watcher.result + if (reservation != null) await this.confirmNonce(reservation) + return result } catch (error) { - if (signerAddress != null) this.clearNonce(signerAddress) + if (reservation != null) await this.failNonce(reservation, !submitted || nonceReusableAfterSubmissionError(error)) throw error } } @@ -1195,26 +1254,84 @@ export class Client { } } - async accountNextIndex(address: string, useCache = true): Promise { - if (useCache && this.nonceCache.has(address)) { - const nonce = this.nonceCache.get(address)! - this.nonceCache.set(address, nonce + 1) - return nonce - } + async peekNextIndex(address: string): Promise { const nonce = Number(await this.rpc('system_accountNextIndex', [address])) - this.nonceCache.set(address, nonce + 1) return nonce } + async accountNextIndex(address: string, useCache = true): Promise { + if (!useCache) return this.peekNextIndex(address) + return (await this.reserveNonce(address)).nonce + } + clearNonce(address: string): void { - this.nonceCache.delete(address) + this.nonceAccounts.delete(address) + } + + private async reserveNonce(address: string): Promise { + return this.withNonceAccount(address, async (state) => { + if (state.next == null) state.next = await this.peekNextIndex(address) + const nonce = state.reusable.shift() ?? state.next + if (nonce === state.next) state.next += 1 + state.statuses.set(nonce, 'reserved') + return { address, nonce } + }) + } + + private async submitNonce(reservation: NonceReservation): Promise { + await this.withNonceAccount(reservation.address, (state) => { + state.statuses.set(reservation.nonce, 'submitted') + }) + } + + private async confirmNonce(reservation: NonceReservation): Promise { + await this.withNonceAccount(reservation.address, (state) => { + state.statuses.set(reservation.nonce, 'confirmed') + pruneNonceStatuses(state) + }) + } + + private async failNonce(reservation: NonceReservation, reusable: boolean): Promise { + await this.withNonceAccount(reservation.address, (state) => { + const current = state.statuses.get(reservation.nonce) + if (current === 'confirmed') return + state.statuses.set(reservation.nonce, reusable ? 'reusable' : 'failed') + if (reusable && !state.reusable.includes(reservation.nonce)) { + state.reusable.push(reservation.nonce) + state.reusable.sort((left, right) => left - right) + } + pruneNonceStatuses(state) + }) + } + + private withNonceAccount( + address: string, + operation: (state: NonceAccountState) => T | Promise, + ): Promise { + const state = this.nonceAccount(address) + const run = state.queue.then(() => operation(state), () => operation(state)) + state.queue = run.then(() => undefined, () => undefined) + return run + } + + private nonceAccount(address: string): NonceAccountState { + let state = this.nonceAccounts.get(address) + if (state == null) { + state = { + reusable: [], + statuses: new Map(), + queue: Promise.resolve(), + } + this.nonceAccounts.set(address, state) + } + return state } async estimateFee(call: CallLike, signer: SignerLike): Promise { const runtime = await this.runtimeAt() const account = await this.resolveSigner(signer, runtime) const signed = await this.signExtrinsic(call, signer, { - nonce: await this.accountNextIndex(account.ss58Address, false), + nonce: await this.peekNextIndex(account.ss58Address), period: null, }) const length = Buffer.alloc(4) @@ -2124,6 +2241,26 @@ function staticSignerAddress(signer: unknown): string | undefined { return stringValue(value.ss58Address) ?? stringValue(value.address) } +function managedNonceReservation(extrinsic: unknown): NonceReservation | undefined { + if (extrinsic == null || typeof extrinsic !== 'object') return undefined + if (Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array) return undefined + return (extrinsic as ManagedSignedExtrinsicResult)[MANAGED_NONCE] +} + +function nonceReusableAfterSubmissionError(error: unknown): boolean { + if (error instanceof JsonRpcError) return true + if (!(error instanceof ChainError)) return false + return /\b(dropped|invalid)\b/i.test(error.message) +} + +function pruneNonceStatuses(state: NonceAccountState): void { + if (state.statuses.size <= 512) return + for (const [nonce, status] of state.statuses) { + if (state.statuses.size <= 256) break + if (status === 'confirmed' || status === 'failed') state.statuses.delete(nonce) + } +} + function stringValue(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined } diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 497da22719..71cceee895 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -2,9 +2,11 @@ const assert = require('node:assert/strict') const fs = require('node:fs') +const { createRequire } = require('node:module') const os = require('node:os') const path = require('node:path') const test = require('node:test') +const { pathToFileURL } = require('node:url') const core = require('../dist/index.js') @@ -82,12 +84,12 @@ function fakeSigningClient(runtime, callData) { const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) client.runtimeAt = async () => runtime client.callData = async () => Buffer.from(callData) - client.accountNextIndex = async (address) => { - client.lastNonceAddress = address - return 12 - } client.genesisHash = async () => `0x${'41'.repeat(32)}` - client.rpc = async (method) => { + client.rpc = async (method, params = []) => { + if (method === 'system_accountNextIndex') { + client.lastNonceAddress = params[0] + return 12 + } if (method === 'state_getRuntimeVersion') { return { specName: 'node-subtensor', @@ -242,6 +244,7 @@ test('package exposes a WASM browser subset without the Node native addon', () = assert.equal(Object.prototype.hasOwnProperty.call(packageJson.exports['.'], 'browser'), false) assert.equal(packageJson.exports['.'].node.import, './dist/index.mjs') assert.equal(packageJson.exports['./browser'].import, './dist/browser.mjs') + assert.equal(Object.prototype.hasOwnProperty.call(packageJson.exports['./browser'], 'require'), false) assert.equal(packageJson.exports['./native'].node.import, './native.cjs') const browserSource = fs.readFileSync(path.join(root, 'dist', 'browser.js'), 'utf8') @@ -250,6 +253,33 @@ test('package exposes a WASM browser subset without the Node native addon', () = assert.equal(browserSource.includes('.node'), false) }) +test('browser package subpath is ESM-only for package consumers', async (t) => { + const packageRoot = path.join(__dirname, '..') + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bittensor-sdk-package-')) + t.after(() => fs.rmSync(temp, { recursive: true, force: true })) + const scope = path.join(temp, 'node_modules', '@bittensor') + fs.mkdirSync(scope, { recursive: true }) + fs.symlinkSync(packageRoot, path.join(scope, 'sdk'), 'dir') + + const consumerRequire = createRequire(path.join(temp, 'consumer.cjs')) + assert.throws( + () => consumerRequire('@bittensor/sdk/browser'), + (error) => error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' || error.code === 'ERR_REQUIRE_ESM', + ) + + const consumer = path.join(temp, 'consumer.mjs') + fs.writeFileSync( + consumer, + [ + "import * as browser from '@bittensor/sdk/browser'", + "export const ok = typeof browser.initBrowser === 'function' && typeof browser.Keypair === 'function'", + '', + ].join('\n'), + ) + const imported = await import(`${pathToFileURL(consumer).href}?${Date.now()}`) + assert.equal(imported.ok, true) +}) + test('browser Runtime exposes WASM codec, call, storage, and extrinsic helpers', async () => { const browser = require('../dist/browser.js') @@ -839,6 +869,38 @@ test('JsonRpcTransport bounds retries and supports request cancellation', async ) }) +test('Client accepts an injected WebSocket factory when no global WebSocket exists', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + restore() + const original = globalThis.WebSocket + globalThis.WebSocket = undefined + t.after(() => { + globalThis.WebSocket = original + }) + + const urls = [] + FakeWebSocket.onSend = (socket, message) => { + queueMicrotask(() => socket.serverMessage({ + jsonrpc: '2.0', + id: message.id, + result: '0x1234', + })) + } + + const client = new core.Client('local', { + endpoint: 'ws://node-a', + webSocketFactory(url) { + urls.push(url) + return new FakeWebSocket(url) + }, + requestTimeoutMs: 100, + maxRequestRetries: 0, + }) + + assert.equal(await client.rpc('state_getMetadata'), '0x1234') + assert.deepEqual(urls, ['ws://node-a']) +}) + test('Client expires head runtime metadata and invalidates it on runtime upgrade', async () => { const { client, calls, setHeadVersion } = fakeRuntimeCacheClient({ headRuntimeTtlMs: 1_000, @@ -963,6 +1025,130 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => assert.equal(captures.encoded.params.metadataHashEnabled, false) }) +test('Client estimateFee peeks the chain nonce without reserving it', async () => { + const callData = Buffer.from([9, 8, 7]) + const { runtime, captures } = fakeSigningRuntime({ + runtimeApis() { + return { + TransactionPaymentApi: { + query_info: { + outputTypeId: 1, + }, + }, + } + }, + decodeTypeId() { + return { partial_fee: 123n } + }, + }) + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 8) + const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 4), + ]) + const nonceReads = [] + client.rpc = async (method, params = []) => { + if (method === 'system_accountNextIndex') { + nonceReads.push(params[0]) + return 7 + } + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + if (method === 'state_call') return '0x00' + throw new Error(`unexpected RPC ${method}`) + } + const signer = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } + + const fee = await client.estimateFee(callData, signer) + const firstRealNonce = await client.accountNextIndex(address) + const secondRealNonce = await client.accountNextIndex(address) + + assert.equal(fee.rao, 123n) + assert.equal(captures.payloadParams.nonce, 7) + assert.equal(firstRealNonce, 7) + assert.equal(secondRealNonce, 8) + assert.deepEqual(nonceReads, [address, address]) +}) + +test('Client serializes concurrent initial nonce reservations', async () => { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const address = core.ss58FromPublic(Buffer.alloc(32, 11), 42) + let reads = 0 + let releaseRead + const readGate = new Promise((resolve) => { + releaseRead = resolve + }) + client.rpc = async (method, params = []) => { + assert.equal(method, 'system_accountNextIndex') + assert.equal(params[0], address) + reads += 1 + await readGate + return 14 + } + + const first = client.accountNextIndex(address) + const second = client.accountNextIndex(address) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(reads, 1) + releaseRead() + + assert.deepEqual(await Promise.all([first, second]), [14, 15]) + assert.equal(reads, 1) +}) + +test('Client releases only the failed reserved nonce', async () => { + const callData = Buffer.from([3, 2, 1]) + const { runtime } = fakeSigningRuntime() + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 12) + const address = core.ss58FromPublic(publicKey, 42) + let releaseSign + let signStarted + const signGate = new Promise((resolve) => { + releaseSign = resolve + }) + const started = new Promise((resolve) => { + signStarted = resolve + }) + const signer = { + address, + publicKey, + async signRaw() { + signStarted() + await signGate + throw new Error('signer declined') + }, + } + + const signing = client.signExtrinsic(callData, signer, { period: null }) + await started + const independentlyReserved = await client.accountNextIndex(address) + releaseSign() + await assert.rejects(signing, /signer declined/) + const reusable = await client.accountNextIndex(address) + const next = await client.accountNextIndex(address) + + assert.equal(independentlyReserved, 13) + assert.equal(reusable, 12) + assert.equal(next, 14) +}) + test('Client generates RFC-0078 proof for Ledger metadata-verifying signers', async () => { const vector = ledgerProofVector() const metadataBytes = goldenMetadataBytes() From d5fbc3bc5b9fd554905c042969b989b899c61acc Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 16:48:59 -0700 Subject: [PATCH 28/72] fix e2e --- .github/workflows/typescript-e2e.yml | 39 ++++++++++++++++------------ 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index f9537c4bf5..f8b0f6bbab 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -119,7 +119,7 @@ jobs: with: node-version-file: ts-tests/.nvmrc - - name: Install Chromium + - name: Install browser for TypeScript SDK browser tests run: | set -euo pipefail @@ -140,28 +140,35 @@ jobs: return 1 } - install_chromium_package() { + install_google_chrome() { + arch="$(dpkg --print-architecture)" + if [ "$arch" != "amd64" ]; then + echo "::error::No supported non-snap browser installer is configured for architecture $arch" + return 1 + fi sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update - for package in chromium chromium-browser; do - if apt-cache show "$package" >/dev/null 2>&1; then - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ - -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ - "$package" || true - if resolve_chrome >/dev/null; then - return 0 - fi - fi - done - return 1 + sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ + -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ + ca-certificates curl gnupg + sudo install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://dl.google.com/linux/linux_signing_key.pub \ + | sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/google-linux-signing-keyring.gpg + sudo chmod 0644 /etc/apt/keyrings/google-linux-signing-keyring.gpg + echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-linux-signing-keyring.gpg] https://dl.google.com/linux/chrome/deb/ stable main" \ + | sudo tee /etc/apt/sources.list.d/google-chrome.list >/dev/null + sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update + sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ + -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ + google-chrome-stable } if ! chrome_bin="$(resolve_chrome)"; then - if ! install_chromium_package; then - echo "::error::Unable to install a runnable Chromium browser for TypeScript SDK browser tests" + if ! install_google_chrome; then + echo "::error::Unable to install a runnable browser for TypeScript SDK browser tests" exit 1 fi if ! chrome_bin="$(resolve_chrome)"; then - echo "::error::Chromium install completed but no runnable browser executable was found" + echo "::error::Browser install completed but no runnable executable was found" exit 1 fi fi From 0351315be665b057c71fae99ed16b8f37bee589e Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 16:58:11 -0700 Subject: [PATCH 29/72] Keep sp-core/std out of the portable WASM --- sdk/bittensor-core/Cargo.toml | 2 +- sdk/bittensor-core/src/keys/base58.rs | 51 +++++++++++++++++++++++++++ sdk/bittensor-core/src/keys/mod.rs | 35 +++++++++++++----- 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/sdk/bittensor-core/Cargo.toml b/sdk/bittensor-core/Cargo.toml index be5cb3b916..2e1677f62a 100644 --- a/sdk/bittensor-core/Cargo.toml +++ b/sdk/bittensor-core/Cargo.toml @@ -33,7 +33,7 @@ serde = { version = "1.0.132", features = ["derive"] } serde_json = { version = "1.0.132", features = ["arbitrary_precision"] } sha2 = "0.10.8" sodiumoxide = { version = "0.2", default-features = false, features = ["std"], optional = true } -sp-core = { workspace = true, features = ["std", "full_crypto"] } +sp-core = { workspace = true, default-features = false, features = ["full_crypto"] } subtensor-macros.workspace = true zeroize = "1" diff --git a/sdk/bittensor-core/src/keys/base58.rs b/sdk/bittensor-core/src/keys/base58.rs index 1b6369dbd9..793a5c9373 100644 --- a/sdk/bittensor-core/src/keys/base58.rs +++ b/sdk/bittensor-core/src/keys/base58.rs @@ -79,6 +79,37 @@ pub fn base58_encode(input: &[u8]) -> String { out } +fn alphabet_index(byte: u8) -> Option { + ALPHABET + .iter() + .position(|&item| item == byte) + .map(|index| index as u8) +} + +/// Base58-decode `input` (bitcoin alphabet). +pub fn base58_decode(input: &str) -> Option> { + let zeros = input.bytes().take_while(|&b| b == b'1').count(); + let mut bytes: Vec = Vec::with_capacity(input.len()); + + for digit in input.bytes().map(alphabet_index) { + let mut carry = u32::from(digit?); + for byte in bytes.iter_mut().rev() { + carry += u32::from(*byte) * 58; + *byte = (carry & 0xff) as u8; + carry >>= 8; + } + while carry > 0 { + bytes.insert(0, (carry & 0xff) as u8); + carry >>= 8; + } + } + + let mut out = Vec::with_capacity(zeros + bytes.len()); + out.resize(zeros, 0); + out.extend(bytes); + Some(out) +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] @@ -118,4 +149,24 @@ mod tests { } } } + + #[test] + fn decode_matches_bs58_reference() { + let cases = [ + "", + "1", + "111", + "2", + "z", + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + ]; + for case in cases { + assert_eq!( + base58_decode(case).unwrap(), + bs58::decode(case).into_vec().unwrap(), + ); + } + assert!(base58_decode("0").is_none()); + assert!(base58_decode("O").is_none()); + } } diff --git a/sdk/bittensor-core/src/keys/mod.rs b/sdk/bittensor-core/src/keys/mod.rs index bd05e5cef4..007fe4e52d 100644 --- a/sdk/bittensor-core/src/keys/mod.rs +++ b/sdk/bittensor-core/src/keys/mod.rs @@ -14,8 +14,8 @@ use sodiumoxide::crypto::box_; use sodiumoxide::crypto::sealedbox; #[cfg(feature = "host")] use sodiumoxide::crypto::sign::ed25519 as sign_ed25519; -use sp_core::crypto::{AccountId32, Pair as PairT, Ss58Codec}; -use sp_core::{ed25519, sr25519, ByteArray}; +use sp_core::crypto::Pair as PairT; +use sp_core::{ByteArray, ed25519, sr25519}; use zeroize::Zeroizing; use crate::error::CoreError; @@ -46,9 +46,29 @@ fn as_bytes>(value: &T) -> Vec { } pub fn public_key_from_ss58(ss58_address: &str) -> Result<[u8; 32], CoreError> { - let account = AccountId32::from_ss58check(ss58_address) - .map_err(|e| crypto_err(format!("invalid ss58 address: {e:?}")))?; - Ok(account.into()) + let decoded = base58::base58_decode(ss58_address) + .ok_or_else(|| crypto_err("invalid ss58 address: invalid base58"))?; + if decoded.len() != 35 && decoded.len() != 36 { + return Err(crypto_err("invalid ss58 address length")); + } + let prefix_len = match decoded[0] & 0b1100_0000 { + 0b0000_0000 => 1, + 0b0100_0000 => 2, + _ => return Err(crypto_err("invalid ss58 address prefix")), + }; + if decoded.len() != prefix_len + 32 + 2 { + return Err(crypto_err("invalid ss58 account length")); + } + let body_end = prefix_len + 32; + let mut checksum_input = Vec::with_capacity(7 + body_end); + checksum_input.extend_from_slice(b"SS58PRE"); + checksum_input.extend_from_slice(&decoded[..body_end]); + let checksum = sp_core::hashing::blake2_512(&checksum_input); + if decoded[body_end] != checksum[0] || decoded[body_end + 1] != checksum[1] { + return Err(crypto_err("invalid ss58 checksum")); + } + <[u8; 32]>::try_from(&decoded[prefix_len..body_end]) + .map_err(|_| crypto_err("invalid ss58 public key length")) } /// ss58 rendering, byte-identical to sp-core's `to_ss58check_with_version` @@ -530,7 +550,7 @@ pub fn verify( mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] - use sp_core::crypto::Ss58AddressFormat; + use sp_core::crypto::{AccountId32, Ss58AddressFormat, Ss58Codec}; use super::*; @@ -566,8 +586,7 @@ mod tests { #[test] fn password_protected_mnemonic_derives_inside_keypair() { - let mnemonic = - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; let password = "protected-derivation-password"; let parent = Keypair::from_mnemonic(mnemonic, CRYPTO_SR25519, Some(password)).unwrap(); let child = parent.derive("//child").unwrap(); From a8fa5a7fc708940cbe416103d19fd0bc12ab4ee4 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 17:16:01 -0700 Subject: [PATCH 30/72] fix leak --- sdk/typescript-sdk/src/client.ts | 187 ++++++++++++++++++----- sdk/typescript-sdk/src/wallet.ts | 97 +++++++++++- sdk/typescript-sdk/test/basic.test.cjs | 196 ++++++++++++++++++++++++- 3 files changed, 437 insertions(+), 43 deletions(-) diff --git a/sdk/typescript-sdk/src/client.ts b/sdk/typescript-sdk/src/client.ts index f30639c67e..8a516f024a 100644 --- a/sdk/typescript-sdk/src/client.ts +++ b/sdk/typescript-sdk/src/client.ts @@ -14,6 +14,7 @@ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 export const DEFAULT_MAX_REQUEST_RETRIES = 2 export const DEFAULT_RETRY_BACKOFF_MS = 250 export const DEFAULT_MAX_RETRY_BACKOFF_MS = 5_000 +export const DEFAULT_NONCE_RECONCILE_BLOCKS = 8 export const NETWORKS = Object.freeze({ finney: 'wss://entrypoint-finney.opentensor.ai:443', test: 'wss://test.finney.opentensor.ai:443', @@ -142,9 +143,11 @@ export interface ChainSigner { } export type SignerLike = Keypair | ChainSigner | LedgerDevice +export type ExtrinsicStatus = 'submitted' | 'inBlock' | 'finalized' | 'failed' export interface ExtrinsicResult { - success: boolean + status: ExtrinsicStatus + success?: boolean message: string extrinsicHash: string blockHash?: string @@ -199,6 +202,7 @@ interface SubscriptionState { queue: unknown[] waiters: SubscriptionWaiter[] closed: boolean + resubscribe: boolean subscribeMethod: string params: unknown[] unsubscribeMethod: string @@ -242,6 +246,12 @@ interface NonceReservation { nonce: number } +type SubmittedExtrinsicLocation = 'pool' | 'block' | null + +interface SubscriptionOptions { + resubscribe?: boolean +} + const MANAGED_NONCE = Symbol('managedNonce') type ManagedSignedExtrinsicResult = SignedExtrinsicResult & { @@ -367,12 +377,14 @@ export class JsonRpcTransport { subscribeMethod: string, params: unknown[] = [], unsubscribeMethod: string, + options: SubscriptionOptions = {}, ): Promise & { unsubscribe(): Promise }> { if (this.isHttpEndpoint()) throw new ChainError('subscriptions require a WebSocket endpoint') const state: SubscriptionState = { queue: [], waiters: [], closed: false, + resubscribe: options.resubscribe ?? true, subscribeMethod, params, unsubscribeMethod, @@ -578,7 +590,8 @@ export class JsonRpcTransport { if (this.closed) return for (const state of this.subscriptions) { state.subscription = undefined - void this.resubscribe(state) + if (state.resubscribe) void this.resubscribe(state) + else this.closeSubscription(state, error) } } @@ -1114,14 +1127,25 @@ export class Client { } async signExtrinsic(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { + return this.signExtrinsicWithNonce(call, signer, options, false) + } + + private async signExtrinsicWithNonce( + call: CallLike, + signer: SignerLike, + options: SubmitOptions, + manageNonce: boolean, + ): Promise { let resolved: ResolvedSigner | undefined let reservation: NonceReservation | undefined try { const runtime = await this.runtimeAt() const callData = await this.callData(call) resolved = await this.resolveSigner(signer, runtime) - reservation = options.nonce == null ? await this.reserveNonce(resolved.ss58Address) : undefined - const nonce = options.nonce ?? reservation!.nonce + reservation = manageNonce && options.nonce == null + ? await this.reserveNonce(resolved.ss58Address) + : undefined + const nonce = options.nonce ?? reservation?.nonce ?? await this.peekNextIndex(resolved.ss58Address) const period = options.period === undefined ? DEFAULT_ERA_PERIOD : options.period const { era, eraBlockHash } = await this.normalizeEra(period) const tip = balanceRao(options.tip ?? 0) @@ -1194,7 +1218,7 @@ export class Client { } async submit(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { - const signed = await this.signExtrinsic(call, signer, options) + const signed = await this.signExtrinsicWithNonce(call, signer, options, true) return this.submitSigned(signed, signed.signerAddress, options) } @@ -1208,24 +1232,18 @@ export class Client { : toBuffer(extrinsic.bytes, 'extrinsic.bytes') const extrinsicHash = hex(blake2_256(bytes)) const reservation = managedNonceReservation(extrinsic) - let submitted = false - try { - if (!options.waitForInclusion && !options.waitForFinalization) { - const hash = String(await this.rpc('author_submitExtrinsic', [hex(bytes)])) - submitted = true - if (reservation != null) await this.submitNonce(reservation) - return { success: true, message: 'Submitted', extrinsicHash: hash, events: [] } - } - const watcher = await this.watchSigned(bytes, { + if (options.waitForInclusion || options.waitForFinalization) { + const watcher = await this.watchSigned(extrinsic, { waitForFinalization: options.waitForFinalization ?? false, }) - submitted = true + return await watcher.result + } + try { + const hash = String(await this.rpc('author_submitExtrinsic', [hex(bytes)])) if (reservation != null) await this.submitNonce(reservation) - const result = await watcher.result - if (reservation != null) await this.confirmNonce(reservation) - return result + return { status: 'submitted', message: 'Submitted', extrinsicHash: hash, events: [] } } catch (error) { - if (reservation != null) await this.failNonce(reservation, !submitted || nonceReusableAfterSubmissionError(error)) + if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) throw error } } @@ -1238,18 +1256,37 @@ export class Client { ? toBuffer(extrinsic, 'extrinsic') : toBuffer(extrinsic.bytes, 'extrinsic.bytes') const extrinsicHash = hex(blake2_256(bytes)) - const subscription = await this.transport.subscribe( - 'author_submitAndWatchExtrinsic', - [hex(bytes)], - 'author_unwatchExtrinsic', + const reservation = managedNonceReservation(extrinsic) + let subscription: AsyncIterable & { unsubscribe(): Promise } + try { + subscription = await this.transport.subscribe( + 'author_submitAndWatchExtrinsic', + [hex(bytes)], + 'author_unwatchExtrinsic', + { resubscribe: false }, + ) + if (reservation != null) await this.submitNonce(reservation) + } catch (error) { + if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) + throw error + } + const result = this.resolveWatchedExtrinsic( + subscription, + extrinsicHash, + options.waitForFinalization ?? false, + ).then( + async (value) => { + if (reservation != null) await this.confirmNonce(reservation) + return value + }, + async (error) => { + if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) + throw error + }, ) return { extrinsicHash, - result: this.resolveWatchedExtrinsic( - subscription, - extrinsicHash, - options.waitForFinalization ?? false, - ), + result, unsubscribe: () => subscription.unsubscribe(), } } @@ -1271,8 +1308,13 @@ export class Client { private async reserveNonce(address: string): Promise { return this.withNonceAccount(address, async (state) => { if (state.next == null) state.next = await this.peekNextIndex(address) - const nonce = state.reusable.shift() ?? state.next - if (nonce === state.next) state.next += 1 + state.reusable.sort((left, right) => left - right) + let nonce = state.next + if (state.reusable.length > 0 && state.reusable[0] <= state.next) { + nonce = state.reusable.shift() as number + } + while (state.statuses.has(nonce) && state.statuses.get(nonce) !== 'reusable') nonce += 1 + if (nonce >= state.next) state.next = nonce + 1 state.statuses.set(nonce, 'reserved') return { address, nonce } }) @@ -1299,11 +1341,81 @@ export class Client { if (reusable && !state.reusable.includes(reservation.nonce)) { state.reusable.push(reservation.nonce) state.reusable.sort((left, right) => left - right) + } else if (!reusable) { + state.reusable = state.reusable.filter((nonce) => nonce !== reservation.nonce) } pruneNonceStatuses(state) }) } + private async reconcileNonceReservation( + reservation: NonceReservation, + extrinsicHash?: string, + ): Promise { + const [location, chainNext] = await Promise.all([ + extrinsicHash == null + ? Promise.resolve(null) + : this.submittedExtrinsicLocation(extrinsicHash).catch(() => null), + this.peekNextIndex(reservation.address).catch(() => undefined), + ]) + await this.withNonceAccount(reservation.address, (state) => { + const submitted = location != null || (chainNext != null && chainNext > reservation.nonce) + if (chainNext != null) state.next = chainNext + else if (state.next == null || state.next <= reservation.nonce) state.next = reservation.nonce + 1 + + const minimumReusableNonce = submitted + ? Math.max(state.next, reservation.nonce + 1) + : state.next + state.reusable = state.reusable.filter( + (nonce) => nonce >= minimumReusableNonce && nonce !== reservation.nonce, + ) + for (const [nonce, status] of state.statuses) { + if (status === 'reusable' || (submitted && nonce <= reservation.nonce)) { + state.statuses.delete(nonce) + } + } + + if (submitted) { + if (state.next <= reservation.nonce) state.next = reservation.nonce + 1 + state.statuses.set(reservation.nonce, location === 'block' ? 'confirmed' : 'submitted') + } else { + state.statuses.set(reservation.nonce, 'reusable') + if (!state.reusable.includes(reservation.nonce)) { + state.reusable.push(reservation.nonce) + state.reusable.sort((left, right) => left - right) + } + } + while (state.statuses.has(state.next) && state.statuses.get(state.next) !== 'reusable') { + state.next += 1 + } + pruneNonceStatuses(state) + }) + } + + private async submittedExtrinsicLocation(extrinsicHash: string): Promise { + const normalized = extrinsicHash.toLowerCase() + if (await this.pendingExtrinsicsContain(normalized).catch(() => false)) return 'pool' + return await this.recentBlocksContainExtrinsic(normalized).catch(() => false) ? 'block' : null + } + + private async pendingExtrinsicsContain(extrinsicHash: string): Promise { + const pending = await this.rpc('author_pendingExtrinsics') + if (!Array.isArray(pending)) return false + return pending.some((extrinsic) => hashExtrinsicHex(extrinsic) === extrinsicHash) + } + + private async recentBlocksContainExtrinsic(extrinsicHash: string): Promise { + const current = await this.blockNumber() + const earliest = Math.max(0, current - DEFAULT_NONCE_RECONCILE_BLOCKS + 1) + for (let number = current; number >= earliest; number -= 1) { + const blockHash = await this.blockHash(number) + const raw = await this.rpc('chain_getBlock', [blockHash]) + const extrinsics = (raw as { block?: { extrinsics?: unknown[] } })?.block?.extrinsics ?? [] + if (extrinsics.some((extrinsic) => hashExtrinsicHex(extrinsic) === extrinsicHash)) return true + } + return false + } + private withNonceAccount( address: string, operation: (state: NonceAccountState) => T | Promise, @@ -1605,8 +1717,10 @@ export class Client { const failed = triggered.find((event) => eventName(event) === 'System.ExtrinsicFailed') const success = triggered.some((event) => eventName(event) === 'System.ExtrinsicSuccess') const feeEvent = triggered.find((event) => eventName(event) === 'TransactionPayment.TransactionFeePaid') + const dispatchSuccess = success && failed == null return { - success: success && failed == null, + status: dispatchSuccess ? finalized ? 'finalized' : 'inBlock' : 'failed', + success: dispatchSuccess, message: failed == null ? 'Success' : 'Extrinsic failed', extrinsicHash, blockHash, @@ -2247,10 +2361,13 @@ function managedNonceReservation(extrinsic: unknown): NonceReservation | undefin return (extrinsic as ManagedSignedExtrinsicResult)[MANAGED_NONCE] } -function nonceReusableAfterSubmissionError(error: unknown): boolean { - if (error instanceof JsonRpcError) return true - if (!(error instanceof ChainError)) return false - return /\b(dropped|invalid)\b/i.test(error.message) +function hashExtrinsicHex(extrinsic: unknown): string | undefined { + if (typeof extrinsic !== 'string') return undefined + try { + return hex(blake2_256(hexToBuffer(extrinsic))).toLowerCase() + } catch { + return undefined + } } function pruneNonceStatuses(state: NonceAccountState): void { diff --git a/sdk/typescript-sdk/src/wallet.ts b/sdk/typescript-sdk/src/wallet.ts index 554be4fdbe..8fa52ab66d 100644 --- a/sdk/typescript-sdk/src/wallet.ts +++ b/sdk/typescript-sdk/src/wallet.ts @@ -1,6 +1,21 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { randomBytes } from 'node:crypto' +import { + chmodSync, + closeSync, + constants, + existsSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeSync, +} from 'node:fs' import { homedir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { CRYPTO_SR25519, @@ -60,13 +75,11 @@ export class Keyfile { if (encrypt && password == null) { throw new Error(`Password is required to encrypt ${this.path}`) } - if (this.exists() && !overwrite) { - throw new Error(`Keyfile ${this.path} already exists`) - } - mkdirSync(dirname(this.path), { recursive: true }) + validateKeyfileTarget(this.path, overwrite) + ensurePrivateDirectory(dirname(this.path)) const serialized = serializeKeypair(keypair) const data = encrypt ? encryptKeyfileData(serialized, password as string) : serialized - writeFileSync(this.path, data, { mode: 0o600 }) + atomicWriteKeyfile(this.path, data, overwrite) } } @@ -182,3 +195,73 @@ function keyfilePassword(options: SaveKeyOptions): string | null | undefined { function publicOnly(keypair: Keypair): Keypair { return new Keypair(keypair.ss58Address, keypair.publicKey, keypair.cryptoType, keypair.ss58Format) } + +function ensurePrivateDirectory(path: string): void { + mkdirSync(path, { recursive: true, mode: 0o700 }) + const stat = lstatSync(path) + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error(`Wallet path ${path} must be a real directory`) + } + chmodSync(path, 0o700) +} + +function validateKeyfileTarget(path: string, overwrite: boolean): void { + try { + const stat = lstatSync(path) + if (stat.isSymbolicLink()) throw new Error(`Refusing to write keyfile through symlink ${path}`) + if (!stat.isFile()) throw new Error(`Refusing to overwrite non-file keyfile path ${path}`) + if (!overwrite) throw new Error(`Keyfile ${path} already exists`) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } +} + +function atomicWriteKeyfile(path: string, data: Uint8Array, overwrite: boolean): void { + const dir = dirname(path) + const temp = join(dir, `.${basename(path)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`) + let fd: number | undefined + try { + fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600) + let offset = 0 + while (offset < data.length) { + offset += writeSync(fd, data, offset, data.length - offset) + } + fsyncSync(fd) + closeSync(fd) + fd = undefined + if (overwrite) renameSync(temp, path) + else { + linkSync(temp, path) + unlinkSync(temp) + } + chmodSync(path, 0o600) + fsyncDirectory(dir) + } catch (error) { + if (fd != null) { + try { + closeSync(fd) + } catch { + // Best effort cleanup. + } + } + try { + unlinkSync(temp) + } catch { + // Best effort cleanup. + } + throw error + } +} + +function fsyncDirectory(path: string): void { + let fd: number | undefined + try { + fd = openSync(path, constants.O_RDONLY) + fsyncSync(fd) + } catch { + // Directory fsync is best-effort across platforms and filesystems. + } finally { + if (fd != null) closeSync(fd) + } +} diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 71cceee895..0395839262 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -836,6 +836,38 @@ test('JsonRpcTransport restores websocket subscriptions after reconnect', async await subscription.unsubscribe() }) +test('JsonRpcTransport does not resubmit submit-and-watch subscriptions after reconnect', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + let submissions = 0 + FakeWebSocket.onSend = (socket, message) => { + if (message.method === 'author_submitAndWatchExtrinsic') { + submissions += 1 + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: 'watch-1' })) + } + } + + const transport = new core.JsonRpcTransport('ws://node-a', [], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + }) + const subscription = await transport.subscribe( + 'author_submitAndWatchExtrinsic', + ['0x01'], + 'author_unwatchExtrinsic', + { resubscribe: false }, + ) + const iterator = subscription[Symbol.asyncIterator]() + const pending = iterator.next() + + FakeWebSocket.sockets[0].close() + + await assert.rejects(pending, /connection closed/) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(submissions, 1) + assert.equal(FakeWebSocket.sockets.length, 1) +}) + test('JsonRpcTransport bounds retries and supports request cancellation', async (t) => { const { FakeWebSocket, restore } = installFakeWebSocket() t.after(restore) @@ -1013,6 +1045,7 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => const signed = await client.signExtrinsic(callData, signer, { period: null }) assert.equal(signed.signerAddress, address) + assert.equal(signed.nonce, 12) assert.equal(client.lastNonceAddress, address) assert.equal(request.address, address) assert.equal(request.type, 'bytes') @@ -1023,6 +1056,8 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => assert.deepEqual(captures.encoded.signature, Buffer.alloc(64, 9)) assert.equal(captures.encoded.signatureVersion, core.CRYPTO_SR25519) assert.equal(captures.encoded.params.metadataHashEnabled, false) + assert.equal(await client.accountNextIndex(address), 12) + assert.equal(await client.accountNextIndex(address), 13) }) test('Client estimateFee peeks the chain nonce without reserving it', async () => { @@ -1136,7 +1171,7 @@ test('Client releases only the failed reserved nonce', async () => { }, } - const signing = client.signExtrinsic(callData, signer, { period: null }) + const signing = client.submit(callData, signer, { period: null }) await started const independentlyReserved = await client.accountNextIndex(address) releaseSign() @@ -1149,6 +1184,135 @@ test('Client releases only the failed reserved nonce', async () => { assert.equal(next, 14) }) +test('Client reuses an ambiguous submit nonce only after reconciliation proves it absent', async () => { + const callData = Buffer.from([4, 5, 6]) + const { runtime } = fakeSigningRuntime() + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 13) + const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 8), + ]) + const nonceReads = [] + client.rpc = async (method, params = []) => { + if (method === 'system_accountNextIndex') { + nonceReads.push(params[0]) + return 20 + } + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + if (method === 'author_submitExtrinsic') throw new core.JsonRpcError('lost response') + throw new Error(`unexpected RPC ${method}`) + } + const signer = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } + + await assert.rejects(() => client.submit(callData, signer, { period: null }), /lost response/) + assert.equal(await client.accountNextIndex(address), 20) + assert.deepEqual(nonceReads, [address, address]) +}) + +test('Client protects an ambiguous submit nonce when the extrinsic is still pending', async () => { + const callData = Buffer.from([4, 5, 7]) + const { runtime } = fakeSigningRuntime() + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 15) + const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 3), + ]) + const nonceReads = [] + let submittedHex + client.rpc = async (method, params = []) => { + if (method === 'system_accountNextIndex') { + nonceReads.push(params[0]) + return 40 + } + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + if (method === 'author_submitExtrinsic') { + submittedHex = params[0] + throw new core.JsonRpcError('lost response') + } + if (method === 'author_pendingExtrinsics') return [submittedHex] + throw new Error(`unexpected RPC ${method}`) + } + const signer = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } + + await assert.rejects(() => client.submit(callData, signer, { period: null }), /lost response/) + assert.equal(await client.accountNextIndex(address), 41) + assert.deepEqual(nonceReads, [address, address]) +}) + +test('Client submit without inclusion reports pool submission, not execution success', async () => { + const callData = Buffer.from([7, 7, 7]) + const { runtime } = fakeSigningRuntime() + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 14) + const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 6), + ]) + client.rpc = async (method) => { + if (method === 'system_accountNextIndex') return 30 + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + if (method === 'author_submitExtrinsic') return `0x${'ab'.repeat(32)}` + throw new Error(`unexpected RPC ${method}`) + } + const signer = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } + + const result = await client.submit(callData, signer, { period: null }) + + assert.equal(result.status, 'submitted') + assert.equal(result.success, undefined) + assert.equal(result.message, 'Submitted') +}) + test('Client generates RFC-0078 proof for Ledger metadata-verifying signers', async () => { const vector = ledgerProofVector() const metadataBytes = goldenMetadataBytes() @@ -1275,6 +1439,36 @@ test('wallet helpers keep mnemonic and keyfile passwords separate', (t) => { ) }) +test('wallet keyfile writes are restrictive and reject symlink targets', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bittensor-wallet-atomic-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const keypair = core.Keypair.fromUri('//Alice') + const wallet = new core.Wallet({ name: 'atomic', hotkey: 'default', path: root }) + + wallet.setHotkey(keypair) + + const hotkeyPath = wallet.hotkeyFile.path + const hotkeyDir = path.dirname(hotkeyPath) + assert.equal(fs.lstatSync(hotkeyPath).isFile(), true) + assert.equal(fs.statSync(hotkeyPath).mode & 0o777, 0o600) + assert.equal(fs.statSync(hotkeyDir).mode & 0o777, 0o700) + assert.deepEqual( + fs.readdirSync(hotkeyDir).filter((name) => name.endsWith('.tmp')), + [], + ) + + const target = path.join(root, 'outside-target') + fs.writeFileSync(target, 'do not replace') + const linkedWallet = new core.Wallet({ name: 'atomic', hotkey: 'linked', path: root }) + fs.symlinkSync(target, linkedWallet.hotkeyFile.path) + + assert.throws( + () => linkedWallet.setHotkey(keypair, { overwrite: true }), + /symlink/, + ) + assert.equal(fs.readFileSync(target, 'utf8'), 'do not replace') +}) + test('arbitrary StorageInfo helpers call Rust directly', () => { const entry = { pallet: 'System', From e32ad17c4d499ddf58891c9776c9de3f9cd7b5d5 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 17:21:32 -0700 Subject: [PATCH 31/72] fmt --- sdk/bittensor-core/src/keys/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/bittensor-core/src/keys/mod.rs b/sdk/bittensor-core/src/keys/mod.rs index 007fe4e52d..891dc9d0b4 100644 --- a/sdk/bittensor-core/src/keys/mod.rs +++ b/sdk/bittensor-core/src/keys/mod.rs @@ -15,7 +15,7 @@ use sodiumoxide::crypto::sealedbox; #[cfg(feature = "host")] use sodiumoxide::crypto::sign::ed25519 as sign_ed25519; use sp_core::crypto::Pair as PairT; -use sp_core::{ByteArray, ed25519, sr25519}; +use sp_core::{ed25519, sr25519, ByteArray}; use zeroize::Zeroizing; use crate::error::CoreError; From 3a174b04362d6b90c439d2ce17d3814d68ee7d10 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 17:31:27 -0700 Subject: [PATCH 32/72] fix js version --- .github/workflows/typescript-e2e.yml | 30 ++ sdk/bittensor-core/Cargo.toml | 2 + sdk/bittensor-core/binding-manifest.json | 381 ------------------ sdk/typescript-sdk/README.md | 30 +- sdk/typescript-sdk/package-lock.json | 2 +- sdk/typescript-sdk/package.json | 2 +- .../scripts/check-native-parity.cjs | 228 ++++++++--- sdk/typescript-sdk/test/basic.test.cjs | 4 + 8 files changed, 227 insertions(+), 452 deletions(-) delete mode 100644 sdk/bittensor-core/binding-manifest.json diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index f8b0f6bbab..d758503851 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -198,6 +198,36 @@ jobs: sdk/typescript-sdk/*.node if-no-files-found: error + test-typescript-sdk-node-min: + name: TypeScript SDK package test (Node 22) + runs-on: ubuntu-latest + needs: [trusted-pr, typescript-formatting, changes, build-typescript-sdk] + if: needs.changes.outputs.e2e == 'true' + timeout-minutes: 15 + steps: + - name: Check-out repository + uses: actions/checkout@v4 + + - name: Setup Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install TypeScript SDK dependencies + run: npm --prefix sdk/typescript-sdk ci + + - name: Download TypeScript SDK build + uses: actions/download-artifact@v4 + with: + name: bittensor-typescript-sdk + path: sdk/typescript-sdk + + - name: Test package on minimum supported Node + run: | + node --version + node -e "if (typeof globalThis.WebSocket !== 'function') throw new Error('globalThis.WebSocket is required by the default WSS client path')" + node --test sdk/typescript-sdk/test/*.test.cjs + # Build the node binary in both variants and share as artifacts. build: runs-on: [self-hosted, fireactions-heavy] diff --git a/sdk/bittensor-core/Cargo.toml b/sdk/bittensor-core/Cargo.toml index 2e1677f62a..3e93cf56bc 100644 --- a/sdk/bittensor-core/Cargo.toml +++ b/sdk/bittensor-core/Cargo.toml @@ -33,6 +33,8 @@ serde = { version = "1.0.132", features = ["derive"] } serde_json = { version = "1.0.132", features = ["arbitrary_precision"] } sha2 = "0.10.8" sodiumoxide = { version = "0.2", default-features = false, features = ["std"], optional = true } +# `full_crypto` is sp-core's portable no-std crypto feature. Keep `sp-core/std` +# gated by `host` below so bittensor-core-wasm never enables it implicitly. sp-core = { workspace = true, default-features = false, features = ["full_crypto"] } subtensor-macros.workspace = true zeroize = "1" diff --git a/sdk/bittensor-core/binding-manifest.json b/sdk/bittensor-core/binding-manifest.json deleted file mode 100644 index 36ef339289..0000000000 --- a/sdk/bittensor-core/binding-manifest.json +++ /dev/null @@ -1,381 +0,0 @@ -{ - "native": { - "values": [ - "bindingVersion", - "concatHashLength", - "convertTypeString", - "coreValueBool", - "coreValueBytes", - "coreValueDescriptorDisplay", - "coreValueDescriptorRoundtrip", - "coreValueDescriptorToCorpusJson", - "coreValueDescriptorToWire", - "coreValueDict", - "coreValueHex", - "coreValueInt", - "coreValueList", - "coreValueNull", - "coreValueRecord", - "coreValueString", - "coreValueTuple", - "coreValueU256Le", - "coreValueUint", - "cryptoEd25519", - "cryptoSr25519", - "decodeCompactLength", - "decodeCompactU128", - "decodeTimelockUserData", - "decodeWeightsTlockPayload", - "decryptKeyfileData", - "defaultSs58Format", - "deserializeKeypair", - "encodeCompact", - "encodeTimelockUserData", - "encodeWeightsTlockPayload", - "encryptFor", - "encryptKeyfileData", - "epochAdvanceBlocks", - "epochCurrentPreRunCoinbase", - "epochPredictFirstRevealBlock", - "epochPredictFirstRevealBlockResult", - "epochShouldRun", - "epochSimulateRunCoinbase", - "eraBirth", - "generateExtrinsicProof", - "generateMnemonic", - "getPasswordFromEnvironment", - "hashStorageParam", - "keyfileDataEncryptionMethod", - "keyfileDataIsEncrypted", - "keyfileDataIsEncryptedAnsible", - "keyfileDataIsEncryptedLegacy", - "keyfileDataIsEncryptedNacl", - "keypairFromEncryptedJson", - "keypairFromMnemonic", - "keypairFromPrivateKey", - "keypairFromSeed", - "keypairFromUri", - "keypairNew", - "ledgerEnabled", - "metadataDigest", - "mlkemKdfId", - "mlkemNonceLength", - "mlkemSeal", - "mlkemTwox128", - "multisigAccountId", - "multisigSs58", - "normalizeTypeSpec", - "parallelDecodeThreshold", - "primitiveFromName", - "publicKeyFromSs58", - "savePasswordToEnvironment", - "serializeKeypair", - "ss58FromPublic", - "storagePrefixFor", - "timelockCommitInclusionBlockOffset", - "timelockDecrypt", - "timelockDecryptAndDecompress", - "timelockDecryptWithSignature", - "timelockDrandEndpoints", - "timelockDrandPeriod", - "timelockDrandPublicKey", - "timelockEncryptAndCompress", - "timelockEncryptAtRound", - "timelockEncryptCommitment", - "timelockEncryptNBlocks", - "timelockGenerateCommitV2", - "timelockGenesisTime", - "timelockGetRevealRoundSignature", - "timelockGetRoundInfo", - "timelockMaxSimulationBlocks", - "timelockMaxTempo", - "timelockMaxTempoU64", - "timelockQuicknetChainHash", - "timelockSecurityBlockOffset", - "u256LeToDecimal", - "valueToCorpusJson", - "verifySignature", - "wireRoundtrip", - "wireTag", - "wireToCoreValueDescriptor" - ], - "classes": { - "NativeCursor": { - "statics": ["fromBytes"], - "instance": [ - "byte", - "data", - "decodeCompactLength", - "decodeCompactU128", - "offset", - "remaining", - "reset", - "seek", - "setStrict", - "strict", - "take" - ] - }, - "NativeKeypair": { - "statics": [], - "instance": [ - "cryptoType", - "decrypt", - "derive", - "encrypt", - "kind", - "publicKey", - "sign", - "ss58Address", - "ss58Format", - "verify" - ] - }, - "NativeLedgerDevice": { - "statics": ["open"], - "instance": ["address", "appVersion", "sign"] - }, - "NativeRuntime": { - "statics": ["fromMetadata"], - "instance": [ - "coerceAccountId", - "coerceAccountIdDescriptor", - "composeCall", - "constant", - "constantInfo", - "decode", - "decodeBatch", - "decodeCall", - "decodeCallValue", - "decodeCallValueDescriptor", - "decodeExtrinsic", - "decodeMapChanges", - "decodeMapPairs", - "decodePartial", - "decodeSpec", - "decodeSpecDescriptor", - "decodeStorageKeyParams", - "decodeTypeId", - "decodeTypeIdDescriptor", - "decodeTypeIdDescriptorPartial", - "decodeTypeIdPartial", - "decodeValue", - "decodeValueDescriptor", - "encode", - "encodeEra", - "encodeId", - "encodeIdDescriptor", - "encodeSignedExtrinsic", - "encodeSpec", - "encodeSpecDescriptor", - "encodeTypeId", - "encodeValue", - "encodeValueDescriptor", - "extrinsicInfo", - "extrinsicVersion", - "isV15", - "metadataBytes", - "metadataIr", - "moduleError", - "outerEventType", - "pallet", - "palletAt", - "pallets", - "registry", - "registryJson", - "resolveType", - "runtimeApiInfos", - "runtimeApiMap", - "runtimeApis", - "runtimeSnapshot", - "signaturePayload", - "signaturePayloadParts", - "signedExtensionIdentifiers", - "specVersion", - "ss58Format", - "storageEntry", - "storageKey", - "storageKeyBatch", - "storagePrefix", - "transactionVersion", - "typeIdOf", - "typeNameOf", - "typeSpec" - ] - } - } - }, - "wasm": { - "values": [ - "CryptoType", - "coreVersion", - "decryptWithSignature", - "encrypt", - "encryptAtRound", - "encryptMlkem768", - "eraBirth", - "generateExtrinsicProof", - "getEncryptedCommitV2", - "getEncryptedCommitment", - "metadataDigest", - "mlkemKdfId", - "multisigAccountId", - "revealRound", - "ss58Decode", - "ss58Encode", - "verifySignature" - ], - "classes": { - "Keypair": { - "statics": [ - "fromMnemonic", - "fromPrivateKey", - "fromSeed", - "fromUri", - "generateMnemonic" - ], - "instance": [ - "cryptoType", - "derive", - "kind", - "publicKey", - "sign", - "ss58Address", - "ss58Format", - "verify" - ] - }, - "Runtime": { - "statics": [], - "instance": [ - "batchDecode", - "composeCall", - "constant", - "decode", - "decodeCall", - "decodeExtrinsic", - "decodeMapChanges", - "decodeMapPairs", - "decodeStorageKeyParams", - "encode", - "encodeEra", - "encodeSignedExtrinsic", - "extrinsicVersion", - "isV15", - "metadataIr", - "moduleError", - "registryJson", - "runtimeApiMap", - "signaturePayload", - "signaturePayloadParts", - "signedExtensionIdentifiers", - "specVersion", - "ss58Format", - "storageEntry", - "storageKey", - "storageKeyBatch", - "storagePrefix", - "transactionVersion", - "typeIdOf", - "typeNameOf" - ] - } - } - }, - "browser": { - "values": [ - "CRYPTO_ED25519", - "CRYPTO_SR25519", - "DEFAULT_SS58_FORMAT", - "configureBrowserWasm", - "coreVersion", - "decryptWithSignature", - "encrypt", - "encryptAtRound", - "eraBirth", - "generateCommitV2", - "generateExtrinsicProof", - "getEncryptedCommitment", - "initBrowser", - "loadBrowser", - "metadataDigest", - "mlkemKdfId", - "multisigAccountId", - "publicKeyFromSs58", - "ready", - "revealRound", - "sealMevShieldTransaction", - "setDefaultBrowserWasmLoader", - "ss58FromPublic", - "verifySignature" - ], - "classes": { - "Keypair": { - "statics": [ - "createFromMnemonic", - "createFromPrivateKey", - "createFromSeed", - "createFromUri", - "fromMnemonic", - "fromPrivateKey", - "fromSeed", - "fromUri", - "generateMnemonic" - ], - "instance": [ - "address", - "addressRaw", - "cryptoType", - "derive", - "isLocked", - "kind", - "meta", - "publicKey", - "scheme", - "setMeta", - "sign", - "ss58Address", - "ss58Format", - "type", - "verify" - ] - }, - "Runtime": { - "statics": [], - "instance": [ - "composeCall", - "constant", - "decode", - "decodeBatch", - "decodeCall", - "decodeExtrinsic", - "decodeMapChanges", - "decodeMapPairs", - "decodeStorageKeyParams", - "encode", - "encodeEra", - "encodeSignedExtrinsic", - "extrinsicVersion", - "isV15", - "metadataIr", - "moduleError", - "registryJson", - "runtimeApiMap", - "runtimeApis", - "signaturePayload", - "signaturePayloadParts", - "signedExtensionIdentifiers", - "specVersion", - "ss58Format", - "storageEntry", - "storageKey", - "storageKeyBatch", - "storagePrefix", - "transactionVersion", - "typeIdOf", - "typeNameOf" - ] - } - } - } -} diff --git a/sdk/typescript-sdk/README.md b/sdk/typescript-sdk/README.md index a463f06a42..a11b6e6062 100644 --- a/sdk/typescript-sdk/README.md +++ b/sdk/typescript-sdk/README.md @@ -32,12 +32,16 @@ entry point is callable even when an ergonomic wrapper has not yet been added. Browser bundlers should import the explicit `@bittensor/sdk/browser` subpath. -That entrypoint does not load `native.cjs`, `.node` binaries, Node `Buffer`, -or native HID. It returns `Uint8Array` values and exposes the portable subset: -key generation, SS58, signing and verification, RFC-0078 metadata proofs, -ML-KEM sealing, and timelock encryption/decryption when the caller fetches the -drand signature. Host-only features such as wallet keyfiles, encrypted JSON -import, native Ledger HID, and direct drand fetching remain Node-only. +That entrypoint is a portable browser subset, not a method-for-method mirror of +the Node API. It does not load `native.cjs`, `.node` binaries, Node `Buffer`, +or native HID. It returns `Uint8Array` values and exposes browser-safe Rust +WASM operations: key generation, SS58, signing and verification, SCALE +encoding and decoding, runtime metadata parsing, storage keys, call and +extrinsic composition, RFC-0078 metadata proofs, ML-KEM sealing, and timelock +encryption/decryption when the caller fetches the drand signature. Host-only +features such as wallet keyfiles, encrypted JSON import, native Ledger HID, +direct drand fetching, and lower-level Node-native runtime introspection +helpers remain Node-only. ## Build locally @@ -53,7 +57,9 @@ The native crate is isolated under `sdk/typescript-sdk/native`; it links `sdk/bittensor-core` directly and contains binding glue only. No chain algorithm is reimplemented in TypeScript. -Node.js 20.17 or newer is required. +Node.js 22 or newer is required for the default WSS client path because the +SDK uses the unflagged global `WebSocket`. Older Node runtimes can still use +HTTP endpoints or pass `webSocketFactory`/`webSocketConstructor` explicitly. Browser builds also require `wasm-pack` so `npm run build` can emit the `dist/wasm/bittensor_core_wasm.js` bundle used by `@bittensor/sdk/browser`. @@ -129,8 +135,8 @@ await initBrowser(() => import('./vendor/bittensor_core_wasm.js')) Mnemonic, password, and secret-URI derivation state is retained only by the Rust `Keypair`; TypeScript calls the native handle's `derive(path)` method and never -reconstructs a child secret URI. `npm run build` also checks the Rust-side -binding manifest against the generated N-API declarations, `src/native.ts`, the -generated WASM declarations, `BrowserWasmModule`, and the public browser -wrapper, so Rust additions cannot silently disappear from either TypeScript -boundary. +reconstructs a child secret URI. `npm run build` also generates binding +coverage from the Rust-side `#[napi]` and `#[wasm_bindgen]` annotations, then +checks that surface against the generated N-API declarations, `src/native.ts`, +the generated WASM declarations, and `BrowserWasmModule`, so binding additions +cannot silently disappear from either TypeScript boundary. diff --git a/sdk/typescript-sdk/package-lock.json b/sdk/typescript-sdk/package-lock.json index 045863f995..f555573339 100644 --- a/sdk/typescript-sdk/package-lock.json +++ b/sdk/typescript-sdk/package-lock.json @@ -15,7 +15,7 @@ "typescript": "5.8.3" }, "engines": { - "node": ">=20.17" + "node": ">=22" } }, "node_modules/@emnapi/core": { diff --git a/sdk/typescript-sdk/package.json b/sdk/typescript-sdk/package.json index 762aa19b8d..19e39e28b6 100644 --- a/sdk/typescript-sdk/package.json +++ b/sdk/typescript-sdk/package.json @@ -44,7 +44,7 @@ "README.md" ], "engines": { - "node": ">=20.17" + "node": ">=22" }, "scripts": { "build:native": "napi build --manifest-path native/Cargo.toml --output-dir . --platform --release --all-features --js native.cjs --dts native.generated.d.ts", diff --git a/sdk/typescript-sdk/scripts/check-native-parity.cjs b/sdk/typescript-sdk/scripts/check-native-parity.cjs index 495e222345..37843843f3 100755 --- a/sdk/typescript-sdk/scripts/check-native-parity.cjs +++ b/sdk/typescript-sdk/scripts/check-native-parity.cjs @@ -8,7 +8,8 @@ const ts = require('typescript') const root = path.resolve(__dirname, '..') const sdkRoot = path.resolve(root, '..') const repoRoot = path.resolve(sdkRoot, '..') -const manifestPath = path.join(sdkRoot, 'bittensor-core', 'binding-manifest.json') +const nativeRustRoot = path.join(root, 'native', 'src') +const wasmRustRoot = path.join(sdkRoot, 'bittensor-core-wasm', 'src') const nativeGeneratedPath = path.join(root, 'native.generated.d.ts') const nativeDocumentedPath = path.join(root, 'src', 'native.ts') const browserPath = path.join(root, 'src', 'browser.ts') @@ -105,57 +106,148 @@ function exportedSurface(source, options = {}) { return { values, classes } } -function sorted(values) { - return [...values].sort() +function readRustFiles(directory) { + if (!fs.existsSync(directory)) throw new Error(`missing ${repoRelative(directory)}`) + const out = [] + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const item = path.join(directory, entry.name) + if (entry.isDirectory()) out.push(...readRustFiles(item)) + else if (entry.isFile() && entry.name.endsWith('.rs')) out.push(item) + } + return out.sort() } -function uniqueSet(values, label) { - if (!Array.isArray(values)) throw new Error(`${label} must be an array`) - const out = new Set() - for (const value of values) { - if (typeof value !== 'string') throw new Error(`${label} entries must be strings`) - if (out.has(value)) throw new Error(`${label} contains duplicate entry ${value}`) - out.add(value) +function snakeToCamel(name) { + return name.replace(/_([a-zA-Z0-9])/g, (_, value) => value.toUpperCase()) +} + +function jsNameFrom(attrs, rustName, mode) { + for (const attr of attrs) { + const match = attr.match(/\bjs_name\s*=\s*(?:"([^"]+)"|([A-Za-z0-9_]+))/) + if (match != null) return match[1] ?? match[2] } - return out + return mode === 'napi' ? snakeToCamel(rustName) : rustName } -function manifestSurface(manifest, sectionName) { - const section = manifest[sectionName] - if (section == null || typeof section !== 'object') { - throw new Error(`binding manifest is missing ${sectionName}`) +function hasAttrFlag(attrs, flag) { + return attrs.some((attr) => new RegExp(`\\b${flag}\\b`).test(attr)) +} + +function hasBindingAttr(attrs, mode) { + return attrs.some((attr) => attr.startsWith(mode)) +} + +function ensureClass(surface, className) { + surface.values.add(className) + let members = surface.classes.get(className) + if (members == null) { + members = { instance: new Set(), statics: new Set() } + surface.classes.set(className, members) } + return members +} - const values = uniqueSet(section.values ?? [], `${sectionName}.values`) - const classes = new Map() - const manifestClasses = section.classes ?? {} - if (manifestClasses == null || typeof manifestClasses !== 'object' || Array.isArray(manifestClasses)) { - throw new Error(`${sectionName}.classes must be an object`) +function rustBindingSurface(directory, mode) { + const surface = { values: new Set(), classes: new Map() } + for (const filePath of readRustFiles(directory)) { + const source = fs.readFileSync(filePath, 'utf8') + parseRustBindingSource(source, mode, surface) } + return surface +} + +function parseRustBindingSource(source, mode, surface) { + const lines = source.split(/\r?\n/) + const stack = [] + let pendingAttrs = [] + let pendingImpl = null + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const rawLine = lines[lineIndex] + const line = rawLine.trim() + if (line.startsWith('#[')) { + const attr = line.replace(/^#\[/, '').replace(/\]\s*$/, '') + if (attr.startsWith(mode)) pendingAttrs.push(attr) + updateRustStack(rawLine, stack) + continue + } + + const implStart = line.match(/^impl\s+([A-Za-z0-9_]+)/) + if (implStart != null && line.includes('{')) { + pendingImpl = { + name: implStart[1], + binding: hasBindingAttr(pendingAttrs, mode), + } + } - for (const [className, members] of Object.entries(manifestClasses)) { - if (members == null || typeof members !== 'object' || Array.isArray(members)) { - throw new Error(`${sectionName}.classes.${className} must be an object`) + const classMatch = line.match(/^pub\s+(?:struct|enum)\s+([A-Za-z0-9_]+)/) + if (classMatch != null && hasBindingAttr(pendingAttrs, mode) && !hasAttrFlag(pendingAttrs, 'object')) { + if (line.startsWith('pub enum ')) surface.values.add(classMatch[1]) + else ensureClass(surface, classMatch[1]) + pendingAttrs = [] } - const instance = uniqueSet(members.instance ?? [], `${sectionName}.classes.${className}.instance`) - const statics = uniqueSet(members.statics ?? [], `${sectionName}.classes.${className}.statics`) - values.add(className) - classes.set(className, { instance, statics }) + + const functionMatch = line.match(/^pub\s+fn\s+([A-Za-z0-9_]+)/) + const implInfo = currentRustImpl(stack) + const hasFunctionBinding = + hasBindingAttr(pendingAttrs, mode) || (mode === 'wasm_bindgen' && implInfo?.binding) + if (functionMatch != null && hasFunctionBinding) { + const rustName = functionMatch[1] + if (!hasAttrFlag(pendingAttrs, 'constructor')) { + const name = jsNameFrom(pendingAttrs, rustName, mode) + if (implInfo == null) { + surface.values.add(name) + } else { + const classMembers = ensureClass(surface, implInfo.name) + const signature = rustFunctionSignature(lines, lineIndex) + const isStatic = hasAttrFlag(pendingAttrs, 'factory') || !/\bself\b/.test(signature) + ;(isStatic ? classMembers.statics : classMembers.instance).add(name) + } + } + pendingAttrs = [] + } else if (line.length > 0 && !line.startsWith('#') && !line.startsWith('//')) { + pendingAttrs = [] + } + + updateRustStack(rawLine, stack, pendingImpl) + pendingImpl = null } +} - return { values, classes } +function currentRustImpl(stack) { + for (let index = stack.length - 1; index >= 0; index -= 1) { + if (stack[index].kind === 'impl') return stack[index] + } + return null } -function readManifest() { - if (!fs.existsSync(manifestPath)) { - throw new Error(`missing ${repoRelative(manifestPath)}`) +function updateRustStack(rawLine, stack, pendingImpl = null) { + for (const char of rawLine) { + if (char === '{') { + if (pendingImpl != null) stack.push({ kind: 'impl', ...pendingImpl }) + else stack.push({ kind: 'block' }) + pendingImpl = null + } else if (char === '}') { + stack.pop() + } } - const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) - return { - native: manifestSurface(raw, 'native'), - wasm: manifestSurface(raw, 'wasm'), - browser: manifestSurface(raw, 'browser'), +} + +function rustFunctionSignature(lines, startIndex) { + const parts = [] + for (let index = startIndex; index < lines.length; index += 1) { + const line = lines[index] + parts.push(line) + if (line.includes(')')) break } + const text = parts.join('\n') + const start = text.indexOf('(') + const end = text.indexOf(')', Math.max(0, start)) + return start >= 0 && end >= 0 ? text.slice(start + 1, end) : '' +} + +function sorted(values) { + return [...values].sort() } function compareSet(label, actual, expected, actualLabel, expectedLabel) { @@ -168,7 +260,7 @@ function compareSet(label, actual, expected, actualLabel, expectedLabel) { return lines } -function compareSurface(label, actual, expected, actualLabel = 'binding', expectedLabel = 'manifest') { +function compareSurface(label, actual, expected, actualLabel = 'binding', expectedLabel = 'expected surface') { const failures = compareSet( `${label} top-level exports`, actual.values, @@ -204,7 +296,13 @@ function compareSurface(label, actual, expected, actualLabel = 'binding', expect return failures } -function compareClassInterfaces(label, interfaces, expected, mapping) { +function compareClassInterfaces( + label, + interfaces, + expected, + mapping, + expectedLabel = 'Rust annotations', +) { const failures = [] for (const [className, expectedMembers] of expected.classes) { const interfaceNames = mapping[className] @@ -223,7 +321,7 @@ function compareClassInterfaces(label, interfaces, expected, mapping) { instance, expectedMembers.instance, 'interface', - 'manifest', + expectedLabel, ), ) } @@ -242,7 +340,7 @@ function compareClassInterfaces(label, interfaces, expected, mapping) { statics, expectedMembers.statics, 'interface', - 'manifest', + expectedLabel, ), ) } @@ -278,7 +376,8 @@ const wasmClassInterfaces = { } try { - const manifest = readManifest() + const nativeExpected = rustBindingSurface(nativeRustRoot, 'napi') + const wasmExpected = rustBindingSurface(wasmRustRoot, 'wasm_bindgen') const nativeGenerated = exportedSurface( parse(nativeGeneratedPath, 'run npm run build:native first'), ) @@ -296,34 +395,49 @@ try { const browserPublic = exportedSurface(browserSource, { publicOnly: true }) const browserModule = browserInterfaces.get('BrowserWasmModule') if (browserModule == null) throw new Error('src/browser.ts does not declare BrowserWasmModule') - const expectedBrowserModule = cloneSurface(manifest.wasm) + const expectedBrowserModule = cloneSurface(wasmExpected) expectedBrowserModule.values.add('default') const failures = [ - ...compareSurface('native N-API generated declarations', nativeGenerated, manifest.native), + ...compareSurface( + 'native N-API generated declarations', + nativeGenerated, + nativeExpected, + 'generated declarations', + 'Rust #[napi] annotations', + ), ...compareSet( 'src/native.ts NativeBinding', nativeBinding, - manifest.native.values, + nativeExpected.values, 'interface', - 'manifest', + 'Rust #[napi] annotations', + ), + ...compareClassInterfaces( + 'src/native.ts class handles', + nativeDocumented, + nativeExpected, + nativeClassInterfaces, + ), + ...compareSurface( + 'browser WASM generated declarations', + wasmGenerated, + wasmExpected, + 'generated declarations', + 'Rust #[wasm_bindgen] annotations', ), - ...compareClassInterfaces('src/native.ts class handles', nativeDocumented, manifest.native, nativeClassInterfaces), - ...compareSurface('browser WASM generated declarations', wasmGenerated, manifest.wasm), ...compareSet( 'src/browser.ts BrowserWasmModule', browserModule, expectedBrowserModule.values, 'interface', - 'manifest', + 'Rust #[wasm_bindgen] annotations', ), - ...compareClassInterfaces('src/browser.ts WASM class handles', browserInterfaces, manifest.wasm, wasmClassInterfaces), - ...compareSurface( - 'src/browser.ts public browser wrapper', - browserPublic, - manifest.browser, - 'wrapper', - 'manifest', + ...compareClassInterfaces( + 'src/browser.ts WASM class handles', + browserInterfaces, + wasmExpected, + wasmClassInterfaces, ), ] @@ -333,8 +447,8 @@ try { process.exit(1) } console.log( - `Binding parity OK: native ${manifest.native.values.size} exports, ` + - `WASM ${manifest.wasm.values.size} exports, browser ${manifest.browser.values.size} exports`, + `Binding parity OK: native ${nativeExpected.values.size} Rust exports, ` + + `WASM ${wasmExpected.values.size} Rust exports, browser portable wrapper ${browserPublic.values.size} exports`, ) } catch (error) { console.error(error instanceof Error ? error.message : String(error)) diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/typescript-sdk/test/basic.test.cjs index 0395839262..60b25310b8 100644 --- a/sdk/typescript-sdk/test/basic.test.cjs +++ b/sdk/typescript-sdk/test/basic.test.cjs @@ -771,6 +771,10 @@ test('chain client surface is exported without Polkadot.js glue', () => { ]) }) +test('declared Node runtime supports the default WebSocket client path', () => { + assert.equal(typeof globalThis.WebSocket, 'function') +}) + test('Balance numeric getters throw before losing precision', () => { const small = core.Balance.fromTao('1.25') assert.equal(small.amount, 1.25) From 112a89d633bf4c8e70cdde05bc3943fd2c5b392b Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 17:37:05 -0700 Subject: [PATCH 33/72] Restore parity validation for the browser wrapper --- sdk/typescript-sdk/README.md | 5 +- .../scripts/check-native-parity.cjs | 120 +++++++++++++++++- 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/sdk/typescript-sdk/README.md b/sdk/typescript-sdk/README.md index a11b6e6062..58322811c3 100644 --- a/sdk/typescript-sdk/README.md +++ b/sdk/typescript-sdk/README.md @@ -138,5 +138,6 @@ Mnemonic, password, and secret-URI derivation state is retained only by the Rust reconstructs a child secret URI. `npm run build` also generates binding coverage from the Rust-side `#[napi]` and `#[wasm_bindgen]` annotations, then checks that surface against the generated N-API declarations, `src/native.ts`, -the generated WASM declarations, and `BrowserWasmModule`, so binding additions -cannot silently disappear from either TypeScript boundary. +the generated WASM declarations, `BrowserWasmModule`, and an explicit +public-browser-wrapper allowlist, so binding additions cannot silently +disappear from either TypeScript boundary. diff --git a/sdk/typescript-sdk/scripts/check-native-parity.cjs b/sdk/typescript-sdk/scripts/check-native-parity.cjs index 37843843f3..a5280df6ae 100755 --- a/sdk/typescript-sdk/scripts/check-native-parity.cjs +++ b/sdk/typescript-sdk/scripts/check-native-parity.cjs @@ -363,6 +363,19 @@ function cloneSurface(surface) { } } +function allowlistedSurface(values, classes = {}) { + return { + values: new Set(values), + classes: new Map( + Object.entries(classes).map(([className, members]) => { + const instance = new Set(members.instance ?? []) + const statics = new Set(members.statics ?? []) + return [className, { instance, statics }] + }), + ), + } +} + const nativeClassInterfaces = { NativeKeypair: { instance: 'NativeKeypairHandle' }, NativeRuntime: { instance: 'NativeRuntimeHandle', statics: 'NativeRuntimeConstructor' }, @@ -375,6 +388,104 @@ const wasmClassInterfaces = { Runtime: { instance: 'BrowserWasmRuntime', statics: 'BrowserWasmRuntimeConstructor' }, } +const browserWrapperExpected = allowlistedSurface( + [ + 'CRYPTO_ED25519', + 'CRYPTO_SR25519', + 'DEFAULT_SS58_FORMAT', + 'Keypair', + 'Runtime', + 'configureBrowserWasm', + 'coreVersion', + 'decryptWithSignature', + 'encrypt', + 'encryptAtRound', + 'eraBirth', + 'generateCommitV2', + 'generateExtrinsicProof', + 'getEncryptedCommitment', + 'initBrowser', + 'loadBrowser', + 'metadataDigest', + 'mlkemKdfId', + 'multisigAccountId', + 'publicKeyFromSs58', + 'ready', + 'revealRound', + 'sealMevShieldTransaction', + 'setDefaultBrowserWasmLoader', + 'ss58FromPublic', + 'verifySignature', + ], + { + Runtime: { + instance: [ + 'composeCall', + 'constant', + 'decode', + 'decodeBatch', + 'decodeCall', + 'decodeExtrinsic', + 'decodeMapChanges', + 'decodeMapPairs', + 'decodeStorageKeyParams', + 'encode', + 'encodeEra', + 'encodeSignedExtrinsic', + 'extrinsicVersion', + 'isV15', + 'metadataIr', + 'moduleError', + 'registryJson', + 'runtimeApiMap', + 'runtimeApis', + 'signaturePayload', + 'signaturePayloadParts', + 'signedExtensionIdentifiers', + 'specVersion', + 'ss58Format', + 'storageEntry', + 'storageKey', + 'storageKeyBatch', + 'storagePrefix', + 'transactionVersion', + 'typeIdOf', + 'typeNameOf', + ], + }, + Keypair: { + statics: [ + 'createFromMnemonic', + 'createFromPrivateKey', + 'createFromSeed', + 'createFromUri', + 'fromMnemonic', + 'fromPrivateKey', + 'fromSeed', + 'fromUri', + 'generateMnemonic', + ], + instance: [ + 'address', + 'addressRaw', + 'cryptoType', + 'derive', + 'isLocked', + 'kind', + 'meta', + 'publicKey', + 'scheme', + 'setMeta', + 'sign', + 'ss58Address', + 'ss58Format', + 'type', + 'verify', + ], + }, + }, +) + try { const nativeExpected = rustBindingSurface(nativeRustRoot, 'napi') const wasmExpected = rustBindingSurface(wasmRustRoot, 'wasm_bindgen') @@ -439,6 +550,13 @@ try { wasmExpected, wasmClassInterfaces, ), + ...compareSurface( + 'src/browser.ts public browser wrapper', + browserPublic, + browserWrapperExpected, + 'wrapper', + 'browser wrapper allowlist', + ), ] if (failures.length > 0) { @@ -448,7 +566,7 @@ try { } console.log( `Binding parity OK: native ${nativeExpected.values.size} Rust exports, ` + - `WASM ${wasmExpected.values.size} Rust exports, browser portable wrapper ${browserPublic.values.size} exports`, + `WASM ${wasmExpected.values.size} Rust exports, browser portable wrapper ${browserWrapperExpected.values.size} exports`, ) } catch (error) { console.error(error instanceof Error ? error.message : String(error)) From 62916480577e63d53fddadc99a8d6629d121af71 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 17:40:56 -0700 Subject: [PATCH 34/72] rename to bittensor-ts --- ...ypescript-e2e.yml => bittensor-ts-e2e.yml} | 68 +++++++++---------- Cargo.lock | 2 +- Cargo.toml | 2 +- .../.gitignore | 0 .../README.md | 12 ++-- .../native/Cargo.toml | 4 +- .../native/build.rs | 0 .../native/src/digest.rs | 0 .../native/src/errors.rs | 0 .../native/src/keys.rs | 0 .../native/src/ledger.rs | 0 .../native/src/lib.rs | 0 .../native/src/mlkem.rs | 0 .../native/src/runtime.rs | 0 .../native/src/timelock.rs | 0 .../native/src/values.rs | 0 .../package-lock.json | 0 .../package.json | 6 +- .../scripts/browser-smoke.cjs | 0 .../scripts/check-native-parity.cjs | 0 .../scripts/clean.cjs | 0 .../scripts/generate-esm.cjs | 0 .../src/balance.ts | 0 .../src/browser.ts | 0 .../src/client.ts | 0 .../src/crypto.ts | 0 .../src/errors.ts | 0 .../src/index.ts | 0 .../src/keys.ts | 0 .../src/ledger.ts | 0 .../src/modules.ts | 0 .../src/native.ts | 0 .../src/runtime.ts | 0 .../src/timelock.ts | 0 .../src/types.ts | 0 .../src/value.ts | 0 .../src/wallet.ts | 0 .../src/wire.ts | 0 .../test/basic.test.cjs | 0 .../tsconfig.json | 0 ts-tests/package.json | 2 +- ts-tests/pnpm-lock.yaml | 4 +- ...typescript-sdk.ts => test-bittensor-ts.ts} | 4 +- 43 files changed, 52 insertions(+), 52 deletions(-) rename .github/workflows/{typescript-e2e.yml => bittensor-ts-e2e.yml} (86%) rename sdk/{typescript-sdk => bittensor-ts}/.gitignore (100%) rename sdk/{typescript-sdk => bittensor-ts}/README.md (93%) rename sdk/{typescript-sdk => bittensor-ts}/native/Cargo.toml (84%) rename sdk/{typescript-sdk => bittensor-ts}/native/build.rs (100%) rename sdk/{typescript-sdk => bittensor-ts}/native/src/digest.rs (100%) rename sdk/{typescript-sdk => bittensor-ts}/native/src/errors.rs (100%) rename sdk/{typescript-sdk => bittensor-ts}/native/src/keys.rs (100%) rename sdk/{typescript-sdk => bittensor-ts}/native/src/ledger.rs (100%) rename sdk/{typescript-sdk => bittensor-ts}/native/src/lib.rs (100%) rename sdk/{typescript-sdk => bittensor-ts}/native/src/mlkem.rs (100%) rename sdk/{typescript-sdk => bittensor-ts}/native/src/runtime.rs (100%) rename sdk/{typescript-sdk => bittensor-ts}/native/src/timelock.rs (100%) rename sdk/{typescript-sdk => bittensor-ts}/native/src/values.rs (100%) rename sdk/{typescript-sdk => bittensor-ts}/package-lock.json (100%) rename sdk/{typescript-sdk => bittensor-ts}/package.json (91%) rename sdk/{typescript-sdk => bittensor-ts}/scripts/browser-smoke.cjs (100%) rename sdk/{typescript-sdk => bittensor-ts}/scripts/check-native-parity.cjs (100%) rename sdk/{typescript-sdk => bittensor-ts}/scripts/clean.cjs (100%) rename sdk/{typescript-sdk => bittensor-ts}/scripts/generate-esm.cjs (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/balance.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/browser.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/client.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/crypto.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/errors.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/index.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/keys.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/ledger.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/modules.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/native.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/runtime.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/timelock.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/types.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/value.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/wallet.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/src/wire.ts (100%) rename sdk/{typescript-sdk => bittensor-ts}/test/basic.test.cjs (100%) rename sdk/{typescript-sdk => bittensor-ts}/tsconfig.json (100%) rename ts-tests/suites/dev/sdk/{test-typescript-sdk.ts => test-bittensor-ts.ts} (94%) diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/bittensor-ts-e2e.yml similarity index 86% rename from .github/workflows/typescript-e2e.yml rename to .github/workflows/bittensor-ts-e2e.yml index d758503851..88dc05933d 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/bittensor-ts-e2e.yml @@ -1,10 +1,10 @@ -name: Typescript E2E Tests +name: Bittensor TS E2E Tests on: pull_request: concurrency: - group: typescript-e2e-${{ github.ref }} + group: bittensor-ts-e2e-${{ github.ref }} cancel-in-progress: true env: @@ -43,7 +43,7 @@ jobs: run: | set -euo pipefail files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename') - pattern='^sdk/(typescript-sdk|bittensor-core|bittensor-core-wasm)/|^(ts-tests|common|node|pallets|precompiles|primitives|runtime|support|chain-extensions|src|vendor)/|^(Cargo\.(toml|lock)|build\.rs|rust-toolchain\.toml)$|^\.github/workflows/typescript-e2e\.yml$' + pattern='^sdk/(bittensor-ts|bittensor-core|bittensor-core-wasm)/|^(ts-tests|common|node|pallets|precompiles|primitives|runtime|support|chain-extensions|src|vendor)/|^(Cargo\.(toml|lock)|build\.rs|rust-toolchain\.toml)$|^\.github/workflows/bittensor-ts-e2e\.yml$' if grep -qE "$pattern" <<< "$files"; then echo "e2e=true" >> "$GITHUB_OUTPUT" else @@ -74,11 +74,11 @@ jobs: -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ build-essential clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config - - name: Install TypeScript SDK dependencies - run: npm --prefix sdk/typescript-sdk ci + - name: Install bittensor-ts dependencies + run: npm --prefix sdk/bittensor-ts ci - - name: Type-check TypeScript SDK - run: npm --prefix sdk/typescript-sdk run typecheck + - name: Type-check bittensor-ts + run: npm --prefix sdk/bittensor-ts run typecheck - name: Install e2e dependencies working-directory: ts-tests @@ -89,7 +89,7 @@ jobs: cd ts-tests pnpm run fmt - build-typescript-sdk: + build-bittensor-ts: runs-on: [self-hosted, fireactions-heavy] needs: [trusted-pr, typescript-formatting, changes] if: needs.changes.outputs.e2e == 'true' @@ -111,7 +111,7 @@ jobs: - name: Utilize Shared Rust Cache uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: - key: typescript-sdk + key: bittensor-ts cache-on-failure: true - name: Setup Node.js @@ -119,7 +119,7 @@ jobs: with: node-version-file: ts-tests/.nvmrc - - name: Install browser for TypeScript SDK browser tests + - name: Install browser for bittensor-ts browser tests run: | set -euo pipefail @@ -164,7 +164,7 @@ jobs: if ! chrome_bin="$(resolve_chrome)"; then if ! install_google_chrome; then - echo "::error::Unable to install a runnable browser for TypeScript SDK browser tests" + echo "::error::Unable to install a runnable browser for bittensor-ts browser tests" exit 1 fi if ! chrome_bin="$(resolve_chrome)"; then @@ -182,26 +182,26 @@ jobs: echo "$RUNNER_TEMP/wasm-pack/bin" >> "$GITHUB_PATH" "$RUNNER_TEMP/wasm-pack/bin/wasm-pack" --version - - name: Build and test TypeScript SDK + - name: Build and test bittensor-ts run: | - npm --prefix sdk/typescript-sdk ci - npm --prefix sdk/typescript-sdk run check + npm --prefix sdk/bittensor-ts ci + npm --prefix sdk/bittensor-ts run check - - name: Upload TypeScript SDK build + - name: Upload bittensor-ts build uses: actions/upload-artifact@v4 with: - name: bittensor-typescript-sdk + name: bittensor-ts path: | - sdk/typescript-sdk/dist - sdk/typescript-sdk/native.cjs - sdk/typescript-sdk/native.generated.d.ts - sdk/typescript-sdk/*.node + sdk/bittensor-ts/dist + sdk/bittensor-ts/native.cjs + sdk/bittensor-ts/native.generated.d.ts + sdk/bittensor-ts/*.node if-no-files-found: error - test-typescript-sdk-node-min: - name: TypeScript SDK package test (Node 22) + test-bittensor-ts-node-min: + name: bittensor-ts package test (Node 22) runs-on: ubuntu-latest - needs: [trusted-pr, typescript-formatting, changes, build-typescript-sdk] + needs: [trusted-pr, typescript-formatting, changes, build-bittensor-ts] if: needs.changes.outputs.e2e == 'true' timeout-minutes: 15 steps: @@ -213,20 +213,20 @@ jobs: with: node-version: 22 - - name: Install TypeScript SDK dependencies - run: npm --prefix sdk/typescript-sdk ci + - name: Install bittensor-ts dependencies + run: npm --prefix sdk/bittensor-ts ci - - name: Download TypeScript SDK build + - name: Download bittensor-ts build uses: actions/download-artifact@v4 with: - name: bittensor-typescript-sdk - path: sdk/typescript-sdk + name: bittensor-ts + path: sdk/bittensor-ts - name: Test package on minimum supported Node run: | node --version node -e "if (typeof globalThis.WebSocket !== 'function') throw new Error('globalThis.WebSocket is required by the default WSS client path')" - node --test sdk/typescript-sdk/test/*.test.cjs + node --test sdk/bittensor-ts/test/*.test.cjs # Build the node binary in both variants and share as artifacts. build: @@ -274,7 +274,7 @@ jobs: if-no-files-found: error run-e2e-tests: - needs: [trusted-pr, build, build-typescript-sdk] + needs: [trusted-pr, build, build-bittensor-ts] runs-on: [self-hosted, fireactions-heavy] timeout-minutes: 30 @@ -295,7 +295,7 @@ jobs: - test: zombienet_evm binary: fast - name: "typescript-e2e-${{ matrix.test }}" + name: "bittensor-ts-e2e-${{ matrix.test }}" steps: - name: Check-out repository @@ -310,11 +310,11 @@ jobs: - name: Make binary executable run: chmod +x target/release/node-subtensor - - name: Download TypeScript SDK build + - name: Download bittensor-ts build uses: actions/download-artifact@v4 with: - name: bittensor-typescript-sdk - path: sdk/typescript-sdk + name: bittensor-ts + path: sdk/bittensor-ts - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/Cargo.lock b/Cargo.lock index 4f0cda3e84..a7e10aeb46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1688,7 +1688,7 @@ dependencies = [ ] [[package]] -name = "bittensor-typescript-sdk-native" +name = "bittensor-ts-native" version = "0.1.0" dependencies = [ "bittensor-core", diff --git a/Cargo.toml b/Cargo.toml index 5542dbb9af..fa34692643 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ members = [ "primitives/*", "runtime", "sdk/bittensor-core", - "sdk/typescript-sdk/native", + "sdk/bittensor-ts/native", "sdk/bittensor-core-py", "sdk/bittensor-core-wasm", "support/*", diff --git a/sdk/typescript-sdk/.gitignore b/sdk/bittensor-ts/.gitignore similarity index 100% rename from sdk/typescript-sdk/.gitignore rename to sdk/bittensor-ts/.gitignore diff --git a/sdk/typescript-sdk/README.md b/sdk/bittensor-ts/README.md similarity index 93% rename from sdk/typescript-sdk/README.md rename to sdk/bittensor-ts/README.md index 58322811c3..39890c3b5d 100644 --- a/sdk/typescript-sdk/README.md +++ b/sdk/bittensor-ts/README.md @@ -1,7 +1,7 @@ # `@bittensor/sdk` -The monorepo's TypeScript SDK. It lives in its own `sdk/typescript-sdk` -package. In Node.js it is a deliberately thin Node-API wrapper around the +`bittensor-ts` is the monorepo's TypeScript SDK. It lives in its own +`sdk/bittensor-ts` package. In Node.js it is a deliberately thin Node-API wrapper around the sibling `bittensor-core` Rust crate. Browser applications use the explicit `@bittensor/sdk/browser` entrypoint backed by `sdk/bittensor-core-wasm`. @@ -48,12 +48,12 @@ helpers remain Node-only. From the repository root: ```sh -cargo test -p bittensor-typescript-sdk-native --all-features -npm --prefix sdk/typescript-sdk ci -npm --prefix sdk/typescript-sdk run check +cargo test -p bittensor-ts-native --all-features +npm --prefix sdk/bittensor-ts ci +npm --prefix sdk/bittensor-ts run check ``` -The native crate is isolated under `sdk/typescript-sdk/native`; it links +The native crate is isolated under `sdk/bittensor-ts/native`; it links `sdk/bittensor-core` directly and contains binding glue only. No chain algorithm is reimplemented in TypeScript. diff --git a/sdk/typescript-sdk/native/Cargo.toml b/sdk/bittensor-ts/native/Cargo.toml similarity index 84% rename from sdk/typescript-sdk/native/Cargo.toml rename to sdk/bittensor-ts/native/Cargo.toml index 0fd4862988..31ac5901cd 100644 --- a/sdk/typescript-sdk/native/Cargo.toml +++ b/sdk/bittensor-ts/native/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "bittensor-typescript-sdk-native" +name = "bittensor-ts-native" version = "0.1.0" edition = "2021" publish = false -description = "Native Node-API bridge used by the Bittensor TypeScript SDK" +description = "Native Node-API bridge used by bittensor-ts" license = "Apache-2.0" [lib] diff --git a/sdk/typescript-sdk/native/build.rs b/sdk/bittensor-ts/native/build.rs similarity index 100% rename from sdk/typescript-sdk/native/build.rs rename to sdk/bittensor-ts/native/build.rs diff --git a/sdk/typescript-sdk/native/src/digest.rs b/sdk/bittensor-ts/native/src/digest.rs similarity index 100% rename from sdk/typescript-sdk/native/src/digest.rs rename to sdk/bittensor-ts/native/src/digest.rs diff --git a/sdk/typescript-sdk/native/src/errors.rs b/sdk/bittensor-ts/native/src/errors.rs similarity index 100% rename from sdk/typescript-sdk/native/src/errors.rs rename to sdk/bittensor-ts/native/src/errors.rs diff --git a/sdk/typescript-sdk/native/src/keys.rs b/sdk/bittensor-ts/native/src/keys.rs similarity index 100% rename from sdk/typescript-sdk/native/src/keys.rs rename to sdk/bittensor-ts/native/src/keys.rs diff --git a/sdk/typescript-sdk/native/src/ledger.rs b/sdk/bittensor-ts/native/src/ledger.rs similarity index 100% rename from sdk/typescript-sdk/native/src/ledger.rs rename to sdk/bittensor-ts/native/src/ledger.rs diff --git a/sdk/typescript-sdk/native/src/lib.rs b/sdk/bittensor-ts/native/src/lib.rs similarity index 100% rename from sdk/typescript-sdk/native/src/lib.rs rename to sdk/bittensor-ts/native/src/lib.rs diff --git a/sdk/typescript-sdk/native/src/mlkem.rs b/sdk/bittensor-ts/native/src/mlkem.rs similarity index 100% rename from sdk/typescript-sdk/native/src/mlkem.rs rename to sdk/bittensor-ts/native/src/mlkem.rs diff --git a/sdk/typescript-sdk/native/src/runtime.rs b/sdk/bittensor-ts/native/src/runtime.rs similarity index 100% rename from sdk/typescript-sdk/native/src/runtime.rs rename to sdk/bittensor-ts/native/src/runtime.rs diff --git a/sdk/typescript-sdk/native/src/timelock.rs b/sdk/bittensor-ts/native/src/timelock.rs similarity index 100% rename from sdk/typescript-sdk/native/src/timelock.rs rename to sdk/bittensor-ts/native/src/timelock.rs diff --git a/sdk/typescript-sdk/native/src/values.rs b/sdk/bittensor-ts/native/src/values.rs similarity index 100% rename from sdk/typescript-sdk/native/src/values.rs rename to sdk/bittensor-ts/native/src/values.rs diff --git a/sdk/typescript-sdk/package-lock.json b/sdk/bittensor-ts/package-lock.json similarity index 100% rename from sdk/typescript-sdk/package-lock.json rename to sdk/bittensor-ts/package-lock.json diff --git a/sdk/typescript-sdk/package.json b/sdk/bittensor-ts/package.json similarity index 91% rename from sdk/typescript-sdk/package.json rename to sdk/bittensor-ts/package.json index 19e39e28b6..634a4667e0 100644 --- a/sdk/typescript-sdk/package.json +++ b/sdk/bittensor-ts/package.json @@ -2,7 +2,7 @@ "name": "@bittensor/sdk", "version": "0.1.0", "private": true, - "description": "Thin TypeScript SDK backed by the monorepo's bittensor-core Rust SDK", + "description": "bittensor-ts: the TypeScript SDK backed by the monorepo's bittensor-core Rust SDK", "license": "Apache-2.0", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -48,7 +48,7 @@ }, "scripts": { "build:native": "napi build --manifest-path native/Cargo.toml --output-dir . --platform --release --all-features --js native.cjs --dts native.generated.d.ts", - "build:wasm": "wasm-pack build ../bittensor-core-wasm --target bundler --out-dir ../typescript-sdk/dist/wasm --out-name bittensor_core_wasm", + "build:wasm": "wasm-pack build ../bittensor-core-wasm --target bundler --out-dir ../bittensor-ts/dist/wasm --out-name bittensor_core_wasm", "build:ts": "tsc -p tsconfig.json && node scripts/generate-esm.cjs", "build": "npm run build:native && npm run build:ts && npm run build:wasm && npm run check:binding-parity", "check": "npm run build && npm test", @@ -71,7 +71,7 @@ "repository": { "type": "git", "url": "https://github.com/RaoFoundation/subtensor.git", - "directory": "sdk/typescript-sdk" + "directory": "sdk/bittensor-ts" }, "type": "commonjs", "module": "dist/index.mjs" diff --git a/sdk/typescript-sdk/scripts/browser-smoke.cjs b/sdk/bittensor-ts/scripts/browser-smoke.cjs similarity index 100% rename from sdk/typescript-sdk/scripts/browser-smoke.cjs rename to sdk/bittensor-ts/scripts/browser-smoke.cjs diff --git a/sdk/typescript-sdk/scripts/check-native-parity.cjs b/sdk/bittensor-ts/scripts/check-native-parity.cjs similarity index 100% rename from sdk/typescript-sdk/scripts/check-native-parity.cjs rename to sdk/bittensor-ts/scripts/check-native-parity.cjs diff --git a/sdk/typescript-sdk/scripts/clean.cjs b/sdk/bittensor-ts/scripts/clean.cjs similarity index 100% rename from sdk/typescript-sdk/scripts/clean.cjs rename to sdk/bittensor-ts/scripts/clean.cjs diff --git a/sdk/typescript-sdk/scripts/generate-esm.cjs b/sdk/bittensor-ts/scripts/generate-esm.cjs similarity index 100% rename from sdk/typescript-sdk/scripts/generate-esm.cjs rename to sdk/bittensor-ts/scripts/generate-esm.cjs diff --git a/sdk/typescript-sdk/src/balance.ts b/sdk/bittensor-ts/src/balance.ts similarity index 100% rename from sdk/typescript-sdk/src/balance.ts rename to sdk/bittensor-ts/src/balance.ts diff --git a/sdk/typescript-sdk/src/browser.ts b/sdk/bittensor-ts/src/browser.ts similarity index 100% rename from sdk/typescript-sdk/src/browser.ts rename to sdk/bittensor-ts/src/browser.ts diff --git a/sdk/typescript-sdk/src/client.ts b/sdk/bittensor-ts/src/client.ts similarity index 100% rename from sdk/typescript-sdk/src/client.ts rename to sdk/bittensor-ts/src/client.ts diff --git a/sdk/typescript-sdk/src/crypto.ts b/sdk/bittensor-ts/src/crypto.ts similarity index 100% rename from sdk/typescript-sdk/src/crypto.ts rename to sdk/bittensor-ts/src/crypto.ts diff --git a/sdk/typescript-sdk/src/errors.ts b/sdk/bittensor-ts/src/errors.ts similarity index 100% rename from sdk/typescript-sdk/src/errors.ts rename to sdk/bittensor-ts/src/errors.ts diff --git a/sdk/typescript-sdk/src/index.ts b/sdk/bittensor-ts/src/index.ts similarity index 100% rename from sdk/typescript-sdk/src/index.ts rename to sdk/bittensor-ts/src/index.ts diff --git a/sdk/typescript-sdk/src/keys.ts b/sdk/bittensor-ts/src/keys.ts similarity index 100% rename from sdk/typescript-sdk/src/keys.ts rename to sdk/bittensor-ts/src/keys.ts diff --git a/sdk/typescript-sdk/src/ledger.ts b/sdk/bittensor-ts/src/ledger.ts similarity index 100% rename from sdk/typescript-sdk/src/ledger.ts rename to sdk/bittensor-ts/src/ledger.ts diff --git a/sdk/typescript-sdk/src/modules.ts b/sdk/bittensor-ts/src/modules.ts similarity index 100% rename from sdk/typescript-sdk/src/modules.ts rename to sdk/bittensor-ts/src/modules.ts diff --git a/sdk/typescript-sdk/src/native.ts b/sdk/bittensor-ts/src/native.ts similarity index 100% rename from sdk/typescript-sdk/src/native.ts rename to sdk/bittensor-ts/src/native.ts diff --git a/sdk/typescript-sdk/src/runtime.ts b/sdk/bittensor-ts/src/runtime.ts similarity index 100% rename from sdk/typescript-sdk/src/runtime.ts rename to sdk/bittensor-ts/src/runtime.ts diff --git a/sdk/typescript-sdk/src/timelock.ts b/sdk/bittensor-ts/src/timelock.ts similarity index 100% rename from sdk/typescript-sdk/src/timelock.ts rename to sdk/bittensor-ts/src/timelock.ts diff --git a/sdk/typescript-sdk/src/types.ts b/sdk/bittensor-ts/src/types.ts similarity index 100% rename from sdk/typescript-sdk/src/types.ts rename to sdk/bittensor-ts/src/types.ts diff --git a/sdk/typescript-sdk/src/value.ts b/sdk/bittensor-ts/src/value.ts similarity index 100% rename from sdk/typescript-sdk/src/value.ts rename to sdk/bittensor-ts/src/value.ts diff --git a/sdk/typescript-sdk/src/wallet.ts b/sdk/bittensor-ts/src/wallet.ts similarity index 100% rename from sdk/typescript-sdk/src/wallet.ts rename to sdk/bittensor-ts/src/wallet.ts diff --git a/sdk/typescript-sdk/src/wire.ts b/sdk/bittensor-ts/src/wire.ts similarity index 100% rename from sdk/typescript-sdk/src/wire.ts rename to sdk/bittensor-ts/src/wire.ts diff --git a/sdk/typescript-sdk/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs similarity index 100% rename from sdk/typescript-sdk/test/basic.test.cjs rename to sdk/bittensor-ts/test/basic.test.cjs diff --git a/sdk/typescript-sdk/tsconfig.json b/sdk/bittensor-ts/tsconfig.json similarity index 100% rename from sdk/typescript-sdk/tsconfig.json rename to sdk/bittensor-ts/tsconfig.json diff --git a/ts-tests/package.json b/ts-tests/package.json index d2e7302362..19a7a91a58 100644 --- a/ts-tests/package.json +++ b/ts-tests/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@inquirer/prompts": "7.3.1", - "@bittensor/sdk": "link:../sdk/typescript-sdk", + "@bittensor/sdk": "link:../sdk/bittensor-ts", "@polkadot-api/ink-contracts": "^0.4.1", "@polkadot-api/merkleize-metadata": "^1.1.15", "@polkadot-api/sdk-ink": "^0.5.1", diff --git a/ts-tests/pnpm-lock.yaml b/ts-tests/pnpm-lock.yaml index 4b1e86bc6e..72ce256e39 100644 --- a/ts-tests/pnpm-lock.yaml +++ b/ts-tests/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: 7.3.1 version: 7.3.1(@types/node@25.9.2) '@bittensor/sdk': - specifier: link:../sdk/typescript-sdk - version: link:../sdk/typescript-sdk + specifier: link:../sdk/bittensor-ts + version: link:../sdk/bittensor-ts '@polkadot-api/ink-contracts': specifier: ^0.4.1 version: 0.4.6 diff --git a/ts-tests/suites/dev/sdk/test-typescript-sdk.ts b/ts-tests/suites/dev/sdk/test-bittensor-ts.ts similarity index 94% rename from ts-tests/suites/dev/sdk/test-typescript-sdk.ts rename to ts-tests/suites/dev/sdk/test-bittensor-ts.ts index 6b3144b1cb..7650883548 100644 --- a/ts-tests/suites/dev/sdk/test-typescript-sdk.ts +++ b/ts-tests/suites/dev/sdk/test-bittensor-ts.ts @@ -15,7 +15,7 @@ function getSdkEndpoint(): string | undefined { describeSuite({ id: "DEV_TYPESCRIPT_SDK_01", - title: "Rust-backed TypeScript SDK integration", + title: "Rust-backed bittensor-ts integration", foundationMethods: "dev", testCases: ({ it, context }) => { it({ @@ -30,7 +30,7 @@ describeSuite({ const client = await new Client(endpoint).connect(); const alice = Keypair.fromUri("//Alice"); - const remark = blake2_256(Buffer.from(`typescript-sdk:${BINDING_VERSION}`)); + const remark = blake2_256(Buffer.from(`bittensor-ts:${BINDING_VERSION}`)); const call = await client.composeCall("System", "remark", { remark }); try { From 766dbe05ec64ee969ccef751d49a56a2a040f872 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 17:57:41 -0700 Subject: [PATCH 35/72] fix for review --- sdk/bittensor-core/src/keyfiles/mod.rs | 241 +++++++++++++ sdk/bittensor-core/src/keys/mod.rs | 41 ++- sdk/bittensor-ts/native/src/keys.rs | 39 +- sdk/bittensor-ts/scripts/browser-smoke.cjs | 68 ++-- .../scripts/check-native-parity.cjs | 114 ++++++ sdk/bittensor-ts/src/client.ts | 92 ++++- sdk/bittensor-ts/src/errors.ts | 4 +- sdk/bittensor-ts/src/keys.ts | 37 ++ sdk/bittensor-ts/src/modules.ts | 2 + sdk/bittensor-ts/src/native.ts | 11 + sdk/bittensor-ts/src/wallet.ts | 100 +----- sdk/bittensor-ts/test/basic.test.cjs | 333 ++++++++++++++++-- 12 files changed, 912 insertions(+), 170 deletions(-) diff --git a/sdk/bittensor-core/src/keyfiles/mod.rs b/sdk/bittensor-core/src/keyfiles/mod.rs index b58c5db187..4f607ea113 100644 --- a/sdk/bittensor-core/src/keyfiles/mod.rs +++ b/sdk/bittensor-core/src/keyfiles/mod.rs @@ -6,6 +6,11 @@ #![allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)] use std::collections::HashMap; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; use base64::{engine::general_purpose, Engine as _}; use fernet::Fernet; @@ -197,6 +202,242 @@ pub fn serialized_keypair_to_keyfile_data(keypair: &Keypair) -> Result, .map_err(|error| key_err(format!("serialization error: {error}"))) } +pub fn keypair_to_keyfile_data( + keypair: &Keypair, + password: Option<&str>, +) -> Result, CoreError> { + let plaintext = Zeroizing::new(serialized_keypair_to_keyfile_data(keypair)?); + if let Some(password) = password { + return encrypt_keyfile_data(&plaintext, password); + } + if keypair.has_private_key() { + return Err(key_err( + "plaintext private key serialization is disabled; provide a password or write through save_keypair_to_keyfile", + )); + } + Ok(plaintext.to_vec()) +} + +pub fn deserialize_keypair_from_keyfile( + keyfile_data: &[u8], + password: Option<&str>, +) -> Result { + if keyfile_data_is_encrypted(keyfile_data) { + let plaintext = Zeroizing::new(decrypt_keyfile_data(keyfile_data, password)?); + return deserialize_keypair_from_keyfile_data(&plaintext); + } + deserialize_keypair_from_keyfile_data(keyfile_data) +} + +pub fn save_keypair_to_keyfile( + keypair: &Keypair, + path: &Path, + password: Option<&str>, + overwrite: bool, +) -> Result<(), CoreError> { + let parent = path + .parent() + .ok_or_else(|| key_err("keyfile path must have a parent directory"))?; + ensure_private_directory(parent)?; + validate_keyfile_target(path, overwrite)?; + + let plaintext = Zeroizing::new(serialized_keypair_to_keyfile_data(keypair)?); + let data = if let Some(password) = password { + encrypt_keyfile_data(&plaintext, password)? + } else { + plaintext.to_vec() + }; + atomic_write_keyfile(path, &data, overwrite) +} + +fn ensure_private_directory(path: &Path) -> Result<(), CoreError> { + fs::create_dir_all(path).map_err(|error| { + key_err(format!( + "failed to create wallet directory {}: {error}", + path.display() + )) + })?; + let metadata = fs::symlink_metadata(path).map_err(|error| { + key_err(format!( + "failed to inspect wallet directory {}: {error}", + path.display() + )) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(key_err(format!( + "wallet path {} must be a real directory", + path.display() + ))); + } + set_private_directory_permissions(path) +} + +#[cfg(unix)] +fn set_private_directory_permissions(path: &Path) -> Result<(), CoreError> { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| { + key_err(format!( + "failed to set wallet directory permissions on {}: {error}", + path.display() + )) + }) +} + +#[cfg(not(unix))] +fn set_private_directory_permissions(_path: &Path) -> Result<(), CoreError> { + Ok(()) +} + +fn validate_keyfile_target(path: &Path, overwrite: bool) -> Result<(), CoreError> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(key_err(format!( + "refusing to write keyfile through symlink {}", + path.display() + ))); + } + if !metadata.is_file() { + return Err(key_err(format!( + "refusing to overwrite non-file keyfile path {}", + path.display() + ))); + } + if !overwrite { + return Err(key_err(format!( + "keyfile {} already exists", + path.display() + ))); + } + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(key_err(format!( + "failed to inspect keyfile {}: {error}", + path.display() + ))), + } +} + +fn atomic_write_keyfile(path: &Path, data: &[u8], overwrite: bool) -> Result<(), CoreError> { + let dir = path + .parent() + .ok_or_else(|| key_err("keyfile path must have a parent directory"))?; + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| key_err("keyfile path must be valid UTF-8"))?; + let mut temp_path = PathBuf::new(); + let mut temp_file = None; + + for attempt in 0..128u32 { + let candidate = dir.join(format!( + ".{file_name}.{}.{}.tmp", + std::process::id(), + attempt + )); + match private_create_new(&candidate) { + Ok(file) => { + temp_path = candidate; + temp_file = Some(file); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(key_err(format!( + "failed to create temporary keyfile {}: {error}", + candidate.display() + ))) + } + } + } + + let mut file = temp_file.ok_or_else(|| { + key_err(format!( + "failed to allocate temporary keyfile for {}", + path.display() + )) + })?; + let write_result = (|| -> Result<(), CoreError> { + file.write_all(data).map_err(|error| { + key_err(format!( + "failed to write temporary keyfile {}: {error}", + temp_path.display() + )) + })?; + file.sync_all().map_err(|error| { + key_err(format!( + "failed to fsync temporary keyfile {}: {error}", + temp_path.display() + )) + })?; + drop(file); + if overwrite { + fs::rename(&temp_path, path).map_err(|error| { + key_err(format!( + "failed to atomically replace keyfile {}: {error}", + path.display() + )) + })?; + } else { + fs::hard_link(&temp_path, path).map_err(|error| { + key_err(format!( + "failed to atomically create keyfile {}: {error}", + path.display() + )) + })?; + fs::remove_file(&temp_path).map_err(|error| { + key_err(format!( + "failed to remove temporary keyfile {}: {error}", + temp_path.display() + )) + })?; + } + set_private_file_permissions(path)?; + fsync_directory(dir); + Ok(()) + })(); + + if write_result.is_err() { + let _ = fs::remove_file(&temp_path); + } + write_result +} + +#[cfg(unix)] +fn private_create_new(path: &Path) -> std::io::Result { + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) +} + +#[cfg(not(unix))] +fn private_create_new(path: &Path) -> std::io::Result { + OpenOptions::new().write(true).create_new(true).open(path) +} + +#[cfg(unix)] +fn set_private_file_permissions(path: &Path) -> Result<(), CoreError> { + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| { + key_err(format!( + "failed to set keyfile permissions on {}: {error}", + path.display() + )) + }) +} + +#[cfg(not(unix))] +fn set_private_file_permissions(_path: &Path) -> Result<(), CoreError> { + Ok(()) +} + +fn fsync_directory(path: &Path) { + if let Ok(dir) = File::open(path) { + let _ = dir.sync_all(); + } +} + pub fn deserialize_keypair_from_keyfile_data(keyfile_data: &[u8]) -> Result { let decoded = std::str::from_utf8(keyfile_data).map_err(|_| key_err("failed to decode keyfile data"))?; diff --git a/sdk/bittensor-core/src/keys/mod.rs b/sdk/bittensor-core/src/keys/mod.rs index 891dc9d0b4..e2c066e4f0 100644 --- a/sdk/bittensor-core/src/keys/mod.rs +++ b/sdk/bittensor-core/src/keys/mod.rs @@ -14,7 +14,7 @@ use sodiumoxide::crypto::box_; use sodiumoxide::crypto::sealedbox; #[cfg(feature = "host")] use sodiumoxide::crypto::sign::ed25519 as sign_ed25519; -use sp_core::crypto::Pair as PairT; +use sp_core::crypto::{Pair as PairT, Ss58AddressFormat}; use sp_core::{ed25519, sr25519, ByteArray}; use zeroize::Zeroizing; @@ -51,11 +51,18 @@ pub fn public_key_from_ss58(ss58_address: &str) -> Result<[u8; 32], CoreError> { if decoded.len() != 35 && decoded.len() != 36 { return Err(crypto_err("invalid ss58 address length")); } - let prefix_len = match decoded[0] & 0b1100_0000 { - 0b0000_0000 => 1, - 0b0100_0000 => 2, + let (prefix_len, ident) = match decoded[0] { + 0..=63 => (1, decoded[0] as u16), + 64..=127 => { + let lower = (decoded[0] << 2) | (decoded[1] >> 6); + let upper = decoded[1] & 0b0011_1111; + (2, (lower as u16) | ((upper as u16) << 8)) + } _ => return Err(crypto_err("invalid ss58 address prefix")), }; + if Ss58AddressFormat::from(ident).is_reserved() { + return Err(crypto_err("invalid ss58 address format")); + } if decoded.len() != prefix_len + 32 + 2 { return Err(crypto_err("invalid ss58 account length")); } @@ -575,6 +582,32 @@ mod tests { } } + #[test] + fn ss58_decoding_matches_sp_core_allowed_and_reserved_formats() { + let mut key = [0u8; 32]; + key[..4].copy_from_slice(&42u32.to_le_bytes()); + for format in [0u16, 2, 42, 45, 46, 47, 48, 63, 64, 255, 4096, 16383] { + let address = ss58_from_public(key, format); + let ours = public_key_from_ss58(&address); + let sp_core = AccountId32::from_ss58check_with_version(&address).map(|(account, _)| { + let bytes: &[u8] = account.as_ref(); + <[u8; 32]>::try_from(bytes).unwrap() + }); + assert_eq!( + ours.is_ok(), + sp_core.is_ok(), + "decode acceptance diverged for format {format}", + ); + if let Ok(expected) = sp_core { + assert_eq!( + ours.unwrap(), + expected, + "decoded key diverged for format {format}" + ); + } + } + } + #[test] fn alice_uri_matches_known_address() { let kp = Keypair::from_uri("//Alice", CRYPTO_SR25519).unwrap(); diff --git a/sdk/bittensor-ts/native/src/keys.rs b/sdk/bittensor-ts/native/src/keys.rs index b86f03fb8e..cf137ae356 100644 --- a/sdk/bittensor-ts/native/src/keys.rs +++ b/sdk/bittensor-ts/native/src/keys.rs @@ -2,6 +2,7 @@ use bittensor_core::keyfiles; use bittensor_core::keys::{self, Keypair, CRYPTO_ED25519, CRYPTO_SR25519, DEFAULT_SS58_FORMAT}; use napi::bindgen_prelude::Buffer; use napi_derive::napi; +use std::path::PathBuf; use crate::errors::{invalid_arg, CoreResultExt, NapiResult}; @@ -188,7 +189,17 @@ pub fn ss58_from_public(public_key: Buffer, ss58_format: u16) -> NapiResult NapiResult { - keyfiles::serialized_keypair_to_keyfile_data(&keypair.inner) + keyfiles::keypair_to_keyfile_data(&keypair.inner, None) + .napi() + .map(Into::into) +} + +#[napi(js_name = "keypairToKeyfileData")] +pub fn keypair_to_keyfile_data( + keypair: &NativeKeypair, + password: Option, +) -> NapiResult { + keyfiles::keypair_to_keyfile_data(&keypair.inner, password.as_deref()) .napi() .map(Into::into) } @@ -200,6 +211,32 @@ pub fn deserialize_keypair(keyfile_data: Buffer) -> NapiResult { .map(NativeKeypair::new) } +#[napi(js_name = "deserializeKeypairFromKeyfile")] +pub fn deserialize_keypair_from_keyfile( + keyfile_data: Buffer, + password: Option, +) -> NapiResult { + keyfiles::deserialize_keypair_from_keyfile(keyfile_data.as_ref(), password.as_deref()) + .napi() + .map(NativeKeypair::new) +} + +#[napi(js_name = "writeKeypairKeyfile")] +pub fn write_keypair_keyfile( + keypair: &NativeKeypair, + path: String, + password: Option, + overwrite: bool, +) -> NapiResult<()> { + keyfiles::save_keypair_to_keyfile( + &keypair.inner, + &PathBuf::from(path), + password.as_deref(), + overwrite, + ) + .napi() +} + #[napi(js_name = "encryptKeyfileData")] pub fn encrypt_keyfile_data(keyfile_data: Buffer, password: String) -> NapiResult { keyfiles::encrypt_keyfile_data(keyfile_data.as_ref(), &password) diff --git a/sdk/bittensor-ts/scripts/browser-smoke.cjs b/sdk/bittensor-ts/scripts/browser-smoke.cjs index 2d8396355a..c71810167c 100644 --- a/sdk/bittensor-ts/scripts/browser-smoke.cjs +++ b/sdk/bittensor-ts/scripts/browser-smoke.cjs @@ -43,14 +43,17 @@ function modulePath(filePath) { return relative.startsWith('.') ? relative : `./${relative}` } -function browserEntry(metadataHex) { +function browserEntry(metadataHex, mode) { + const useCustomLoader = mode === 'custom' return ` import * as sdk from ${JSON.stringify(modulePath(path.join(root, 'dist', 'browser.mjs')))} -import * as wasmBindings from ${JSON.stringify(modulePath(path.join(root, 'dist', 'wasm', 'bittensor_core_wasm_bg.js')))} -import wasmUrl from ${JSON.stringify(modulePath(path.join(root, 'dist', 'wasm', 'bittensor_core_wasm_bg.wasm')))} +${useCustomLoader ? `import * as wasmBindings from ${JSON.stringify(modulePath(path.join(root, 'dist', 'wasm', 'bittensor_core_wasm_bg.js')))} +import wasmUrl from ${JSON.stringify(modulePath(path.join(root, 'dist', 'wasm', 'bittensor_core_wasm_bg.wasm')))}` : ''} const metadataHex = ${JSON.stringify(metadataHex)} const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const mode = ${JSON.stringify(mode)} +const useCustomLoader = ${JSON.stringify(useCustomLoader)} function assert(condition, message) { if (!condition) throw new Error(message) @@ -62,7 +65,7 @@ function hexToBytes(hex) { return out } -async function loadWasm() { +${useCustomLoader ? `async function loadWasm() { const response = await fetch(wasmUrl) if (!response.ok) throw new Error('failed to fetch WASM: ' + response.status) const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), { @@ -73,10 +76,10 @@ async function loadWasm() { const module = { ...wasmBindings } delete module.default return module -} +}` : ''} try { - sdk.configureBrowserWasm(() => loadWasm()) + if (useCustomLoader) sdk.configureBrowserWasm(() => loadWasm()) const wasm = await sdk.initBrowser() assert(typeof wasm.Runtime === 'function', 'WASM Runtime constructor is available') assert(typeof sdk.coreVersion() === 'string' && sdk.coreVersion().length > 0, 'WASM initialized') @@ -132,6 +135,7 @@ try { window.__SDK_BROWSER_RESULT__ = { ok: true, + mode, address: keypair.ss58Address, callLength: call.length, extrinsicLength: signed.bytes.length, @@ -146,25 +150,32 @@ try { } async function bundleBrowserEntry() { - const entry = path.join(tmp, 'entry.js') - fs.writeFileSync(entry, browserEntry(goldenMetadataHex())) + const metadataHex = goldenMetadataHex() + const customEntry = path.join(tmp, 'custom-entry.js') + const defaultEntry = path.join(tmp, 'default-entry.js') + fs.writeFileSync(customEntry, browserEntry(metadataHex, 'custom')) + fs.writeFileSync(defaultEntry, browserEntry(metadataHex, 'default')) const outdir = path.join(tmp, 'site') fs.mkdirSync(outdir, { recursive: true }) await esbuild.build({ - entryPoints: [entry], + entryPoints: [customEntry, defaultEntry], bundle: true, format: 'esm', platform: 'browser', target: ['chrome110'], outdir, - entryNames: 'bundle', + entryNames: '[name]', assetNames: 'assets/[name]-[hash]', loader: { '.wasm': 'file' }, logLevel: 'silent', }) fs.writeFileSync( - path.join(outdir, 'index.html'), - 'Bittensor SDK Browser Smoke', + path.join(outdir, 'custom.html'), + 'Bittensor SDK Browser Smoke', + ) + fs.writeFileSync( + path.join(outdir, 'default.html'), + 'Bittensor SDK Browser Smoke', ) return outdir } @@ -329,20 +340,11 @@ async function waitForBrowserResult(client) { } } -async function main() { - let server +async function runBrowserPage(chromePath, url, mode) { let chrome let client try { - const site = await bundleBrowserEntry() - const chromePath = findChrome() - if (chromePath == null) { - console.warn('Skipping browser smoke tests because Chromium is not installed; CI requires Chromium.') - return - } - const served = await serve(site) - server = served.server - chrome = launchChrome(chromePath, served.url) + chrome = launchChrome(chromePath, url) const browserWsUrl = await chrome.devtools client = cdp(await pageWebSocketUrl(browserWsUrl)) await client.ready @@ -350,13 +352,31 @@ async function main() { await client.send('Page.enable') const result = await waitForBrowserResult(client) assert.equal(result.ok, true) + assert.equal(result.mode, mode) assert.equal(typeof result.address, 'string') assert.ok(result.callLength > 0) assert.ok(result.extrinsicLength > result.callLength) } finally { client?.close() - server?.close() if (chrome?.child.exitCode == null) chrome?.child.kill() + } +} + +async function main() { + let server + try { + const site = await bundleBrowserEntry() + const chromePath = findChrome() + if (chromePath == null) { + console.warn('Skipping browser smoke tests because Chromium is not installed; CI requires Chromium.') + return + } + const served = await serve(site) + server = served.server + await runBrowserPage(chromePath, new URL('/custom.html', served.url).href, 'custom') + await runBrowserPage(chromePath, new URL('/default.html', served.url).href, 'default') + } finally { + server?.close() fs.rmSync(tmp, { recursive: true, force: true }) } } diff --git a/sdk/bittensor-ts/scripts/check-native-parity.cjs b/sdk/bittensor-ts/scripts/check-native-parity.cjs index a5280df6ae..274bf755c6 100755 --- a/sdk/bittensor-ts/scripts/check-native-parity.cjs +++ b/sdk/bittensor-ts/scripts/check-native-parity.cjs @@ -8,6 +8,7 @@ const ts = require('typescript') const root = path.resolve(__dirname, '..') const sdkRoot = path.resolve(root, '..') const repoRoot = path.resolve(sdkRoot, '..') +const coreRustRoot = path.join(sdkRoot, 'bittensor-core', 'src') const nativeRustRoot = path.join(root, 'native', 'src') const wasmRustRoot = path.join(sdkRoot, 'bittensor-core-wasm', 'src') const nativeGeneratedPath = path.join(root, 'native.generated.d.ts') @@ -376,6 +377,118 @@ function allowlistedSurface(values, classes = {}) { } } +function flattenSurface(surface) { + const out = new Set(surface.values) + for (const members of surface.classes.values()) { + for (const name of members.instance) out.add(name) + for (const name of members.statics) out.add(name) + } + return out +} + +function rustCorePublicFunctions(directory) { + const items = [] + for (const filePath of readRustFiles(directory)) { + const relative = path.relative(directory, filePath).replace(/\\/g, '/') + const source = fs.readFileSync(filePath, 'utf8') + for (const match of source.matchAll(/^\s*pub\s+fn\s+([A-Za-z0-9_]+)/gm)) { + items.push({ + key: `${relative}#${match[1]}`, + file: relative, + rustName: match[1], + jsName: snakeToCamel(match[1]), + }) + } + } + return items +} + +const coreCoveragePrivateFiles = new Set([ + // Private implementation modules; their public helpers are not crate-public API. + 'keys/base58.rs', + 'keys/encrypted_json.rs', + 'signers/hid.rs', + // Fixture vectors are test support, not SDK API. + 'timelock/epoch_schedule_vectors.rs', +]) + +const coreCoverageAliases = new Map([ + ['codec/batch.rs#decode_map_page', ['decodeMapPairs']], + ['codec/decode.rs#new', ['fromBytes']], + ['codec/decode.rs#compact_u128', ['decodeCompactU128']], + ['codec/decode.rs#compact_len', ['decodeCompactLength']], + ['codec/decode.rs#decode_id', ['decodeTypeId']], + ['codec/encode.rs#compact', ['encodeCompact']], + ['codec/encode.rs#encode_era_value', ['encodeEra']], + ['codec/storage.rs#hash_param', ['hashStorageParam']], + ['codec/storage.rs#concat_hash_len', ['concatHashLength']], + ['codec/storage.rs#storage_prefix', ['storagePrefixFor']], + ['codec/value.rs#str', ['coreValueString']], + ['codec/value.rs#hex', ['coreValueHex']], + ['codec/value.rs#record', ['coreValueRecord']], + ['codec/value.rs#to_corpus_json', ['valueToCorpusJson', 'coreValueDescriptorToCorpusJson']], + ['codec/value.rs#u256_decimal', ['u256LeToDecimal']], + ['keyfiles/mod.rs#serialized_keypair_to_keyfile_data', ['serializeKeypair']], + ['keyfiles/mod.rs#deserialize_keypair_from_keyfile_data', ['deserializeKeypair']], + ['keyfiles/mod.rs#save_keypair_to_keyfile', ['writeKeypairKeyfile']], + ['keys/mod.rs#new', ['keypairNew', 'Keypair']], + ['keys/mod.rs#from_mnemonic', ['keypairFromMnemonic', 'fromMnemonic']], + ['keys/mod.rs#from_seed', ['keypairFromSeed', 'fromSeed']], + ['keys/mod.rs#from_uri', ['keypairFromUri', 'fromUri']], + ['keys/mod.rs#from_private_key', ['keypairFromPrivateKey', 'fromPrivateKey']], + ['keys/mod.rs#from_encrypted_json', ['keypairFromEncryptedJson', 'fromEncryptedJson']], + ['keys/mod.rs#public_key_bytes', ['publicKey']], + ['keys/mod.rs#verify', ['verifySignature', 'verify']], + ['mlkem/mod.rs#twox_128', ['mlkemTwox128', 'twox_128']], + ['mlkem/mod.rs#seal', ['mlkemSeal', 'sealMevShieldTransaction']], + ['runtime/mod.rs#parse', ['fromMetadata', 'Runtime']], + ['runtime/mod.rs#resolve', ['resolveType']], + ['runtime/type_string.rs#from_name', ['primitiveFromName']], + ['timelock/constants.rs#max_simulation_blocks', ['timelockMaxSimulationBlocks', 'maxSimulationBlocks']], + ['timelock/epoch_schedule.rs#should_run_epoch', ['epochShouldRun', 'shouldRunEpoch']], + ['timelock/epoch_schedule.rs#current_epoch_pre_run_coinbase', ['epochCurrentPreRunCoinbase', 'currentEpochPreRunCoinbase']], + ['timelock/epoch_schedule.rs#simulate_run_coinbase', ['epochSimulateRunCoinbase', 'simulateRunCoinbase']], + ['timelock/epoch_schedule.rs#advance_blocks', ['epochAdvanceBlocks', 'advanceBlocks']], + ['timelock/epoch_schedule.rs#predict_first_reveal_block', ['epochPredictFirstRevealBlock', 'predictFirstRevealBlock']], + ['timelock/mod.rs#reveal_round', ['revealRound']], + ['timelock/mod.rs#encrypt_and_compress', ['timelockEncryptAndCompress', 'encryptAndCompress']], + ['timelock/mod.rs#decrypt_and_decompress', ['timelockDecryptAndDecompress', 'decryptAndDecompress']], + ['timelock/mod.rs#generate_commit_v2', ['timelockGenerateCommitV2', 'generateCommitV2']], + ['timelock/mod.rs#encrypt_commitment', ['timelockEncryptCommitment', 'encryptCommitment']], + ['timelock/mod.rs#encrypt_n_blocks', ['timelockEncryptNBlocks', 'encryptNBlocks']], + ['timelock/mod.rs#encrypt_at_round', ['timelockEncryptAtRound', 'encryptAtRound']], + ['timelock/mod.rs#get_round_info', ['timelockGetRoundInfo', 'getRoundInfo']], + ['timelock/mod.rs#get_reveal_round_signature', ['timelockGetRevealRoundSignature', 'getRevealRoundSignature']], + ['timelock/mod.rs#decrypt', ['timelockDecrypt', 'decrypt']], + ['timelock/mod.rs#decrypt_with_signature', ['timelockDecryptWithSignature', 'decryptWithSignature']], +]) + +const coreCoverageIntentionalCoreOnly = new Map([ + ['keys/mod.rs#has_private_key', 'private-key presence is represented by Keypair.kind, not exported as a raw core method'], + ['keys/mod.rs#private_key_bytes', 'secret key bytes must not be exportable to JavaScript'], +]) + +function compareCoreCoverage(nativeExpected, wasmExpected, browserWrapperExpected) { + const bindingNames = new Set([ + ...flattenSurface(nativeExpected), + ...flattenSurface(wasmExpected), + ...flattenSurface(browserWrapperExpected), + ]) + const failures = [] + for (const item of rustCorePublicFunctions(coreRustRoot)) { + if (coreCoveragePrivateFiles.has(item.file)) continue + if (coreCoverageIntentionalCoreOnly.has(item.key)) continue + const candidates = coreCoverageAliases.get(item.key) ?? [item.jsName] + if (!candidates.some((name) => bindingNames.has(name))) { + failures.push( + `bittensor-core public function ${item.key} is not covered by N-API/WASM bindings ` + + `(checked names: ${candidates.join(', ')})`, + ) + } + } + return failures +} + const nativeClassInterfaces = { NativeKeypair: { instance: 'NativeKeypairHandle' }, NativeRuntime: { instance: 'NativeRuntimeHandle', statics: 'NativeRuntimeConstructor' }, @@ -557,6 +670,7 @@ try { 'wrapper', 'browser wrapper allowlist', ), + ...compareCoreCoverage(nativeExpected, wasmExpected, browserWrapperExpected), ] if (failures.length > 0) { diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 8a516f024a..4c4aac3112 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -71,6 +71,8 @@ export interface SubmitOptions { metadataHash?: ByteLike | null waitForInclusion?: boolean waitForFinalization?: boolean + timeoutMs?: number + signal?: AbortSignal } export interface SignerAccountContext { @@ -206,6 +208,7 @@ interface SubscriptionState { subscribeMethod: string params: unknown[] unsubscribeMethod: string + requestOptions: RpcRequestOptions subscription?: string resubscribing?: Promise } @@ -248,7 +251,7 @@ interface NonceReservation { type SubmittedExtrinsicLocation = 'pool' | 'block' | null -interface SubscriptionOptions { +interface SubscriptionOptions extends RpcRequestOptions { resubscribe?: boolean } @@ -388,6 +391,13 @@ export class JsonRpcTransport { subscribeMethod, params, unsubscribeMethod, + requestOptions: { + signal: options.signal, + timeoutMs: options.timeoutMs, + maxRetries: options.maxRetries, + retryBackoffMs: options.retryBackoffMs, + maxRetryBackoffMs: options.maxRetryBackoffMs, + }, } this.subscriptions.add(state) try { @@ -596,7 +606,9 @@ export class JsonRpcTransport { } private async activateSubscription(state: SubscriptionState): Promise { - const subscription = String(await this.request(state.subscribeMethod, state.params)) + const subscription = String( + await this.request(state.subscribeMethod, state.params, state.requestOptions), + ) if (state.closed) { await this.request(state.unsubscribeMethod, [subscription]).catch(() => undefined) return @@ -1235,11 +1247,16 @@ export class Client { if (options.waitForInclusion || options.waitForFinalization) { const watcher = await this.watchSigned(extrinsic, { waitForFinalization: options.waitForFinalization ?? false, + timeoutMs: options.timeoutMs, + signal: options.signal, }) return await watcher.result } try { - const hash = String(await this.rpc('author_submitExtrinsic', [hex(bytes)])) + const hash = String(await this.transport.request('author_submitExtrinsic', [hex(bytes)], { + timeoutMs: options.timeoutMs, + signal: options.signal, + })) if (reservation != null) await this.submitNonce(reservation) return { status: 'submitted', message: 'Submitted', extrinsicHash: hash, events: [] } } catch (error) { @@ -1250,7 +1267,7 @@ export class Client { async watchSigned( extrinsic: SignedExtrinsicResult | SignedExtrinsic | ByteLike, - options: Pick = {}, + options: Pick = {}, ): Promise { const bytes = Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array ? toBuffer(extrinsic, 'extrinsic') @@ -1263,7 +1280,11 @@ export class Client { 'author_submitAndWatchExtrinsic', [hex(bytes)], 'author_unwatchExtrinsic', - { resubscribe: false }, + { + resubscribe: false, + timeoutMs: options.timeoutMs, + signal: options.signal, + }, ) if (reservation != null) await this.submitNonce(reservation) } catch (error) { @@ -1274,6 +1295,10 @@ export class Client { subscription, extrinsicHash, options.waitForFinalization ?? false, + { + timeoutMs: options.timeoutMs, + signal: options.signal, + }, ).then( async (value) => { if (reservation != null) await this.confirmNonce(reservation) @@ -1296,9 +1321,8 @@ export class Client { return nonce } - async accountNextIndex(address: string, useCache = true): Promise { - if (!useCache) return this.peekNextIndex(address) - return (await this.reserveNonce(address)).nonce + async accountNextIndex(address: string, _useCache = false): Promise { + return this.peekNextIndex(address) } clearNonce(address: string): void { @@ -1352,14 +1376,35 @@ export class Client { reservation: NonceReservation, extrinsicHash?: string, ): Promise { - const [location, chainNext] = await Promise.all([ + const [locationResult, chainNextResult] = await Promise.all([ extrinsicHash == null - ? Promise.resolve(null) - : this.submittedExtrinsicLocation(extrinsicHash).catch(() => null), - this.peekNextIndex(reservation.address).catch(() => undefined), + ? Promise.resolve<{ ok: true; location: SubmittedExtrinsicLocation }>({ + ok: true, + location: null, + }) + : this.submittedExtrinsicLocation(extrinsicHash).then( + (location) => ({ ok: true as const, location }), + (error) => ({ ok: false as const, error }), + ), + this.peekNextIndex(reservation.address).then( + (nonce) => ({ ok: true as const, nonce }), + (error) => ({ ok: false as const, error }), + ), ]) await this.withNonceAccount(reservation.address, (state) => { + const location = locationResult.ok ? locationResult.location : undefined + const chainNext = chainNextResult.ok ? chainNextResult.nonce : undefined const submitted = location != null || (chainNext != null && chainNext > reservation.nonce) + const definitelyAbsent = + locationResult.ok && + location == null && + chainNext != null && + chainNext <= reservation.nonce + if (!submitted && !definitelyAbsent) { + invalidateNonceState(state) + return + } + if (chainNext != null) state.next = chainNext else if (state.next == null || state.next <= reservation.nonce) state.next = reservation.nonce + 1 @@ -1394,8 +1439,8 @@ export class Client { private async submittedExtrinsicLocation(extrinsicHash: string): Promise { const normalized = extrinsicHash.toLowerCase() - if (await this.pendingExtrinsicsContain(normalized).catch(() => false)) return 'pool' - return await this.recentBlocksContainExtrinsic(normalized).catch(() => false) ? 'block' : null + if (await this.pendingExtrinsicsContain(normalized)) return 'pool' + return await this.recentBlocksContainExtrinsic(normalized) ? 'block' : null } private async pendingExtrinsicsContain(extrinsicHash: string): Promise { @@ -1687,9 +1732,20 @@ export class Client { subscription: AsyncIterable & { unsubscribe(): Promise }, extrinsicHash: string, waitForFinalization: boolean, + options: Pick = {}, ): Promise { + const request = withRequestSignal({ + timeoutMs: options.timeoutMs ?? 0, + signal: options.signal, + }) + let abortError: Error | undefined + request.onAbort((error) => { + abortError = error + void subscription.unsubscribe() + }) try { for await (const status of subscription) { + if (abortError != null) throw abortError const normalized = normalizeStatus(status) const fatal = ['usurped', 'retracted', 'finalitytimeout', 'dropped', 'invalid'].find((name) => normalized[name] != null) if (fatal != null) throw new ChainError(`Extrinsic ${fatal}`, status) @@ -1700,8 +1756,10 @@ export class Client { return this.resolveInclusion(extrinsicHash, String(normalized.inblock), false) } } + if (abortError != null) throw abortError throw new ChainError('extrinsic watch ended before inclusion') } finally { + request.cleanup() await subscription.unsubscribe() } } @@ -2378,6 +2436,12 @@ function pruneNonceStatuses(state: NonceAccountState): void { } } +function invalidateNonceState(state: NonceAccountState): void { + state.next = undefined + state.reusable = [] + state.statuses.clear() +} + function stringValue(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined } diff --git a/sdk/bittensor-ts/src/errors.ts b/sdk/bittensor-ts/src/errors.ts index 984a65bf04..84bfbb4de8 100644 --- a/sdk/bittensor-ts/src/errors.ts +++ b/sdk/bittensor-ts/src/errors.ts @@ -88,7 +88,9 @@ export function mapNativeError(error: unknown): Error { export function nativeCall(operation: () => T): T { try { - return operation() + const value = operation() + if (value instanceof Error) throw value + return value } catch (error) { throw mapNativeError(error) } diff --git a/sdk/bittensor-ts/src/keys.ts b/sdk/bittensor-ts/src/keys.ts index 10f89f1886..4725563763 100644 --- a/sdk/bittensor-ts/src/keys.ts +++ b/sdk/bittensor-ts/src/keys.ts @@ -208,6 +208,17 @@ export class Keypair implements PolkadotCompatibleKeypair { ) } + static fromKeyfileData(keyfileData: ByteLike, password?: string | null): Keypair { + return Keypair.wrap( + nativeCall(() => + native.deserializeKeypairFromKeyfile( + toBuffer(keyfileData, 'keyfileData'), + password ?? undefined, + ), + ), + ) + } + get cryptoType(): number { return this.handle.cryptoType } @@ -350,6 +361,18 @@ export class Keypair implements PolkadotCompatibleKeypair { serialize(): Buffer { return nativeCall(() => native.serializeKeypair(this.handle)) } + + toKeyfileData(password?: string | null): Buffer { + return nativeCall(() => + native.keypairToKeyfileData(this.handle, password ?? undefined), + ) + } + + writeKeyfile(path: string, password?: string | null, overwrite = false): void { + nativeCall(() => + native.writeKeypairKeyfile(this.handle, path, password ?? undefined, overwrite), + ) + } } export function createKeyringPairFromUri( @@ -442,6 +465,20 @@ export function deserializeKeypair(keyfileData: ByteLike): Keypair { export const deserializeKeypairFromKeyfileData = deserializeKeypair +export function keypairToKeyfileData( + keypair: Keypair, + password?: string | null, +): Buffer { + return keypair.toKeyfileData(password) +} + +export function deserializeKeypairFromKeyfile( + keyfileData: ByteLike, + password?: string | null, +): Keypair { + return Keypair.fromKeyfileData(keyfileData, password) +} + export function encryptKeyfileData(keyfileData: ByteLike, password: string): Buffer { return nativeCall(() => native.encryptKeyfileData(toBuffer(keyfileData, 'keyfileData'), password), diff --git a/sdk/bittensor-ts/src/modules.ts b/sdk/bittensor-ts/src/modules.ts index 4514dd0c65..40ad0d7d2f 100644 --- a/sdk/bittensor-ts/src/modules.ts +++ b/sdk/bittensor-ts/src/modules.ts @@ -40,8 +40,10 @@ export const rustCore = Object.freeze({ keyfiles: Object.freeze({ serializeKeypair: keys.serializeKeypair, serializedKeypairToKeyfileData: keys.serializedKeypairToKeyfileData, + keypairToKeyfileData: keys.keypairToKeyfileData, deserializeKeypair: keys.deserializeKeypair, deserializeKeypairFromKeyfileData: keys.deserializeKeypairFromKeyfileData, + deserializeKeypairFromKeyfile: keys.deserializeKeypairFromKeyfile, encryptKeyfileData: keys.encryptKeyfileData, decryptKeyfileData: keys.decryptKeyfileData, keyfileDataIsEncrypted: keys.keyfileDataIsEncrypted, diff --git a/sdk/bittensor-ts/src/native.ts b/sdk/bittensor-ts/src/native.ts index c8ca40447f..7711b0a334 100644 --- a/sdk/bittensor-ts/src/native.ts +++ b/sdk/bittensor-ts/src/native.ts @@ -295,7 +295,18 @@ export interface NativeBinding { publicKeyFromSs58(ss58Address: string): Buffer ss58FromPublic(publicKey: Buffer, ss58Format: number): string serializeKeypair(keypair: NativeKeypairHandle): Buffer + keypairToKeyfileData(keypair: NativeKeypairHandle, password?: string | null): Buffer deserializeKeypair(keyfileData: Buffer): NativeKeypairHandle + deserializeKeypairFromKeyfile( + keyfileData: Buffer, + password?: string | null, + ): NativeKeypairHandle + writeKeypairKeyfile( + keypair: NativeKeypairHandle, + path: string, + password: string | null | undefined, + overwrite: boolean, + ): void encryptKeyfileData(keyfileData: Buffer, password: string): Buffer decryptKeyfileData(keyfileData: Buffer, password?: string | null): Buffer keyfileDataIsEncrypted(keyfileData: Buffer): boolean diff --git a/sdk/bittensor-ts/src/wallet.ts b/sdk/bittensor-ts/src/wallet.ts index 8fa52ab66d..3f9b937b0c 100644 --- a/sdk/bittensor-ts/src/wallet.ts +++ b/sdk/bittensor-ts/src/wallet.ts @@ -1,30 +1,13 @@ -import { randomBytes } from 'node:crypto' import { - chmodSync, - closeSync, - constants, existsSync, - fsyncSync, - linkSync, - lstatSync, - mkdirSync, - openSync, readFileSync, - renameSync, - unlinkSync, - writeSync, } from 'node:fs' import { homedir } from 'node:os' -import { basename, dirname, join } from 'node:path' +import { join } from 'node:path' import { CRYPTO_SR25519, Keypair, - decryptKeyfileData, - deserializeKeypair, - encryptKeyfileData, - keyfileDataIsEncrypted, - serializeKeypair, } from './keys' export const DEFAULT_WALLET_PATH = join(homedir(), '.bittensor', 'wallets') @@ -63,10 +46,7 @@ export class Keyfile { getKeypair(password?: string | null): Keypair { const data = readFileSync(this.path) - const decoded = keyfileDataIsEncrypted(data) - ? decryptKeyfileData(data, password ?? undefined) - : data - return deserializeKeypair(decoded) + return Keypair.fromKeyfileData(data, password ?? undefined) } setKeypair(keypair: Keypair, options: SaveKeyOptions = {}): void { @@ -75,11 +55,7 @@ export class Keyfile { if (encrypt && password == null) { throw new Error(`Password is required to encrypt ${this.path}`) } - validateKeyfileTarget(this.path, overwrite) - ensurePrivateDirectory(dirname(this.path)) - const serialized = serializeKeypair(keypair) - const data = encrypt ? encryptKeyfileData(serialized, password as string) : serialized - atomicWriteKeyfile(this.path, data, overwrite) + keypair.writeKeyfile(this.path, encrypt ? password : undefined, overwrite) } } @@ -195,73 +171,3 @@ function keyfilePassword(options: SaveKeyOptions): string | null | undefined { function publicOnly(keypair: Keypair): Keypair { return new Keypair(keypair.ss58Address, keypair.publicKey, keypair.cryptoType, keypair.ss58Format) } - -function ensurePrivateDirectory(path: string): void { - mkdirSync(path, { recursive: true, mode: 0o700 }) - const stat = lstatSync(path) - if (stat.isSymbolicLink() || !stat.isDirectory()) { - throw new Error(`Wallet path ${path} must be a real directory`) - } - chmodSync(path, 0o700) -} - -function validateKeyfileTarget(path: string, overwrite: boolean): void { - try { - const stat = lstatSync(path) - if (stat.isSymbolicLink()) throw new Error(`Refusing to write keyfile through symlink ${path}`) - if (!stat.isFile()) throw new Error(`Refusing to overwrite non-file keyfile path ${path}`) - if (!overwrite) throw new Error(`Keyfile ${path} already exists`) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return - throw error - } -} - -function atomicWriteKeyfile(path: string, data: Uint8Array, overwrite: boolean): void { - const dir = dirname(path) - const temp = join(dir, `.${basename(path)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`) - let fd: number | undefined - try { - fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600) - let offset = 0 - while (offset < data.length) { - offset += writeSync(fd, data, offset, data.length - offset) - } - fsyncSync(fd) - closeSync(fd) - fd = undefined - if (overwrite) renameSync(temp, path) - else { - linkSync(temp, path) - unlinkSync(temp) - } - chmodSync(path, 0o600) - fsyncDirectory(dir) - } catch (error) { - if (fd != null) { - try { - closeSync(fd) - } catch { - // Best effort cleanup. - } - } - try { - unlinkSync(temp) - } catch { - // Best effort cleanup. - } - throw error - } -} - -function fsyncDirectory(path: string): void { - let fd: number | undefined - try { - fd = openSync(path, constants.O_RDONLY) - fsyncSync(fd) - } catch { - // Directory fsync is best-effort across platforms and filesystems. - } finally { - if (fd != null) closeSync(fd) - } -} diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 60b25310b8..5e819cd74c 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -102,6 +102,7 @@ function fakeSigningClient(runtime, callData) { } throw new Error(`unexpected RPC ${method}`) } + client.transport.request = (method, params = [], options = {}) => client.rpc(method, params, options) return client } @@ -596,6 +597,20 @@ test('private key bytes are not exported to JavaScript', () => { Object.prototype.hasOwnProperty.call(core.native.NativeKeypair.prototype, 'privateKey'), false, ) + assert.throws(() => alice.serialize(), /plaintext private key serialization is disabled/) + assert.throws(() => core.serializeKeypair(alice), /plaintext private key serialization is disabled/) + const rawNativeSerialize = core.native.serializeKeypair( + core.native.keypairFromUri('//Alice', core.CRYPTO_SR25519), + ) + assert.equal(rawNativeSerialize instanceof Error, true) + assert.match(rawNativeSerialize.message, /plaintext private key serialization is disabled/) + + const publicOnly = new core.Keypair(alice.ss58Address) + const publicKeyfile = JSON.parse(publicOnly.serialize().toString('utf8')) + assert.equal(publicKeyfile.privateKey, undefined) + + const encrypted = alice.toKeyfileData('review-password') + assert.equal(core.keyfileDataIsEncrypted(encrypted), true) }) test('fallible Runtime construction uses the native factory', () => { @@ -1061,7 +1076,7 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => assert.equal(captures.encoded.signatureVersion, core.CRYPTO_SR25519) assert.equal(captures.encoded.params.metadataHashEnabled, false) assert.equal(await client.accountNextIndex(address), 12) - assert.equal(await client.accountNextIndex(address), 13) + assert.equal(await client.accountNextIndex(address), 12) }) test('Client estimateFee peeks the chain nonce without reserving it', async () => { @@ -1121,42 +1136,98 @@ test('Client estimateFee peeks the chain nonce without reserving it', async () = assert.equal(fee.rao, 123n) assert.equal(captures.payloadParams.nonce, 7) assert.equal(firstRealNonce, 7) - assert.equal(secondRealNonce, 8) - assert.deepEqual(nonceReads, [address, address]) + assert.equal(secondRealNonce, 7) + assert.deepEqual(nonceReads, [address, address, address]) }) -test('Client serializes concurrent initial nonce reservations', async () => { - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) - const address = core.ss58FromPublic(Buffer.alloc(32, 11), 42) +test('Client serializes concurrent initial nonce reservations during submit', async () => { + const callData = Buffer.from([8, 8, 8]) + const capturedNonces = [] + const { runtime } = fakeSigningRuntime({ + signaturePayload(_callData, params) { + capturedNonces.push(params.nonce) + return Buffer.from([Number(params.nonce)]) + }, + encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { + return { + bytes: Buffer.from([signatureVersion, Number(params.nonce)]), + hash: Buffer.alloc(32, Number(params.nonce)), + } + }, + }) + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 11) + const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 5), + ]) let reads = 0 let releaseRead const readGate = new Promise((resolve) => { releaseRead = resolve }) client.rpc = async (method, params = []) => { - assert.equal(method, 'system_accountNextIndex') - assert.equal(params[0], address) - reads += 1 - await readGate - return 14 + if (method === 'system_accountNextIndex') { + assert.equal(params[0], address) + reads += 1 + await readGate + return 14 + } + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + if (method === 'author_submitExtrinsic') return `0x${'cd'.repeat(32)}` + throw new Error(`unexpected RPC ${method}`) + } + const signer = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, } - const first = client.accountNextIndex(address) - const second = client.accountNextIndex(address) + const first = client.submit(callData, signer, { period: null }) + const second = client.submit(callData, signer, { period: null }) await new Promise((resolve) => setImmediate(resolve)) assert.equal(reads, 1) releaseRead() - assert.deepEqual(await Promise.all([first, second]), [14, 15]) + await Promise.all([first, second]) assert.equal(reads, 1) + assert.deepEqual(capturedNonces.sort((a, b) => a - b), [14, 15]) }) test('Client releases only the failed reserved nonce', async () => { const callData = Buffer.from([3, 2, 1]) - const { runtime } = fakeSigningRuntime() + const capturedNonces = [] + const { runtime } = fakeSigningRuntime({ + signaturePayload(_callData, params) { + capturedNonces.push(params.nonce) + return Buffer.from([Number(params.nonce)]) + }, + encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { + return { + bytes: Buffer.from([signatureVersion, Number(params.nonce)]), + hash: Buffer.alloc(32, Number(params.nonce)), + } + }, + }) const client = fakeSigningClient(runtime, callData) const publicKey = Buffer.alloc(32, 12) const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 2), + ]) let releaseSign let signStarted const signGate = new Promise((resolve) => { @@ -1165,7 +1236,22 @@ test('Client releases only the failed reserved nonce', async () => { const started = new Promise((resolve) => { signStarted = resolve }) - const signer = { + client.rpc = async (method, params = []) => { + if (method === 'system_accountNextIndex') return 12 + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + if (method === 'author_submitExtrinsic') return `0x${'ef'.repeat(32)}` + throw new Error(`unexpected RPC ${method}`) + } + const failingSigner = { address, publicKey, async signRaw() { @@ -1174,23 +1260,40 @@ test('Client releases only the failed reserved nonce', async () => { throw new Error('signer declined') }, } + const succeedingSigner = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } - const signing = client.submit(callData, signer, { period: null }) + const signing = client.submit(callData, failingSigner, { period: null }) await started - const independentlyReserved = await client.accountNextIndex(address) + const independentlySubmitted = await client.submit(callData, succeedingSigner, { period: null }) releaseSign() await assert.rejects(signing, /signer declined/) - const reusable = await client.accountNextIndex(address) - const next = await client.accountNextIndex(address) + await independentlySubmitted + await client.submit(callData, succeedingSigner, { period: null }) - assert.equal(independentlyReserved, 13) - assert.equal(reusable, 12) - assert.equal(next, 14) + assert.deepEqual(capturedNonces, [12, 13, 12]) }) test('Client reuses an ambiguous submit nonce only after reconciliation proves it absent', async () => { const callData = Buffer.from([4, 5, 6]) - const { runtime } = fakeSigningRuntime() + const capturedNonces = [] + const { runtime } = fakeSigningRuntime({ + signaturePayload(_callData, params) { + capturedNonces.push(params.nonce) + return Buffer.from([Number(params.nonce)]) + }, + encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { + return { + bytes: Buffer.from([signatureVersion, Number(params.nonce)]), + hash: Buffer.alloc(32, Number(params.nonce)), + } + }, + }) const client = fakeSigningClient(runtime, callData) const publicKey = Buffer.alloc(32, 13) const address = core.ss58FromPublic(publicKey, 42) @@ -1199,6 +1302,7 @@ test('Client reuses an ambiguous submit nonce only after reconciliation proves i Buffer.alloc(64, 8), ]) const nonceReads = [] + let submitAttempts = 0 client.rpc = async (method, params = []) => { if (method === 'system_accountNextIndex') { nonceReads.push(params[0]) @@ -1214,7 +1318,15 @@ test('Client reuses an ambiguous submit nonce only after reconciliation proves i if (method === 'system_properties') { return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } } - if (method === 'author_submitExtrinsic') throw new core.JsonRpcError('lost response') + if (method === 'author_submitExtrinsic') { + submitAttempts += 1 + if (submitAttempts === 1) throw new core.JsonRpcError('lost response') + return `0x${'aa'.repeat(32)}` + } + if (method === 'author_pendingExtrinsics') return [] + if (method === 'chain_getHeader') return { number: '0x0' } + if (method === 'chain_getBlockHash') return `0x${'01'.repeat(32)}` + if (method === 'chain_getBlock') return { block: { extrinsics: [] } } throw new Error(`unexpected RPC ${method}`) } const signer = { @@ -1226,13 +1338,91 @@ test('Client reuses an ambiguous submit nonce only after reconciliation proves i } await assert.rejects(() => client.submit(callData, signer, { period: null }), /lost response/) - assert.equal(await client.accountNextIndex(address), 20) + await client.submit(callData, signer, { period: null }) + assert.deepEqual(capturedNonces, [20, 20]) assert.deepEqual(nonceReads, [address, address]) }) +test('Client invalidates nonce state after unknown ambiguous submission reconciliation', async () => { + const callData = Buffer.from([4, 5, 8]) + const capturedNonces = [] + const { runtime } = fakeSigningRuntime({ + signaturePayload(_callData, params) { + capturedNonces.push(params.nonce) + return Buffer.from([Number(params.nonce)]) + }, + encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { + return { + bytes: Buffer.from([signatureVersion, Number(params.nonce)]), + hash: Buffer.alloc(32, Number(params.nonce)), + } + }, + }) + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 16) + const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 9), + ]) + let submitAttempts = 0 + let networkRestored = false + client.rpc = async (method, params = []) => { + if (method === 'system_accountNextIndex') { + if (submitAttempts === 0) return 50 + if (!networkRestored) throw new Error('network still unavailable') + return 55 + } + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + if (method === 'author_submitExtrinsic') { + submitAttempts += 1 + if (submitAttempts === 1) { + throw new core.JsonRpcError('lost response') + } + return `0x${'bb'.repeat(32)}` + } + if (method === 'author_pendingExtrinsics') throw new Error('network still unavailable') + throw new Error(`unexpected RPC ${method}`) + } + const signer = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } + + await assert.rejects(() => client.submit(callData, signer, { period: null }), /lost response/) + networkRestored = true + await client.submit(callData, signer, { period: null }) + + assert.deepEqual(capturedNonces, [50, 55]) +}) + test('Client protects an ambiguous submit nonce when the extrinsic is still pending', async () => { const callData = Buffer.from([4, 5, 7]) - const { runtime } = fakeSigningRuntime() + const capturedNonces = [] + const { runtime } = fakeSigningRuntime({ + signaturePayload(_callData, params) { + capturedNonces.push(params.nonce) + return Buffer.from([Number(params.nonce)]) + }, + encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { + return { + bytes: Buffer.from([signatureVersion, Number(params.nonce)]), + hash: Buffer.alloc(32, Number(params.nonce)), + } + }, + }) const client = fakeSigningClient(runtime, callData) const publicKey = Buffer.alloc(32, 15) const address = core.ss58FromPublic(publicKey, 42) @@ -1241,6 +1431,7 @@ test('Client protects an ambiguous submit nonce when the extrinsic is still pend Buffer.alloc(64, 3), ]) const nonceReads = [] + let submitAttempts = 0 let submittedHex client.rpc = async (method, params = []) => { if (method === 'system_accountNextIndex') { @@ -1258,8 +1449,10 @@ test('Client protects an ambiguous submit nonce when the extrinsic is still pend return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } } if (method === 'author_submitExtrinsic') { + submitAttempts += 1 submittedHex = params[0] - throw new core.JsonRpcError('lost response') + if (submitAttempts === 1) throw new core.JsonRpcError('lost response') + return `0x${'bc'.repeat(32)}` } if (method === 'author_pendingExtrinsics') return [submittedHex] throw new Error(`unexpected RPC ${method}`) @@ -1273,7 +1466,8 @@ test('Client protects an ambiguous submit nonce when the extrinsic is still pend } await assert.rejects(() => client.submit(callData, signer, { period: null }), /lost response/) - assert.equal(await client.accountNextIndex(address), 41) + await client.submit(callData, signer, { period: null }) + assert.deepEqual(capturedNonces, [40, 41]) assert.deepEqual(nonceReads, [address, address]) }) @@ -1317,6 +1511,87 @@ test('Client submit without inclusion reports pool submission, not execution suc assert.equal(result.message, 'Submitted') }) +test('Client submission watches support timeout and reconcile managed nonces', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + FakeWebSocket.onSend = (socket, message) => { + if (message.method === 'author_submitAndWatchExtrinsic') { + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: 'watch-1' })) + return + } + if (message.method === 'author_unwatchExtrinsic') { + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: true })) + return + } + if (message.method === 'author_submitExtrinsic') { + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: `0x${'cc'.repeat(32)}` })) + } + } + + const callData = Buffer.from([6, 6, 6]) + const capturedNonces = [] + const { runtime } = fakeSigningRuntime({ + signaturePayload(_callData, params) { + capturedNonces.push(params.nonce) + return Buffer.from([Number(params.nonce)]) + }, + encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { + return { + bytes: Buffer.from([signatureVersion, Number(params.nonce)]), + hash: Buffer.alloc(32, Number(params.nonce)), + } + }, + }) + const client = fakeSigningClient(runtime, callData) + client.transport = new core.JsonRpcTransport('ws://node-a', [], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + }) + const publicKey = Buffer.alloc(32, 17) + const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 1), + ]) + client.rpc = async (method, params = []) => { + if (method === 'system_accountNextIndex') return 60 + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + if (method === 'author_pendingExtrinsics') return [] + if (method === 'chain_getHeader') return { number: '0x0' } + if (method === 'chain_getBlockHash') return `0x${'02'.repeat(32)}` + if (method === 'chain_getBlock') return { block: { extrinsics: [] } } + if (method === 'author_submitExtrinsic') return `0x${'cc'.repeat(32)}` + throw new Error(`unexpected RPC ${method}`) + } + const signer = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } + + await assert.rejects( + () => client.submit(callData, signer, { period: null, waitForInclusion: true, timeoutMs: 5 }), + (error) => error.name === 'RequestTimeoutError', + ) + await client.submit(callData, signer, { period: null }) + assert.deepEqual(capturedNonces, [60, 60]) + assert.equal( + FakeWebSocket.sockets[0].sent.some((message) => message.method === 'author_unwatchExtrinsic'), + true, + ) +}) + test('Client generates RFC-0078 proof for Ledger metadata-verifying signers', async () => { const vector = ledgerProofVector() const metadataBytes = goldenMetadataBytes() From 8cd9033421e3eaa78bd922c58929bfbde640adb3 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 18:08:20 -0700 Subject: [PATCH 36/72] fix ci --- .github/actions/rust-setup/action.yml | 4 ++++ .github/workflows/bittensor-ts-e2e.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/actions/rust-setup/action.yml b/.github/actions/rust-setup/action.yml index 25a848bb1b..29b1fe71ea 100644 --- a/.github/actions/rust-setup/action.yml +++ b/.github/actions/rust-setup/action.yml @@ -104,6 +104,10 @@ runs: { echo "RUSTC_WRAPPER=sccache" echo "SCCACHE_GHA_ENABLED=true" + # The GitHub Actions cache backend can have transient DNS/storage + # failures. Treat those as cache misses so rustc still runs locally + # instead of failing before compilation starts. + echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" # sccache cannot cache incremental compilation, and CI never reuses # a working tree, so incremental is pure overhead here. echo "CARGO_INCREMENTAL=0" diff --git a/.github/workflows/bittensor-ts-e2e.yml b/.github/workflows/bittensor-ts-e2e.yml index 88dc05933d..0c36106fcc 100644 --- a/.github/workflows/bittensor-ts-e2e.yml +++ b/.github/workflows/bittensor-ts-e2e.yml @@ -9,6 +9,10 @@ concurrency: env: CARGO_TERM_COLOR: always + # Self-hosted runners may have RUSTC_WRAPPER=sccache available for Rust + # builds. If the GitHub Actions cache backend has a transient DNS/storage + # failure, compile locally instead of failing before rustc starts. + SCCACHE_IGNORE_SERVER_IO_ERROR: "1" permissions: contents: read From cb54cb3810e989b1d9a46227e0cb82327cf6c661 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 18:19:10 -0700 Subject: [PATCH 37/72] another round of improvments --- sdk/bittensor-core/src/keyfiles/mod.rs | 31 +++ sdk/bittensor-ts/README.md | 5 + sdk/bittensor-ts/native/src/keys.rs | 7 + .../scripts/check-native-parity.cjs | 1 + sdk/bittensor-ts/src/client.ts | 102 ++++++--- sdk/bittensor-ts/src/keys.ts | 10 + sdk/bittensor-ts/src/modules.ts | 1 + sdk/bittensor-ts/src/native.ts | 1 + sdk/bittensor-ts/src/wallet.ts | 4 +- sdk/bittensor-ts/test/basic.test.cjs | 203 +++++++++++++++++- 10 files changed, 334 insertions(+), 31 deletions(-) diff --git a/sdk/bittensor-core/src/keyfiles/mod.rs b/sdk/bittensor-core/src/keyfiles/mod.rs index 4f607ea113..e2f446548e 100644 --- a/sdk/bittensor-core/src/keyfiles/mod.rs +++ b/sdk/bittensor-core/src/keyfiles/mod.rs @@ -229,6 +229,37 @@ pub fn deserialize_keypair_from_keyfile( deserialize_keypair_from_keyfile_data(keyfile_data) } +pub fn read_keypair_from_keyfile( + path: &Path, + password: Option<&str>, +) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|error| { + key_err(format!( + "failed to inspect keyfile {}: {error}", + path.display() + )) + })?; + if metadata.file_type().is_symlink() { + return Err(key_err(format!( + "refusing to read keyfile through symlink {}", + path.display() + ))); + } + if !metadata.is_file() { + return Err(key_err(format!( + "keyfile path {} is not a regular file", + path.display() + ))); + } + let keyfile_data = Zeroizing::new(fs::read(path).map_err(|error| { + key_err(format!( + "failed to read keyfile {}: {error}", + path.display() + )) + })?); + deserialize_keypair_from_keyfile(&keyfile_data, password) +} + pub fn save_keypair_to_keyfile( keypair: &Keypair, path: &Path, diff --git a/sdk/bittensor-ts/README.md b/sdk/bittensor-ts/README.md index 39890c3b5d..e3fa39219e 100644 --- a/sdk/bittensor-ts/README.md +++ b/sdk/bittensor-ts/README.md @@ -112,6 +112,11 @@ await client.transfer(alice, '5F...', Balance.fromTao('0.01'), { await client.close() ``` +`client.submit()` manages nonce reservation for in-process submissions. Detached +flows using `signExtrinsic()` followed by `submitSigned()` or `watchSigned()` +record the signed nonce after submission, but callers producing multiple +detached transactions concurrently should pass explicit `nonce` values. + ## Browser example ```ts diff --git a/sdk/bittensor-ts/native/src/keys.rs b/sdk/bittensor-ts/native/src/keys.rs index cf137ae356..20a824cd90 100644 --- a/sdk/bittensor-ts/native/src/keys.rs +++ b/sdk/bittensor-ts/native/src/keys.rs @@ -221,6 +221,13 @@ pub fn deserialize_keypair_from_keyfile( .map(NativeKeypair::new) } +#[napi(js_name = "readKeypairKeyfile")] +pub fn read_keypair_keyfile(path: String, password: Option) -> NapiResult { + keyfiles::read_keypair_from_keyfile(&PathBuf::from(path), password.as_deref()) + .napi() + .map(NativeKeypair::new) +} + #[napi(js_name = "writeKeypairKeyfile")] pub fn write_keypair_keyfile( keypair: &NativeKeypair, diff --git a/sdk/bittensor-ts/scripts/check-native-parity.cjs b/sdk/bittensor-ts/scripts/check-native-parity.cjs index 274bf755c6..50587c29dd 100755 --- a/sdk/bittensor-ts/scripts/check-native-parity.cjs +++ b/sdk/bittensor-ts/scripts/check-native-parity.cjs @@ -430,6 +430,7 @@ const coreCoverageAliases = new Map([ ['codec/value.rs#u256_decimal', ['u256LeToDecimal']], ['keyfiles/mod.rs#serialized_keypair_to_keyfile_data', ['serializeKeypair']], ['keyfiles/mod.rs#deserialize_keypair_from_keyfile_data', ['deserializeKeypair']], + ['keyfiles/mod.rs#read_keypair_from_keyfile', ['readKeypairKeyfile']], ['keyfiles/mod.rs#save_keypair_to_keyfile', ['writeKeypairKeyfile']], ['keys/mod.rs#new', ['keypairNew', 'Keypair']], ['keys/mod.rs#from_mnemonic', ['keypairFromMnemonic', 'fromMnemonic']], diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 4c4aac3112..46fa0a73d2 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -49,6 +49,7 @@ export interface RpcRequestOptions { signal?: AbortSignal timeoutMs?: number maxRetries?: number + retryForever?: boolean retryBackoffMs?: number maxRetryBackoffMs?: number } @@ -357,6 +358,7 @@ export class JsonRpcTransport { } throwIfAborted(requestOptions.signal) const maxRetries = requestOptions.maxRetries ?? this.maxRequestRetries + const retryForever = requestOptions.retryForever ?? this.retryForever const retryBackoffMs = requestOptions.retryBackoffMs ?? this.retryBackoffMs const maxRetryBackoffMs = requestOptions.maxRetryBackoffMs ?? this.maxRetryBackoffMs let attempt = 0 @@ -367,7 +369,7 @@ export class JsonRpcTransport { : await this.wsRequest(method, params, requestOptions) } catch (error) { if (error instanceof JsonRpcError || error instanceof RequestAbortedError) throw error - if (!this.retryForever && attempt >= maxRetries) throw error + if (!retryForever && attempt >= maxRetries) throw error attempt += 1 this.rotateEndpoint() const capped = Math.min(retryBackoffMs * (2 ** Math.max(0, attempt - 1)), maxRetryBackoffMs) @@ -395,6 +397,7 @@ export class JsonRpcTransport { signal: options.signal, timeoutMs: options.timeoutMs, maxRetries: options.maxRetries, + retryForever: options.retryForever, retryBackoffMs: options.retryBackoffMs, maxRetryBackoffMs: options.maxRetryBackoffMs, }, @@ -1244,6 +1247,7 @@ export class Client { : toBuffer(extrinsic.bytes, 'extrinsic.bytes') const extrinsicHash = hex(blake2_256(bytes)) const reservation = managedNonceReservation(extrinsic) + const detachedReservation = reservation == null ? detachedNonceReservation(extrinsic) : undefined if (options.waitForInclusion || options.waitForFinalization) { const watcher = await this.watchSigned(extrinsic, { waitForFinalization: options.waitForFinalization ?? false, @@ -1256,11 +1260,15 @@ export class Client { const hash = String(await this.transport.request('author_submitExtrinsic', [hex(bytes)], { timeoutMs: options.timeoutMs, signal: options.signal, + maxRetries: 0, + retryForever: false, })) if (reservation != null) await this.submitNonce(reservation) + else if (detachedReservation != null) await this.submitDetachedNonce(detachedReservation) return { status: 'submitted', message: 'Submitted', extrinsicHash: hash, events: [] } } catch (error) { if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) + else if (detachedReservation != null) await this.quarantineNonce(detachedReservation) throw error } } @@ -1274,6 +1282,7 @@ export class Client { : toBuffer(extrinsic.bytes, 'extrinsic.bytes') const extrinsicHash = hex(blake2_256(bytes)) const reservation = managedNonceReservation(extrinsic) + const detachedReservation = reservation == null ? detachedNonceReservation(extrinsic) : undefined let subscription: AsyncIterable & { unsubscribe(): Promise } try { subscription = await this.transport.subscribe( @@ -1284,11 +1293,15 @@ export class Client { resubscribe: false, timeoutMs: options.timeoutMs, signal: options.signal, + maxRetries: 0, + retryForever: false, }, ) if (reservation != null) await this.submitNonce(reservation) + else if (detachedReservation != null) await this.submitDetachedNonce(detachedReservation) } catch (error) { if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) + else if (detachedReservation != null) await this.quarantineNonce(detachedReservation) throw error } const result = this.resolveWatchedExtrinsic( @@ -1302,10 +1315,12 @@ export class Client { ).then( async (value) => { if (reservation != null) await this.confirmNonce(reservation) + else if (detachedReservation != null) await this.confirmDetachedNonce(detachedReservation) return value }, async (error) => { if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) + else if (detachedReservation != null) await this.reconcileDetachedNonce(detachedReservation, extrinsicHash) throw error }, ) @@ -1350,6 +1365,10 @@ export class Client { }) } + private async submitDetachedNonce(reservation: NonceReservation): Promise { + await this.noteExternalNonce(reservation, 'submitted') + } + private async confirmNonce(reservation: NonceReservation): Promise { await this.withNonceAccount(reservation.address, (state) => { state.statuses.set(reservation.nonce, 'confirmed') @@ -1357,6 +1376,10 @@ export class Client { }) } + private async confirmDetachedNonce(reservation: NonceReservation): Promise { + await this.noteExternalNonce(reservation, 'confirmed') + } + private async failNonce(reservation: NonceReservation, reusable: boolean): Promise { await this.withNonceAccount(reservation.address, (state) => { const current = state.statuses.get(reservation.nonce) @@ -1372,6 +1395,37 @@ export class Client { }) } + private async quarantineNonce(reservation: NonceReservation): Promise { + await this.withNonceAccount(reservation.address, (state) => { + quarantineNonceState(state, reservation.nonce) + }) + } + + private async noteExternalNonce( + reservation: NonceReservation, + status: Exclude, + ): Promise { + await this.withNonceAccount(reservation.address, (state) => { + if (state.next == null || state.next <= reservation.nonce) state.next = reservation.nonce + 1 + state.reusable = state.reusable.filter((nonce) => nonce > reservation.nonce) + for (const [nonce, existingStatus] of state.statuses) { + if (existingStatus === 'reusable' || nonce <= reservation.nonce) state.statuses.delete(nonce) + } + state.statuses.set(reservation.nonce, status) + while (state.next != null && state.statuses.has(state.next) && state.statuses.get(state.next) !== 'reusable') { + state.next += 1 + } + pruneNonceStatuses(state) + }) + } + + private async reconcileDetachedNonce( + reservation: NonceReservation, + extrinsicHash?: string, + ): Promise { + await this.reconcileNonceReservation(reservation, extrinsicHash) + } + private async reconcileNonceReservation( reservation: NonceReservation, extrinsicHash?: string, @@ -1395,41 +1449,26 @@ export class Client { const location = locationResult.ok ? locationResult.location : undefined const chainNext = chainNextResult.ok ? chainNextResult.nonce : undefined const submitted = location != null || (chainNext != null && chainNext > reservation.nonce) - const definitelyAbsent = - locationResult.ok && - location == null && - chainNext != null && - chainNext <= reservation.nonce - if (!submitted && !definitelyAbsent) { - invalidateNonceState(state) + if (!submitted) { + quarantineNonceState(state, reservation.nonce) return } if (chainNext != null) state.next = chainNext else if (state.next == null || state.next <= reservation.nonce) state.next = reservation.nonce + 1 - const minimumReusableNonce = submitted - ? Math.max(state.next, reservation.nonce + 1) - : state.next + const minimumReusableNonce = Math.max(state.next, reservation.nonce + 1) state.reusable = state.reusable.filter( (nonce) => nonce >= minimumReusableNonce && nonce !== reservation.nonce, ) for (const [nonce, status] of state.statuses) { - if (status === 'reusable' || (submitted && nonce <= reservation.nonce)) { + if (status === 'reusable' || nonce <= reservation.nonce) { state.statuses.delete(nonce) } } - if (submitted) { - if (state.next <= reservation.nonce) state.next = reservation.nonce + 1 - state.statuses.set(reservation.nonce, location === 'block' ? 'confirmed' : 'submitted') - } else { - state.statuses.set(reservation.nonce, 'reusable') - if (!state.reusable.includes(reservation.nonce)) { - state.reusable.push(reservation.nonce) - state.reusable.sort((left, right) => left - right) - } - } + if (state.next <= reservation.nonce) state.next = reservation.nonce + 1 + state.statuses.set(reservation.nonce, location === 'block' ? 'confirmed' : 'submitted') while (state.statuses.has(state.next) && state.statuses.get(state.next) !== 'reusable') { state.next += 1 } @@ -2419,6 +2458,17 @@ function managedNonceReservation(extrinsic: unknown): NonceReservation | undefin return (extrinsic as ManagedSignedExtrinsicResult)[MANAGED_NONCE] } +function detachedNonceReservation(extrinsic: unknown): NonceReservation | undefined { + if (managedNonceReservation(extrinsic) != null) return undefined + if (extrinsic == null || typeof extrinsic !== 'object') return undefined + if (Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array) return undefined + const signed = extrinsic as Partial + const address = stringValue(signed.signerAddress) + const nonce = Number(signed.nonce) + if (address == null || !Number.isSafeInteger(nonce) || nonce < 0) return undefined + return { address, nonce } +} + function hashExtrinsicHex(extrinsic: unknown): string | undefined { if (typeof extrinsic !== 'string') return undefined try { @@ -2436,10 +2486,14 @@ function pruneNonceStatuses(state: NonceAccountState): void { } } -function invalidateNonceState(state: NonceAccountState): void { +function quarantineNonceState(state: NonceAccountState, nonce: number): void { state.next = undefined state.reusable = [] - state.statuses.clear() + for (const [existingNonce, status] of state.statuses) { + if (status === 'reusable') state.statuses.delete(existingNonce) + } + state.statuses.set(nonce, 'failed') + pruneNonceStatuses(state) } function stringValue(value: unknown): string | undefined { diff --git a/sdk/bittensor-ts/src/keys.ts b/sdk/bittensor-ts/src/keys.ts index 4725563763..6a103c66f9 100644 --- a/sdk/bittensor-ts/src/keys.ts +++ b/sdk/bittensor-ts/src/keys.ts @@ -219,6 +219,12 @@ export class Keypair implements PolkadotCompatibleKeypair { ) } + static fromKeyfile(path: string, password?: string | null): Keypair { + return Keypair.wrap( + nativeCall(() => native.readKeypairKeyfile(path, password ?? undefined)), + ) + } + get cryptoType(): number { return this.handle.cryptoType } @@ -479,6 +485,10 @@ export function deserializeKeypairFromKeyfile( return Keypair.fromKeyfileData(keyfileData, password) } +export function readKeypairKeyfile(path: string, password?: string | null): Keypair { + return Keypair.fromKeyfile(path, password) +} + export function encryptKeyfileData(keyfileData: ByteLike, password: string): Buffer { return nativeCall(() => native.encryptKeyfileData(toBuffer(keyfileData, 'keyfileData'), password), diff --git a/sdk/bittensor-ts/src/modules.ts b/sdk/bittensor-ts/src/modules.ts index 40ad0d7d2f..2b6a1741f9 100644 --- a/sdk/bittensor-ts/src/modules.ts +++ b/sdk/bittensor-ts/src/modules.ts @@ -44,6 +44,7 @@ export const rustCore = Object.freeze({ deserializeKeypair: keys.deserializeKeypair, deserializeKeypairFromKeyfileData: keys.deserializeKeypairFromKeyfileData, deserializeKeypairFromKeyfile: keys.deserializeKeypairFromKeyfile, + readKeypairKeyfile: keys.readKeypairKeyfile, encryptKeyfileData: keys.encryptKeyfileData, decryptKeyfileData: keys.decryptKeyfileData, keyfileDataIsEncrypted: keys.keyfileDataIsEncrypted, diff --git a/sdk/bittensor-ts/src/native.ts b/sdk/bittensor-ts/src/native.ts index 7711b0a334..6eacde5955 100644 --- a/sdk/bittensor-ts/src/native.ts +++ b/sdk/bittensor-ts/src/native.ts @@ -301,6 +301,7 @@ export interface NativeBinding { keyfileData: Buffer, password?: string | null, ): NativeKeypairHandle + readKeypairKeyfile(path: string, password?: string | null): NativeKeypairHandle writeKeypairKeyfile( keypair: NativeKeypairHandle, path: string, diff --git a/sdk/bittensor-ts/src/wallet.ts b/sdk/bittensor-ts/src/wallet.ts index 3f9b937b0c..7762a0e4a2 100644 --- a/sdk/bittensor-ts/src/wallet.ts +++ b/sdk/bittensor-ts/src/wallet.ts @@ -1,6 +1,5 @@ import { existsSync, - readFileSync, } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' @@ -45,8 +44,7 @@ export class Keyfile { } getKeypair(password?: string | null): Keypair { - const data = readFileSync(this.path) - return Keypair.fromKeyfileData(data, password ?? undefined) + return Keypair.fromKeyfile(this.path, password ?? undefined) } setKeypair(keypair: Keypair, options: SaveKeyOptions = {}): void { diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 5e819cd74c..be07eb20a3 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -920,6 +920,31 @@ test('JsonRpcTransport bounds retries and supports request cancellation', async ) }) +test('JsonRpcTransport can disable retryForever for transaction submissions', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + let requests = 0 + FakeWebSocket.onSend = () => { + requests += 1 + } + const transport = new core.JsonRpcTransport('ws://node-a', ['ws://node-b'], true, { + requestTimeoutMs: 5, + retryBackoffMs: 1, + maxRetryBackoffMs: 1, + }) + + await assert.rejects( + () => transport.request('author_submitExtrinsic', ['0x00'], { + timeoutMs: 5, + maxRetries: 0, + retryForever: false, + }), + (error) => error.name === 'RequestTimeoutError', + ) + assert.equal(requests, 1) + assert.equal(FakeWebSocket.sockets.length, 1) +}) + test('Client accepts an injected WebSocket factory when no global WebSocket exists', async (t) => { const { FakeWebSocket, restore } = installFakeWebSocket() restore() @@ -1279,7 +1304,7 @@ test('Client releases only the failed reserved nonce', async () => { assert.deepEqual(capturedNonces, [12, 13, 12]) }) -test('Client reuses an ambiguous submit nonce only after reconciliation proves it absent', async () => { +test('Client quarantines an ambiguous submit nonce even when a fallback node reports it absent', async () => { const callData = Buffer.from([4, 5, 6]) const capturedNonces = [] const { runtime } = fakeSigningRuntime({ @@ -1339,8 +1364,8 @@ test('Client reuses an ambiguous submit nonce only after reconciliation proves i await assert.rejects(() => client.submit(callData, signer, { period: null }), /lost response/) await client.submit(callData, signer, { period: null }) - assert.deepEqual(capturedNonces, [20, 20]) - assert.deepEqual(nonceReads, [address, address]) + assert.deepEqual(capturedNonces, [20, 21]) + assert.deepEqual(nonceReads, [address, address, address]) }) test('Client invalidates nonce state after unknown ambiguous submission reconciliation', async () => { @@ -1511,6 +1536,170 @@ test('Client submit without inclusion reports pool submission, not execution suc assert.equal(result.message, 'Submitted') }) +test('Client records detached submitSigned nonces before the next managed submit', async () => { + const callData = Buffer.from([2, 4, 6]) + const capturedNonces = [] + const submitMaxRetries = [] + const submitRetryForever = [] + const { runtime } = fakeSigningRuntime({ + signaturePayload(_callData, params) { + capturedNonces.push(params.nonce) + return Buffer.from([Number(params.nonce)]) + }, + encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { + return { + bytes: Buffer.from([signatureVersion, Number(params.nonce)]), + hash: Buffer.alloc(32, Number(params.nonce)), + } + }, + }) + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 18) + const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 4), + ]) + let nonceReads = 0 + client.rpc = async (method, params = [], options = {}) => { + if (method === 'system_accountNextIndex') { + assert.equal(params[0], address) + nonceReads += 1 + return nonceReads === 1 ? 70 : 71 + } + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + if (method === 'author_submitExtrinsic') { + submitMaxRetries.push(options.maxRetries) + submitRetryForever.push(options.retryForever) + return `0x${'dd'.repeat(32)}` + } + throw new Error(`unexpected RPC ${method}`) + } + const signer = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } + + await client.submit(callData, signer, { period: null }) + const detached = await client.signExtrinsic(callData, signer, { period: null }) + assert.equal(detached.nonce, 71) + await client.submitSigned(detached) + await client.submit(callData, signer, { period: null }) + + assert.deepEqual(capturedNonces, [70, 71, 72]) + assert.deepEqual(submitMaxRetries, [0, 0, 0]) + assert.deepEqual(submitRetryForever, [false, false, false]) + assert.equal(nonceReads, 2) +}) + +test('Client records detached watchSigned nonces before the next managed submit', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + FakeWebSocket.onSend = (socket, message) => { + if (message.method === 'author_submitAndWatchExtrinsic') { + assert.equal(message.params.length, 1) + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: 'watch-detached' })) + return + } + if (message.method === 'author_unwatchExtrinsic') { + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: true })) + return + } + if (message.method === 'author_submitExtrinsic') { + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: `0x${'de'.repeat(32)}` })) + } + } + + const callData = Buffer.from([2, 4, 8]) + const capturedNonces = [] + const { runtime } = fakeSigningRuntime({ + signaturePayload(_callData, params) { + capturedNonces.push(params.nonce) + return Buffer.from([Number(params.nonce)]) + }, + encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { + return { + bytes: Buffer.from([signatureVersion, Number(params.nonce)]), + hash: Buffer.alloc(32, Number(params.nonce)), + } + }, + }) + const client = fakeSigningClient(runtime, callData) + client.transport = new core.JsonRpcTransport('ws://node-a', [], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + }) + client.resolveInclusion = async (extrinsicHash, blockHash) => ({ + status: 'inBlock', + success: true, + message: 'Success', + extrinsicHash, + blockHash, + events: [], + }) + const publicKey = Buffer.alloc(32, 19) + const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 5), + ]) + let nonceReads = 0 + client.rpc = async (method, params = []) => { + if (method === 'system_accountNextIndex') { + assert.equal(params[0], address) + nonceReads += 1 + return 80 + } + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + throw new Error(`unexpected RPC ${method}`) + } + const signer = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } + + const detached = await client.signExtrinsic(callData, signer, { period: null }) + const watcher = await client.watchSigned(detached, { timeoutMs: 100 }) + await waitFor( + () => FakeWebSocket.sockets[0].sent.some((message) => message.method === 'author_submitAndWatchExtrinsic'), + 'detached submit-and-watch request', + ) + FakeWebSocket.sockets[0].serverMessage({ + jsonrpc: '2.0', + method: 'author_extrinsicUpdate', + params: { subscription: 'watch-detached', result: { inBlock: `0x${'44'.repeat(32)}` } }, + }) + await watcher.result + await client.submit(callData, signer, { period: null }) + + assert.deepEqual(capturedNonces, [80, 81]) + assert.equal(nonceReads, 1) +}) + test('Client submission watches support timeout and reconcile managed nonces', async (t) => { const { FakeWebSocket, restore } = installFakeWebSocket() t.after(restore) @@ -1585,7 +1774,7 @@ test('Client submission watches support timeout and reconcile managed nonces', a (error) => error.name === 'RequestTimeoutError', ) await client.submit(callData, signer, { period: null }) - assert.deepEqual(capturedNonces, [60, 60]) + assert.deepEqual(capturedNonces, [60, 61]) assert.equal( FakeWebSocket.sockets[0].sent.some((message) => message.method === 'author_unwatchExtrinsic'), true, @@ -1728,6 +1917,8 @@ test('wallet keyfile writes are restrictive and reject symlink targets', (t) => const hotkeyPath = wallet.hotkeyFile.path const hotkeyDir = path.dirname(hotkeyPath) + assert.deepEqual(wallet.hotkeyFile.getKeypair().publicKey, keypair.publicKey) + assert.deepEqual(core.readKeypairKeyfile(hotkeyPath).publicKey, keypair.publicKey) assert.equal(fs.lstatSync(hotkeyPath).isFile(), true) assert.equal(fs.statSync(hotkeyPath).mode & 0o777, 0o600) assert.equal(fs.statSync(hotkeyDir).mode & 0o777, 0o700) @@ -1745,6 +1936,10 @@ test('wallet keyfile writes are restrictive and reject symlink targets', (t) => () => linkedWallet.setHotkey(keypair, { overwrite: true }), /symlink/, ) + assert.throws( + () => core.readKeypairKeyfile(linkedWallet.hotkeyFile.path), + /symlink/, + ) assert.equal(fs.readFileSync(target, 'utf8'), 'do not replace') }) From 1b0a4741b89e29d90f688530be985fa0324e69fe Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 18:39:27 -0700 Subject: [PATCH 38/72] Update generate-esm.cjs --- sdk/bittensor-ts/scripts/generate-esm.cjs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/sdk/bittensor-ts/scripts/generate-esm.cjs b/sdk/bittensor-ts/scripts/generate-esm.cjs index 39c3efee70..692e8e6dbd 100644 --- a/sdk/bittensor-ts/scripts/generate-esm.cjs +++ b/sdk/bittensor-ts/scripts/generate-esm.cjs @@ -43,7 +43,25 @@ function generate(entry) { `import * as sdk from './${entry}.js'`, '', ...(entry === 'browser' - ? ["sdk.setDefaultBrowserWasmLoader(() => import('./wasm/bittensor_core_wasm.js'))", ''] + ? [ + "async function __loadBittensorCoreWasm() {", + " const wasmBindings = await import('./wasm/bittensor_core_wasm_bg.js')", + " const wasmAsset = await import('./wasm/bittensor_core_wasm_bg.wasm')", + " const wasmUrl = wasmAsset.default ?? wasmAsset", + " const response = await fetch(wasmUrl)", + " if (!response.ok) throw new Error(`failed to fetch bittensor-core WASM: ${response.status}`)", + " const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), {", + " './bittensor_core_wasm_bg.js': wasmBindings,", + " })", + " wasmBindings.__wbg_set_wasm(instance.exports)", + " if (typeof instance.exports.__wbindgen_start === 'function') instance.exports.__wbindgen_start()", + " const module = { ...wasmBindings }", + " delete module.default", + " return module", + "}", + "sdk.setDefaultBrowserWasmLoader(__loadBittensorCoreWasm)", + '', + ] : []), ...sorted.map((name) => `export const ${name} = sdk.${name}`), '', From d1c9ae816014ba0d93402e4859c4ceca0dc0a100 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 19:20:14 -0700 Subject: [PATCH 39/72] another round of fixes --- Cargo.lock | 1 + sdk/bittensor-core/Cargo.toml | 2 + sdk/bittensor-core/src/keyfiles/mod.rs | 54 ++++- sdk/bittensor-ts/README.md | 12 +- sdk/bittensor-ts/src/client.ts | 286 +++++++++++++++++++------ sdk/bittensor-ts/src/keys.ts | 32 ++- sdk/bittensor-ts/src/wallet.ts | 46 ++-- sdk/bittensor-ts/test/basic.test.cjs | 265 +++++++++++++++++++++-- 8 files changed, 600 insertions(+), 98 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a7e10aeb46..2cd8149a49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1641,6 +1641,7 @@ dependencies = [ "hex", "hidapi", "ledger-apdu", + "libc", "merkleized-metadata", "ml-kem", "openssl", diff --git a/sdk/bittensor-core/Cargo.toml b/sdk/bittensor-core/Cargo.toml index 3e93cf56bc..3530415854 100644 --- a/sdk/bittensor-core/Cargo.toml +++ b/sdk/bittensor-core/Cargo.toml @@ -24,6 +24,7 @@ fernet = { version = "=0.2.1", optional = true } # Homebrew-pathed on macOS runners (broken wheel for anyone without it). openssl = { version = "0.10", features = ["vendored"], optional = true } hex = "0.4.3" +libc = { version = "0.2", optional = true } pbkdf2 = { version = "0.12.2", optional = true } schnorrkel = "0.11.5" scrypt = { version = "0.11.0", optional = true } @@ -104,6 +105,7 @@ default = ["host"] host = [ "dep:ansible-vault", "dep:fernet", + "dep:libc", "dep:openssl", "dep:pbkdf2", "dep:rayon", diff --git a/sdk/bittensor-core/src/keyfiles/mod.rs b/sdk/bittensor-core/src/keyfiles/mod.rs index e2f446548e..6aa2c5e2c9 100644 --- a/sdk/bittensor-core/src/keyfiles/mod.rs +++ b/sdk/bittensor-core/src/keyfiles/mod.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::fs::{self, File, OpenOptions}; -use std::io::Write; +use std::io::{Read, Write}; #[cfg(unix)] use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; @@ -233,6 +233,51 @@ pub fn read_keypair_from_keyfile( path: &Path, password: Option<&str>, ) -> Result { + let keyfile_data = read_keyfile_bytes(path)?; + deserialize_keypair_from_keyfile(&keyfile_data, password) +} + +fn read_keyfile_bytes(path: &Path) -> Result>, CoreError> { + let mut file = open_keyfile_for_read(path)?; + let mut keyfile_data = Zeroizing::new(Vec::new()); + file.read_to_end(&mut keyfile_data).map_err(|error| { + key_err(format!( + "failed to read keyfile {}: {error}", + path.display() + )) + })?; + Ok(keyfile_data) +} + +#[cfg(unix)] +fn open_keyfile_for_read(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(|error| { + key_err(format!( + "failed to open keyfile {} without following symlinks: {error}", + path.display() + )) + })?; + let metadata = file.metadata().map_err(|error| { + key_err(format!( + "failed to inspect opened keyfile {}: {error}", + path.display() + )) + })?; + if !metadata.is_file() { + return Err(key_err(format!( + "keyfile path {} is not a regular file", + path.display() + ))); + } + Ok(file) +} + +#[cfg(not(unix))] +fn open_keyfile_for_read(path: &Path) -> Result { let metadata = fs::symlink_metadata(path).map_err(|error| { key_err(format!( "failed to inspect keyfile {}: {error}", @@ -251,13 +296,12 @@ pub fn read_keypair_from_keyfile( path.display() ))); } - let keyfile_data = Zeroizing::new(fs::read(path).map_err(|error| { + File::open(path).map_err(|error| { key_err(format!( - "failed to read keyfile {}: {error}", + "failed to open keyfile {}: {error}", path.display() )) - })?); - deserialize_keypair_from_keyfile(&keyfile_data, password) + }) } pub fn save_keypair_to_keyfile( diff --git a/sdk/bittensor-ts/README.md b/sdk/bittensor-ts/README.md index e3fa39219e..23146025ae 100644 --- a/sdk/bittensor-ts/README.md +++ b/sdk/bittensor-ts/README.md @@ -17,7 +17,7 @@ Chain-defined work runs in Rust: The Node TypeScript layer is limited to JavaScript-friendly names and defaults, lossless `Buffer`/`bigint`/`Map` boundary conversion, error classes, -compatibility adapters for the signer objects expected by Polkadot.js, +signing-compatibility adapters for the signer objects expected by Polkadot.js, Polkadot API, and Moonwall, plus the client responsibilities that deliberately do not live in Rust: WebSocket/HTTP JSON-RPC transport, reconnect/fallback handling, subscriptions, storage queries, runtime API calls, nonce tracking, @@ -78,7 +78,9 @@ const signature = alice.sign(Buffer.from('hello')) console.log(alice.verify(Buffer.from('hello'), signature)) // Compatible with tx.signAsync(...) and Moonwall helpers, while the secret -// key and signing operation remain in Rust. +// key and signing operation remain in Rust. This is signing compatibility, +// not full Polkadot.js KeyringPair keystore compatibility: PKCS#8/JSON export, +// lock/unlock, and VRF methods intentionally throw. const signer = createKeyringPairFromUri('//Alice') const runtime = new Runtime(metadataBytes, specVersion, transactionVersion) @@ -117,6 +119,12 @@ flows using `signExtrinsic()` followed by `submitSigned()` or `watchSigned()` record the signed nonce after submission, but callers producing multiple detached transactions concurrently should pass explicit `nonce` values. +Wallet private keyfiles are encrypted when `keyfilePassword` is supplied. +Plaintext private keyfile writes require `allowPlaintext: true`; public-only +keyfiles are still written without encryption. `createNewColdkey()` and +`createNewHotkey()` return `{ wallet, keypair, mnemonic }` so callers can store +the recovery phrase before relying on the persisted wallet. + ## Browser example ```ts diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 46fa0a73d2..863026a725 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -1,5 +1,5 @@ import { blake2_256, generateExtrinsicProof, metadataDigest } from './crypto' -import { CRYPTO_SR25519, Keypair, publicKeyFromSs58 } from './keys' +import { CRYPTO_SR25519, Keypair, publicKeyFromSs58, ss58FromPublic } from './keys' import { LedgerDevice } from './ledger' import { Runtime, eraBirth } from './runtime' import { toBuffer } from './wire' @@ -58,6 +58,7 @@ export interface JsonRpcTransportOptions { webSocket?: WebSocketConstructor webSocketConstructor?: WebSocketConstructor webSocketFactory?: WebSocketFactory + validateEndpoint?: EndpointValidator requestTimeoutMs?: number maxRequestRetries?: number retryBackoffMs?: number @@ -146,7 +147,7 @@ export interface ChainSigner { } export type SignerLike = Keypair | ChainSigner | LedgerDevice -export type ExtrinsicStatus = 'submitted' | 'inBlock' | 'finalized' | 'failed' +export type ExtrinsicStatus = 'submitted' | 'inBlock' | 'finalized' | 'failed' | 'unknown' export interface ExtrinsicResult { status: ExtrinsicStatus @@ -236,12 +237,19 @@ interface HeadRuntimeCacheEntry extends RuntimeCacheEntry { expiresAtMs: number } -type NonceStatus = 'reserved' | 'submitted' | 'confirmed' | 'failed' | 'reusable' +type NonceStatus = 'reserved' | 'submitted' | 'confirmed' | 'failed' | 'reusable' | 'ambiguous' + +interface AmbiguousNonce { + nonce: number + extrinsicHash?: string + sinceMs: number +} interface NonceAccountState { next?: number reusable: number[] statuses: Map + ambiguous?: AmbiguousNonce queue: Promise } @@ -250,6 +258,11 @@ interface NonceReservation { nonce: number } +interface NonceTrackingInfo { + reservation?: NonceReservation + invalidateAddress?: string +} + type SubmittedExtrinsicLocation = 'pool' | 'block' | null interface SubscriptionOptions extends RpcRequestOptions { @@ -276,6 +289,8 @@ export type WebSocketLike = { export type WebSocketConstructor = new (url: string) => WebSocketLike export type WebSocketFactory = (url: string) => WebSocketLike +export type EndpointRequest = (method: string, params?: unknown[]) => Promise +export type EndpointValidator = (endpoint: string, request: EndpointRequest) => Promise export class JsonRpcError extends Error { readonly code?: number @@ -313,6 +328,13 @@ export class RequestAbortedError extends ChainError { } } +export class EndpointValidationError extends ChainError { + constructor(message: string, details?: unknown) { + super(message, details) + this.name = 'EndpointValidationError' + } +} + export class JsonRpcTransport { private readonly endpoints: string[] private readonly retryForever: boolean @@ -322,6 +344,8 @@ export class JsonRpcTransport { private readonly maxRetryBackoffMs: number private readonly webSocketConstructor?: WebSocketConstructor private readonly webSocketFactory?: WebSocketFactory + private readonly validateEndpoint?: EndpointValidator + private readonly validatedEndpoints = new Set() private endpointIndex = 0 private id = 1 private socket?: WebSocketLike @@ -345,6 +369,7 @@ export class JsonRpcTransport { this.maxRetryBackoffMs = nonNegativeNumber(options.maxRetryBackoffMs, DEFAULT_MAX_RETRY_BACKOFF_MS) this.webSocketConstructor = options.webSocketConstructor ?? options.webSocket this.webSocketFactory = options.webSocketFactory + this.validateEndpoint = options.validateEndpoint } get endpoint(): string { @@ -364,11 +389,12 @@ export class JsonRpcTransport { let attempt = 0 for (;;) { try { + await this.ensureEndpointValidated(requestOptions) return this.isHttpEndpoint() ? await this.httpRequest(method, params, requestOptions) : await this.wsRequest(method, params, requestOptions) } catch (error) { - if (error instanceof JsonRpcError || error instanceof RequestAbortedError) throw error + if (error instanceof JsonRpcError || error instanceof RequestAbortedError || error instanceof EndpointValidationError) throw error if (!retryForever && attempt >= maxRetries) throw error attempt += 1 this.rotateEndpoint() @@ -378,6 +404,23 @@ export class JsonRpcTransport { } } + private async ensureEndpointValidated(options: RpcRequestOptions): Promise { + if (this.validateEndpoint == null) return + const endpoint = this.endpoint + if (this.validatedEndpoints.has(endpoint)) return + try { + await this.validateEndpoint(endpoint, (method, params = []) => + this.isHttpEndpoint() + ? this.httpRequest(method, params, options) + : this.wsRequest(method, params, options), + ) + } catch (error) { + if (error instanceof EndpointValidationError) throw error + throw error + } + this.validatedEndpoints.add(endpoint) + } + async subscribe( subscribeMethod: string, params: unknown[] = [], @@ -668,6 +711,7 @@ export class Client { private readonly headRuntimeTtlMs: number private readonly historicalRuntimeCacheSize: number + private readonly endpointValidationOptions: JsonRpcTransportOptions private headRuntimeCache?: HeadRuntimeCacheEntry private runtimesBySpecVersion = new Map() private historicalRuntimeCache = new Map() @@ -678,10 +722,20 @@ export class Client { const [label, endpoint] = resolveEndpoint(options.endpoint ?? network) this.network = label this.endpoint = endpoint + this.endpointValidationOptions = { + webSocket: options.webSocket, + webSocketConstructor: options.webSocketConstructor, + webSocketFactory: options.webSocketFactory, + requestTimeoutMs: options.requestTimeoutMs, + maxRequestRetries: 0, + retryBackoffMs: options.retryBackoffMs, + maxRetryBackoffMs: options.maxRetryBackoffMs, + } this.transport = new JsonRpcTransport(endpoint, options.fallbackEndpoints, options.retryForever, { webSocket: options.webSocket, webSocketConstructor: options.webSocketConstructor, webSocketFactory: options.webSocketFactory, + validateEndpoint: (activeEndpoint, request) => this.validateEndpointGenesis(activeEndpoint, request), requestTimeoutMs: options.requestTimeoutMs, maxRequestRetries: options.maxRequestRetries, retryBackoffMs: options.retryBackoffMs, @@ -746,6 +800,34 @@ export class Client { return this.genesis } + private async validateEndpointGenesis(endpoint: string, request: EndpointRequest): Promise { + if (this.genesis == null && endpoint !== this.endpoint) { + this.genesis = await this.fetchGenesisFromEndpoint(this.endpoint) + } + const genesis = String(await request('chain_getBlockHash', [0])) + if (this.genesis == null) { + this.genesis = genesis + return + } + if (!sameHex(genesis, this.genesis)) { + throw new EndpointValidationError( + `endpoint ${endpoint} genesis ${genesis} does not match primary genesis ${this.genesis}`, + ) + } + } + + private async fetchGenesisFromEndpoint(endpoint: string): Promise { + const transport = new JsonRpcTransport(endpoint, [], false, this.endpointValidationOptions) + try { + return String(await transport.request('chain_getBlockHash', [0], { + maxRetries: 0, + retryForever: false, + })) + } finally { + transport.close() + } + } + async runtimeAt(block?: number | string | null): Promise { const blockHash = await this.resolveBlockHash(block) return blockHash == null ? this.headRuntime() : this.historicalRuntimeAt(blockHash) @@ -1246,10 +1328,11 @@ export class Client { ? toBuffer(extrinsic, 'extrinsic') : toBuffer(extrinsic.bytes, 'extrinsic.bytes') const extrinsicHash = hex(blake2_256(bytes)) - const reservation = managedNonceReservation(extrinsic) - const detachedReservation = reservation == null ? detachedNonceReservation(extrinsic) : undefined + const tracking = await this.signedExtrinsicNonceTracking(bytes, extrinsic, signerAddress) + const reservation = tracking.reservation if (options.waitForInclusion || options.waitForFinalization) { const watcher = await this.watchSigned(extrinsic, { + signerAddress, waitForFinalization: options.waitForFinalization ?? false, timeoutMs: options.timeoutMs, signal: options.signal, @@ -1264,25 +1347,25 @@ export class Client { retryForever: false, })) if (reservation != null) await this.submitNonce(reservation) - else if (detachedReservation != null) await this.submitDetachedNonce(detachedReservation) + else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) return { status: 'submitted', message: 'Submitted', extrinsicHash: hash, events: [] } } catch (error) { if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) - else if (detachedReservation != null) await this.quarantineNonce(detachedReservation) + else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) throw error } } async watchSigned( extrinsic: SignedExtrinsicResult | SignedExtrinsic | ByteLike, - options: Pick = {}, + options: Pick & { signerAddress?: string } = {}, ): Promise { const bytes = Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array ? toBuffer(extrinsic, 'extrinsic') : toBuffer(extrinsic.bytes, 'extrinsic.bytes') const extrinsicHash = hex(blake2_256(bytes)) - const reservation = managedNonceReservation(extrinsic) - const detachedReservation = reservation == null ? detachedNonceReservation(extrinsic) : undefined + const tracking = await this.signedExtrinsicNonceTracking(bytes, extrinsic, options.signerAddress) + const reservation = tracking.reservation let subscription: AsyncIterable & { unsubscribe(): Promise } try { subscription = await this.transport.subscribe( @@ -1298,10 +1381,10 @@ export class Client { }, ) if (reservation != null) await this.submitNonce(reservation) - else if (detachedReservation != null) await this.submitDetachedNonce(detachedReservation) + else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) } catch (error) { if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) - else if (detachedReservation != null) await this.quarantineNonce(detachedReservation) + else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) throw error } const result = this.resolveWatchedExtrinsic( @@ -1315,12 +1398,12 @@ export class Client { ).then( async (value) => { if (reservation != null) await this.confirmNonce(reservation) - else if (detachedReservation != null) await this.confirmDetachedNonce(detachedReservation) + else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) return value }, async (error) => { if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) - else if (detachedReservation != null) await this.reconcileDetachedNonce(detachedReservation, extrinsicHash) + else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) throw error }, ) @@ -1344,9 +1427,39 @@ export class Client { this.nonceAccounts.delete(address) } + private async signedExtrinsicNonceTracking( + bytes: Buffer, + extrinsic: unknown, + signerAddress?: string, + ): Promise { + const decoded = await this.decodeExtrinsicNonceReservation(bytes).catch(() => undefined) + if (decoded != null) return { reservation: decoded } + + const managed = managedNonceReservation(extrinsic) + if (managed != null) return { reservation: managed } + + const address = stringValue(signerAddress) + return address == null ? {} : { invalidateAddress: address } + } + + private async decodeExtrinsicNonceReservation(bytes: Buffer): Promise { + const runtime = await this.runtimeAt() + const decoded = runtime.decodeExtrinsic(bytes, false) as unknown + const fields = decoded == null || typeof decoded !== 'object' + ? {} + : decoded as Record + const address = extrinsicSignerAddress(fields.address, runtime.ss58Format) + const nonce = safeNonceNumber(fields.nonce) + return address == null || nonce == null ? undefined : { address, nonce } + } + private async reserveNonce(address: string): Promise { return this.withNonceAccount(address, async (state) => { - if (state.next == null) state.next = await this.peekNextIndex(address) + if (state.next == null) { + const chainNext = await this.peekNextIndex(address) + await this.resolveAmbiguousNonce(address, state, chainNext) + if (state.next == null) state.next = chainNext + } state.reusable.sort((left, right) => left - right) let nonce = state.next if (state.reusable.length > 0 && state.reusable[0] <= state.next) { @@ -1365,10 +1478,6 @@ export class Client { }) } - private async submitDetachedNonce(reservation: NonceReservation): Promise { - await this.noteExternalNonce(reservation, 'submitted') - } - private async confirmNonce(reservation: NonceReservation): Promise { await this.withNonceAccount(reservation.address, (state) => { state.statuses.set(reservation.nonce, 'confirmed') @@ -1376,10 +1485,6 @@ export class Client { }) } - private async confirmDetachedNonce(reservation: NonceReservation): Promise { - await this.noteExternalNonce(reservation, 'confirmed') - } - private async failNonce(reservation: NonceReservation, reusable: boolean): Promise { await this.withNonceAccount(reservation.address, (state) => { const current = state.statuses.get(reservation.nonce) @@ -1395,37 +1500,12 @@ export class Client { }) } - private async quarantineNonce(reservation: NonceReservation): Promise { - await this.withNonceAccount(reservation.address, (state) => { - quarantineNonceState(state, reservation.nonce) - }) - } - - private async noteExternalNonce( - reservation: NonceReservation, - status: Exclude, - ): Promise { + private async quarantineNonce(reservation: NonceReservation, extrinsicHash?: string): Promise { await this.withNonceAccount(reservation.address, (state) => { - if (state.next == null || state.next <= reservation.nonce) state.next = reservation.nonce + 1 - state.reusable = state.reusable.filter((nonce) => nonce > reservation.nonce) - for (const [nonce, existingStatus] of state.statuses) { - if (existingStatus === 'reusable' || nonce <= reservation.nonce) state.statuses.delete(nonce) - } - state.statuses.set(reservation.nonce, status) - while (state.next != null && state.statuses.has(state.next) && state.statuses.get(state.next) !== 'reusable') { - state.next += 1 - } - pruneNonceStatuses(state) + quarantineNonceState(state, reservation.nonce, extrinsicHash) }) } - private async reconcileDetachedNonce( - reservation: NonceReservation, - extrinsicHash?: string, - ): Promise { - await this.reconcileNonceReservation(reservation, extrinsicHash) - } - private async reconcileNonceReservation( reservation: NonceReservation, extrinsicHash?: string, @@ -1450,12 +1530,15 @@ export class Client { const chainNext = chainNextResult.ok ? chainNextResult.nonce : undefined const submitted = location != null || (chainNext != null && chainNext > reservation.nonce) if (!submitted) { - quarantineNonceState(state, reservation.nonce) + quarantineNonceState(state, reservation.nonce, extrinsicHash) return } if (chainNext != null) state.next = chainNext else if (state.next == null || state.next <= reservation.nonce) state.next = reservation.nonce + 1 + if (state.ambiguous != null && reservation.nonce >= state.ambiguous.nonce) { + state.ambiguous = undefined + } const minimumReusableNonce = Math.max(state.next, reservation.nonce + 1) state.reusable = state.reusable.filter( @@ -1476,6 +1559,34 @@ export class Client { }) } + private async resolveAmbiguousNonce( + address: string, + state: NonceAccountState, + chainNext: number, + ): Promise { + const ambiguous = state.ambiguous + if (ambiguous == null) return + + if (chainNext > ambiguous.nonce) { + clearAmbiguousNonceState(state, chainNext) + return + } + + if (ambiguous.extrinsicHash != null) { + const location = await this.submittedExtrinsicLocation(ambiguous.extrinsicHash).catch(() => undefined) + if (location != null) { + clearAmbiguousNonceState(state, Math.max(chainNext, ambiguous.nonce + 1)) + state.statuses.set(ambiguous.nonce, location === 'block' ? 'confirmed' : 'submitted') + pruneNonceStatuses(state) + return + } + } + + throw new ChainError( + `nonce ${ambiguous.nonce} for ${address} is ambiguous after a failed submission; automatic submissions are paused until the chain nonce advances, the original extrinsic is located, or clearNonce(address) is called`, + ) + } + private async submittedExtrinsicLocation(extrinsicHash: string): Promise { const normalized = extrinsicHash.toLowerCase() if (await this.pendingExtrinsicsContain(normalized)) return 'pool' @@ -1786,7 +1897,9 @@ export class Client { for await (const status of subscription) { if (abortError != null) throw abortError const normalized = normalizeStatus(status) - const fatal = ['usurped', 'retracted', 'finalitytimeout', 'dropped', 'invalid'].find((name) => normalized[name] != null) + const fatal = ['usurped', 'retracted', 'finalitytimeout', 'dropped', 'invalid'].find((name) => + hasOwn(normalized, name), + ) if (fatal != null) throw new ChainError(`Extrinsic ${fatal}`, status) if (waitForFinalization && normalized.finalized != null) { return this.resolveInclusion(extrinsicHash, String(normalized.finalized), true) @@ -1814,6 +1927,21 @@ export class Client { const failed = triggered.find((event) => eventName(event) === 'System.ExtrinsicFailed') const success = triggered.some((event) => eventName(event) === 'System.ExtrinsicSuccess') const feeEvent = triggered.find((event) => eventName(event) === 'TransactionPayment.TransactionFeePaid') + if (!success && failed == null) { + return { + status: 'unknown', + success: undefined, + message: 'Included, but no dispatch outcome event was found', + extrinsicHash, + blockHash, + blockNumber, + extrinsicIndex, + extrinsicId: `${blockNumber}-${String(extrinsicIndex).padStart(4, '0')}`, + finalized, + fee: feeEvent == null ? undefined : feeFromEvent(feeEvent), + events: triggered, + } + } const dispatchSuccess = success && failed == null return { status: dispatchSuccess ? finalized ? 'finalized' : 'inBlock' : 'failed', @@ -2458,15 +2586,22 @@ function managedNonceReservation(extrinsic: unknown): NonceReservation | undefin return (extrinsic as ManagedSignedExtrinsicResult)[MANAGED_NONCE] } -function detachedNonceReservation(extrinsic: unknown): NonceReservation | undefined { - if (managedNonceReservation(extrinsic) != null) return undefined - if (extrinsic == null || typeof extrinsic !== 'object') return undefined - if (Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array) return undefined - const signed = extrinsic as Partial - const address = stringValue(signed.signerAddress) - const nonce = Number(signed.nonce) - if (address == null || !Number.isSafeInteger(nonce) || nonce < 0) return undefined - return { address, nonce } +function extrinsicSignerAddress(value: unknown, ss58Format: number): string | undefined { + if (typeof value === 'string') return value + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + return value.length === 32 ? ss58FromPublic(value, ss58Format) : undefined + } + if (value == null || typeof value !== 'object') return undefined + for (const candidate of Object.values(value as Record)) { + const address = extrinsicSignerAddress(candidate, ss58Format) + if (address != null) return address + } + return undefined +} + +function safeNonceNumber(value: unknown): number | undefined { + const nonce = typeof value === 'bigint' ? Number(value) : Number(value) + return Number.isSafeInteger(nonce) && nonce >= 0 ? nonce : undefined } function hashExtrinsicHex(extrinsic: unknown): string | undefined { @@ -2486,16 +2621,39 @@ function pruneNonceStatuses(state: NonceAccountState): void { } } -function quarantineNonceState(state: NonceAccountState, nonce: number): void { +function quarantineNonceState(state: NonceAccountState, nonce: number, extrinsicHash?: string): void { state.next = undefined state.reusable = [] for (const [existingNonce, status] of state.statuses) { if (status === 'reusable') state.statuses.delete(existingNonce) } - state.statuses.set(nonce, 'failed') + state.statuses.set(nonce, 'ambiguous') + state.ambiguous = { + nonce, + extrinsicHash, + sinceMs: Date.now(), + } pruneNonceStatuses(state) } +function clearAmbiguousNonceState(state: NonceAccountState, next: number): void { + const ambiguous = state.ambiguous + if (ambiguous != null && state.statuses.get(ambiguous.nonce) === 'ambiguous') { + state.statuses.delete(ambiguous.nonce) + } + state.ambiguous = undefined + state.next = next + state.reusable = state.reusable.filter((nonce) => nonce >= next) +} + +function sameHex(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase() +} + +function hasOwn(value: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key) +} + function stringValue(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined } diff --git a/sdk/bittensor-ts/src/keys.ts b/sdk/bittensor-ts/src/keys.ts index 6a103c66f9..a17c5afc93 100644 --- a/sdk/bittensor-ts/src/keys.ts +++ b/sdk/bittensor-ts/src/keys.ts @@ -22,9 +22,16 @@ export interface KeypairSignOptions { withType?: boolean } +export interface GeneratedKeypair { + keypair: Keypair + mnemonic: string +} + /** - * Structural Polkadot.js/Moonwall keyring-pair interface. Public-key - * derivation and signatures stay inside bittensor-core Rust. + * Signing-compatible subset of Polkadot.js/Moonwall keyring-pair behavior. + * Public-key derivation and signatures stay inside bittensor-core Rust, while + * full keystore lifecycle APIs such as PKCS#8, JSON export, locking, and VRF + * deliberately remain unsupported. */ export interface PolkadotCompatibleKeypair { readonly address: string @@ -190,8 +197,19 @@ export class Keypair implements PolkadotCompatibleKeypair { nWords = 12, password?: string | null, ): Keypair { + return Keypair.generateWithMnemonic(cryptoType, nWords, password).keypair + } + + static generateWithMnemonic( + cryptoType = CRYPTO_SR25519, + nWords = 12, + password?: string | null, + ): GeneratedKeypair { const mnemonic = Keypair.generateMnemonic(nWords) - return Keypair.fromMnemonic(mnemonic, cryptoType, password) + return { + keypair: Keypair.fromMnemonic(mnemonic, cryptoType, password), + mnemonic, + } } static encryptFor( @@ -412,6 +430,14 @@ export function generateKeyringPair( return pair } +export function generateKeypairWithMnemonic( + cryptoType = CRYPTO_SR25519, + nWords = 12, + password?: string | null, +): GeneratedKeypair { + return Keypair.generateWithMnemonic(cryptoType, nWords, password) +} + export function generateMnemonic(nWords = 12): string { return Keypair.generateMnemonic(nWords) } diff --git a/sdk/bittensor-ts/src/wallet.ts b/sdk/bittensor-ts/src/wallet.ts index 7762a0e4a2..c82e0e452b 100644 --- a/sdk/bittensor-ts/src/wallet.ts +++ b/sdk/bittensor-ts/src/wallet.ts @@ -20,6 +20,7 @@ export interface WalletOptions { export interface SaveKeyOptions { encrypt?: boolean overwrite?: boolean + allowPlaintext?: boolean keyfilePassword?: string | null /** @deprecated Use keyfilePassword. */ password?: string | null @@ -30,6 +31,12 @@ export interface RegenerateKeyOptions extends SaveKeyOptions { mnemonicPassword?: string | null } +export interface CreatedWalletKey { + wallet: Wallet + keypair: Keypair + mnemonic: string +} + export class Keyfile { readonly path: string readonly name: string @@ -53,6 +60,9 @@ export class Keyfile { if (encrypt && password == null) { throw new Error(`Password is required to encrypt ${this.path}`) } + if (!encrypt && keypair.kind !== 'PublicOnly' && options.allowPlaintext !== true) { + throw new Error(`Refusing to write plaintext private keyfile ${this.path}; pass allowPlaintext: true or provide keyfilePassword`) + } keypair.writeKeyfile(this.path, encrypt ? password : undefined, overwrite) } } @@ -116,29 +126,41 @@ export class Wallet { } setColdkey(keypair: Keypair, options: SaveKeyOptions = {}): this { + const coldkeypub = publicOnly(keypair) + this.coldkeyFile.setKeypair(keypair, { + ...options, + encrypt: options.encrypt ?? keyfilePassword(options) != null, + }) + this.coldkeypubFile.setKeypair(coldkeypub, { overwrite: options.overwrite ?? true }) this.coldkeyCache = keypair - this.coldkeyFile.setKeypair(keypair, options) - this.coldkeypubCache = publicOnly(keypair) - this.coldkeypubFile.setKeypair(this.coldkeypubCache, { overwrite: options.overwrite ?? true }) + this.coldkeypubCache = coldkeypub return this } setHotkey(keypair: Keypair, options: SaveKeyOptions = {}): this { + const hotkeypub = publicOnly(keypair) + this.hotkeyFile.setKeypair(keypair, { + ...options, + encrypt: options.encrypt ?? keyfilePassword(options) != null, + }) + this.hotkeypubFile.setKeypair(hotkeypub, { overwrite: options.overwrite ?? true }) this.hotkeyCache = keypair - this.hotkeyFile.setKeypair(keypair, options) - this.hotkeypubCache = publicOnly(keypair) - this.hotkeypubFile.setKeypair(this.hotkeypubCache, { overwrite: options.overwrite ?? true }) + this.hotkeypubCache = hotkeypub return this } - createNewColdkey(options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}): this { - const keypair = Keypair.generate(options.cryptoType ?? CRYPTO_SR25519, options.nWords ?? 12) - return this.setColdkey(keypair, { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null }) + createNewColdkey(options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}): CreatedWalletKey { + const mnemonic = Keypair.generateMnemonic(options.nWords ?? 12) + const keypair = Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519) + this.setColdkey(keypair, { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null }) + return { wallet: this, keypair, mnemonic } } - createNewHotkey(options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}): this { - const keypair = Keypair.generate(options.cryptoType ?? CRYPTO_SR25519, options.nWords ?? 12) - return this.setHotkey(keypair, { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null }) + createNewHotkey(options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}): CreatedWalletKey { + const mnemonic = Keypair.generateMnemonic(options.nWords ?? 12) + const keypair = Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519) + this.setHotkey(keypair, { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null }) + return { wallet: this, keypair, mnemonic } } regenerateColdkey( diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index be07eb20a3..46e6c8ea06 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -977,6 +977,51 @@ test('Client accepts an injected WebSocket factory when no global WebSocket exis assert.deepEqual(urls, ['ws://node-a']) }) +test('Client validates fallback endpoint genesis before use', async (t) => { + const originalFetch = globalThis.fetch + t.after(() => { + globalThis.fetch = originalFetch + }) + const primaryGenesis = `0x${'aa'.repeat(32)}` + const fallbackGenesis = `0x${'bb'.repeat(32)}` + globalThis.fetch = async (url, init) => { + const request = JSON.parse(String(init.body)) + const endpoint = String(url) + if (request.method === 'chain_getBlockHash') { + return { + ok: true, + json: async () => ({ + jsonrpc: '2.0', + id: request.id, + result: endpoint.includes('fallback') ? fallbackGenesis : primaryGenesis, + }), + } + } + if (endpoint.includes('primary') && request.method === 'state_getMetadata') { + throw new Error('primary unavailable') + } + return { + ok: true, + json: async () => ({ jsonrpc: '2.0', id: request.id, result: '0x00' }), + } + } + + const client = new core.Client('local', { + endpoint: 'http://primary', + fallbackEndpoints: ['http://fallback'], + maxRequestRetries: 1, + requestTimeoutMs: 100, + retryBackoffMs: 0, + maxRetryBackoffMs: 0, + }) + + await assert.rejects( + () => client.rpc('state_getMetadata'), + (error) => error.name === 'EndpointValidationError' && + /does not match primary genesis/.test(error.message), + ) +}) + test('Client expires head runtime metadata and invalidates it on runtime upgrade', async () => { const { client, calls, setHeadVersion } = fakeRuntimeCacheClient({ headRuntimeTtlMs: 1_000, @@ -1363,8 +1408,11 @@ test('Client quarantines an ambiguous submit nonce even when a fallback node rep } await assert.rejects(() => client.submit(callData, signer, { period: null }), /lost response/) - await client.submit(callData, signer, { period: null }) - assert.deepEqual(capturedNonces, [20, 21]) + await assert.rejects( + () => client.submit(callData, signer, { period: null }), + /nonce 20 .* ambiguous/, + ) + assert.deepEqual(capturedNonces, [20]) assert.deepEqual(nonceReads, [address, address, address]) }) @@ -1536,11 +1584,53 @@ test('Client submit without inclusion reports pool submission, not execution suc assert.equal(result.message, 'Submitted') }) +test('Client treats string and object fatal watch statuses as failures', async () => { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const subscriptionFor = (status) => ({ + async *[Symbol.asyncIterator]() { + yield status + }, + async unsubscribe() {}, + }) + + await assert.rejects( + () => client.resolveWatchedExtrinsic(subscriptionFor('dropped'), `0x${'01'.repeat(32)}`, false), + /Extrinsic dropped/, + ) + await assert.rejects( + () => client.resolveWatchedExtrinsic(subscriptionFor({ invalid: '0xdead' }), `0x${'02'.repeat(32)}`, false), + /Extrinsic invalid/, + ) +}) + +test('Client reports included extrinsics with missing dispatch outcome as unknown', async () => { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const extrinsic = '0x0102' + const extrinsicHash = `0x${core.blake2_256(Buffer.from([1, 2])).toString('hex')}` + client.rpc = async (method) => { + if (method === 'chain_getBlock') { + return { block: { header: { number: '0x2a' }, extrinsics: [extrinsic] } } + } + throw new Error(`unexpected RPC ${method}`) + } + client.query = async () => [] + + const result = await client.resolveInclusion(extrinsicHash, `0x${'12'.repeat(32)}`, false) + + assert.equal(result.status, 'unknown') + assert.equal(result.success, undefined) + assert.equal(result.message, 'Included, but no dispatch outcome event was found') + assert.equal(result.extrinsicIndex, 0) + assert.equal(result.extrinsicId, '42-0000') +}) + test('Client records detached submitSigned nonces before the next managed submit', async () => { const callData = Buffer.from([2, 4, 6]) const capturedNonces = [] const submitMaxRetries = [] const submitRetryForever = [] + const publicKey = Buffer.alloc(32, 18) + const address = core.ss58FromPublic(publicKey, 42) const { runtime } = fakeSigningRuntime({ signaturePayload(_callData, params) { capturedNonces.push(params.nonce) @@ -1552,10 +1642,11 @@ test('Client records detached submitSigned nonces before the next managed submit hash: Buffer.alloc(32, Number(params.nonce)), } }, + decodeExtrinsic(data) { + return { address, nonce: data[1] } + }, }) const client = fakeSigningClient(runtime, callData) - const publicKey = Buffer.alloc(32, 18) - const address = core.ss58FromPublic(publicKey, 42) const typedSignature = Buffer.concat([ Buffer.from([core.CRYPTO_SR25519]), Buffer.alloc(64, 4), @@ -1604,6 +1695,124 @@ test('Client records detached submitSigned nonces before the next managed submit assert.equal(nonceReads, 2) }) +test('Client decodes detached signed nonce instead of trusting mutable public fields', async () => { + const callData = Buffer.from([2, 4, 5]) + const capturedNonces = [] + const publicKey = Buffer.alloc(32, 20) + const address = core.ss58FromPublic(publicKey, 42) + const { runtime } = fakeSigningRuntime({ + signaturePayload(_callData, params) { + capturedNonces.push(params.nonce) + return Buffer.from([Number(params.nonce)]) + }, + encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { + return { + bytes: Buffer.from([signatureVersion, Number(params.nonce)]), + hash: Buffer.alloc(32, Number(params.nonce)), + } + }, + decodeExtrinsic(data) { + return { address, nonce: data[1] } + }, + }) + const client = fakeSigningClient(runtime, callData) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 6), + ]) + client.rpc = async (method, params = []) => { + if (method === 'system_accountNextIndex') { + assert.equal(params[0], address) + return 70 + } + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + if (method === 'author_submitExtrinsic') return `0x${'df'.repeat(32)}` + throw new Error(`unexpected RPC ${method}`) + } + const signer = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } + + const detached = await client.signExtrinsic(callData, signer, { period: null }) + detached.signerAddress = core.ss58FromPublic(Buffer.alloc(32, 99), 42) + detached.nonce = 999 + await client.submitSigned(detached) + await client.submit(callData, signer, { period: null }) + + assert.deepEqual(capturedNonces, [70, 71]) +}) + +test('Client invalidates nonce state for opaque externally signed submissions', async () => { + const callData = Buffer.from([2, 4, 7]) + const capturedNonces = [] + const { runtime } = fakeSigningRuntime({ + signaturePayload(_callData, params) { + capturedNonces.push(params.nonce) + return Buffer.from([Number(params.nonce)]) + }, + encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { + return { + bytes: Buffer.from([signatureVersion, Number(params.nonce)]), + hash: Buffer.alloc(32, Number(params.nonce)), + } + }, + }) + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 21) + const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 2), + ]) + let nonceReads = 0 + client.rpc = async (method, params = []) => { + if (method === 'system_accountNextIndex') { + assert.equal(params[0], address) + nonceReads += 1 + return nonceReads === 1 ? 90 : 100 + } + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + if (method === 'author_submitExtrinsic') return `0x${'ce'.repeat(32)}` + throw new Error(`unexpected RPC ${method}`) + } + const signer = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } + + await client.submit(callData, signer, { period: null }) + await client.submitSigned(Buffer.from([9, 9, 9]), address) + await client.submit(callData, signer, { period: null }) + + assert.deepEqual(capturedNonces, [90, 100]) + assert.equal(nonceReads, 2) +}) + test('Client records detached watchSigned nonces before the next managed submit', async (t) => { const { FakeWebSocket, restore } = installFakeWebSocket() t.after(restore) @@ -1624,6 +1833,8 @@ test('Client records detached watchSigned nonces before the next managed submit' const callData = Buffer.from([2, 4, 8]) const capturedNonces = [] + const publicKey = Buffer.alloc(32, 19) + const address = core.ss58FromPublic(publicKey, 42) const { runtime } = fakeSigningRuntime({ signaturePayload(_callData, params) { capturedNonces.push(params.nonce) @@ -1635,6 +1846,9 @@ test('Client records detached watchSigned nonces before the next managed submit' hash: Buffer.alloc(32, Number(params.nonce)), } }, + decodeExtrinsic(data) { + return { address, nonce: data[1] } + }, }) const client = fakeSigningClient(runtime, callData) client.transport = new core.JsonRpcTransport('ws://node-a', [], false, { @@ -1649,8 +1863,6 @@ test('Client records detached watchSigned nonces before the next managed submit' blockHash, events: [], }) - const publicKey = Buffer.alloc(32, 19) - const address = core.ss58FromPublic(publicKey, 42) const typedSignature = Buffer.concat([ Buffer.from([core.CRYPTO_SR25519]), Buffer.alloc(64, 5), @@ -1697,7 +1909,7 @@ test('Client records detached watchSigned nonces before the next managed submit' await client.submit(callData, signer, { period: null }) assert.deepEqual(capturedNonces, [80, 81]) - assert.equal(nonceReads, 1) + assert.equal(nonceReads, 2) }) test('Client submission watches support timeout and reconcile managed nonces', async (t) => { @@ -1773,8 +1985,11 @@ test('Client submission watches support timeout and reconcile managed nonces', a () => client.submit(callData, signer, { period: null, waitForInclusion: true, timeoutMs: 5 }), (error) => error.name === 'RequestTimeoutError', ) - await client.submit(callData, signer, { period: null }) - assert.deepEqual(capturedNonces, [60, 61]) + await assert.rejects( + () => client.submit(callData, signer, { period: null }), + /nonce 60 .* ambiguous/, + ) + assert.deepEqual(capturedNonces, [60]) assert.equal( FakeWebSocket.sockets[0].sent.some((message) => message.method === 'author_unwatchExtrinsic'), true, @@ -1872,11 +2087,26 @@ test('wallet helpers keep mnemonic and keyfile passwords separate', (t) => { const mnemonicPassword = 'review-mnemonic-password' const created = new core.Wallet({ name: 'created', hotkey: 'default', path: root }) - created.createNewHotkey({ keyfilePassword }) + const createdHotkey = created.createNewHotkey({ keyfilePassword }) + assert.equal(createdHotkey.wallet, created) + assert.equal(createdHotkey.mnemonic.split(/\s+/).length, 12) + assert.deepEqual(createdHotkey.keypair.publicKey, created.getHotkey(keyfilePassword).publicKey) assert.equal( core.keyfileDataIsEncrypted(fs.readFileSync(created.hotkeyFile.path)), true, ) + const plaintextDefault = new core.Wallet({ name: 'plaintext-default', hotkey: 'default', path: root }) + assert.throws( + () => plaintextDefault.createNewHotkey(), + /allowPlaintext/, + ) + const plaintextAllowed = new core.Wallet({ name: 'plaintext-allowed', hotkey: 'default', path: root }) + const plaintextHotkey = plaintextAllowed.createNewHotkey({ allowPlaintext: true }) + assert.equal(plaintextHotkey.mnemonic.split(/\s+/).length, 12) + assert.equal( + core.keyfileDataIsEncrypted(fs.readFileSync(plaintextAllowed.hotkeyFile.path)), + false, + ) const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' @@ -1913,7 +2143,7 @@ test('wallet keyfile writes are restrictive and reject symlink targets', (t) => const keypair = core.Keypair.fromUri('//Alice') const wallet = new core.Wallet({ name: 'atomic', hotkey: 'default', path: root }) - wallet.setHotkey(keypair) + wallet.setHotkey(keypair, { allowPlaintext: true }) const hotkeyPath = wallet.hotkeyFile.path const hotkeyDir = path.dirname(hotkeyPath) @@ -1929,11 +2159,22 @@ test('wallet keyfile writes are restrictive and reject symlink targets', (t) => const target = path.join(root, 'outside-target') fs.writeFileSync(target, 'do not replace') + const bob = core.Keypair.fromUri('//Bob') + fs.rmSync(wallet.hotkeypubFile.path) + fs.symlinkSync(target, wallet.hotkeypubFile.path) + assert.throws( + () => wallet.setHotkey(bob, { overwrite: true, allowPlaintext: true }), + /symlink/, + ) + assert.deepEqual(wallet.hotkey.publicKey, keypair.publicKey) + assert.deepEqual(wallet.hotkeypub.publicKey, keypair.publicKey) + fs.rmSync(wallet.hotkeypubFile.path) + const linkedWallet = new core.Wallet({ name: 'atomic', hotkey: 'linked', path: root }) fs.symlinkSync(target, linkedWallet.hotkeyFile.path) assert.throws( - () => linkedWallet.setHotkey(keypair, { overwrite: true }), + () => linkedWallet.setHotkey(keypair, { overwrite: true, allowPlaintext: true }), /symlink/, ) assert.throws( From f7166e70271412a181c306102003f8054510dc2c Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 19:24:08 -0700 Subject: [PATCH 40/72] use get_subnet_hyperparams_v3 --- sdk/bittensor-ts/src/client.ts | 4 ++-- sdk/bittensor-ts/test/basic.test.cjs | 32 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 863026a725..763423a95a 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -2098,7 +2098,7 @@ export class SubnetsNamespace { } hyperparameters(netuid: number, block?: number | string | null): Promise { - return this.client.runtime(runtimeApi.SubnetInfoRuntimeApi.get_subnet_hyperparams, [netuid], block) + return this.client.runtime(runtimeApi.SubnetInfoRuntimeApi.get_subnet_hyperparams_v3, [netuid], block) } subnetHyperparameters(netuid: number, block?: number | string | null): Promise { @@ -2310,7 +2310,7 @@ export const runtimeApi = Object.freeze({ SubnetInfoRuntimeApi: Object.freeze({ get_subnet_info: descriptor('SubnetInfoRuntimeApi', 'get_subnet_info'), get_subnets_info: descriptor('SubnetInfoRuntimeApi', 'get_subnets_info'), - get_subnet_hyperparams: descriptor('SubnetInfoRuntimeApi', 'get_subnet_hyperparams'), + get_subnet_hyperparams_v3: descriptor('SubnetInfoRuntimeApi', 'get_subnet_hyperparams_v3'), get_all_dynamic_info: descriptor('SubnetInfoRuntimeApi', 'get_all_dynamic_info'), get_all_metagraphs: descriptor('SubnetInfoRuntimeApi', 'get_all_metagraphs'), get_metagraph: descriptor('SubnetInfoRuntimeApi', 'get_metagraph'), diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 46e6c8ea06..65c2108cad 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -774,6 +774,14 @@ test('chain client surface is exported without Polkadot.js glue', () => { 'SubnetInfoRuntimeApi', 'get_metagraph', ]) + assert.deepEqual(core.runtimeApi.SubnetInfoRuntimeApi.get_subnet_hyperparams_v3, [ + 'SubnetInfoRuntimeApi', + 'get_subnet_hyperparams_v3', + ]) + assert.equal( + Object.prototype.hasOwnProperty.call(core.runtimeApi.SubnetInfoRuntimeApi, 'get_subnet_hyperparams'), + false, + ) assert.deepEqual(core.calls.subtensor.rootRegister('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'), [ 'SubtensorModule', 'root_register', @@ -790,6 +798,30 @@ test('declared Node runtime supports the default WebSocket client path', () => { assert.equal(typeof globalThis.WebSocket, 'function') }) +test('Client subnet hyperparameters read uses the v3 runtime API', async () => { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const calls = [] + client.runtime = async (method, params, block) => { + calls.push({ method, params, block }) + return { ok: true } + } + + assert.deepEqual(await client.subnets.hyperparameters(7, '0xabc'), { ok: true }) + assert.deepEqual(await client.getSubnetHyperparameters(8), { ok: true }) + assert.deepEqual(calls, [ + { + method: ['SubnetInfoRuntimeApi', 'get_subnet_hyperparams_v3'], + params: [7], + block: '0xabc', + }, + { + method: ['SubnetInfoRuntimeApi', 'get_subnet_hyperparams_v3'], + params: [8], + block: undefined, + }, + ]) +}) + test('Balance numeric getters throw before losing precision', () => { const small = core.Balance.fromTao('1.25') assert.equal(small.amount, 1.25) From 7fb7777f3017b50140b46170b568994d97b172be Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sat, 11 Jul 2026 19:58:26 -0700 Subject: [PATCH 41/72] finish impl --- sdk/bittensor-ts/src/crypto.ts | 55 +++++++ sdk/bittensor-ts/src/index.ts | 1 + sdk/bittensor-ts/src/keys.ts | 111 +++++++++++++ sdk/bittensor-ts/src/ledger.ts | 26 ++- sdk/bittensor-ts/src/modules.ts | 36 +++++ sdk/bittensor-ts/src/runtime.ts | 229 ++++++++++++++++++++++++++- sdk/bittensor-ts/src/timelock.ts | 84 ++++++++++ sdk/bittensor-ts/src/types.ts | 12 ++ sdk/bittensor-ts/test/basic.test.cjs | 43 +++++ 9 files changed, 592 insertions(+), 5 deletions(-) diff --git a/sdk/bittensor-ts/src/crypto.ts b/sdk/bittensor-ts/src/crypto.ts index 2be51d7ed5..f6ab6d1002 100644 --- a/sdk/bittensor-ts/src/crypto.ts +++ b/sdk/bittensor-ts/src/crypto.ts @@ -3,6 +3,10 @@ import { nativeCall } from './errors' import { toBuffer } from './wire' import type { ByteLike, ChainInfo } from './types' +export const DEFAULT_BASE58_PREFIX = 42 +export const DEFAULT_DECIMALS = 9 +export const DEFAULT_TOKEN_SYMBOL = 'TAO' + function nativeChainInfo(info: ChainInfo): Record { return { specVersion: info.specVersion, @@ -19,6 +23,23 @@ export function metadataDigest(metadataBytes: ByteLike, info: ChainInfo): Buffer ) } +export function metadata_digest( + metadataBytes: ByteLike, + specVersion: number, + specName: string, + base58Prefix = DEFAULT_BASE58_PREFIX, + decimals = DEFAULT_DECIMALS, + tokenSymbol = DEFAULT_TOKEN_SYMBOL, +): Buffer { + return metadataDigest(metadataBytes, { + specVersion, + specName, + base58Prefix, + decimals, + tokenSymbol, + }) +} + export function generateExtrinsicProof( callData: ByteLike, includedInExtrinsic: ByteLike, @@ -37,6 +58,32 @@ export function generateExtrinsicProof( ) } +export function generate_extrinsic_proof( + callData: ByteLike, + includedInExtrinsic: ByteLike, + includedInSignedData: ByteLike, + metadataBytes: ByteLike, + specVersion: number, + specName: string, + base58Prefix = DEFAULT_BASE58_PREFIX, + decimals = DEFAULT_DECIMALS, + tokenSymbol = DEFAULT_TOKEN_SYMBOL, +): Buffer { + return generateExtrinsicProof( + callData, + includedInExtrinsic, + includedInSignedData, + metadataBytes, + { + specVersion, + specName, + base58Prefix, + decimals, + tokenSymbol, + }, + ) +} + export const MLKEM_NONCE_LENGTH = native.mlkemNonceLength() export const MLKEM_KDF_ID = Buffer.from(native.mlkemKdfId()) /** Rust-name aliases. */ @@ -57,10 +104,18 @@ export function mlkemSeal( ) } +export const encryptMlkem768 = mlkemSeal +export const encrypt_mlkem768 = mlkemSeal + export function mlkemTwox128(data: ByteLike): Buffer { return nativeCall(() => native.mlkemTwox128(toBuffer(data, 'data'))) } +export function mlkemKdfId(): Buffer { + return Buffer.from(MLKEM_KDF_ID) +} + +export const mlkem_kdf_id = mlkemKdfId /** Substrate-compatible twox_128, implemented by bittensor-core Rust. */ export function twox_128(data: ByteLike): Buffer { diff --git a/sdk/bittensor-ts/src/index.ts b/sdk/bittensor-ts/src/index.ts index d44baef9b5..e99c8b8a03 100644 --- a/sdk/bittensor-ts/src/index.ts +++ b/sdk/bittensor-ts/src/index.ts @@ -24,6 +24,7 @@ export { fromWire, toWire, WIRE_TAG } export const native = Object.freeze(nativeBinding) export const BINDING_VERSION = nativeBinding.bindingVersion() +export const __core_version__ = BINDING_VERSION export const LEDGER_ENABLED = nativeBinding.ledgerEnabled() export function wireRoundtrip(value: ScaleValue): ScaleValue { diff --git a/sdk/bittensor-ts/src/keys.ts b/sdk/bittensor-ts/src/keys.ts index a17c5afc93..b009284ef8 100644 --- a/sdk/bittensor-ts/src/keys.ts +++ b/sdk/bittensor-ts/src/keys.ts @@ -145,6 +145,22 @@ export class Keypair implements PolkadotCompatibleKeypair { return Keypair.fromMnemonic(mnemonic, cryptoType, password) } + static from_mnemonic( + mnemonic: string, + cryptoType = CRYPTO_SR25519, + password?: string | null, + ): Keypair { + return Keypair.fromMnemonic(mnemonic, cryptoType, password) + } + + static create_from_mnemonic( + mnemonic: string, + cryptoType = CRYPTO_SR25519, + password?: string | null, + ): Keypair { + return Keypair.fromMnemonic(mnemonic, cryptoType, password) + } + static fromSeed(seed: ByteLike, cryptoType = CRYPTO_SR25519): Keypair { return Keypair.wrap( nativeCall(() => native.keypairFromSeed(toBuffer(seed, 'seed'), cryptoType)), @@ -156,6 +172,14 @@ export class Keypair implements PolkadotCompatibleKeypair { return Keypair.fromSeed(seed, cryptoType) } + static from_seed(seed: ByteLike, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.fromSeed(seed, cryptoType) + } + + static create_from_seed(seed: ByteLike, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.fromSeed(seed, cryptoType) + } + static fromUri(uri: string, cryptoType = CRYPTO_SR25519): Keypair { return Keypair.wrap( nativeCall(() => native.keypairFromUri(uri, cryptoType)), @@ -167,6 +191,14 @@ export class Keypair implements PolkadotCompatibleKeypair { return Keypair.fromUri(uri, cryptoType) } + static from_uri(uri: string, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.fromUri(uri, cryptoType) + } + + static create_from_uri(uri: string, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.fromUri(uri, cryptoType) + } + static fromPrivateKey(privateKey: string, cryptoType = CRYPTO_SR25519): Keypair { return Keypair.wrap( nativeCall(() => native.keypairFromPrivateKey(privateKey, cryptoType)), @@ -178,6 +210,14 @@ export class Keypair implements PolkadotCompatibleKeypair { return Keypair.fromPrivateKey(privateKey, cryptoType) } + static from_private_key(privateKey: string, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.fromPrivateKey(privateKey, cryptoType) + } + + static create_from_private_key(privateKey: string, cryptoType = CRYPTO_SR25519): Keypair { + return Keypair.fromPrivateKey(privateKey, cryptoType) + } + static fromEncryptedJson(jsonData: string, passphrase: string): Keypair { return Keypair.wrap( nativeCall(() => native.keypairFromEncryptedJson(jsonData, passphrase)), @@ -188,10 +228,22 @@ export class Keypair implements PolkadotCompatibleKeypair { return Keypair.fromEncryptedJson(jsonData, passphrase) } + static from_encrypted_json(jsonData: string, passphrase: string): Keypair { + return Keypair.fromEncryptedJson(jsonData, passphrase) + } + + static create_from_encrypted_json(jsonData: string, passphrase: string): Keypair { + return Keypair.fromEncryptedJson(jsonData, passphrase) + } + static generateMnemonic(nWords = 12): string { return nativeCall(() => native.generateMnemonic(nWords)) } + static generate_mnemonic(nWords = 12): string { + return Keypair.generateMnemonic(nWords) + } + static generate( cryptoType = CRYPTO_SR25519, nWords = 12, @@ -220,6 +272,14 @@ export class Keypair implements PolkadotCompatibleKeypair { return nativeCall(() => native.encryptFor(ss58Address, coerceMessage(message), cryptoType)) } + static encrypt_for( + ss58Address: string, + message: string | ByteLike, + cryptoType = CRYPTO_ED25519, + ): Buffer { + return Keypair.encryptFor(ss58Address, message, cryptoType) + } + static deserialize(keyfileData: ByteLike): Keypair { return Keypair.wrap( nativeCall(() => native.deserializeKeypair(toBuffer(keyfileData, 'keyfileData'))), @@ -247,6 +307,10 @@ export class Keypair implements PolkadotCompatibleKeypair { return this.handle.cryptoType } + get crypto_type(): number { + return this.cryptoType + } + get kind(): KeypairKind { return this.handle.kind } @@ -255,6 +319,10 @@ export class Keypair implements PolkadotCompatibleKeypair { return Buffer.from(this.handle.publicKey) } + get public_key(): Buffer { + return this.publicKey + } + get addressRaw(): Buffer { return this.publicKey } @@ -263,6 +331,10 @@ export class Keypair implements PolkadotCompatibleKeypair { return this.handle.ss58Address } + get ss58_address(): string { + return this.ss58Address + } + /** Polkadot.js-compatible alias used by Moonwall and PAPI signer helpers. */ get address(): string { return this.ss58Address @@ -280,6 +352,10 @@ export class Keypair implements PolkadotCompatibleKeypair { return this.handle.ss58Format } + get ss58_format(): number { + return this.ss58Format + } + get meta(): KeypairMetadata { return { ...(metadataStore.get(this) ?? {}) } } @@ -442,6 +518,8 @@ export function generateMnemonic(nWords = 12): string { return Keypair.generateMnemonic(nWords) } +export const generate_mnemonic = generateMnemonic + export function verifySignature( message: string | ByteLike, signature: ByteLike, @@ -459,6 +537,7 @@ export function verifySignature( } export const verify = verifySignature +export const verify_signature = verifySignature export function publicKeyFromSs58(ss58Address: string): Buffer { return nativeCall(() => native.publicKeyFromSs58(ss58Address)) @@ -466,6 +545,8 @@ export function publicKeyFromSs58(ss58Address: string): Buffer { export const ss58Decode = publicKeyFromSs58 export const decodeSs58 = publicKeyFromSs58 +export const ss58_decode = publicKeyFromSs58 +export const decode_ss58 = publicKeyFromSs58 export function ss58FromPublic( publicKey: ByteLike, @@ -476,6 +557,8 @@ export function ss58FromPublic( export const ss58Encode = ss58FromPublic export const encodeSs58 = ss58FromPublic +export const ss58_encode = ss58FromPublic +export const encode_ss58 = ss58FromPublic export function encryptFor( ss58Address: string, @@ -485,17 +568,21 @@ export function encryptFor( return Keypair.encryptFor(ss58Address, message, cryptoType) } +export const encrypt_for = encryptFor + export function serializeKeypair(keypair: Keypair): Buffer { return keypair.serialize() } export const serializedKeypairToKeyfileData = serializeKeypair +export const serialized_keypair_to_keyfile_data = serializedKeypairToKeyfileData export function deserializeKeypair(keyfileData: ByteLike): Keypair { return Keypair.deserialize(keyfileData) } export const deserializeKeypairFromKeyfileData = deserializeKeypair +export const deserialize_keypair_from_keyfile_data = deserializeKeypairFromKeyfileData export function keypairToKeyfileData( keypair: Keypair, @@ -504,6 +591,8 @@ export function keypairToKeyfileData( return keypair.toKeyfileData(password) } +export const keypair_to_keyfile_data = keypairToKeyfileData + export function deserializeKeypairFromKeyfile( keyfileData: ByteLike, password?: string | null, @@ -511,16 +600,22 @@ export function deserializeKeypairFromKeyfile( return Keypair.fromKeyfileData(keyfileData, password) } +export const deserialize_keypair_from_keyfile = deserializeKeypairFromKeyfile + export function readKeypairKeyfile(path: string, password?: string | null): Keypair { return Keypair.fromKeyfile(path, password) } +export const read_keypair_keyfile = readKeypairKeyfile + export function encryptKeyfileData(keyfileData: ByteLike, password: string): Buffer { return nativeCall(() => native.encryptKeyfileData(toBuffer(keyfileData, 'keyfileData'), password), ) } +export const encrypt_keyfile_data = encryptKeyfileData + export function decryptKeyfileData( keyfileData: ByteLike, password?: string | null, @@ -530,30 +625,46 @@ export function decryptKeyfileData( ) } +export const decrypt_keyfile_data = decryptKeyfileData + export function keyfileDataIsEncrypted(keyfileData: ByteLike): boolean { return native.keyfileDataIsEncrypted(toBuffer(keyfileData, 'keyfileData')) } +export const keyfile_data_is_encrypted = keyfileDataIsEncrypted + export function keyfileDataIsEncryptedNacl(keyfileData: ByteLike): boolean { return native.keyfileDataIsEncryptedNacl(toBuffer(keyfileData, 'keyfileData')) } +export const keyfile_data_is_encrypted_nacl = keyfileDataIsEncryptedNacl + export function keyfileDataIsEncryptedAnsible(keyfileData: ByteLike): boolean { return native.keyfileDataIsEncryptedAnsible(toBuffer(keyfileData, 'keyfileData')) } +export const keyfile_data_is_encrypted_ansible = keyfileDataIsEncryptedAnsible + export function keyfileDataIsEncryptedLegacy(keyfileData: ByteLike): boolean { return native.keyfileDataIsEncryptedLegacy(toBuffer(keyfileData, 'keyfileData')) } +export const keyfile_data_is_encrypted_legacy = keyfileDataIsEncryptedLegacy + export function keyfileDataEncryptionMethod(keyfileData: ByteLike): string { return native.keyfileDataEncryptionMethod(toBuffer(keyfileData, 'keyfileData')) } +export const keyfile_data_encryption_method = keyfileDataEncryptionMethod + export function getPasswordFromEnvironment(name: string): string | null { return nativeCall(() => native.getPasswordFromEnvironment(name) ?? null) } +export const get_password_from_environment = getPasswordFromEnvironment + export function savePasswordToEnvironment(name: string, password: string): string { return nativeCall(() => native.savePasswordToEnvironment(name, password)) } + +export const save_password_to_environment = savePasswordToEnvironment diff --git a/sdk/bittensor-ts/src/ledger.ts b/sdk/bittensor-ts/src/ledger.ts index 4108fee3f0..8cac75407f 100644 --- a/sdk/bittensor-ts/src/ledger.ts +++ b/sdk/bittensor-ts/src/ledger.ts @@ -65,6 +65,11 @@ export class LedgerDevice { return nativeCall(() => this.handle.appVersion()) } + app_version(): [number, number, number] { + const version = this.appVersion() + return [version.major, version.minor, version.patch] + } + address( account = 0, index = 0, @@ -78,12 +83,25 @@ export class LedgerDevice { return new LedgerSigner(this, options) } + sign(payload: ByteLike, proof: ByteLike, account?: number, index?: number): Buffer + sign(account: number, index: number, payload: ByteLike, proof: ByteLike): Buffer sign( - account: number, - index: number, - payload: ByteLike, - proof: ByteLike, + first: number | ByteLike, + second: number | ByteLike, + third?: number | ByteLike, + fourth?: number | ByteLike, ): Buffer { + const account = typeof first === 'number' ? first : typeof third === 'number' ? third : 0 + const index = typeof first === 'number' && typeof second === 'number' + ? second + : typeof fourth === 'number' + ? fourth + : 0 + const payload = typeof first === 'number' ? third : first + const proof = typeof first === 'number' ? fourth : second + if (payload == null || proof == null || typeof payload === 'number' || typeof proof === 'number') { + throw new TypeError('payload and proof are required') + } return nativeCall(() => this.handle.sign( account, diff --git a/sdk/bittensor-ts/src/modules.ts b/sdk/bittensor-ts/src/modules.ts index 2b6a1741f9..46b0f961a2 100644 --- a/sdk/bittensor-ts/src/modules.ts +++ b/sdk/bittensor-ts/src/modules.ts @@ -32,28 +32,49 @@ export const rustCore = Object.freeze({ CRYPTO_SR25519: keys.CRYPTO_SR25519, DEFAULT_SS58_FORMAT: keys.DEFAULT_SS58_FORMAT, generateMnemonic: keys.generateMnemonic, + generate_mnemonic: keys.generate_mnemonic, verify: keys.verify, + verify_signature: keys.verify_signature, publicKeyFromSs58: keys.publicKeyFromSs58, + ss58_decode: keys.ss58_decode, + decode_ss58: keys.decode_ss58, ss58FromPublic: keys.ss58FromPublic, + ss58_encode: keys.ss58_encode, + encode_ss58: keys.encode_ss58, encryptFor: keys.encryptFor, + encrypt_for: keys.encrypt_for, }), keyfiles: Object.freeze({ serializeKeypair: keys.serializeKeypair, serializedKeypairToKeyfileData: keys.serializedKeypairToKeyfileData, + serialized_keypair_to_keyfile_data: keys.serialized_keypair_to_keyfile_data, keypairToKeyfileData: keys.keypairToKeyfileData, + keypair_to_keyfile_data: keys.keypair_to_keyfile_data, deserializeKeypair: keys.deserializeKeypair, deserializeKeypairFromKeyfileData: keys.deserializeKeypairFromKeyfileData, + deserialize_keypair_from_keyfile_data: keys.deserialize_keypair_from_keyfile_data, deserializeKeypairFromKeyfile: keys.deserializeKeypairFromKeyfile, + deserialize_keypair_from_keyfile: keys.deserialize_keypair_from_keyfile, readKeypairKeyfile: keys.readKeypairKeyfile, + read_keypair_keyfile: keys.read_keypair_keyfile, encryptKeyfileData: keys.encryptKeyfileData, + encrypt_keyfile_data: keys.encrypt_keyfile_data, decryptKeyfileData: keys.decryptKeyfileData, + decrypt_keyfile_data: keys.decrypt_keyfile_data, keyfileDataIsEncrypted: keys.keyfileDataIsEncrypted, + keyfile_data_is_encrypted: keys.keyfile_data_is_encrypted, keyfileDataIsEncryptedNacl: keys.keyfileDataIsEncryptedNacl, + keyfile_data_is_encrypted_nacl: keys.keyfile_data_is_encrypted_nacl, keyfileDataIsEncryptedAnsible: keys.keyfileDataIsEncryptedAnsible, + keyfile_data_is_encrypted_ansible: keys.keyfile_data_is_encrypted_ansible, keyfileDataIsEncryptedLegacy: keys.keyfileDataIsEncryptedLegacy, + keyfile_data_is_encrypted_legacy: keys.keyfile_data_is_encrypted_legacy, keyfileDataEncryptionMethod: keys.keyfileDataEncryptionMethod, + keyfile_data_encryption_method: keys.keyfile_data_encryption_method, getPasswordFromEnvironment: keys.getPasswordFromEnvironment, + get_password_from_environment: keys.get_password_from_environment, savePasswordToEnvironment: keys.savePasswordToEnvironment, + save_password_to_environment: keys.save_password_to_environment, }), codec: Object.freeze({ value: Object.freeze({ @@ -81,7 +102,9 @@ export const rustCore = Object.freeze({ }), extrinsic: Object.freeze({ eraBirth: runtime.eraBirth, + era_birth: runtime.era_birth, multisigAccountId: runtime.multisigAccountId, + multisig_account_id: runtime.multisig_account_id, multisigSs58: runtime.multisigSs58, }), batch: Object.freeze({ @@ -90,13 +113,19 @@ export const rustCore = Object.freeze({ }), digest: Object.freeze({ metadataDigest: crypto.metadataDigest, + metadata_digest: crypto.metadata_digest, generateExtrinsicProof: crypto.generateExtrinsicProof, + generate_extrinsic_proof: crypto.generate_extrinsic_proof, }), mlkem: Object.freeze({ seal: crypto.mlkemSeal, + encryptMlkem768: crypto.encryptMlkem768, + encrypt_mlkem768: crypto.encrypt_mlkem768, twox128: crypto.mlkemTwox128, MLKEM_NONCE_LEN: crypto.MLKEM_NONCE_LEN, KDF_ID: crypto.KDF_ID, + mlkemKdfId: crypto.mlkemKdfId, + mlkem_kdf_id: crypto.mlkem_kdf_id, }), runtime: Object.freeze({ Runtime: runtime.Runtime, @@ -115,13 +144,20 @@ export const rustCore = Object.freeze({ encryptAndCompress: timelock.encryptAndCompress, decryptAndDecompress: timelock.decryptAndDecompress, generateCommitV2: timelock.generateCommitV2, + get_encrypted_commit_v2: timelock.get_encrypted_commit_v2, encryptCommitment: timelock.encryptCommitment, + get_encrypted_commitment: timelock.get_encrypted_commitment, encryptNBlocks: timelock.encryptNBlocks, + encrypt: timelock.encrypt, encryptAtRound: timelock.encryptAtRound, + encrypt_at_round: timelock.encrypt_at_round, getRoundInfo: timelock.getRoundInfo, + get_latest_round: timelock.get_latest_round, getRevealRoundSignature: timelock.getRevealRoundSignature, + get_signature_for_round: timelock.get_signature_for_round, decrypt: timelock.decrypt, decryptWithSignature: timelock.decryptWithSignature, + decrypt_with_signature: timelock.decrypt_with_signature, constants: Object.freeze({ MAX_TEMPO: timelock.MAX_TEMPO, MAX_TEMPO_U64: timelock.MAX_TEMPO_U64, diff --git a/sdk/bittensor-ts/src/runtime.ts b/sdk/bittensor-ts/src/runtime.ts index 6a2cf24c79..722d40036d 100644 --- a/sdk/bittensor-ts/src/runtime.ts +++ b/sdk/bittensor-ts/src/runtime.ts @@ -35,6 +35,9 @@ import type { TypeSpec, } from './types' +export type PayloadPartsTuple = [Buffer, Buffer] +export type SignedExtrinsicTuple = [Buffer, Buffer] + function nativeTxParams(params: TransactionParams): NativeTxParams { return { era: toWire(params.era), @@ -61,9 +64,16 @@ function nativeExtrinsicParams(params: SignedExtrinsicParams): NativeExtrinsicPa } function storageEntry(value: import('./native').NativeStorageEntry): StorageEntry { + const defaultBytes = Buffer.from(value.defaultBytes) return { ...value, - defaultBytes: Buffer.from(value.defaultBytes), + value_type: value.valueType, + value_type_id: value.valueTypeId, + param_types: value.paramTypes, + param_type_ids: value.paramTypeIds, + param_hashers: value.paramHashers, + defaultBytes, + default_bytes: defaultBytes, } } @@ -203,22 +213,42 @@ export class Runtime { return this.handle.specVersion } + get spec_version(): number { + return this.specVersion + } + get transactionVersion(): number { return this.handle.transactionVersion } + get transaction_version(): number { + return this.transactionVersion + } + get ss58Format(): number { return this.handle.ss58Format } + get ss58_format(): number { + return this.ss58Format + } + get isV15(): boolean { return this.handle.isV15 } + get is_v15(): boolean { + return this.isV15 + } + get extrinsicVersion(): number { return this.handle.extrinsicVersion } + get extrinsic_version(): number { + return this.extrinsicVersion + } + get outerEventType(): number | null { return this.handle.outerEventType ?? null } @@ -292,6 +322,13 @@ export class Runtime { ) } + batch_decode( + typeStrings: string[], + data: ByteLike[], + ): T[] { + return this.decodeBatch(typeStrings, data) + } + encode(typeString: string, value: ScaleValue): Buffer { return nativeCall(() => this.handle.encode(typeString, toWire(value))) } @@ -304,10 +341,18 @@ export class Runtime { return this.handle.typeIdOf(name) ?? null } + type_id_of(name: string): number | null { + return this.typeIdOf(name) + } + typeNameOf(id: number): string | null { return this.handle.typeNameOf(id) ?? null } + type_name_of(id: number): string | null { + return this.typeNameOf(id) + } + typeSpec(typeString: string): TypeSpec { return nativeCall(() => this.handle.typeSpec(typeString) as TypeSpec) } @@ -488,6 +533,10 @@ export class Runtime { return nativeCall(() => this.handle.registryJson()) } + registry_json(): string { + return this.registryJson() + } + registry(): unknown { return nativeCall(() => this.handle.registry()) } @@ -524,12 +573,20 @@ export class Runtime { return nativeCall(() => this.handle.composeCall(pallet, fn, toWire(params))) } + compose_call(pallet: string, fn: string, params: ScaleValue): Buffer { + return this.composeCall(pallet, fn, params) + } + decodeCall(data: ByteLike): T { return nativeCall( () => fromWire(this.handle.decodeCall(toBuffer(data, 'data'))) as T, ) } + decode_call(data: ByteLike): T { + return this.decodeCall(data) + } + decodeCallValue( data: ByteLike, offset = 0, @@ -564,10 +621,18 @@ export class Runtime { return nativeCall(() => storageEntry(this.handle.storageEntry(pallet, storageFunction))) } + storage_entry(pallet: string, storageFunction: string): StorageEntry { + return this.storageEntry(pallet, storageFunction) + } + storagePrefix(pallet: string, storageFunction: string): Buffer { return nativeCall(() => this.handle.storagePrefix(pallet, storageFunction)) } + storage_prefix(pallet: string, storageFunction: string): Buffer { + return this.storagePrefix(pallet, storageFunction) + } + storageKey( pallet: string, storageFunction: string, @@ -578,6 +643,14 @@ export class Runtime { ) } + storage_key( + pallet: string, + storageFunction: string, + params: ScaleValue[] = [], + ): Buffer { + return this.storageKey(pallet, storageFunction, params) + } + storageKeyBatch( pallet: string, storageFunction: string, @@ -588,6 +661,14 @@ export class Runtime { ) } + storage_key_batch( + pallet: string, + storageFunction: string, + paramsList: ScaleValue[][], + ): Buffer[] { + return this.storageKeyBatch(pallet, storageFunction, paramsList) + } + decodeStorageKeyParams( pallet: string, storageFunction: string, @@ -607,6 +688,15 @@ export class Runtime { ) } + decode_storage_key_params( + pallet: string, + storageFunction: string, + key: ByteLike, + fixed = 0, + ): T[] { + return this.decodeStorageKeyParams(pallet, storageFunction, key, fixed) + } + decodeMapPairs( pallet: string, storageFunction: string, @@ -627,6 +717,22 @@ export class Runtime { ) } + decode_map_pairs( + pallet: string, + storageFunction: string, + rawKeys: ByteLike[], + rawValues: ByteLike[], + fixed = 0, + ): Array<[K, V]> { + return this.decodeMapPairs( + pallet, + storageFunction, + rawKeys, + rawValues, + fixed, + ).map((pair) => [pair.key, pair.value]) + } + decodeMapChanges( pallet: string, storageFunction: string, @@ -644,6 +750,20 @@ export class Runtime { ) } + decode_map_changes( + pallet: string, + storageFunction: string, + changes: StorageChange[], + fixed = 0, + ): Array<[K, V]> { + return this.decodeMapChanges( + pallet, + storageFunction, + changes, + fixed, + ).map((pair) => [pair.key, pair.value]) + } + constant(pallet: string, name: string): T | undefined { return nativeCall(() => { const value = this.handle.constant(pallet, name) @@ -664,24 +784,79 @@ export class Runtime { return nativeCall(() => this.handle.moduleError(moduleIndex, errorIndex)) } + module_error(moduleIndex: number, errorIndex: number): [string, string[]] { + const error = this.moduleError(moduleIndex, errorIndex) + return [error.name, error.docs] + } + signedExtensionIdentifiers(): string[] { return this.handle.signedExtensionIdentifiers() } + signed_extension_identifiers(): string[] { + return this.signedExtensionIdentifiers() + } + encodeEra(era: ScaleValue): Buffer { return nativeCall(() => this.handle.encodeEra(toWire(era))) } + encode_era(era: ScaleValue): Buffer { + return this.encodeEra(era) + } + signaturePayloadParts(params: TransactionParams): PayloadParts { return nativeCall(() => this.handle.signaturePayloadParts(nativeTxParams(params))) } + signature_payload_parts( + era: ScaleValue, + nonce: IntegerLike, + tip: IntegerLike, + tip_asset_id: IntegerLike | null, + genesis_hash: ByteLike, + era_block_hash: ByteLike, + metadata_hash?: ByteLike | null, + ): PayloadPartsTuple { + const parts = this.signaturePayloadParts({ + era, + nonce, + tip, + tipAssetId: tip_asset_id, + genesisHash: genesis_hash, + eraBlockHash: era_block_hash, + metadataHash: metadata_hash, + }) + return [parts.includedInExtrinsic, parts.includedInSignedData] + } + signaturePayload(callData: ByteLike, params: TransactionParams): Buffer { return nativeCall(() => this.handle.signaturePayload(toBuffer(callData, 'callData'), nativeTxParams(params)), ) } + signature_payload( + call_data: ByteLike, + era: ScaleValue, + nonce: IntegerLike, + tip: IntegerLike, + tip_asset_id: IntegerLike | null, + genesis_hash: ByteLike, + era_block_hash: ByteLike, + metadata_hash?: ByteLike | null, + ): Buffer { + return this.signaturePayload(call_data, { + era, + nonce, + tip, + tipAssetId: tip_asset_id, + genesisHash: genesis_hash, + eraBlockHash: era_block_hash, + metadataHash: metadata_hash, + }) + } + encodeSignedExtrinsic( callData: ByteLike, publicKey: ByteLike, @@ -700,6 +875,33 @@ export class Runtime { ) } + encode_signed_extrinsic( + call_data: ByteLike, + public_key: ByteLike, + signature: ByteLike, + signature_version: number, + era: ScaleValue, + nonce: IntegerLike, + tip: IntegerLike, + tip_asset_id: IntegerLike | null, + metadata_hash_enabled = false, + ): SignedExtrinsicTuple { + const extrinsic = this.encodeSignedExtrinsic( + call_data, + public_key, + signature, + signature_version, + { + era, + nonce, + tip, + tipAssetId: tip_asset_id, + metadataHashEnabled: metadata_hash_enabled, + }, + ) + return [extrinsic.bytes, extrinsic.hash] + } + decodeExtrinsic( data: ByteLike, strict = true, @@ -709,19 +911,36 @@ export class Runtime { ) } + decode_extrinsic( + data: ByteLike, + strict = true, + ): T { + return this.decodeExtrinsic(data, strict) + } + runtimeApiMap(): RuntimeApiMap { return this.handle.runtimeApiMap() as RuntimeApiMap } + runtime_api_map(): RuntimeApiMap { + return this.runtimeApiMap() + } + metadataIr(): MetadataIr { return nativeCall(() => this.handle.metadataIr() as MetadataIr) } + + metadata_ir(): MetadataIr { + return this.metadataIr() + } } export function eraBirth(period: IntegerLike, current: IntegerLike): bigint { return nativeCall(() => native.eraBirth(toBigInt(period, 'period'), toBigInt(current, 'current'))) } +export const era_birth = eraBirth + export function multisigAccountId( signatories: ByteLike[], threshold: number, @@ -734,6 +953,14 @@ export function multisigAccountId( ) } +export function multisig_account_id( + signatories: ByteLike[], + threshold: number, +): [Buffer, Buffer[]] { + const account = multisigAccountId(signatories, threshold) + return [account.accountId, account.sortedSignatories] +} + export function multisigSs58( accountId: ByteLike, ss58Format = 42, diff --git a/sdk/bittensor-ts/src/timelock.ts b/sdk/bittensor-ts/src/timelock.ts index 6875479d90..8daa4621c7 100644 --- a/sdk/bittensor-ts/src/timelock.ts +++ b/sdk/bittensor-ts/src/timelock.ts @@ -12,6 +12,8 @@ import type { WeightsTlockPayload, } from './types' +export type CiphertextRoundTuple = [Buffer, bigint] + function nativeState(state: EpochScheduleState): NativeEpochScheduleState { return { lastEpochBlock: toBigInt(state.lastEpochBlock, 'lastEpochBlock'), @@ -34,6 +36,10 @@ function publicState(state: NativeEpochScheduleState): EpochScheduleState { } } +function roundTuple(value: CiphertextRound): CiphertextRoundTuple { + return [Buffer.from(value.ciphertext), value.revealRound] +} + export const MAX_TEMPO = native.timelockMaxTempo() export const MAX_TEMPO_U64 = native.timelockMaxTempoU64() export const DRAND_PUBLIC_KEY = native.timelockDrandPublicKey() @@ -97,6 +103,40 @@ export function generateCommitV2( ) } +export function get_encrypted_commit_v2( + uids: number[], + weights: number[], + versionKey: IntegerLike, + lastEpochBlock: IntegerLike, + pendingEpochAt: IntegerLike, + subnetEpochIndex: IntegerLike, + tempo: number, + blocksSinceLastStep: IntegerLike, + currentBlock: IntegerLike, + subnetRevealPeriodEpochs: IntegerLike, + blockTime: number, + hotkey: ByteLike, +): CiphertextRoundTuple { + return roundTuple( + generateCommitV2( + uids, + weights, + versionKey, + { + lastEpochBlock, + pendingEpochAt, + subnetEpochIndex, + tempo, + blocksSinceLastStep, + currentBlock, + }, + subnetRevealPeriodEpochs, + blockTime, + hotkey, + ), + ) +} + export function encryptCommitment( data: string, blocksUntilReveal: IntegerLike, @@ -111,6 +151,14 @@ export function encryptCommitment( ) } +export function get_encrypted_commitment( + data: string, + blocksUntilReveal: IntegerLike, + blockTime = 12.0, +): CiphertextRoundTuple { + return roundTuple(encryptCommitment(data, blocksUntilReveal, blockTime)) +} + export function encryptNBlocks( data: ByteLike, nBlocks: IntegerLike, @@ -125,6 +173,14 @@ export function encryptNBlocks( ) } +export function encrypt( + data: ByteLike, + nBlocks: IntegerLike, + blockTime = 12.0, +): CiphertextRoundTuple { + return roundTuple(encryptNBlocks(data, nBlocks, blockTime)) +} + export function encryptAtRound(data: ByteLike, revealRound: IntegerLike): CiphertextRound { return nativeCall(() => native.timelockEncryptAtRound( @@ -134,12 +190,23 @@ export function encryptAtRound(data: ByteLike, revealRound: IntegerLike): Cipher ) } +export function encrypt_at_round( + data: ByteLike, + revealRound: IntegerLike, +): CiphertextRoundTuple { + return roundTuple(encryptAtRound(data, revealRound)) +} + export function getRoundInfo(round?: IntegerLike | null): DrandResponse { return nativeCall(() => native.timelockGetRoundInfo(round == null ? undefined : toBigInt(round, 'round')), ) } +export function get_latest_round(): bigint { + return getRoundInfo().round +} + export function getRevealRoundSignature( revealRound?: IntegerLike | null, noErrors = true, @@ -153,6 +220,14 @@ export function getRevealRoundSignature( ) } +export function get_signature_for_round(revealRound: IntegerLike): string { + const signature = getRevealRoundSignature(revealRound, false) + if (signature == null) { + throw new Error('Signature not available') + } + return signature +} + export function decrypt(encryptedData: ByteLike, noErrors = true): Buffer | null { return nativeCall( () => native.timelockDecrypt(toBuffer(encryptedData, 'encryptedData'), noErrors) ?? null, @@ -171,6 +246,8 @@ export function decryptWithSignature( ) } +export const decrypt_with_signature = decryptWithSignature + export function shouldRunEpoch(state: EpochScheduleState, block: IntegerLike): boolean { return native.epochShouldRun(nativeState(state), toBigInt(block, 'block')) } @@ -271,13 +348,20 @@ export const timelock = Object.freeze({ encryptAndCompress, decryptAndDecompress, generateCommitV2, + get_encrypted_commit_v2, encryptCommitment, + get_encrypted_commitment, encryptNBlocks, + encrypt, encryptAtRound, + encrypt_at_round, getRoundInfo, + get_latest_round, getRevealRoundSignature, + get_signature_for_round, decrypt, decryptWithSignature, + decrypt_with_signature, shouldRunEpoch, currentEpochPreRunCoinbase, simulateRunCoinbase, diff --git a/sdk/bittensor-ts/src/types.ts b/sdk/bittensor-ts/src/types.ts index f856c8df64..f4fe673edd 100644 --- a/sdk/bittensor-ts/src/types.ts +++ b/sdk/bittensor-ts/src/types.ts @@ -80,22 +80,34 @@ export interface StorageEntryLike { prefix: string modifier?: string valueType?: string + value_type?: string valueTypeId?: number + value_type_id?: number paramTypes?: string[] + param_types?: string[] paramTypeIds?: number[] + param_type_ids?: number[] paramHashers?: string[] + param_hashers?: string[] defaultBytes?: ByteLike + default_bytes?: ByteLike } export interface StorageEntry extends StorageEntryLike { pallet: string modifier: string valueType: string + value_type: string valueTypeId: number + value_type_id: number paramTypes: string[] + param_types: string[] paramTypeIds: number[] + param_type_ids: number[] paramHashers: string[] + param_hashers: string[] defaultBytes: Buffer + default_bytes: Buffer } export interface StorageChange { diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 65c2108cad..c2d7b02a0e 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -544,6 +544,49 @@ test('sr25519 keypair forwards signing and SS58 work to Rust', () => { assert.deepEqual(core.publicKeyFromSs58(alice.ss58Address), alice.publicKey) }) +test('Python-compatible bittensor_core names are exported', () => { + const alice = core.Keypair.create_from_uri('//Alice') + const bob = core.Keypair.from_uri('//Bob') + const message = Buffer.from('python parity') + const signature = alice.sign(message) + + assert.equal(core.__core_version__, core.BINDING_VERSION) + assert.equal(alice.crypto_type, alice.cryptoType) + assert.deepEqual(alice.public_key, alice.publicKey) + assert.equal(alice.ss58_address, alice.ss58Address) + assert.equal(alice.ss58_format, alice.ss58Format) + assert.deepEqual(core.ss58_decode(alice.ss58_address), alice.publicKey) + assert.equal(core.decode_ss58, core.ss58_decode) + assert.equal(core.ss58_encode(alice.public_key, 42), alice.ss58_address) + assert.equal(core.encode_ss58, core.ss58_encode) + assert.equal( + core.verify_signature(message, signature, alice.ss58_address, core.CRYPTO_SR25519), + true, + ) + + const publicOnly = new core.Keypair(alice.ss58_address) + const publicKeyfile = JSON.parse( + core.serialized_keypair_to_keyfile_data(publicOnly).toString('utf8'), + ) + assert.equal(publicKeyfile.ss58Address, alice.ss58_address) + assert.equal(core.keyfile_data_is_encrypted(Buffer.from('plain')), false) + assert.deepEqual(core.mlkem_kdf_id(), core.MLKEM_KDF_ID) + assert.equal(typeof core.metadata_digest, 'function') + assert.equal(typeof core.generate_extrinsic_proof, 'function') + assert.equal(typeof core.get_encrypted_commitment, 'function') + assert.equal(typeof core.get_signature_for_round, 'function') + assert.equal(core.era_birth(64n, 70n), core.eraBirth(64n, 70n)) + + const camelMultisig = core.multisigAccountId([alice.public_key, bob.public_key], 2) + const [snakeAccountId, snakeSorted] = core.multisig_account_id( + [alice.public_key, bob.public_key], + 2, + ) + assert.deepEqual(snakeAccountId, camelMultisig.accountId) + assert.deepEqual(snakeSorted, camelMultisig.sortedSignatories) + assert.equal(core.rustCore.keys.ss58_decode, core.ss58_decode) +}) + test('Rust keypair is compatible with Polkadot.js and Moonwall signer expectations', () => { const alice = core.createKeyringPairFromUri('//Alice') assert.equal(alice.address, alice.ss58Address) From c8bf57cca96376861c49a7f871d4242b645c64ab Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 08:50:59 -0700 Subject: [PATCH 42/72] more fixes --- sdk/bittensor-core/src/keyfiles/mod.rs | 7 + sdk/bittensor-core/src/keys/mod.rs | 3 + sdk/bittensor-ts/README.md | 19 +- sdk/bittensor-ts/native/src/keys.rs | 218 ++++++++++++--- sdk/bittensor-ts/native/src/ledger.rs | 154 +++++++++-- sdk/bittensor-ts/native/src/timelock.rs | 352 ++++++++++++++++++++---- sdk/bittensor-ts/src/balance.ts | 62 ++++- sdk/bittensor-ts/src/client.ts | 247 +++++++++++++---- sdk/bittensor-ts/src/errors.ts | 10 + sdk/bittensor-ts/src/keys.ts | 88 ++++-- sdk/bittensor-ts/src/ledger.ts | 43 ++- sdk/bittensor-ts/src/native.ts | 49 ++-- sdk/bittensor-ts/src/timelock.ts | 81 +++--- sdk/bittensor-ts/src/wallet.ts | 166 +++++++---- sdk/bittensor-ts/test/basic.test.cjs | 143 ++++++++-- 15 files changed, 1306 insertions(+), 336 deletions(-) diff --git a/sdk/bittensor-core/src/keyfiles/mod.rs b/sdk/bittensor-core/src/keyfiles/mod.rs index 6aa2c5e2c9..980b5c6de4 100644 --- a/sdk/bittensor-core/src/keyfiles/mod.rs +++ b/sdk/bittensor-core/src/keyfiles/mod.rs @@ -309,7 +309,14 @@ pub fn save_keypair_to_keyfile( path: &Path, password: Option<&str>, overwrite: bool, + allow_plaintext: bool, ) -> Result<(), CoreError> { + if keypair.has_private_key() && password.is_none() && !allow_plaintext { + return Err(key_err( + "plaintext private keyfile writes are disabled; provide a password or set allow_plaintext", + )); + } + let parent = path .parent() .ok_or_else(|| key_err("keyfile path must have a parent directory"))?; diff --git a/sdk/bittensor-core/src/keys/mod.rs b/sdk/bittensor-core/src/keys/mod.rs index e2c066e4f0..241e7bf729 100644 --- a/sdk/bittensor-core/src/keys/mod.rs +++ b/sdk/bittensor-core/src/keys/mod.rs @@ -154,6 +154,7 @@ fn ed25519_x25519_from_pair( // Each Keypair holds exactly one variant; the size skew between ed25519 and // public-only is irrelevant here. +#[derive(Clone)] #[allow(clippy::large_enum_variant)] pub enum KeypairInner { Ed25519(ed25519::Pair), @@ -178,6 +179,7 @@ impl KeypairInner { } } +#[derive(Clone)] struct DerivationSource { base_uri: Zeroizing, password: Option>, @@ -235,6 +237,7 @@ impl DerivationSource { } /// An sr25519 or ed25519 keypair backed by the workspace's sp-core. +#[derive(Clone)] pub struct Keypair { inner: KeypairInner, ss58_format: u16, diff --git a/sdk/bittensor-ts/README.md b/sdk/bittensor-ts/README.md index 23146025ae..525de38da7 100644 --- a/sdk/bittensor-ts/README.md +++ b/sdk/bittensor-ts/README.md @@ -114,16 +114,29 @@ await client.transfer(alice, '5F...', Balance.fromTao('0.01'), { await client.close() ``` +Transaction amount inputs are intentionally explicit. Pass `Balance.fromTao("1.25")` +or `taoAmount("1.25")` for TAO-denominated values, and pass `123n` or +`raoAmount("123")` for rao. Raw `number` and `string` amounts are rejected by +transaction builders to avoid confusing `"1"` rao with `"1.0"` TAO. Decimal +TAO/alpha amounts with more than nine fractional digits are rejected. + `client.submit()` manages nonce reservation for in-process submissions. Detached flows using `signExtrinsic()` followed by `submitSigned()` or `watchSigned()` record the signed nonce after submission, but callers producing multiple detached transactions concurrently should pass explicit `nonce` values. +`client.assertDescriptorSchema()` checks the exported storage, call, constant, +and runtime API descriptor tables against the chain metadata loaded for a block. +Run it in CI or application startup when relying on the convenience descriptor +exports. + Wallet private keyfiles are encrypted when `keyfilePassword` is supplied. Plaintext private keyfile writes require `allowPlaintext: true`; public-only -keyfiles are still written without encryption. `createNewColdkey()` and -`createNewHotkey()` return `{ wallet, keypair, mnemonic }` so callers can store -the recovery phrase before relying on the persisted wallet. +keyfiles are still written without encryption. Keyfile, wallet persistence, +Ledger, and Drand-backed timelock operations are Promise-based so blocking I/O +and expensive KDF work run off the JavaScript thread. `createNewColdkey()` and +`createNewHotkey()` resolve to `{ wallet, keypair, mnemonic }` so callers can +store the recovery phrase before relying on the persisted wallet. ## Browser example diff --git a/sdk/bittensor-ts/native/src/keys.rs b/sdk/bittensor-ts/native/src/keys.rs index 20a824cd90..7fb8e946e7 100644 --- a/sdk/bittensor-ts/native/src/keys.rs +++ b/sdk/bittensor-ts/native/src/keys.rs @@ -1,6 +1,7 @@ use bittensor_core::keyfiles; use bittensor_core::keys::{self, Keypair, CRYPTO_ED25519, CRYPTO_SR25519, DEFAULT_SS58_FORMAT}; -use napi::bindgen_prelude::Buffer; +use napi::bindgen_prelude::{AsyncTask, Buffer}; +use napi::{Env, Task}; use napi_derive::napi; use std::path::PathBuf; @@ -17,6 +18,143 @@ impl NativeKeypair { } } +pub struct KeypairFromEncryptedJsonTask { + json_data: String, + passphrase: String, +} + +impl Task for KeypairFromEncryptedJsonTask { + type Output = Keypair; + type JsValue = NativeKeypair; + + fn compute(&mut self) -> napi::Result { + Keypair::from_encrypted_json(&self.json_data, &self.passphrase).napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(NativeKeypair::new(output)) + } +} + +pub struct KeypairToKeyfileDataTask { + keypair: Keypair, + password: Option, +} + +impl Task for KeypairToKeyfileDataTask { + type Output = Vec; + type JsValue = Buffer; + + fn compute(&mut self) -> napi::Result { + keyfiles::keypair_to_keyfile_data(&self.keypair, self.password.as_deref()).napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(output.into()) + } +} + +pub struct DeserializeKeypairFromKeyfileTask { + keyfile_data: Vec, + password: Option, +} + +impl Task for DeserializeKeypairFromKeyfileTask { + type Output = Keypair; + type JsValue = NativeKeypair; + + fn compute(&mut self) -> napi::Result { + keyfiles::deserialize_keypair_from_keyfile(&self.keyfile_data, self.password.as_deref()) + .napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(NativeKeypair::new(output)) + } +} + +pub struct ReadKeypairKeyfileTask { + path: PathBuf, + password: Option, +} + +impl Task for ReadKeypairKeyfileTask { + type Output = Keypair; + type JsValue = NativeKeypair; + + fn compute(&mut self) -> napi::Result { + keyfiles::read_keypair_from_keyfile(&self.path, self.password.as_deref()).napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(NativeKeypair::new(output)) + } +} + +pub struct WriteKeypairKeyfileTask { + keypair: Keypair, + path: PathBuf, + password: Option, + overwrite: bool, + allow_plaintext: bool, +} + +impl Task for WriteKeypairKeyfileTask { + type Output = (); + type JsValue = (); + + fn compute(&mut self) -> napi::Result { + keyfiles::save_keypair_to_keyfile( + &self.keypair, + &self.path, + self.password.as_deref(), + self.overwrite, + self.allow_plaintext, + ) + .napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(output) + } +} + +pub struct EncryptKeyfileDataTask { + keyfile_data: Vec, + password: String, +} + +impl Task for EncryptKeyfileDataTask { + type Output = Vec; + type JsValue = Buffer; + + fn compute(&mut self) -> napi::Result { + keyfiles::encrypt_keyfile_data(&self.keyfile_data, &self.password).napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(output.into()) + } +} + +pub struct DecryptKeyfileDataTask { + keyfile_data: Vec, + password: Option, +} + +impl Task for DecryptKeyfileDataTask { + type Output = Vec; + type JsValue = Buffer; + + fn compute(&mut self) -> napi::Result { + keyfiles::decrypt_keyfile_data(&self.keyfile_data, self.password.as_deref()).napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(output.into()) + } +} + #[napi] impl NativeKeypair { #[napi(getter)] @@ -135,10 +273,11 @@ pub fn keypair_from_private_key(private_key: String, crypto_type: u8) -> NapiRes pub fn keypair_from_encrypted_json( json_data: String, passphrase: String, -) -> NapiResult { - Keypair::from_encrypted_json(&json_data, &passphrase) - .napi() - .map(NativeKeypair::new) +) -> AsyncTask { + AsyncTask::new(KeypairFromEncryptedJsonTask { + json_data, + passphrase, + }) } #[napi(js_name = "generateMnemonic")] @@ -198,10 +337,11 @@ pub fn serialize_keypair(keypair: &NativeKeypair) -> NapiResult { pub fn keypair_to_keyfile_data( keypair: &NativeKeypair, password: Option, -) -> NapiResult { - keyfiles::keypair_to_keyfile_data(&keypair.inner, password.as_deref()) - .napi() - .map(Into::into) +) -> AsyncTask { + AsyncTask::new(KeypairToKeyfileDataTask { + keypair: keypair.inner.clone(), + password, + }) } #[napi(js_name = "deserializeKeypair")] @@ -215,17 +355,22 @@ pub fn deserialize_keypair(keyfile_data: Buffer) -> NapiResult { pub fn deserialize_keypair_from_keyfile( keyfile_data: Buffer, password: Option, -) -> NapiResult { - keyfiles::deserialize_keypair_from_keyfile(keyfile_data.as_ref(), password.as_deref()) - .napi() - .map(NativeKeypair::new) +) -> AsyncTask { + AsyncTask::new(DeserializeKeypairFromKeyfileTask { + keyfile_data: keyfile_data.to_vec(), + password, + }) } #[napi(js_name = "readKeypairKeyfile")] -pub fn read_keypair_keyfile(path: String, password: Option) -> NapiResult { - keyfiles::read_keypair_from_keyfile(&PathBuf::from(path), password.as_deref()) - .napi() - .map(NativeKeypair::new) +pub fn read_keypair_keyfile( + path: String, + password: Option, +) -> AsyncTask { + AsyncTask::new(ReadKeypairKeyfileTask { + path: PathBuf::from(path), + password, + }) } #[napi(js_name = "writeKeypairKeyfile")] @@ -234,28 +379,37 @@ pub fn write_keypair_keyfile( path: String, password: Option, overwrite: bool, -) -> NapiResult<()> { - keyfiles::save_keypair_to_keyfile( - &keypair.inner, - &PathBuf::from(path), - password.as_deref(), + allow_plaintext: bool, +) -> AsyncTask { + AsyncTask::new(WriteKeypairKeyfileTask { + keypair: keypair.inner.clone(), + path: PathBuf::from(path), + password, overwrite, - ) - .napi() + allow_plaintext, + }) } #[napi(js_name = "encryptKeyfileData")] -pub fn encrypt_keyfile_data(keyfile_data: Buffer, password: String) -> NapiResult { - keyfiles::encrypt_keyfile_data(keyfile_data.as_ref(), &password) - .napi() - .map(Into::into) +pub fn encrypt_keyfile_data( + keyfile_data: Buffer, + password: String, +) -> AsyncTask { + AsyncTask::new(EncryptKeyfileDataTask { + keyfile_data: keyfile_data.to_vec(), + password, + }) } #[napi(js_name = "decryptKeyfileData")] -pub fn decrypt_keyfile_data(keyfile_data: Buffer, password: Option) -> NapiResult { - keyfiles::decrypt_keyfile_data(keyfile_data.as_ref(), password.as_deref()) - .napi() - .map(Into::into) +pub fn decrypt_keyfile_data( + keyfile_data: Buffer, + password: Option, +) -> AsyncTask { + AsyncTask::new(DecryptKeyfileDataTask { + keyfile_data: keyfile_data.to_vec(), + password, + }) } #[napi(js_name = "keyfileDataIsEncrypted")] diff --git a/sdk/bittensor-ts/native/src/ledger.rs b/sdk/bittensor-ts/native/src/ledger.rs index 54c7bf483a..18f8f3c8b3 100644 --- a/sdk/bittensor-ts/native/src/ledger.rs +++ b/sdk/bittensor-ts/native/src/ledger.rs @@ -1,8 +1,10 @@ use bittensor_core::signers::ledger::LedgerDevice; -use napi::bindgen_prelude::Buffer; +use napi::bindgen_prelude::{AsyncTask, Buffer}; +use napi::{Env, Task}; use napi_derive::napi; +use std::sync::{Arc, Mutex, MutexGuard}; -use crate::errors::{CoreResultExt, NapiResult}; +use crate::errors::CoreResultExt; #[napi(object)] pub struct NativeLedgerVersion { @@ -17,27 +19,129 @@ pub struct NativeLedgerAddress { pub ss58_address: String, } +type SharedLedgerDevice = Arc>; + #[napi] pub struct NativeLedgerDevice { - inner: LedgerDevice, + inner: SharedLedgerDevice, } -#[napi] -impl NativeLedgerDevice { - #[napi(factory)] - pub fn open() -> napi::Result { - LedgerDevice::open().napi().map(|inner| Self { inner }) +pub struct OpenLedgerTask; + +impl Task for OpenLedgerTask { + type Output = LedgerDevice; + type JsValue = NativeLedgerDevice; + + fn compute(&mut self) -> napi::Result { + LedgerDevice::open().napi() } - #[napi] - pub fn app_version(&self) -> NapiResult { - let (major, minor, patch) = self.inner.app_version().napi()?; + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(NativeLedgerDevice { + inner: Arc::new(Mutex::new(output)), + }) + } +} + +pub struct LedgerAppVersionTask { + inner: SharedLedgerDevice, +} + +impl Task for LedgerAppVersionTask { + type Output = (u16, u16, u16); + type JsValue = NativeLedgerVersion; + + fn compute(&mut self) -> napi::Result { + lock_ledger(&self.inner)?.app_version().napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + let (major, minor, patch) = output; Ok(NativeLedgerVersion { major, minor, patch, }) } +} + +pub struct LedgerAddressOutput { + public_key: Vec, + ss58_address: String, +} + +pub struct LedgerAddressTask { + inner: SharedLedgerDevice, + account: u32, + index: u32, + ss58_prefix: u16, + confirm: bool, +} + +impl Task for LedgerAddressTask { + type Output = LedgerAddressOutput; + type JsValue = NativeLedgerAddress; + + fn compute(&mut self) -> napi::Result { + let address = lock_ledger(&self.inner)? + .address(self.account, self.index, self.ss58_prefix, self.confirm) + .napi()?; + Ok(LedgerAddressOutput { + public_key: address.public_key.to_vec(), + ss58_address: address.ss58_address, + }) + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(NativeLedgerAddress { + public_key: output.public_key.into(), + ss58_address: output.ss58_address, + }) + } +} + +pub struct LedgerSignTask { + inner: SharedLedgerDevice, + account: u32, + index: u32, + payload: Vec, + proof: Vec, +} + +impl Task for LedgerSignTask { + type Output = Vec; + type JsValue = Buffer; + + fn compute(&mut self) -> napi::Result { + lock_ledger(&self.inner)? + .sign(self.account, self.index, &self.payload, &self.proof) + .napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(output.into()) + } +} + +fn lock_ledger(inner: &SharedLedgerDevice) -> napi::Result> { + inner + .lock() + .map_err(|_| napi::Error::from_reason("Ledger device lock was poisoned")) +} + +#[napi] +impl NativeLedgerDevice { + #[napi] + pub fn open() -> AsyncTask { + AsyncTask::new(OpenLedgerTask) + } + + #[napi] + pub fn app_version(&self) -> AsyncTask { + AsyncTask::new(LedgerAppVersionTask { + inner: Arc::clone(&self.inner), + }) + } #[napi] pub fn address( @@ -46,14 +150,13 @@ impl NativeLedgerDevice { index: u32, ss58_prefix: u16, confirm: bool, - ) -> NapiResult { - let address = self - .inner - .address(account, index, ss58_prefix, confirm) - .napi()?; - Ok(NativeLedgerAddress { - public_key: address.public_key.to_vec().into(), - ss58_address: address.ss58_address, + ) -> AsyncTask { + AsyncTask::new(LedgerAddressTask { + inner: Arc::clone(&self.inner), + account, + index, + ss58_prefix, + confirm, }) } @@ -64,10 +167,13 @@ impl NativeLedgerDevice { index: u32, payload: Buffer, proof: Buffer, - ) -> NapiResult { - self.inner - .sign(account, index, payload.as_ref(), proof.as_ref()) - .napi() - .map(Into::into) + ) -> AsyncTask { + AsyncTask::new(LedgerSignTask { + inner: Arc::clone(&self.inner), + account, + index, + payload: payload.to_vec(), + proof: proof.to_vec(), + }) } } diff --git a/sdk/bittensor-ts/native/src/timelock.rs b/sdk/bittensor-ts/native/src/timelock.rs index a64f8728a2..ef880aca14 100644 --- a/sdk/bittensor-ts/native/src/timelock.rs +++ b/sdk/bittensor-ts/native/src/timelock.rs @@ -8,7 +8,8 @@ use bittensor_core::timelock::constants; use bittensor_core::timelock::epoch_schedule::{self, EpochScheduleError, EpochScheduleState}; use bittensor_core::timelock::{self, UserData, WeightsTlockPayload}; use codec::{Decode, Encode}; -use napi::bindgen_prelude::{BigInt, Buffer}; +use napi::bindgen_prelude::{AsyncTask, BigInt, Buffer}; +use napi::{Env, Task}; use napi_derive::napi; use crate::errors::{invalid_arg, CoreResultExt, NapiResult}; @@ -88,28 +89,266 @@ fn state_to_native(value: EpochScheduleState) -> NativeEpochScheduleState { } } -fn ciphertext_round(value: (Vec, u64)) -> NativeCiphertextRound { - NativeCiphertextRound { - ciphertext: value.0.into(), - reveal_round: BigInt::from(value.1), +pub struct CiphertextRoundOutput { + ciphertext: Vec, + reveal_round: u64, +} + +impl From<(Vec, u64)> for CiphertextRoundOutput { + fn from(value: (Vec, u64)) -> Self { + Self { + ciphertext: value.0, + reveal_round: value.1, + } } } -#[napi(js_name = "timelockEncryptAndCompress")] -pub fn encrypt_and_compress(data: Buffer, reveal_round: BigInt) -> NapiResult { - timelock::encrypt_and_compress(data.as_ref(), bigint_u64("revealRound", &reveal_round)?) +pub struct TimelockEncryptAndCompressTask { + data: Vec, + reveal_round: u64, +} + +impl Task for TimelockEncryptAndCompressTask { + type Output = Vec; + type JsValue = Buffer; + + fn compute(&mut self) -> napi::Result { + timelock::encrypt_and_compress(&self.data, self.reveal_round).napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(output.into()) + } +} + +pub struct TimelockDecryptAndDecompressTask { + encrypted_data: Vec, + signature_bytes: Vec, +} + +impl Task for TimelockDecryptAndDecompressTask { + type Output = Vec; + type JsValue = Buffer; + + fn compute(&mut self) -> napi::Result { + timelock::decrypt_and_decompress(&self.encrypted_data, &self.signature_bytes).napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(output.into()) + } +} + +pub struct TimelockGenerateCommitV2Task { + uids: Vec, + values: Vec, + version_key: u64, + state: EpochScheduleState, + subnet_reveal_period_epochs: u64, + block_time: f64, + hotkey: Vec, +} + +impl Task for TimelockGenerateCommitV2Task { + type Output = CiphertextRoundOutput; + type JsValue = NativeCiphertextRound; + + fn compute(&mut self) -> napi::Result { + timelock::generate_commit_v2( + self.uids.clone(), + self.values.clone(), + self.version_key, + self.state.clone(), + self.subnet_reveal_period_epochs, + self.block_time, + self.hotkey.clone(), + ) .napi() .map(Into::into) + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(NativeCiphertextRound { + ciphertext: output.ciphertext.into(), + reveal_round: BigInt::from(output.reveal_round), + }) + } +} + +pub struct TimelockEncryptCommitmentTask { + data: String, + blocks_until_reveal: u64, + block_time: f64, +} + +impl Task for TimelockEncryptCommitmentTask { + type Output = CiphertextRoundOutput; + type JsValue = NativeCiphertextRound; + + fn compute(&mut self) -> napi::Result { + timelock::encrypt_commitment(&self.data, self.blocks_until_reveal, self.block_time) + .napi() + .map(Into::into) + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(NativeCiphertextRound { + ciphertext: output.ciphertext.into(), + reveal_round: BigInt::from(output.reveal_round), + }) + } +} + +pub struct TimelockEncryptNBlocksTask { + data: Vec, + n_blocks: u64, + block_time: f64, +} + +impl Task for TimelockEncryptNBlocksTask { + type Output = CiphertextRoundOutput; + type JsValue = NativeCiphertextRound; + + fn compute(&mut self) -> napi::Result { + timelock::encrypt_n_blocks(&self.data, self.n_blocks, self.block_time) + .napi() + .map(Into::into) + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(NativeCiphertextRound { + ciphertext: output.ciphertext.into(), + reveal_round: BigInt::from(output.reveal_round), + }) + } +} + +pub struct TimelockEncryptAtRoundTask { + data: Vec, + reveal_round: u64, +} + +impl Task for TimelockEncryptAtRoundTask { + type Output = CiphertextRoundOutput; + type JsValue = NativeCiphertextRound; + + fn compute(&mut self) -> napi::Result { + timelock::encrypt_at_round(&self.data, self.reveal_round) + .napi() + .map(Into::into) + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(NativeCiphertextRound { + ciphertext: output.ciphertext.into(), + reveal_round: BigInt::from(output.reveal_round), + }) + } +} + +pub struct DrandResponseOutput { + round: u64, + signature: String, +} + +pub struct TimelockGetRoundInfoTask { + round: Option, +} + +impl Task for TimelockGetRoundInfoTask { + type Output = DrandResponseOutput; + type JsValue = NativeDrandResponse; + + fn compute(&mut self) -> napi::Result { + let response = timelock::get_round_info(self.round).napi()?; + Ok(DrandResponseOutput { + round: response.round, + signature: response.signature, + }) + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(NativeDrandResponse { + round: BigInt::from(output.round), + signature: output.signature, + }) + } +} + +pub struct TimelockGetRevealRoundSignatureTask { + reveal_round: Option, + no_errors: bool, +} + +impl Task for TimelockGetRevealRoundSignatureTask { + type Output = Option; + type JsValue = Option; + + fn compute(&mut self) -> napi::Result { + timelock::get_reveal_round_signature(self.reveal_round, self.no_errors).napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(output) + } +} + +pub struct TimelockDecryptTask { + encrypted_data: Vec, + no_errors: bool, +} + +impl Task for TimelockDecryptTask { + type Output = Option>; + type JsValue = Option; + + fn compute(&mut self) -> napi::Result { + timelock::decrypt(&self.encrypted_data, self.no_errors).napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(output.map(Into::into)) + } +} + +pub struct TimelockDecryptWithSignatureTask { + encrypted_data: Vec, + signature_hex: String, +} + +impl Task for TimelockDecryptWithSignatureTask { + type Output = Vec; + type JsValue = Buffer; + + fn compute(&mut self) -> napi::Result { + timelock::decrypt_with_signature(&self.encrypted_data, &self.signature_hex).napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(output.into()) + } +} + +#[napi(js_name = "timelockEncryptAndCompress")] +pub fn encrypt_and_compress( + data: Buffer, + reveal_round: BigInt, +) -> NapiResult> { + Ok(AsyncTask::new(TimelockEncryptAndCompressTask { + data: data.to_vec(), + reveal_round: bigint_u64("revealRound", &reveal_round)?, + })) } #[napi(js_name = "timelockDecryptAndDecompress")] pub fn decrypt_and_decompress( encrypted_data: Buffer, signature_bytes: Buffer, -) -> NapiResult { - timelock::decrypt_and_decompress(encrypted_data.as_ref(), signature_bytes.as_ref()) - .napi() - .map(Into::into) +) -> AsyncTask { + AsyncTask::new(TimelockDecryptAndDecompressTask { + encrypted_data: encrypted_data.to_vec(), + signature_bytes: signature_bytes.to_vec(), + }) } #[napi(js_name = "timelockGenerateCommitV2")] @@ -121,18 +360,19 @@ pub fn generate_commit_v2( subnet_reveal_period_epochs: BigInt, block_time: f64, hotkey: Buffer, -) -> NapiResult { - timelock::generate_commit_v2( +) -> NapiResult> { + Ok(AsyncTask::new(TimelockGenerateCommitV2Task { uids, values, - bigint_u64("versionKey", &version_key)?, - state_from_native(&state)?, - bigint_u64("subnetRevealPeriodEpochs", &subnet_reveal_period_epochs)?, + version_key: bigint_u64("versionKey", &version_key)?, + state: state_from_native(&state)?, + subnet_reveal_period_epochs: bigint_u64( + "subnetRevealPeriodEpochs", + &subnet_reveal_period_epochs, + )?, block_time, - hotkey.as_ref().to_vec(), - ) - .napi() - .map(ciphertext_round) + hotkey: hotkey.to_vec(), + })) } #[napi(js_name = "timelockEncryptCommitment")] @@ -140,14 +380,12 @@ pub fn encrypt_commitment( data: String, blocks_until_reveal: BigInt, block_time: f64, -) -> NapiResult { - timelock::encrypt_commitment( - &data, - bigint_u64("blocksUntilReveal", &blocks_until_reveal)?, +) -> NapiResult> { + Ok(AsyncTask::new(TimelockEncryptCommitmentTask { + data, + blocks_until_reveal: bigint_u64("blocksUntilReveal", &blocks_until_reveal)?, block_time, - ) - .napi() - .map(ciphertext_round) + })) } #[napi(js_name = "timelockEncryptNBlocks")] @@ -155,56 +393,66 @@ pub fn encrypt_n_blocks( data: Buffer, n_blocks: BigInt, block_time: f64, -) -> NapiResult { - timelock::encrypt_n_blocks(data.as_ref(), bigint_u64("nBlocks", &n_blocks)?, block_time) - .napi() - .map(ciphertext_round) +) -> NapiResult> { + Ok(AsyncTask::new(TimelockEncryptNBlocksTask { + data: data.to_vec(), + n_blocks: bigint_u64("nBlocks", &n_blocks)?, + block_time, + })) } #[napi(js_name = "timelockEncryptAtRound")] -pub fn encrypt_at_round(data: Buffer, reveal_round: BigInt) -> NapiResult { - timelock::encrypt_at_round(data.as_ref(), bigint_u64("revealRound", &reveal_round)?) - .napi() - .map(ciphertext_round) +pub fn encrypt_at_round( + data: Buffer, + reveal_round: BigInt, +) -> NapiResult> { + Ok(AsyncTask::new(TimelockEncryptAtRoundTask { + data: data.to_vec(), + reveal_round: bigint_u64("revealRound", &reveal_round)?, + })) } #[napi(js_name = "timelockGetRoundInfo")] -pub fn get_round_info(round: Option) -> NapiResult { +pub fn get_round_info(round: Option) -> NapiResult> { let round = round .as_ref() .map(|value| bigint_u64("round", value)) .transpose()?; - let response = timelock::get_round_info(round).napi()?; - Ok(NativeDrandResponse { - round: BigInt::from(response.round), - signature: response.signature, - }) + Ok(AsyncTask::new(TimelockGetRoundInfoTask { round })) } #[napi(js_name = "timelockGetRevealRoundSignature")] pub fn get_reveal_round_signature( reveal_round: Option, no_errors: bool, -) -> NapiResult> { +) -> NapiResult> { let reveal_round = reveal_round .as_ref() .map(|value| bigint_u64("revealRound", value)) .transpose()?; - timelock::get_reveal_round_signature(reveal_round, no_errors).napi() + Ok(AsyncTask::new(TimelockGetRevealRoundSignatureTask { + reveal_round, + no_errors, + })) } #[napi(js_name = "timelockDecrypt")] -pub fn decrypt(encrypted_data: Buffer, no_errors: bool) -> NapiResult> { - timelock::decrypt(encrypted_data.as_ref(), no_errors) - .napi() - .map(|value| value.map(Into::into)) +pub fn decrypt(encrypted_data: Buffer, no_errors: bool) -> AsyncTask { + AsyncTask::new(TimelockDecryptTask { + encrypted_data: encrypted_data.to_vec(), + no_errors, + }) } #[napi(js_name = "timelockDecryptWithSignature")] -pub fn decrypt_with_signature(encrypted_data: Buffer, signature_hex: String) -> NapiResult { - timelock::decrypt_with_signature(encrypted_data.as_ref(), &signature_hex) - .napi() - .map(Into::into) +pub fn decrypt_with_signature( + encrypted_data: Buffer, + signature_hex: String, +) -> AsyncTask { + AsyncTask::new(TimelockDecryptWithSignatureTask { + encrypted_data: encrypted_data.to_vec(), + signature_hex, + }) } #[napi(js_name = "epochShouldRun")] diff --git a/sdk/bittensor-ts/src/balance.ts b/sdk/bittensor-ts/src/balance.ts index ac6c8450a5..98e524b043 100644 --- a/sdk/bittensor-ts/src/balance.ts +++ b/sdk/bittensor-ts/src/balance.ts @@ -1,7 +1,19 @@ export const RAO_PER_TAO = 1_000_000_000n const MAX_SAFE_RAO = BigInt(Number.MAX_SAFE_INTEGER) +const AMOUNT_UNIT = '__bittensorAmountUnit' export type BalanceLike = Balance | bigint | number | string +export type TransactionAmount = Balance | bigint | RaoAmount | TaoAmount + +export interface RaoAmount { + readonly [AMOUNT_UNIT]: 'rao' + readonly rao: bigint +} + +export interface TaoAmount { + readonly [AMOUNT_UNIT]: 'tao' + readonly rao: bigint +} export class UnitMismatchError extends Error { constructor(message: string) { @@ -49,7 +61,10 @@ export class Balance { const negative = text.startsWith('-') const unsigned = negative ? text.slice(1) : text const [whole, fraction = ''] = unsigned.split('.', 2) - const padded = `${fraction}000000000`.slice(0, 9) + if (fraction.length > 9) { + throw new RangeError('balance amount cannot have more than 9 decimal places') + } + const padded = fraction.padEnd(9, '0') const rao = BigInt(whole || '0') * RAO_PER_TAO + BigInt(padded) return new Balance(negative ? -rao : rao, netuid, symbol) } @@ -146,16 +161,57 @@ function formatRao(rao: bigint): string { } export function balanceRao(value: BalanceLike): bigint { + if (value instanceof Balance) return value.rao + return parseRao(value) +} + +export function transactionAmountRao(value: TransactionAmount): bigint { if (value instanceof Balance) return value.rao + if (typeof value === 'bigint') return value + if (isBrandedAmount(value)) return value.rao + throw new TypeError( + 'transaction amount must be a Balance, bigint rao amount, raoAmount(...), or taoAmount(...)', + ) +} + +export function raoAmount(value: bigint | number | string): RaoAmount { + return Object.freeze({ + [AMOUNT_UNIT]: 'rao', + rao: parseRao(value), + }) as RaoAmount +} + +export function taoAmount(value: number | string): TaoAmount { + return Object.freeze({ + [AMOUNT_UNIT]: 'tao', + rao: Balance.fromTao(value).rao, + }) as TaoAmount +} + +function parseRao(value: bigint | number | string): bigint { if (typeof value === 'bigint') return value if (typeof value === 'number') { if (!Number.isSafeInteger(value)) throw new RangeError('balance rao must be a safe integer') return BigInt(value) } - if (/^-?\d+$/.test(value)) return BigInt(value) - return Balance.fromTao(value).rao + const text = value.trim() + if (/^-?\d+$/.test(text)) return BigInt(text) + throw new RangeError('balance rao must be an integer; use Balance.fromTao or taoAmount for decimal TAO') +} + +function isBrandedAmount(value: unknown): value is RaoAmount | TaoAmount { + return ( + typeof value === 'object' && + value != null && + ((value as Record)[AMOUNT_UNIT] === 'rao' || + (value as Record)[AMOUNT_UNIT] === 'tao') && + typeof (value as { rao?: unknown }).rao === 'bigint' + ) } export const tao = Balance.fromTao export const alpha = Balance.fromAlpha export const rao = Balance.fromRao +export const rao_amount = raoAmount +export const tao_amount = taoAmount +export const transaction_amount_rao = transactionAmountRao diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 763423a95a..b127231409 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -3,7 +3,7 @@ import { CRYPTO_SR25519, Keypair, publicKeyFromSs58, ss58FromPublic } from './ke import { LedgerDevice } from './ledger' import { Runtime, eraBirth } from './runtime' import { toBuffer } from './wire' -import { Balance, type BalanceLike, balanceRao } from './balance' +import { Balance, type BalanceLike, type TransactionAmount, transactionAmountRao } from './balance' import type { ByteLike, ChainInfo, ScaleValue, SignedExtrinsic, StorageEntry, TransactionParams } from './types' export const SS58_FORMAT = 42 @@ -68,8 +68,8 @@ export interface JsonRpcTransportOptions { export interface SubmitOptions { nonce?: number period?: number | null - tip?: BalanceLike - tipAssetId?: BalanceLike | null + tip?: TransactionAmount + tipAssetId?: TransactionAmount | null metadataHash?: ByteLike | null waitForInclusion?: boolean waitForFinalization?: boolean @@ -191,6 +191,13 @@ export interface BlockInfo { timestamp?: Date } +export interface DescriptorSchemaIssue { + kind: 'storage' | 'constant' | 'runtimeApi' | 'call' + path: string + descriptor: Descriptor + message: string +} + interface RpcRequest { resolve(value: unknown): void reject(error: Error): void @@ -237,6 +244,13 @@ interface HeadRuntimeCacheEntry extends RuntimeCacheEntry { expiresAtMs: number } +interface SigningSnapshot { + blockHash: string + blockNumber: number + runtime: Runtime + genesisHash: string +} + type NonceStatus = 'reserved' | 'submitted' | 'confirmed' | 'failed' | 'reusable' | 'ambiguous' interface AmbiguousNonce { @@ -925,10 +939,10 @@ export class Client { } } - async chainInfo(runtime?: Runtime): Promise { - const resolvedRuntime = runtime ?? await this.runtimeAt() + async chainInfo(runtime?: Runtime, blockHash?: string | null): Promise { + const resolvedRuntime = runtime ?? await this.runtimeAt(blockHash) const [version, properties] = await Promise.all([ - this.rpc('state_getRuntimeVersion'), + this.rpc('state_getRuntimeVersion', blockHash == null ? [] : [blockHash]), this.rpc('system_properties').catch(() => ({})), ]) const runtimeVersion = version as { specName?: unknown; specVersion?: unknown } @@ -1223,6 +1237,29 @@ export class Client { return READS.slice() } + async validateDescriptorSchema(block?: number | string | null): Promise { + const blockHash = await this.resolveBlockHash(block) + return validateDescriptorSchema(await this.runtimeAt(blockHash)) + } + + validate_descriptor_schema(block?: number | string | null): Promise { + return this.validateDescriptorSchema(block) + } + + async assertDescriptorSchema(block?: number | string | null): Promise { + const issues = await this.validateDescriptorSchema(block) + if (issues.length === 0) return + const sample = issues.slice(0, 8).map((issue) => `${issue.path}: ${issue.message}`) + throw new ChainError( + `descriptor schema drift detected (${issues.length} issue${issues.length === 1 ? '' : 's'}): ${sample.join('; ')}`, + issues, + ) + } + + assert_descriptor_schema(block?: number | string | null): Promise { + return this.assertDescriptorSchema(block) + } + async signExtrinsic(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { return this.signExtrinsicWithNonce(call, signer, options, false) } @@ -1236,18 +1273,21 @@ export class Client { let resolved: ResolvedSigner | undefined let reservation: NonceReservation | undefined try { - const runtime = await this.runtimeAt() - const callData = await this.callData(call) + const snapshot = await this.signingSnapshot() + const { runtime } = snapshot + const callData = this.callDataWithRuntime(call, runtime) resolved = await this.resolveSigner(signer, runtime) reservation = manageNonce && options.nonce == null ? await this.reserveNonce(resolved.ss58Address) : undefined const nonce = options.nonce ?? reservation?.nonce ?? await this.peekNextIndex(resolved.ss58Address) const period = options.period === undefined ? DEFAULT_ERA_PERIOD : options.period - const { era, eraBlockHash } = await this.normalizeEra(period) - const tip = balanceRao(options.tip ?? 0) - const tipAssetId = options.tipAssetId == null ? null : balanceRao(options.tipAssetId) - const chainInfo = resolved.requiresMetadataProof ? await this.chainInfo(runtime) : undefined + const { era, eraBlockHash } = await this.normalizeEra(period, snapshot) + const tip = transactionAmountRao(options.tip ?? 0n) + const tipAssetId = options.tipAssetId == null ? null : transactionAmountRao(options.tipAssetId) + const chainInfo = resolved.requiresMetadataProof + ? await this.chainInfo(runtime, snapshot.blockHash) + : undefined const metadataHash = options.metadataHash == null ? resolved.requiresMetadataProof @@ -1259,7 +1299,7 @@ export class Client { nonce, tip, tipAssetId, - genesisHash: hexToBuffer(await this.genesisHash()), + genesisHash: hexToBuffer(snapshot.genesisHash), eraBlockHash: hexToBuffer(eraBlockHash), metadataHash, } @@ -1660,18 +1700,18 @@ export class Client { return this.submitCall(pallet, fn, params, signer, options) } - transfer(signer: SignerLike, dest: string, amount: BalanceLike, options: SubmitOptions & { keepAlive?: boolean } = {}): Promise { + transfer(signer: SignerLike, dest: string, amount: TransactionAmount, options: SubmitOptions & { keepAlive?: boolean } = {}): Promise { return this.submit(calls.balances[options.keepAlive === false ? 'transferAllowDeath' : 'transferKeepAlive'](dest, amount), signer, { waitForInclusion: true, ...options, }) } - transfer_keep_alive(signer: SignerLike, dest: string, amount: BalanceLike, options: SubmitOptions = {}): Promise { + transfer_keep_alive(signer: SignerLike, dest: string, amount: TransactionAmount, options: SubmitOptions = {}): Promise { return this.transfer(signer, dest, amount, { keepAlive: true, ...options }) } - transfer_allow_death(signer: SignerLike, dest: string, amount: BalanceLike, options: SubmitOptions = {}): Promise { + transfer_allow_death(signer: SignerLike, dest: string, amount: TransactionAmount, options: SubmitOptions = {}): Promise { return this.transfer(signer, dest, amount, { keepAlive: false, ...options }) } @@ -1801,6 +1841,27 @@ export class Client { return this.composeCall(pallet, fn, params, block) } + private callDataWithRuntime(call: CallLike, runtime: Runtime): Buffer { + if (Buffer.isBuffer(call) || call instanceof Uint8Array) return toBuffer(call, 'call') + const [pallet, fn, params] = normalizeCall(call) + return runtime.composeCall(pallet, fn, params) + } + + private async signingSnapshot(): Promise { + const blockHash = await this.finalizedHead() + const [blockNumber, runtime, genesisHash] = await Promise.all([ + this.blockNumber(blockHash), + this.runtimeAt(blockHash), + this.genesisHash(), + ]) + return { + blockHash, + blockNumber, + runtime, + genesisHash, + } + } + async resolveBlockHash(block?: number | string | null): Promise { if (block == null) return null if (typeof block === 'string') return block @@ -1870,12 +1931,20 @@ export class Client { throw new ChainError('signer must implement sign(), signPayload(), or signRaw()') } - private async normalizeEra(period: number | null): Promise<{ era: ScaleValue; eraBlockHash: string }> { - if (period == null) return { era: '00', eraBlockHash: await this.genesisHash() } - const finalized = await this.finalizedHead() - const current = await this.blockNumber(finalized) + private async normalizeEra( + period: number | null, + snapshot?: SigningSnapshot, + ): Promise<{ era: ScaleValue; eraBlockHash: string }> { + if (period == null) { + return { era: '00', eraBlockHash: snapshot?.genesisHash ?? await this.genesisHash() } + } + const finalized = snapshot?.blockHash ?? await this.finalizedHead() + const current = snapshot?.blockNumber ?? await this.blockNumber(finalized) const birth = Number(eraBirth(period, current)) - return { era: { period, current }, eraBlockHash: await this.blockHash(birth) } + const eraBlockHash = birth === current && snapshot != null + ? snapshot.blockHash + : await this.blockHash(birth) + return { era: { period, current }, eraBlockHash } } private async resolveWatchedExtrinsic( @@ -2191,19 +2260,19 @@ export class StakingNamespace { return this.positions(coldkey, block) } - addStake(signer: SignerLike, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { + addStake(signer: SignerLike, hotkey: string, netuid: number, amount: TransactionAmount, options: SubmitOptions = {}): Promise { return this.client.submit(calls.subtensor.addStake(hotkey, netuid, amount), signer, { waitForInclusion: true, ...options }) } - add_stake(signer: SignerLike, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { + add_stake(signer: SignerLike, hotkey: string, netuid: number, amount: TransactionAmount, options: SubmitOptions = {}): Promise { return this.addStake(signer, hotkey, netuid, amount, options) } - removeStake(signer: SignerLike, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { + removeStake(signer: SignerLike, hotkey: string, netuid: number, amount: TransactionAmount, options: SubmitOptions = {}): Promise { return this.client.submit(calls.subtensor.removeStake(hotkey, netuid, amount), signer, { waitForInclusion: true, ...options }) } - remove_stake(signer: SignerLike, hotkey: string, netuid: number, amount: BalanceLike, options: SubmitOptions = {}): Promise { + remove_stake(signer: SignerLike, hotkey: string, netuid: number, amount: TransactionAmount, options: SubmitOptions = {}): Promise { return this.removeStake(signer, hotkey, netuid, amount, options) } } @@ -2332,16 +2401,16 @@ export const runtimeApis = runtimeApi export const calls = Object.freeze({ balances: Object.freeze({ - transferKeepAlive(dest: string, value: BalanceLike) { - return call('Balances', 'transfer_keep_alive', { dest, value: balanceRao(value) }) + transferKeepAlive(dest: string, value: TransactionAmount) { + return call('Balances', 'transfer_keep_alive', { dest, value: transactionAmountRao(value) }) }, - transferAllowDeath(dest: string, value: BalanceLike) { - return call('Balances', 'transfer_allow_death', { dest, value: balanceRao(value) }) + transferAllowDeath(dest: string, value: TransactionAmount) { + return call('Balances', 'transfer_allow_death', { dest, value: transactionAmountRao(value) }) }, }), subtensor: Object.freeze({ - addStake(hotkey: string, netuid: number, amount: BalanceLike) { - return call('SubtensorModule', 'add_stake', { hotkey, netuid, amount_staked: balanceRao(amount) }) + addStake(hotkey: string, netuid: number, amount: TransactionAmount) { + return call('SubtensorModule', 'add_stake', { hotkey, netuid, amount_staked: transactionAmountRao(amount) }) }, burnedRegister(netuid: number, hotkey: string) { return call('SubtensorModule', 'burned_register', { netuid, hotkey }) @@ -2349,13 +2418,13 @@ export const calls = Object.freeze({ commitWeights(netuid: number, commitHash: ByteLike | string) { return call('SubtensorModule', 'commit_weights', { netuid, commit_hash: commitHash }) }, - moveStake(originHotkey: string, destinationHotkey: string, originNetuid: number, destinationNetuid: number, amount: BalanceLike) { + moveStake(originHotkey: string, destinationHotkey: string, originNetuid: number, destinationNetuid: number, amount: TransactionAmount) { return call('SubtensorModule', 'move_stake', { origin_hotkey: originHotkey, destination_hotkey: destinationHotkey, origin_netuid: originNetuid, destination_netuid: destinationNetuid, - alpha_amount: balanceRao(amount), + alpha_amount: transactionAmountRao(amount), }) }, register(netuid: number, blockNumber: bigint | number | string, nonce: bigint | number | string, work: ByteLike, hotkey: string, coldkey: string) { @@ -2364,8 +2433,8 @@ export const calls = Object.freeze({ registerNetwork(hotkey: string) { return call('SubtensorModule', 'register_network', { hotkey }) }, - removeStake(hotkey: string, netuid: number, amount: BalanceLike) { - return call('SubtensorModule', 'remove_stake', { hotkey, netuid, amount_unstaked: balanceRao(amount) }) + removeStake(hotkey: string, netuid: number, amount: TransactionAmount) { + return call('SubtensorModule', 'remove_stake', { hotkey, netuid, amount_unstaked: transactionAmountRao(amount) }) }, revealWeights(netuid: number, uids: number[], values: number[], salt: number[], versionKey: bigint | number | string) { return call('SubtensorModule', 'reveal_weights', { netuid, uids, values, salt, version_key: BigInt(versionKey) }) @@ -2388,13 +2457,13 @@ export const calls = Object.freeze({ startCall(netuid: number) { return call('SubtensorModule', 'start_call', { netuid }) }, - transferStake(destinationColdkey: string, hotkey: string, originNetuid: number, destinationNetuid: number, amount: BalanceLike) { + transferStake(destinationColdkey: string, hotkey: string, originNetuid: number, destinationNetuid: number, amount: TransactionAmount) { return call('SubtensorModule', 'transfer_stake', { destination_coldkey: destinationColdkey, hotkey, origin_netuid: originNetuid, destination_netuid: destinationNetuid, - alpha_amount: balanceRao(amount), + alpha_amount: transactionAmountRao(amount), }) }, unstakeAll(hotkey: string) { @@ -2402,16 +2471,16 @@ export const calls = Object.freeze({ }, }), Balances: Object.freeze({ - transfer_keep_alive(dest: string, value: BalanceLike) { - return call('Balances', 'transfer_keep_alive', { dest, value: balanceRao(value) }) + transfer_keep_alive(dest: string, value: TransactionAmount) { + return call('Balances', 'transfer_keep_alive', { dest, value: transactionAmountRao(value) }) }, - transfer_allow_death(dest: string, value: BalanceLike) { - return call('Balances', 'transfer_allow_death', { dest, value: balanceRao(value) }) + transfer_allow_death(dest: string, value: TransactionAmount) { + return call('Balances', 'transfer_allow_death', { dest, value: transactionAmountRao(value) }) }, }), SubtensorModule: Object.freeze({ - add_stake(hotkey: string, netuid: number, amountStaked: BalanceLike) { - return call('SubtensorModule', 'add_stake', { hotkey, netuid, amount_staked: balanceRao(amountStaked) }) + add_stake(hotkey: string, netuid: number, amountStaked: TransactionAmount) { + return call('SubtensorModule', 'add_stake', { hotkey, netuid, amount_staked: transactionAmountRao(amountStaked) }) }, burned_register(netuid: number, hotkey: string) { return call('SubtensorModule', 'burned_register', { netuid, hotkey }) @@ -2419,13 +2488,13 @@ export const calls = Object.freeze({ commit_weights(netuid: number, commitHash: ByteLike | string) { return call('SubtensorModule', 'commit_weights', { netuid, commit_hash: commitHash }) }, - move_stake(originHotkey: string, destinationHotkey: string, originNetuid: number, destinationNetuid: number, alphaAmount: BalanceLike) { + move_stake(originHotkey: string, destinationHotkey: string, originNetuid: number, destinationNetuid: number, alphaAmount: TransactionAmount) { return call('SubtensorModule', 'move_stake', { origin_hotkey: originHotkey, destination_hotkey: destinationHotkey, origin_netuid: originNetuid, destination_netuid: destinationNetuid, - alpha_amount: balanceRao(alphaAmount), + alpha_amount: transactionAmountRao(alphaAmount), }) }, register(netuid: number, blockNumber: bigint | number | string, nonce: bigint | number | string, work: ByteLike, hotkey: string, coldkey: string) { @@ -2434,8 +2503,8 @@ export const calls = Object.freeze({ register_network(hotkey: string) { return call('SubtensorModule', 'register_network', { hotkey }) }, - remove_stake(hotkey: string, netuid: number, amountUnstaked: BalanceLike) { - return call('SubtensorModule', 'remove_stake', { hotkey, netuid, amount_unstaked: balanceRao(amountUnstaked) }) + remove_stake(hotkey: string, netuid: number, amountUnstaked: TransactionAmount) { + return call('SubtensorModule', 'remove_stake', { hotkey, netuid, amount_unstaked: transactionAmountRao(amountUnstaked) }) }, reveal_weights(netuid: number, uids: number[], values: number[], salt: number[], versionKey: bigint | number | string) { return call('SubtensorModule', 'reveal_weights', { netuid, uids, values, salt, version_key: BigInt(versionKey) }) @@ -2458,13 +2527,13 @@ export const calls = Object.freeze({ start_call(netuid: number) { return call('SubtensorModule', 'start_call', { netuid }) }, - transfer_stake(destinationColdkey: string, hotkey: string, originNetuid: number, destinationNetuid: number, alphaAmount: BalanceLike) { + transfer_stake(destinationColdkey: string, hotkey: string, originNetuid: number, destinationNetuid: number, alphaAmount: TransactionAmount) { return call('SubtensorModule', 'transfer_stake', { destination_coldkey: destinationColdkey, hotkey, origin_netuid: originNetuid, destination_netuid: destinationNetuid, - alpha_amount: balanceRao(alphaAmount), + alpha_amount: transactionAmountRao(alphaAmount), }) }, unstake_all(hotkey: string) { @@ -2473,6 +2542,86 @@ export const calls = Object.freeze({ }), }) +const CALL_DESCRIPTOR_ENTRIES: Array<{ path: string; descriptor: Descriptor }> = [ + { path: 'calls.balances.transferKeepAlive', descriptor: descriptor('Balances', 'transfer_keep_alive') }, + { path: 'calls.balances.transferAllowDeath', descriptor: descriptor('Balances', 'transfer_allow_death') }, + { path: 'calls.subtensor.addStake', descriptor: descriptor('SubtensorModule', 'add_stake') }, + { path: 'calls.subtensor.burnedRegister', descriptor: descriptor('SubtensorModule', 'burned_register') }, + { path: 'calls.subtensor.commitWeights', descriptor: descriptor('SubtensorModule', 'commit_weights') }, + { path: 'calls.subtensor.moveStake', descriptor: descriptor('SubtensorModule', 'move_stake') }, + { path: 'calls.subtensor.register', descriptor: descriptor('SubtensorModule', 'register') }, + { path: 'calls.subtensor.registerNetwork', descriptor: descriptor('SubtensorModule', 'register_network') }, + { path: 'calls.subtensor.removeStake', descriptor: descriptor('SubtensorModule', 'remove_stake') }, + { path: 'calls.subtensor.revealWeights', descriptor: descriptor('SubtensorModule', 'reveal_weights') }, + { path: 'calls.subtensor.rootRegister', descriptor: descriptor('SubtensorModule', 'root_register') }, + { path: 'calls.subtensor.serveAxon', descriptor: descriptor('SubtensorModule', 'serve_axon') }, + { path: 'calls.subtensor.servePrometheus', descriptor: descriptor('SubtensorModule', 'serve_prometheus') }, + { path: 'calls.subtensor.setChildren', descriptor: descriptor('SubtensorModule', 'set_children') }, + { path: 'calls.subtensor.setWeights', descriptor: descriptor('SubtensorModule', 'set_weights') }, + { path: 'calls.subtensor.startCall', descriptor: descriptor('SubtensorModule', 'start_call') }, + { path: 'calls.subtensor.transferStake', descriptor: descriptor('SubtensorModule', 'transfer_stake') }, + { path: 'calls.subtensor.unstakeAll', descriptor: descriptor('SubtensorModule', 'unstake_all') }, +] + +export function validateDescriptorSchema(runtime: Runtime): DescriptorSchemaIssue[] { + const issues: DescriptorSchemaIssue[] = [] + for (const entry of descriptorEntries(storage, 'storage')) { + const [pallet, item] = entry.descriptor + const palletInfo = runtime.pallet(pallet) + if (palletInfo == null) { + issues.push({ ...entry, kind: 'storage', message: `pallet ${pallet} not found` }) + } else if (!palletInfo.storage.some((storageItem) => storageItem.name === item)) { + issues.push({ ...entry, kind: 'storage', message: `storage item ${pallet}.${item} not found` }) + } + } + for (const entry of descriptorEntries(constants, 'constants')) { + const [pallet, item] = entry.descriptor + const palletInfo = runtime.pallet(pallet) + if (palletInfo == null) { + issues.push({ ...entry, kind: 'constant', message: `pallet ${pallet} not found` }) + } else if (runtime.constantInfo(pallet, item) == null) { + issues.push({ ...entry, kind: 'constant', message: `constant ${pallet}.${item} not found` }) + } + } + const apiMap = runtime.runtimeApis() + for (const entry of descriptorEntries(runtimeApi, 'runtimeApi')) { + const [api, method] = entry.descriptor + if (apiMap[api] == null) { + issues.push({ ...entry, kind: 'runtimeApi', message: `runtime API ${api} not found` }) + } else if (apiMap[api][method] == null) { + issues.push({ ...entry, kind: 'runtimeApi', message: `runtime API method ${api}.${method} not found` }) + } + } + const metadata = runtime.metadataIr() + for (const entry of CALL_DESCRIPTOR_ENTRIES) { + const [pallet, item] = entry.descriptor + const palletInfo = metadata.pallets.find((candidate) => candidate.name === pallet) + if (palletInfo == null) { + issues.push({ ...entry, kind: 'call', message: `pallet ${pallet} not found` }) + } else if (!palletInfo.calls.some((callInfo) => callInfo.name === item)) { + issues.push({ ...entry, kind: 'call', message: `call ${pallet}.${item} not found` }) + } + } + return issues +} + +function descriptorEntries(value: unknown, prefix: string): Array<{ path: string; descriptor: Descriptor }> { + if (isDescriptor(value)) return [{ path: prefix, descriptor: value }] + if (typeof value !== 'object' || value == null) return [] + return Object.entries(value).flatMap(([key, child]) => + descriptorEntries(child, `${prefix}.${key}`), + ) +} + +function isDescriptor(value: unknown): value is Descriptor { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'string' && + typeof value[1] === 'string' + ) +} + const READS = [ { name: 'balance', category: 'Accounts & keys', params: ['coldkey_ss58'] }, { name: 'balances', category: 'Accounts & keys', params: ['coldkey_ss58s'] }, diff --git a/sdk/bittensor-ts/src/errors.ts b/sdk/bittensor-ts/src/errors.ts index 84bfbb4de8..809f203779 100644 --- a/sdk/bittensor-ts/src/errors.ts +++ b/sdk/bittensor-ts/src/errors.ts @@ -96,4 +96,14 @@ export function nativeCall(operation: () => T): T { } } +export async function nativeAsync(operation: () => Promise): Promise { + try { + const value = await operation() + if (value instanceof Error) throw value + return value + } catch (error) { + throw mapNativeError(error) + } +} + export { BittensorCoreError as CoreError } diff --git a/sdk/bittensor-ts/src/keys.ts b/sdk/bittensor-ts/src/keys.ts index b009284ef8..d230a18a20 100644 --- a/sdk/bittensor-ts/src/keys.ts +++ b/sdk/bittensor-ts/src/keys.ts @@ -1,5 +1,5 @@ import native, { type NativeKeypairHandle } from './native' -import { nativeCall } from './errors' +import { nativeAsync, nativeCall } from './errors' import { coerceMessage, toBuffer } from './wire' import type { ByteLike } from './types' @@ -27,6 +27,12 @@ export interface GeneratedKeypair { mnemonic: string } +export interface WriteKeyfileOptions { + password?: string | null + overwrite?: boolean + allowPlaintext?: boolean +} + /** * Signing-compatible subset of Polkadot.js/Moonwall keyring-pair behavior. * Public-key derivation and signatures stay inside bittensor-core Rust, while @@ -73,6 +79,25 @@ function unsupported(operation: string): never { ) } +function normalizeWriteKeyfileOptions( + passwordOrOptions?: string | null | WriteKeyfileOptions, + overwrite = false, + allowPlaintext = false, +): Required { + if (typeof passwordOrOptions === 'object' && passwordOrOptions != null) { + return { + password: passwordOrOptions.password ?? null, + overwrite: passwordOrOptions.overwrite ?? false, + allowPlaintext: passwordOrOptions.allowPlaintext === true, + } + } + return { + password: passwordOrOptions ?? null, + overwrite, + allowPlaintext, + } +} + /** * Keep mutable compatibility metadata outside the JavaScript object shape. * Mnemonics, passwords, and secret URIs stay exclusively in NativeKeypair. @@ -218,21 +243,21 @@ export class Keypair implements PolkadotCompatibleKeypair { return Keypair.fromPrivateKey(privateKey, cryptoType) } - static fromEncryptedJson(jsonData: string, passphrase: string): Keypair { + static async fromEncryptedJson(jsonData: string, passphrase: string): Promise { return Keypair.wrap( - nativeCall(() => native.keypairFromEncryptedJson(jsonData, passphrase)), + await nativeAsync(() => native.keypairFromEncryptedJson(jsonData, passphrase)), ) } - static createFromEncryptedJson(jsonData: string, passphrase: string): Keypair { + static createFromEncryptedJson(jsonData: string, passphrase: string): Promise { return Keypair.fromEncryptedJson(jsonData, passphrase) } - static from_encrypted_json(jsonData: string, passphrase: string): Keypair { + static from_encrypted_json(jsonData: string, passphrase: string): Promise { return Keypair.fromEncryptedJson(jsonData, passphrase) } - static create_from_encrypted_json(jsonData: string, passphrase: string): Keypair { + static create_from_encrypted_json(jsonData: string, passphrase: string): Promise { return Keypair.fromEncryptedJson(jsonData, passphrase) } @@ -286,9 +311,9 @@ export class Keypair implements PolkadotCompatibleKeypair { ) } - static fromKeyfileData(keyfileData: ByteLike, password?: string | null): Keypair { + static async fromKeyfileData(keyfileData: ByteLike, password?: string | null): Promise { return Keypair.wrap( - nativeCall(() => + await nativeAsync(() => native.deserializeKeypairFromKeyfile( toBuffer(keyfileData, 'keyfileData'), password ?? undefined, @@ -297,9 +322,9 @@ export class Keypair implements PolkadotCompatibleKeypair { ) } - static fromKeyfile(path: string, password?: string | null): Keypair { + static async fromKeyfile(path: string, password?: string | null): Promise { return Keypair.wrap( - nativeCall(() => native.readKeypairKeyfile(path, password ?? undefined)), + await nativeAsync(() => native.readKeypairKeyfile(path, password ?? undefined)), ) } @@ -462,15 +487,34 @@ export class Keypair implements PolkadotCompatibleKeypair { return nativeCall(() => native.serializeKeypair(this.handle)) } - toKeyfileData(password?: string | null): Buffer { - return nativeCall(() => + toKeyfileData(password?: string | null): Promise { + return nativeAsync(() => native.keypairToKeyfileData(this.handle, password ?? undefined), ) } - writeKeyfile(path: string, password?: string | null, overwrite = false): void { - nativeCall(() => - native.writeKeypairKeyfile(this.handle, path, password ?? undefined, overwrite), + writeKeyfile(path: string, options?: WriteKeyfileOptions): Promise + writeKeyfile( + path: string, + password?: string | null, + overwrite?: boolean, + allowPlaintext?: boolean, + ): Promise + writeKeyfile( + path: string, + passwordOrOptions?: string | null | WriteKeyfileOptions, + overwrite = false, + allowPlaintext = false, + ): Promise { + const options = normalizeWriteKeyfileOptions(passwordOrOptions, overwrite, allowPlaintext) + return nativeAsync(() => + native.writeKeypairKeyfile( + this.handle, + path, + options.password ?? undefined, + options.overwrite, + options.allowPlaintext, + ), ) } } @@ -587,7 +631,7 @@ export const deserialize_keypair_from_keyfile_data = deserializeKeypairFromKeyfi export function keypairToKeyfileData( keypair: Keypair, password?: string | null, -): Buffer { +): Promise { return keypair.toKeyfileData(password) } @@ -596,20 +640,20 @@ export const keypair_to_keyfile_data = keypairToKeyfileData export function deserializeKeypairFromKeyfile( keyfileData: ByteLike, password?: string | null, -): Keypair { +): Promise { return Keypair.fromKeyfileData(keyfileData, password) } export const deserialize_keypair_from_keyfile = deserializeKeypairFromKeyfile -export function readKeypairKeyfile(path: string, password?: string | null): Keypair { +export function readKeypairKeyfile(path: string, password?: string | null): Promise { return Keypair.fromKeyfile(path, password) } export const read_keypair_keyfile = readKeypairKeyfile -export function encryptKeyfileData(keyfileData: ByteLike, password: string): Buffer { - return nativeCall(() => +export function encryptKeyfileData(keyfileData: ByteLike, password: string): Promise { + return nativeAsync(() => native.encryptKeyfileData(toBuffer(keyfileData, 'keyfileData'), password), ) } @@ -619,8 +663,8 @@ export const encrypt_keyfile_data = encryptKeyfileData export function decryptKeyfileData( keyfileData: ByteLike, password?: string | null, -): Buffer { - return nativeCall(() => +): Promise { + return nativeAsync(() => native.decryptKeyfileData(toBuffer(keyfileData, 'keyfileData'), password ?? undefined), ) } diff --git a/sdk/bittensor-ts/src/ledger.ts b/sdk/bittensor-ts/src/ledger.ts index 8cac75407f..9d3d94b6f7 100644 --- a/sdk/bittensor-ts/src/ledger.ts +++ b/sdk/bittensor-ts/src/ledger.ts @@ -1,5 +1,5 @@ import native, { type NativeLedgerHandle } from './native' -import { nativeCall } from './errors' +import { nativeAsync } from './errors' import { toBuffer } from './wire' import type { ByteLike, LedgerAddress, LedgerVersion } from './types' @@ -18,7 +18,7 @@ export class LedgerSigner { private readonly options: LedgerSignerOptions = {}, ) {} - getAccount(context: { ss58Format?: number } = {}): LedgerAddress { + getAccount(context: { ss58Format?: number } = {}): Promise { return this.device.address( this.options.account ?? 0, this.options.index ?? 0, @@ -30,7 +30,7 @@ export class LedgerSigner { signPayload( payload: ByteLike, context: { proof?: ByteLike; metadataProof?: ByteLike } = {}, - ): Buffer { + ): Promise { const proof = context.metadataProof ?? context.proof if (proof == null) { throw new Error('Ledger signing requires an RFC-0078 metadata proof') @@ -47,26 +47,23 @@ export class LedgerSigner { export class LedgerDevice { private readonly handle: NativeLedgerHandle - constructor() { - this.handle = nativeCall(() => native.NativeLedgerDevice.open()) - } - - private static wrap(handle: NativeLedgerHandle): LedgerDevice { - const device = Object.create(LedgerDevice.prototype) as LedgerDevice - Object.defineProperty(device, 'handle', { value: handle }) - return device + private constructor(handle: NativeLedgerHandle) { + if (handle == null) { + throw new TypeError('Use await LedgerDevice.open()') + } + this.handle = handle } - static open(): LedgerDevice { - return LedgerDevice.wrap(nativeCall(() => native.NativeLedgerDevice.open())) + static async open(): Promise { + return new LedgerDevice(await nativeAsync(() => native.NativeLedgerDevice.open())) } - appVersion(): LedgerVersion { - return nativeCall(() => this.handle.appVersion()) + appVersion(): Promise { + return nativeAsync(() => this.handle.appVersion()) } - app_version(): [number, number, number] { - const version = this.appVersion() + async app_version(): Promise<[number, number, number]> { + const version = await this.appVersion() return [version.major, version.minor, version.patch] } @@ -75,22 +72,22 @@ export class LedgerDevice { index = 0, ss58Prefix = 42, confirm = false, - ): LedgerAddress { - return nativeCall(() => this.handle.address(account, index, ss58Prefix, confirm)) + ): Promise { + return nativeAsync(() => this.handle.address(account, index, ss58Prefix, confirm)) } signer(options: LedgerSignerOptions = {}): LedgerSigner { return new LedgerSigner(this, options) } - sign(payload: ByteLike, proof: ByteLike, account?: number, index?: number): Buffer - sign(account: number, index: number, payload: ByteLike, proof: ByteLike): Buffer + sign(payload: ByteLike, proof: ByteLike, account?: number, index?: number): Promise + sign(account: number, index: number, payload: ByteLike, proof: ByteLike): Promise sign( first: number | ByteLike, second: number | ByteLike, third?: number | ByteLike, fourth?: number | ByteLike, - ): Buffer { + ): Promise { const account = typeof first === 'number' ? first : typeof third === 'number' ? third : 0 const index = typeof first === 'number' && typeof second === 'number' ? second @@ -102,7 +99,7 @@ export class LedgerDevice { if (payload == null || proof == null || typeof payload === 'number' || typeof proof === 'number') { throw new TypeError('payload and proof are required') } - return nativeCall(() => + return nativeAsync(() => this.handle.sign( account, index, diff --git a/sdk/bittensor-ts/src/native.ts b/sdk/bittensor-ts/src/native.ts index 6eacde5955..fffb1889ca 100644 --- a/sdk/bittensor-ts/src/native.ts +++ b/sdk/bittensor-ts/src/native.ts @@ -225,18 +225,18 @@ export interface NativeEpochScheduleState { } export interface NativeLedgerHandle { - appVersion(): { major: number; minor: number; patch: number } + appVersion(): Promise<{ major: number; minor: number; patch: number }> address( account: number, index: number, ss58Prefix: number, confirm: boolean, - ): { publicKey: Buffer; ss58Address: string } - sign(account: number, index: number, payload: Buffer, proof: Buffer): Buffer + ): Promise<{ publicKey: Buffer; ss58Address: string }> + sign(account: number, index: number, payload: Buffer, proof: Buffer): Promise } export interface NativeLedgerConstructor { - open(): NativeLedgerHandle + open(): Promise } export interface NativeBinding { @@ -283,7 +283,7 @@ export interface NativeBinding { keypairFromSeed(seed: Buffer, cryptoType: number): NativeKeypairHandle keypairFromUri(uri: string, cryptoType: number): NativeKeypairHandle keypairFromPrivateKey(privateKey: string, cryptoType: number): NativeKeypairHandle - keypairFromEncryptedJson(jsonData: string, passphrase: string): NativeKeypairHandle + keypairFromEncryptedJson(jsonData: string, passphrase: string): Promise generateMnemonic(nWords: number): string encryptFor(ss58Address: string, message: Buffer, cryptoType: number): Buffer verifySignature( @@ -295,21 +295,25 @@ export interface NativeBinding { publicKeyFromSs58(ss58Address: string): Buffer ss58FromPublic(publicKey: Buffer, ss58Format: number): string serializeKeypair(keypair: NativeKeypairHandle): Buffer - keypairToKeyfileData(keypair: NativeKeypairHandle, password?: string | null): Buffer + keypairToKeyfileData( + keypair: NativeKeypairHandle, + password?: string | null, + ): Promise deserializeKeypair(keyfileData: Buffer): NativeKeypairHandle deserializeKeypairFromKeyfile( keyfileData: Buffer, password?: string | null, - ): NativeKeypairHandle - readKeypairKeyfile(path: string, password?: string | null): NativeKeypairHandle + ): Promise + readKeypairKeyfile(path: string, password?: string | null): Promise writeKeypairKeyfile( keypair: NativeKeypairHandle, path: string, password: string | null | undefined, overwrite: boolean, - ): void - encryptKeyfileData(keyfileData: Buffer, password: string): Buffer - decryptKeyfileData(keyfileData: Buffer, password?: string | null): Buffer + allowPlaintext: boolean, + ): Promise + encryptKeyfileData(keyfileData: Buffer, password: string): Promise + decryptKeyfileData(keyfileData: Buffer, password?: string | null): Promise keyfileDataIsEncrypted(keyfileData: Buffer): boolean keyfileDataIsEncryptedNacl(keyfileData: Buffer): boolean keyfileDataIsEncryptedAnsible(keyfileData: Buffer): boolean @@ -358,8 +362,11 @@ export interface NativeBinding { mlkemNonceLength(): number mlkemKdfId(): Buffer - timelockEncryptAndCompress(data: Buffer, revealRound: bigint): Buffer - timelockDecryptAndDecompress(encryptedData: Buffer, signatureBytes: Buffer): Buffer + timelockEncryptAndCompress(data: Buffer, revealRound: bigint): Promise + timelockDecryptAndDecompress( + encryptedData: Buffer, + signatureBytes: Buffer, + ): Promise timelockGenerateCommitV2( uids: number[], values: number[], @@ -368,31 +375,31 @@ export interface NativeBinding { subnetRevealPeriodEpochs: bigint, blockTime: number, hotkey: Buffer, - ): { ciphertext: Buffer; revealRound: bigint } + ): Promise<{ ciphertext: Buffer; revealRound: bigint }> timelockEncryptCommitment( data: string, blocksUntilReveal: bigint, blockTime: number, - ): { ciphertext: Buffer; revealRound: bigint } + ): Promise<{ ciphertext: Buffer; revealRound: bigint }> timelockEncryptNBlocks( data: Buffer, nBlocks: bigint, blockTime: number, - ): { ciphertext: Buffer; revealRound: bigint } + ): Promise<{ ciphertext: Buffer; revealRound: bigint }> timelockEncryptAtRound( data: Buffer, revealRound: bigint, - ): { ciphertext: Buffer; revealRound: bigint } - timelockGetRoundInfo(round?: bigint | null): { round: bigint; signature: string } + ): Promise<{ ciphertext: Buffer; revealRound: bigint }> + timelockGetRoundInfo(round?: bigint | null): Promise<{ round: bigint; signature: string }> timelockGetRevealRoundSignature( revealRound: bigint | null | undefined, noErrors: boolean, - ): string | null | undefined + ): Promise timelockDecrypt( encryptedData: Buffer, noErrors: boolean, - ): Buffer | null | undefined - timelockDecryptWithSignature(encryptedData: Buffer, signatureHex: string): Buffer + ): Promise + timelockDecryptWithSignature(encryptedData: Buffer, signatureHex: string): Promise epochShouldRun(state: NativeEpochScheduleState, block: bigint): boolean epochCurrentPreRunCoinbase(state: NativeEpochScheduleState, block: bigint): bigint epochSimulateRunCoinbase( diff --git a/sdk/bittensor-ts/src/timelock.ts b/sdk/bittensor-ts/src/timelock.ts index 8daa4621c7..0907fd15f3 100644 --- a/sdk/bittensor-ts/src/timelock.ts +++ b/sdk/bittensor-ts/src/timelock.ts @@ -1,5 +1,5 @@ import native, { type NativeEpochScheduleState } from './native' -import { nativeCall } from './errors' +import { nativeAsync, nativeCall } from './errors' import { toBigInt, toBuffer } from './wire' import type { ByteLike, @@ -40,6 +40,10 @@ function roundTuple(value: CiphertextRound): CiphertextRoundTuple { return [Buffer.from(value.ciphertext), value.revealRound] } +async function roundTupleAsync(value: Promise): Promise { + return roundTuple(await value) +} + export const MAX_TEMPO = native.timelockMaxTempo() export const MAX_TEMPO_U64 = native.timelockMaxTempoU64() export const DRAND_PUBLIC_KEY = native.timelockDrandPublicKey() @@ -60,8 +64,8 @@ export function maxSimulationBlocks(revealPeriodEpochs: IntegerLike): bigint { ) } -export function encryptAndCompress(data: ByteLike, revealRound: IntegerLike): Buffer { - return nativeCall(() => +export function encryptAndCompress(data: ByteLike, revealRound: IntegerLike): Promise { + return nativeAsync(() => native.timelockEncryptAndCompress( toBuffer(data, 'data'), toBigInt(revealRound, 'revealRound'), @@ -72,8 +76,8 @@ export function encryptAndCompress(data: ByteLike, revealRound: IntegerLike): Bu export function decryptAndDecompress( encryptedData: ByteLike, signatureBytes: ByteLike, -): Buffer { - return nativeCall(() => +): Promise { + return nativeAsync(() => native.timelockDecryptAndDecompress( toBuffer(encryptedData, 'encryptedData'), toBuffer(signatureBytes, 'signatureBytes'), @@ -89,8 +93,8 @@ export function generateCommitV2( subnetRevealPeriodEpochs: IntegerLike, blockTime: number, hotkey: ByteLike, -): CiphertextRound { - return nativeCall(() => +): Promise { + return nativeAsync(() => native.timelockGenerateCommitV2( uids, values, @@ -116,8 +120,8 @@ export function get_encrypted_commit_v2( subnetRevealPeriodEpochs: IntegerLike, blockTime: number, hotkey: ByteLike, -): CiphertextRoundTuple { - return roundTuple( +): Promise { + return roundTupleAsync( generateCommitV2( uids, weights, @@ -141,8 +145,8 @@ export function encryptCommitment( data: string, blocksUntilReveal: IntegerLike, blockTime: number, -): CiphertextRound { - return nativeCall(() => +): Promise { + return nativeAsync(() => native.timelockEncryptCommitment( data, toBigInt(blocksUntilReveal, 'blocksUntilReveal'), @@ -155,16 +159,16 @@ export function get_encrypted_commitment( data: string, blocksUntilReveal: IntegerLike, blockTime = 12.0, -): CiphertextRoundTuple { - return roundTuple(encryptCommitment(data, blocksUntilReveal, blockTime)) +): Promise { + return roundTupleAsync(encryptCommitment(data, blocksUntilReveal, blockTime)) } export function encryptNBlocks( data: ByteLike, nBlocks: IntegerLike, blockTime: number, -): CiphertextRound { - return nativeCall(() => +): Promise { + return nativeAsync(() => native.timelockEncryptNBlocks( toBuffer(data, 'data'), toBigInt(nBlocks, 'nBlocks'), @@ -177,12 +181,12 @@ export function encrypt( data: ByteLike, nBlocks: IntegerLike, blockTime = 12.0, -): CiphertextRoundTuple { - return roundTuple(encryptNBlocks(data, nBlocks, blockTime)) +): Promise { + return roundTupleAsync(encryptNBlocks(data, nBlocks, blockTime)) } -export function encryptAtRound(data: ByteLike, revealRound: IntegerLike): CiphertextRound { - return nativeCall(() => +export function encryptAtRound(data: ByteLike, revealRound: IntegerLike): Promise { + return nativeAsync(() => native.timelockEncryptAtRound( toBuffer(data, 'data'), toBigInt(revealRound, 'revealRound'), @@ -193,52 +197,51 @@ export function encryptAtRound(data: ByteLike, revealRound: IntegerLike): Cipher export function encrypt_at_round( data: ByteLike, revealRound: IntegerLike, -): CiphertextRoundTuple { - return roundTuple(encryptAtRound(data, revealRound)) +): Promise { + return roundTupleAsync(encryptAtRound(data, revealRound)) } -export function getRoundInfo(round?: IntegerLike | null): DrandResponse { - return nativeCall(() => +export function getRoundInfo(round?: IntegerLike | null): Promise { + return nativeAsync(() => native.timelockGetRoundInfo(round == null ? undefined : toBigInt(round, 'round')), ) } -export function get_latest_round(): bigint { - return getRoundInfo().round +export async function get_latest_round(): Promise { + return (await getRoundInfo()).round } export function getRevealRoundSignature( revealRound?: IntegerLike | null, noErrors = true, -): string | null { - return nativeCall( - () => - native.timelockGetRevealRoundSignature( - revealRound == null ? undefined : toBigInt(revealRound, 'revealRound'), - noErrors, - ) ?? null, +): Promise { + return nativeAsync(async () => + (await native.timelockGetRevealRoundSignature( + revealRound == null ? undefined : toBigInt(revealRound, 'revealRound'), + noErrors, + )) ?? null, ) } -export function get_signature_for_round(revealRound: IntegerLike): string { - const signature = getRevealRoundSignature(revealRound, false) +export async function get_signature_for_round(revealRound: IntegerLike): Promise { + const signature = await getRevealRoundSignature(revealRound, false) if (signature == null) { throw new Error('Signature not available') } return signature } -export function decrypt(encryptedData: ByteLike, noErrors = true): Buffer | null { - return nativeCall( - () => native.timelockDecrypt(toBuffer(encryptedData, 'encryptedData'), noErrors) ?? null, +export function decrypt(encryptedData: ByteLike, noErrors = true): Promise { + return nativeAsync(async () => + (await native.timelockDecrypt(toBuffer(encryptedData, 'encryptedData'), noErrors)) ?? null, ) } export function decryptWithSignature( encryptedData: ByteLike, signatureHex: string, -): Buffer { - return nativeCall(() => +): Promise { + return nativeAsync(() => native.timelockDecryptWithSignature( toBuffer(encryptedData, 'encryptedData'), signatureHex, diff --git a/sdk/bittensor-ts/src/wallet.ts b/sdk/bittensor-ts/src/wallet.ts index c82e0e452b..eb23df9fbc 100644 --- a/sdk/bittensor-ts/src/wallet.ts +++ b/sdk/bittensor-ts/src/wallet.ts @@ -1,8 +1,8 @@ import { - existsSync, -} from 'node:fs' + access, +} from 'node:fs/promises' import { homedir } from 'node:os' -import { join } from 'node:path' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' import { CRYPTO_SR25519, @@ -46,24 +46,35 @@ export class Keyfile { this.name = name } - exists(): boolean { - return existsSync(this.path) + async exists(): Promise { + try { + await access(this.path) + return true + } catch { + return false + } } - getKeypair(password?: string | null): Keypair { + getKeypair(password?: string | null): Promise { return Keypair.fromKeyfile(this.path, password ?? undefined) } - setKeypair(keypair: Keypair, options: SaveKeyOptions = {}): void { + async setKeypair(keypair: Keypair, options: SaveKeyOptions = {}): Promise { const { encrypt = false, overwrite = false } = options const password = keyfilePassword(options) if (encrypt && password == null) { throw new Error(`Password is required to encrypt ${this.path}`) } if (!encrypt && keypair.kind !== 'PublicOnly' && options.allowPlaintext !== true) { - throw new Error(`Refusing to write plaintext private keyfile ${this.path}; pass allowPlaintext: true or provide keyfilePassword`) + throw new Error( + `Refusing to write plaintext private keyfile ${this.path}; pass allowPlaintext: true or provide keyfilePassword`, + ) } - keypair.writeKeyfile(this.path, encrypt ? password : undefined, overwrite) + await keypair.writeKeyfile(this.path, { + password: encrypt ? password : undefined, + overwrite, + allowPlaintext: options.allowPlaintext === true, + }) } } @@ -76,114 +87,175 @@ export class Wallet { readonly hotkeyFile: Keyfile readonly hotkeypubFile: Keyfile - private coldkeyCache?: Keypair - private coldkeypubCache?: Keypair - private hotkeyCache?: Keypair - private hotkeypubCache?: Keypair + private coldkeyCache?: Promise + private coldkeypubCache?: Promise + private hotkeyCache?: Promise + private hotkeypubCache?: Promise constructor(options: WalletOptions = {}) { - this.name = options.name ?? 'default' - this.hotkeyName = options.hotkey ?? 'default' - this.path = options.path ?? DEFAULT_WALLET_PATH - const walletDir = join(this.path, this.name) - this.coldkeyFile = new Keyfile(join(walletDir, 'coldkey'), 'coldkey') - this.coldkeypubFile = new Keyfile(join(walletDir, 'coldkeypub.txt'), 'coldkeypub.txt') - this.hotkeyFile = new Keyfile(join(walletDir, 'hotkeys', this.hotkeyName), this.hotkeyName) + this.name = validateWalletComponent(options.name ?? 'default', 'wallet name') + this.hotkeyName = validateWalletComponent(options.hotkey ?? 'default', 'hotkey name') + this.path = resolve(options.path ?? DEFAULT_WALLET_PATH) + containedPath(this.path, this.name, 'wallet directory') + this.coldkeyFile = new Keyfile( + containedPath(this.path, this.name, 'coldkey', 'coldkey'), + 'coldkey', + ) + this.coldkeypubFile = new Keyfile( + containedPath(this.path, this.name, 'coldkeypub.txt', 'coldkeypub.txt'), + 'coldkeypub.txt', + ) + this.hotkeyFile = new Keyfile( + containedPath(this.path, this.name, 'hotkeys', this.hotkeyName, 'hotkey'), + this.hotkeyName, + ) this.hotkeypubFile = new Keyfile( - join(walletDir, 'hotkeys', `${this.hotkeyName}pub.txt`), + containedPath( + this.path, + this.name, + 'hotkeys', + `${this.hotkeyName}pub.txt`, + 'hotkey public key', + ), `${this.hotkeyName}pub.txt`, ) } - get coldkey(): Keypair { + get coldkey(): Promise { this.coldkeyCache ??= this.coldkeyFile.getKeypair() return this.coldkeyCache } - get coldkeypub(): Keypair { + get coldkeypub(): Promise { this.coldkeypubCache ??= this.coldkeypubFile.getKeypair() return this.coldkeypubCache } - get hotkey(): Keypair { + get hotkey(): Promise { this.hotkeyCache ??= this.hotkeyFile.getKeypair() return this.hotkeyCache } - get hotkeypub(): Keypair { + get hotkeypub(): Promise { this.hotkeypubCache ??= this.hotkeypubFile.getKeypair() return this.hotkeypubCache } - getColdkey(password?: string | null): Keypair { + getColdkey(password?: string | null): Promise { this.coldkeyCache = this.coldkeyFile.getKeypair(password) return this.coldkeyCache } - getHotkey(password?: string | null): Keypair { + getHotkey(password?: string | null): Promise { this.hotkeyCache = this.hotkeyFile.getKeypair(password) return this.hotkeyCache } - setColdkey(keypair: Keypair, options: SaveKeyOptions = {}): this { + async setColdkey(keypair: Keypair, options: SaveKeyOptions = {}): Promise { const coldkeypub = publicOnly(keypair) - this.coldkeyFile.setKeypair(keypair, { + await this.coldkeyFile.setKeypair(keypair, { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null, }) - this.coldkeypubFile.setKeypair(coldkeypub, { overwrite: options.overwrite ?? true }) - this.coldkeyCache = keypair - this.coldkeypubCache = coldkeypub + await this.coldkeypubFile.setKeypair(coldkeypub, { overwrite: options.overwrite ?? true }) + this.coldkeyCache = Promise.resolve(keypair) + this.coldkeypubCache = Promise.resolve(coldkeypub) return this } - setHotkey(keypair: Keypair, options: SaveKeyOptions = {}): this { + async setHotkey(keypair: Keypair, options: SaveKeyOptions = {}): Promise { const hotkeypub = publicOnly(keypair) - this.hotkeyFile.setKeypair(keypair, { + await this.hotkeyFile.setKeypair(keypair, { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null, }) - this.hotkeypubFile.setKeypair(hotkeypub, { overwrite: options.overwrite ?? true }) - this.hotkeyCache = keypair - this.hotkeypubCache = hotkeypub + await this.hotkeypubFile.setKeypair(hotkeypub, { overwrite: options.overwrite ?? true }) + this.hotkeyCache = Promise.resolve(keypair) + this.hotkeypubCache = Promise.resolve(hotkeypub) return this } - createNewColdkey(options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}): CreatedWalletKey { + async createNewColdkey( + options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}, + ): Promise { const mnemonic = Keypair.generateMnemonic(options.nWords ?? 12) const keypair = Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519) - this.setColdkey(keypair, { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null }) + await this.setColdkey(keypair, { + ...options, + encrypt: options.encrypt ?? keyfilePassword(options) != null, + }) return { wallet: this, keypair, mnemonic } } - createNewHotkey(options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}): CreatedWalletKey { + async createNewHotkey( + options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}, + ): Promise { const mnemonic = Keypair.generateMnemonic(options.nWords ?? 12) const keypair = Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519) - this.setHotkey(keypair, { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null }) + await this.setHotkey(keypair, { + ...options, + encrypt: options.encrypt ?? keyfilePassword(options) != null, + }) return { wallet: this, keypair, mnemonic } } - regenerateColdkey( + async regenerateColdkey( mnemonic: string, options: RegenerateKeyOptions = {}, - ): this { + ): Promise { return this.setColdkey( Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519, options.mnemonicPassword), - { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null }, + { + ...options, + encrypt: options.encrypt ?? keyfilePassword(options) != null, + }, ) } - regenerateHotkey( + async regenerateHotkey( mnemonic: string, options: RegenerateKeyOptions = {}, - ): this { + ): Promise { return this.setHotkey( Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519, options.mnemonicPassword), - { ...options, encrypt: options.encrypt ?? keyfilePassword(options) != null }, + { + ...options, + encrypt: options.encrypt ?? keyfilePassword(options) != null, + }, ) } } +function validateWalletComponent(value: string, label: string): string { + if ( + value.length === 0 || + value === '.' || + value === '..' || + value.includes('/') || + value.includes('\\') || + value.includes('\0') || + isAbsolute(value) + ) { + throw new Error(`${label} must be a single path component`) + } + return value +} + +function containedPath(root: string, ...partsAndLabel: string[]): string { + const label = partsAndLabel.pop() ?? 'wallet path' + const path = resolve(root, ...partsAndLabel) + const relativePath = relative(root, path) + if ( + relativePath === '' || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + throw new Error(`${label} escapes wallet root`) + } + return path +} + function keyfilePassword(options: SaveKeyOptions): string | null | undefined { return options.keyfilePassword ?? options.password } diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index c2d7b02a0e..e48ec1f201 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -82,8 +82,11 @@ function fakeSigningRuntime(overrides = {}) { function fakeSigningClient(runtime, callData) { const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const finalizedHash = `0x${'42'.repeat(32)}` client.runtimeAt = async () => runtime client.callData = async () => Buffer.from(callData) + client.finalizedHead = async () => finalizedHash + client.blockNumber = async () => 64 client.genesisHash = async () => `0x${'41'.repeat(32)}` client.rpc = async (method, params = []) => { if (method === 'system_accountNextIndex') { @@ -632,7 +635,9 @@ test('mnemonics and secret URIs never appear in public keypair metadata', () => assert.equal(derived.meta.suri, undefined) }) -test('private key bytes are not exported to JavaScript', () => { +test('private key bytes are not exported to JavaScript', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bittensor-keyfile-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) const alice = core.Keypair.fromUri('//Alice') assert.equal(Object.prototype.hasOwnProperty.call(alice, 'privateKey'), false) assert.equal('privateKey' in alice, false) @@ -652,7 +657,13 @@ test('private key bytes are not exported to JavaScript', () => { const publicKeyfile = JSON.parse(publicOnly.serialize().toString('utf8')) assert.equal(publicKeyfile.privateKey, undefined) - const encrypted = alice.toKeyfileData('review-password') + await assert.rejects( + () => alice.writeKeyfile(path.join(root, 'plaintext-default')), + /plaintext private keyfile writes are disabled/, + ) + await alice.writeKeyfile(path.join(root, 'plaintext-allowed'), { allowPlaintext: true }) + + const encrypted = await alice.toKeyfileData('review-password') assert.equal(core.keyfileDataIsEncrypted(encrypted), true) }) @@ -882,6 +893,70 @@ test('Balance numeric getters throw before losing precision', () => { assert.throws(() => unsafe.tao, /safe integer precision/) }) +test('transaction amounts require explicit units', () => { + assert.equal(core.Balance.fromRao('1').rao, 1n) + assert.equal(core.Balance.fromTao('1.0').rao, 1_000_000_000n) + assert.throws( + () => core.Balance.fromTao('0.1234567891'), + /more than 9 decimal places/, + ) + assert.throws( + () => core.Balance.fromRao('1.0'), + /rao must be an integer/, + ) + + assert.deepEqual(core.calls.balances.transferKeepAlive('5F', 1n), [ + 'Balances', + 'transfer_keep_alive', + { dest: '5F', value: 1n }, + ]) + assert.deepEqual(core.calls.balances.transferKeepAlive('5F', core.taoAmount('1.0')), [ + 'Balances', + 'transfer_keep_alive', + { dest: '5F', value: 1_000_000_000n }, + ]) + assert.deepEqual(core.calls.balances.transferKeepAlive('5F', core.raoAmount('2')), [ + 'Balances', + 'transfer_keep_alive', + { dest: '5F', value: 2n }, + ]) + assert.deepEqual(core.calls.balances.transferKeepAlive('5F', core.Balance.fromTao('0.5')), [ + 'Balances', + 'transfer_keep_alive', + { dest: '5F', value: 500_000_000n }, + ]) + assert.throws( + () => core.calls.balances.transferKeepAlive('5F', '1'), + /transaction amount must be/, + ) + assert.throws( + () => core.calls.balances.transferKeepAlive('5F', 1), + /transaction amount must be/, + ) +}) + +test('descriptor schema validation reports metadata drift', () => { + const runtime = { + pallet(name) { + if (name === 'Balances') return { storage: [{ name: 'Account' }], constants: [] } + return null + }, + constantInfo() { + return null + }, + runtimeApis() { + return {} + }, + metadataIr() { + return { pallets: [{ name: 'Balances', calls: [{ name: 'transfer_keep_alive' }] }] } + }, + } + const issues = core.validateDescriptorSchema(runtime) + assert.ok(issues.some((issue) => issue.path === 'storage.Balances.TotalIssuance')) + assert.ok(issues.some((issue) => issue.path === 'runtimeApi.StakeInfoRuntimeApi.get_stake_fee')) + assert.ok(issues.some((issue) => issue.path === 'calls.balances.transferAllowDeath')) +}) + test('JsonRpcTransport restores websocket subscriptions after reconnect', async (t) => { const { FakeWebSocket, restore } = installFakeWebSocket() t.after(restore) @@ -2155,28 +2230,28 @@ test('Client generates RFC-0078 proof for Ledger metadata-verifying signers', as assert.equal(captures.encoded.params.metadataHashEnabled, true) }) -test('wallet helpers keep mnemonic and keyfile passwords separate', (t) => { +test('wallet helpers keep mnemonic and keyfile passwords separate', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bittensor-wallet-')) t.after(() => fs.rmSync(root, { recursive: true, force: true })) const keyfilePassword = 'review-keyfile-password' const mnemonicPassword = 'review-mnemonic-password' const created = new core.Wallet({ name: 'created', hotkey: 'default', path: root }) - const createdHotkey = created.createNewHotkey({ keyfilePassword }) + const createdHotkey = await created.createNewHotkey({ keyfilePassword }) assert.equal(createdHotkey.wallet, created) assert.equal(createdHotkey.mnemonic.split(/\s+/).length, 12) - assert.deepEqual(createdHotkey.keypair.publicKey, created.getHotkey(keyfilePassword).publicKey) + assert.deepEqual(createdHotkey.keypair.publicKey, (await created.getHotkey(keyfilePassword)).publicKey) assert.equal( core.keyfileDataIsEncrypted(fs.readFileSync(created.hotkeyFile.path)), true, ) const plaintextDefault = new core.Wallet({ name: 'plaintext-default', hotkey: 'default', path: root }) - assert.throws( + await assert.rejects( () => plaintextDefault.createNewHotkey(), /allowPlaintext/, ) const plaintextAllowed = new core.Wallet({ name: 'plaintext-allowed', hotkey: 'default', path: root }) - const plaintextHotkey = plaintextAllowed.createNewHotkey({ allowPlaintext: true }) + const plaintextHotkey = await plaintextAllowed.createNewHotkey({ allowPlaintext: true }) assert.equal(plaintextHotkey.mnemonic.split(/\s+/).length, 12) assert.equal( core.keyfileDataIsEncrypted(fs.readFileSync(plaintextAllowed.hotkeyFile.path)), @@ -2186,44 +2261,70 @@ test('wallet helpers keep mnemonic and keyfile passwords separate', (t) => { const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' const regenerated = new core.Wallet({ name: 'regenerated', hotkey: 'default', path: root }) - regenerated.regenerateHotkey(mnemonic, { mnemonicPassword, keyfilePassword }) + await regenerated.regenerateHotkey(mnemonic, { mnemonicPassword, keyfilePassword }) assert.equal( core.keyfileDataIsEncrypted(fs.readFileSync(regenerated.hotkeyFile.path)), true, ) assert.deepEqual( - regenerated.getHotkey(keyfilePassword).publicKey, + (await regenerated.getHotkey(keyfilePassword)).publicKey, core.Keypair.fromMnemonic(mnemonic, core.CRYPTO_SR25519, mnemonicPassword).publicKey, ) assert.notDeepEqual( - regenerated.getHotkey(keyfilePassword).publicKey, + (await regenerated.getHotkey(keyfilePassword)).publicKey, core.Keypair.fromMnemonic(mnemonic, core.CRYPTO_SR25519).publicKey, ) const cold = new core.Wallet({ name: 'regenerated-cold', hotkey: 'default', path: root }) - cold.regenerateColdkey(mnemonic, { mnemonicPassword, keyfilePassword }) + await cold.regenerateColdkey(mnemonic, { mnemonicPassword, keyfilePassword }) assert.equal( core.keyfileDataIsEncrypted(fs.readFileSync(cold.coldkeyFile.path)), true, ) assert.deepEqual( - cold.getColdkey(keyfilePassword).publicKey, + (await cold.getColdkey(keyfilePassword)).publicKey, core.Keypair.fromMnemonic(mnemonic, core.CRYPTO_SR25519, mnemonicPassword).publicKey, ) }) -test('wallet keyfile writes are restrictive and reject symlink targets', (t) => { +test('wallet names and hotkeys cannot escape the wallet root', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bittensor-wallet-paths-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + + for (const name of ['', '.', '..', '../escape', 'nested/name', 'nested\\name', '/absolute']) { + assert.throws( + () => new core.Wallet({ name, hotkey: 'default', path: root }), + /single path component/, + ) + } + for (const hotkey of ['', '.', '..', '../escape', 'nested/hotkey', 'nested\\hotkey', '/absolute']) { + assert.throws( + () => new core.Wallet({ name: 'default', hotkey, path: root }), + /single path component/, + ) + } + + const wallet = new core.Wallet({ name: 'contained', hotkey: 'hk', path: root }) + const resolvedRoot = path.resolve(root) + assert.equal(wallet.path, resolvedRoot) + assert.equal( + path.relative(resolvedRoot, wallet.hotkeyFile.path).startsWith('..'), + false, + ) +}) + +test('wallet keyfile writes are restrictive and reject symlink targets', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bittensor-wallet-atomic-')) t.after(() => fs.rmSync(root, { recursive: true, force: true })) const keypair = core.Keypair.fromUri('//Alice') const wallet = new core.Wallet({ name: 'atomic', hotkey: 'default', path: root }) - wallet.setHotkey(keypair, { allowPlaintext: true }) + await wallet.setHotkey(keypair, { allowPlaintext: true }) const hotkeyPath = wallet.hotkeyFile.path const hotkeyDir = path.dirname(hotkeyPath) - assert.deepEqual(wallet.hotkeyFile.getKeypair().publicKey, keypair.publicKey) - assert.deepEqual(core.readKeypairKeyfile(hotkeyPath).publicKey, keypair.publicKey) + assert.deepEqual((await wallet.hotkeyFile.getKeypair()).publicKey, keypair.publicKey) + assert.deepEqual((await core.readKeypairKeyfile(hotkeyPath)).publicKey, keypair.publicKey) assert.equal(fs.lstatSync(hotkeyPath).isFile(), true) assert.equal(fs.statSync(hotkeyPath).mode & 0o777, 0o600) assert.equal(fs.statSync(hotkeyDir).mode & 0o777, 0o700) @@ -2237,22 +2338,22 @@ test('wallet keyfile writes are restrictive and reject symlink targets', (t) => const bob = core.Keypair.fromUri('//Bob') fs.rmSync(wallet.hotkeypubFile.path) fs.symlinkSync(target, wallet.hotkeypubFile.path) - assert.throws( + await assert.rejects( () => wallet.setHotkey(bob, { overwrite: true, allowPlaintext: true }), /symlink/, ) - assert.deepEqual(wallet.hotkey.publicKey, keypair.publicKey) - assert.deepEqual(wallet.hotkeypub.publicKey, keypair.publicKey) + assert.deepEqual((await wallet.hotkey).publicKey, keypair.publicKey) + assert.deepEqual((await wallet.hotkeypub).publicKey, keypair.publicKey) fs.rmSync(wallet.hotkeypubFile.path) const linkedWallet = new core.Wallet({ name: 'atomic', hotkey: 'linked', path: root }) fs.symlinkSync(target, linkedWallet.hotkeyFile.path) - assert.throws( + await assert.rejects( () => linkedWallet.setHotkey(keypair, { overwrite: true, allowPlaintext: true }), /symlink/, ) - assert.throws( + await assert.rejects( () => core.readKeypairKeyfile(linkedWallet.hotkeyFile.path), /symlink/, ) From 8aede23daea38f1f0cdcb8a91769313826bdfba5 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 09:27:09 -0700 Subject: [PATCH 43/72] fixes --- sdk/bittensor-core/src/keyfiles/mod.rs | 408 ++++++++++++++++-- sdk/bittensor-ts/README.md | 30 +- sdk/bittensor-ts/native/src/keys.rs | 53 +++ .../scripts/check-native-parity.cjs | 1 + sdk/bittensor-ts/src/balance.ts | 67 ++- sdk/bittensor-ts/src/client.ts | 140 ++++-- sdk/bittensor-ts/src/keys.ts | 54 ++- sdk/bittensor-ts/src/modules.ts | 9 +- sdk/bittensor-ts/src/native.ts | 9 + sdk/bittensor-ts/src/wallet.ts | 145 +++++-- sdk/bittensor-ts/test/basic.test.cjs | 113 ++++- ts-tests/suites/dev/sdk/test-bittensor-ts.ts | 1 + 12 files changed, 883 insertions(+), 147 deletions(-) diff --git a/sdk/bittensor-core/src/keyfiles/mod.rs b/sdk/bittensor-core/src/keyfiles/mod.rs index 980b5c6de4..f58baea837 100644 --- a/sdk/bittensor-core/src/keyfiles/mod.rs +++ b/sdk/bittensor-core/src/keyfiles/mod.rs @@ -332,6 +332,56 @@ pub fn save_keypair_to_keyfile( atomic_write_keyfile(path, &data, overwrite) } +pub fn save_keypair_pair_to_keyfiles( + private_keypair: &Keypair, + private_path: &Path, + private_password: Option<&str>, + public_keypair: &Keypair, + public_path: &Path, + overwrite: bool, + allow_plaintext: bool, +) -> Result<(), CoreError> { + if private_path == public_path { + return Err(key_err("private and public keyfile paths must be distinct")); + } + if private_keypair.has_private_key() && private_password.is_none() && !allow_plaintext { + return Err(key_err( + "plaintext private keyfile writes are disabled; provide a password or set allow_plaintext", + )); + } + if public_keypair.has_private_key() { + return Err(key_err( + "public keyfile pair member must not contain a private key", + )); + } + + prepare_keyfile_target(private_path, overwrite)?; + prepare_keyfile_target(public_path, overwrite)?; + + let private_plaintext = Zeroizing::new(serialized_keypair_to_keyfile_data(private_keypair)?); + let private_data = if let Some(password) = private_password { + encrypt_keyfile_data(&private_plaintext, password)? + } else { + private_plaintext.to_vec() + }; + let public_data = serialized_keypair_to_keyfile_data(public_keypair)?; + atomic_write_keyfile_pair( + private_path, + &private_data, + public_path, + &public_data, + overwrite, + ) +} + +fn prepare_keyfile_target(path: &Path, overwrite: bool) -> Result<(), CoreError> { + let parent = path + .parent() + .ok_or_else(|| key_err("keyfile path must have a parent directory"))?; + ensure_private_directory(parent)?; + validate_keyfile_target(path, overwrite) +} + fn ensure_private_directory(path: &Path) -> Result<(), CoreError> { fs::create_dir_all(path).map_err(|error| { key_err(format!( @@ -401,6 +451,15 @@ fn validate_keyfile_target(path: &Path, overwrite: bool) -> Result<(), CoreError } fn atomic_write_keyfile(path: &Path, data: &[u8], overwrite: bool) -> Result<(), CoreError> { + let temp_path = write_temp_keyfile(path, data)?; + let write_result = commit_one_keyfile(path, &temp_path, overwrite); + if write_result.is_err() { + let _ = fs::remove_file(&temp_path); + } + write_result +} + +fn write_temp_keyfile(path: &Path, data: &[u8]) -> Result { let dir = path .parent() .ok_or_else(|| key_err("keyfile path must have a parent directory"))?; @@ -408,8 +467,8 @@ fn atomic_write_keyfile(path: &Path, data: &[u8], overwrite: bool) -> Result<(), .file_name() .and_then(|value| value.to_str()) .ok_or_else(|| key_err("keyfile path must be valid UTF-8"))?; - let mut temp_path = PathBuf::new(); let mut temp_file = None; + let mut temp_path = None; for attempt in 0..128u32 { let candidate = dir.join(format!( @@ -419,7 +478,7 @@ fn atomic_write_keyfile(path: &Path, data: &[u8], overwrite: bool) -> Result<(), )); match private_create_new(&candidate) { Ok(file) => { - temp_path = candidate; + temp_path = Some(candidate); temp_file = Some(file); break; } @@ -433,12 +492,19 @@ fn atomic_write_keyfile(path: &Path, data: &[u8], overwrite: bool) -> Result<(), } } + let temp_path = temp_path.ok_or_else(|| { + key_err(format!( + "failed to allocate temporary keyfile for {}", + path.display() + )) + })?; let mut file = temp_file.ok_or_else(|| { key_err(format!( "failed to allocate temporary keyfile for {}", path.display() )) })?; + let write_result = (|| -> Result<(), CoreError> { file.write_all(data).map_err(|error| { key_err(format!( @@ -452,37 +518,276 @@ fn atomic_write_keyfile(path: &Path, data: &[u8], overwrite: bool) -> Result<(), temp_path.display() )) })?; - drop(file); - if overwrite { - fs::rename(&temp_path, path).map_err(|error| { - key_err(format!( - "failed to atomically replace keyfile {}: {error}", - path.display() - )) - })?; - } else { - fs::hard_link(&temp_path, path).map_err(|error| { - key_err(format!( - "failed to atomically create keyfile {}: {error}", - path.display() - )) - })?; - fs::remove_file(&temp_path).map_err(|error| { - key_err(format!( - "failed to remove temporary keyfile {}: {error}", - temp_path.display() - )) - })?; - } - set_private_file_permissions(path)?; - fsync_directory(dir); Ok(()) })(); - + drop(file); if write_result.is_err() { let _ = fs::remove_file(&temp_path); } - write_result + write_result.map(|()| temp_path) +} + +fn commit_one_keyfile(path: &Path, temp_path: &Path, overwrite: bool) -> Result<(), CoreError> { + let dir = path + .parent() + .ok_or_else(|| key_err("keyfile path must have a parent directory"))?; + if overwrite { + fs::rename(temp_path, path).map_err(|error| { + key_err(format!( + "failed to atomically replace keyfile {}: {error}", + path.display() + )) + })?; + } else { + commit_new_keyfile(path, temp_path)?; + return Ok(()); + } + set_private_file_permissions(path)?; + fsync_directory(dir); + Ok(()) +} + +fn commit_new_keyfile(path: &Path, temp_path: &Path) -> Result<(), CoreError> { + let dir = path + .parent() + .ok_or_else(|| key_err("keyfile path must have a parent directory"))?; + fs::hard_link(temp_path, path).map_err(|error| { + key_err(format!( + "failed to atomically create keyfile {}: {error}", + path.display() + )) + })?; + fs::remove_file(temp_path).map_err(|error| { + key_err(format!( + "failed to remove temporary keyfile {}: {error}", + temp_path.display() + )) + })?; + set_private_file_permissions(path)?; + fsync_directory(dir); + Ok(()) +} + +fn atomic_write_keyfile_pair( + private_path: &Path, + private_data: &[u8], + public_path: &Path, + public_data: &[u8], + overwrite: bool, +) -> Result<(), CoreError> { + let private_temp = write_temp_keyfile(private_path, private_data)?; + let public_temp = match write_temp_keyfile(public_path, public_data) { + Ok(path) => path, + Err(error) => { + let _ = fs::remove_file(&private_temp); + return Err(error); + } + }; + + let result = if overwrite { + commit_overwrite_keyfile_pair(private_path, &private_temp, public_path, &public_temp) + } else { + commit_create_keyfile_pair(private_path, &private_temp, public_path, &public_temp) + }; + if result.is_err() { + let _ = fs::remove_file(&private_temp); + let _ = fs::remove_file(&public_temp); + } + result +} + +fn commit_create_keyfile_pair( + private_path: &Path, + private_temp: &Path, + public_path: &Path, + public_temp: &Path, +) -> Result<(), CoreError> { + let mut private_created = false; + let mut public_created = false; + let result = (|| -> Result<(), CoreError> { + fs::hard_link(private_temp, private_path).map_err(|error| { + key_err(format!( + "failed to atomically create private keyfile {}: {error}", + private_path.display() + )) + })?; + private_created = true; + fs::remove_file(private_temp).map_err(|error| { + key_err(format!( + "failed to remove temporary keyfile {}: {error}", + private_temp.display() + )) + })?; + set_private_file_permissions(private_path)?; + + fs::hard_link(public_temp, public_path).map_err(|error| { + key_err(format!( + "failed to atomically create public keyfile {}: {error}", + public_path.display() + )) + })?; + public_created = true; + fs::remove_file(public_temp).map_err(|error| { + key_err(format!( + "failed to remove temporary keyfile {}: {error}", + public_temp.display() + )) + })?; + set_private_file_permissions(public_path)?; + fsync_parent(private_path); + fsync_parent(public_path); + Ok(()) + })(); + + if result.is_err() { + if public_created { + remove_file_if_exists(public_path); + } + if private_created { + remove_file_if_exists(private_path); + } + fsync_parent(private_path); + fsync_parent(public_path); + } + result +} + +fn commit_overwrite_keyfile_pair( + private_path: &Path, + private_temp: &Path, + public_path: &Path, + public_temp: &Path, +) -> Result<(), CoreError> { + let mut private_backup = None; + let mut public_backup = None; + let mut private_committed = false; + let mut public_committed = false; + let result = (|| -> Result<(), CoreError> { + private_backup = move_existing_to_backup(private_path)?; + public_backup = move_existing_to_backup(public_path)?; + + fs::rename(private_temp, private_path).map_err(|error| { + key_err(format!( + "failed to commit private keyfile {}: {error}", + private_path.display() + )) + })?; + private_committed = true; + set_private_file_permissions(private_path)?; + + fs::rename(public_temp, public_path).map_err(|error| { + key_err(format!( + "failed to commit public keyfile {}: {error}", + public_path.display() + )) + })?; + public_committed = true; + set_private_file_permissions(public_path)?; + + fsync_parent(private_path); + fsync_parent(public_path); + Ok(()) + })(); + + if result.is_err() { + if public_committed { + remove_file_if_exists(public_path); + } + if private_committed { + remove_file_if_exists(private_path); + } + restore_backup(private_path, private_backup.as_deref()); + restore_backup(public_path, public_backup.as_deref()); + fsync_parent(private_path); + fsync_parent(public_path); + } else { + if let Some(path) = private_backup { + let _ = fs::remove_file(path); + } + if let Some(path) = public_backup { + let _ = fs::remove_file(path); + } + } + result +} + +fn move_existing_to_backup(path: &Path) -> Result, CoreError> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(key_err(format!( + "refusing to overwrite keyfile symlink {}", + path.display() + ))); + } + if !metadata.is_file() { + return Err(key_err(format!( + "refusing to overwrite non-file keyfile path {}", + path.display() + ))); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(key_err(format!( + "failed to inspect keyfile {}: {error}", + path.display() + ))) + } + } + + let dir = path + .parent() + .ok_or_else(|| key_err("keyfile path must have a parent directory"))?; + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| key_err("keyfile path must be valid UTF-8"))?; + for attempt in 0..128u32 { + let backup = dir.join(format!( + ".{file_name}.{}.{}.rollback", + std::process::id(), + attempt + )); + if backup.exists() { + continue; + } + match fs::rename(path, &backup) { + Ok(()) => return Ok(Some(backup)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(key_err(format!( + "failed to stage existing keyfile {} for rollback: {error}", + path.display() + ))) + } + } + } + Err(key_err(format!( + "failed to allocate rollback path for {}", + path.display() + ))) +} + +fn restore_backup(path: &Path, backup: Option<&Path>) { + if let Some(backup) = backup { + remove_file_if_exists(path); + let _ = fs::rename(backup, path); + } +} + +fn remove_file_if_exists(path: &Path) { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => {} + } +} + +fn fsync_parent(path: &Path) { + if let Some(parent) = path.parent() { + fsync_directory(parent); + } } #[cfg(unix)] @@ -594,6 +899,16 @@ mod tests { .to_string() } + fn temp_test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "bittensor-core-keyfiles-{name}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + #[test] fn nacl_roundtrip() { let message = br#"{"ss58Address":"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"}"#; @@ -660,4 +975,41 @@ mod tests { assert_eq!(restored.crypto_type(), CRYPTO_ED25519); assert_eq!(restored.ss58_address(), original.ss58_address()); } + + #[test] + fn keypair_pair_keyfile_roundtrip() { + let dir = temp_test_dir("pair-roundtrip"); + let private_path = dir.join("hotkey"); + let public_path = dir.join("hotkeypub.txt"); + let original = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_SR25519, None).unwrap(); + let public = Keypair::new( + Some(&original.ss58_address()), + None, + original.crypto_type(), + original.ss58_format(), + ) + .unwrap(); + + save_keypair_pair_to_keyfiles( + &original, + &private_path, + Some("test-password"), + &public, + &public_path, + false, + false, + ) + .unwrap(); + + let restored_private = + read_keypair_from_keyfile(&private_path, Some("test-password")).unwrap(); + let restored_public = read_keypair_from_keyfile(&public_path, None).unwrap(); + assert_eq!(restored_private.ss58_address(), original.ss58_address()); + assert_eq!(restored_public.ss58_address(), original.ss58_address()); + assert!(!restored_public.has_private_key()); + assert!(keyfile_data_is_encrypted(&fs::read(&private_path).unwrap())); + assert!(!keyfile_data_is_encrypted(&fs::read(&public_path).unwrap())); + + fs::remove_dir_all(dir).unwrap(); + } } diff --git a/sdk/bittensor-ts/README.md b/sdk/bittensor-ts/README.md index 525de38da7..5cf150f19f 100644 --- a/sdk/bittensor-ts/README.md +++ b/sdk/bittensor-ts/README.md @@ -114,6 +114,19 @@ await client.transfer(alice, '5F...', Balance.fromTao('0.01'), { await client.close() ``` +Fallback endpoints are validated against a trusted genesis hash before use. +Known mainnet aliases (`finney`, `archive`) use the checked-in mainnet genesis +hash. Custom endpoint sets, and named networks without a built-in trust anchor, +must pass `expectedGenesisHash` when `fallbackEndpoints` are configured: + +```ts +const client = new Client('local', { + endpoint: 'wss://primary.example', + fallbackEndpoints: ['wss://fallback.example'], + expectedGenesisHash: '0x...', +}) +``` + Transaction amount inputs are intentionally explicit. Pass `Balance.fromTao("1.25")` or `taoAmount("1.25")` for TAO-denominated values, and pass `123n` or `raoAmount("123")` for rao. Raw `number` and `string` amounts are rejected by @@ -131,12 +144,21 @@ Run it in CI or application startup when relying on the convenience descriptor exports. Wallet private keyfiles are encrypted when `keyfilePassword` is supplied. -Plaintext private keyfile writes require `allowPlaintext: true`; public-only +Plaintext private keyfile writes require `allowPlaintext: true`; `createNewColdkey()`, +`createNewHotkey()`, `setColdkey()`, and `setHotkey()` require one of those +choices instead of accepting empty options. Use `Wallet.generateColdkey()` or +`Wallet.generateHotkey()` when you need to generate key material and decide how +to persist it later. Private/public wallet keyfile pairs are written through one +native pair-write operation that rolls back on commit failure. Public-only keyfiles are still written without encryption. Keyfile, wallet persistence, Ledger, and Drand-backed timelock operations are Promise-based so blocking I/O -and expensive KDF work run off the JavaScript thread. `createNewColdkey()` and -`createNewHotkey()` resolve to `{ wallet, keypair, mnemonic }` so callers can -store the recovery phrase before relying on the persisted wallet. +and expensive KDF work run off the JavaScript thread. The dangerous +compatibility helper that returns plaintext keyfile JSON is named +`dangerouslyDecryptKeyfileData()` and is also available from +`dangerousKeyfiles`. The environment password helpers are legacy compatibility +APIs (`legacyGetPasswordFromEnvironment()` and +`legacySavePasswordToEnvironment()`); they use reversible obfuscation, not +secure password storage. ## Browser example diff --git a/sdk/bittensor-ts/native/src/keys.rs b/sdk/bittensor-ts/native/src/keys.rs index 7fb8e946e7..8c494851a0 100644 --- a/sdk/bittensor-ts/native/src/keys.rs +++ b/sdk/bittensor-ts/native/src/keys.rs @@ -119,6 +119,38 @@ impl Task for WriteKeypairKeyfileTask { } } +pub struct WriteKeypairPairKeyfileTask { + private_keypair: Keypair, + private_path: PathBuf, + private_password: Option, + public_keypair: Keypair, + public_path: PathBuf, + overwrite: bool, + allow_plaintext: bool, +} + +impl Task for WriteKeypairPairKeyfileTask { + type Output = (); + type JsValue = (); + + fn compute(&mut self) -> napi::Result { + keyfiles::save_keypair_pair_to_keyfiles( + &self.private_keypair, + &self.private_path, + self.private_password.as_deref(), + &self.public_keypair, + &self.public_path, + self.overwrite, + self.allow_plaintext, + ) + .napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(output) + } +} + pub struct EncryptKeyfileDataTask { keyfile_data: Vec, password: String, @@ -390,6 +422,27 @@ pub fn write_keypair_keyfile( }) } +#[napi(js_name = "writeKeypairPairKeyfile")] +pub fn write_keypair_pair_keyfile( + private_keypair: &NativeKeypair, + private_path: String, + private_password: Option, + public_keypair: &NativeKeypair, + public_path: String, + overwrite: bool, + allow_plaintext: bool, +) -> AsyncTask { + AsyncTask::new(WriteKeypairPairKeyfileTask { + private_keypair: private_keypair.inner.clone(), + private_path: PathBuf::from(private_path), + private_password, + public_keypair: public_keypair.inner.clone(), + public_path: PathBuf::from(public_path), + overwrite, + allow_plaintext, + }) +} + #[napi(js_name = "encryptKeyfileData")] pub fn encrypt_keyfile_data( keyfile_data: Buffer, diff --git a/sdk/bittensor-ts/scripts/check-native-parity.cjs b/sdk/bittensor-ts/scripts/check-native-parity.cjs index 50587c29dd..161dca5f84 100755 --- a/sdk/bittensor-ts/scripts/check-native-parity.cjs +++ b/sdk/bittensor-ts/scripts/check-native-parity.cjs @@ -432,6 +432,7 @@ const coreCoverageAliases = new Map([ ['keyfiles/mod.rs#deserialize_keypair_from_keyfile_data', ['deserializeKeypair']], ['keyfiles/mod.rs#read_keypair_from_keyfile', ['readKeypairKeyfile']], ['keyfiles/mod.rs#save_keypair_to_keyfile', ['writeKeypairKeyfile']], + ['keyfiles/mod.rs#save_keypair_pair_to_keyfiles', ['writeKeypairPairKeyfile']], ['keys/mod.rs#new', ['keypairNew', 'Keypair']], ['keys/mod.rs#from_mnemonic', ['keypairFromMnemonic', 'fromMnemonic']], ['keys/mod.rs#from_seed', ['keypairFromSeed', 'fromSeed']], diff --git a/sdk/bittensor-ts/src/balance.ts b/sdk/bittensor-ts/src/balance.ts index 98e524b043..34e0504c72 100644 --- a/sdk/bittensor-ts/src/balance.ts +++ b/sdk/bittensor-ts/src/balance.ts @@ -4,6 +4,7 @@ const AMOUNT_UNIT = '__bittensorAmountUnit' export type BalanceLike = Balance | bigint | number | string export type TransactionAmount = Balance | bigint | RaoAmount | TaoAmount +export type AssetId = bigint | number | string export interface RaoAmount { readonly [AMOUNT_UNIT]: 'rao' @@ -165,13 +166,54 @@ export function balanceRao(value: BalanceLike): bigint { return parseRao(value) } -export function transactionAmountRao(value: TransactionAmount): bigint { - if (value instanceof Balance) return value.rao - if (typeof value === 'bigint') return value - if (isBrandedAmount(value)) return value.rao - throw new TypeError( - 'transaction amount must be a Balance, bigint rao amount, raoAmount(...), or taoAmount(...)', - ) +export function transactionAmountRao( + value: TransactionAmount, + options: { name?: string; taoOnly?: boolean } = {}, +): bigint { + const name = options.name ?? 'transaction amount' + let rao: bigint + if (value instanceof Balance) { + if (options.taoOnly && value.netuid !== 0) { + throw new UnitMismatchError(`${name} must be a TAO balance, not subnet-${value.netuid} alpha`) + } + rao = value.rao + } else if (typeof value === 'bigint') { + rao = value + } else if (isBrandedAmount(value)) { + rao = value.rao + } else { + throw new TypeError( + `${name} must be a Balance, bigint rao amount, raoAmount(...), or taoAmount(...)`, + ) + } + if (rao < 0n) throw new RangeError(`${name} must be non-negative`) + return rao +} + +export function assetIdValue(value: AssetId, name = 'asset ID'): bigint { + const parsed = parseInteger(value, name) + if (parsed < 0n) throw new RangeError(`${name} must be non-negative`) + return parsed +} + +export function asset_id_value(value: AssetId, name = 'asset ID'): bigint { + return assetIdValue(value, name) +} + +export function taoTransactionAmountRao(value: TransactionAmount, name = 'transaction amount'): bigint { + return transactionAmountRao(value, { name, taoOnly: true }) +} + +export function tao_transaction_amount_rao(value: TransactionAmount, name = 'transaction amount'): bigint { + return taoTransactionAmountRao(value, name) +} + +export function alphaTransactionAmountRao(value: TransactionAmount, name = 'transaction amount'): bigint { + return transactionAmountRao(value, { name }) +} + +export function alpha_transaction_amount_rao(value: TransactionAmount, name = 'transaction amount'): bigint { + return alphaTransactionAmountRao(value, name) } export function raoAmount(value: bigint | number | string): RaoAmount { @@ -189,14 +231,21 @@ export function taoAmount(value: number | string): TaoAmount { } function parseRao(value: bigint | number | string): bigint { + return parseInteger(value, 'balance rao') +} + +function parseInteger(value: bigint | number | string, name: string): bigint { if (typeof value === 'bigint') return value if (typeof value === 'number') { - if (!Number.isSafeInteger(value)) throw new RangeError('balance rao must be a safe integer') + if (!Number.isSafeInteger(value)) throw new RangeError(`${name} must be a safe integer`) return BigInt(value) } + if (typeof value !== 'string') { + throw new TypeError(`${name} must be a bigint, safe integer number, or integer string`) + } const text = value.trim() if (/^-?\d+$/.test(text)) return BigInt(text) - throw new RangeError('balance rao must be an integer; use Balance.fromTao or taoAmount for decimal TAO') + throw new RangeError(`${name} must be an integer`) } function isBrandedAmount(value: unknown): value is RaoAmount | TaoAmount { diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index b127231409..529f9af0be 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -3,7 +3,15 @@ import { CRYPTO_SR25519, Keypair, publicKeyFromSs58, ss58FromPublic } from './ke import { LedgerDevice } from './ledger' import { Runtime, eraBirth } from './runtime' import { toBuffer } from './wire' -import { Balance, type BalanceLike, type TransactionAmount, transactionAmountRao } from './balance' +import { + Balance, + type AssetId, + type BalanceLike, + type TransactionAmount, + assetIdValue, + taoTransactionAmountRao, + transactionAmountRao, +} from './balance' import type { ByteLike, ChainInfo, ScaleValue, SignedExtrinsic, StorageEntry, TransactionParams } from './types' export const SS58_FORMAT = 42 @@ -15,12 +23,17 @@ export const DEFAULT_MAX_REQUEST_RETRIES = 2 export const DEFAULT_RETRY_BACKOFF_MS = 250 export const DEFAULT_MAX_RETRY_BACKOFF_MS = 5_000 export const DEFAULT_NONCE_RECONCILE_BLOCKS = 8 +export const DEFAULT_ENDPOINT_VALIDATION_TTL_MS = 60_000 export const NETWORKS = Object.freeze({ finney: 'wss://entrypoint-finney.opentensor.ai:443', test: 'wss://test.finney.opentensor.ai:443', archive: 'wss://archive.chain.opentensor.ai:443', local: process.env.BT_CHAIN_ENDPOINT ?? 'ws://127.0.0.1:9944', }) +export const NETWORK_GENESIS_HASHES = Object.freeze({ + finney: '0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03', + archive: '0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03', +}) export type NetworkName = keyof typeof NETWORKS export type Descriptor = readonly [string, string] @@ -32,6 +45,7 @@ export type CallLike = export interface ClientOptions { endpoint?: string fallbackEndpoints?: string[] + expectedGenesisHash?: string retryForever?: boolean autoConnect?: boolean webSocket?: WebSocketConstructor @@ -43,6 +57,7 @@ export interface ClientOptions { maxRequestRetries?: number retryBackoffMs?: number maxRetryBackoffMs?: number + endpointValidationTtlMs?: number } export interface RpcRequestOptions { @@ -63,13 +78,14 @@ export interface JsonRpcTransportOptions { maxRequestRetries?: number retryBackoffMs?: number maxRetryBackoffMs?: number + endpointValidationTtlMs?: number } export interface SubmitOptions { nonce?: number period?: number | null tip?: TransactionAmount - tipAssetId?: TransactionAmount | null + tipAssetId?: AssetId | null metadataHash?: ByteLike | null waitForInclusion?: boolean waitForFinalization?: boolean @@ -356,10 +372,11 @@ export class JsonRpcTransport { private readonly maxRequestRetries: number private readonly retryBackoffMs: number private readonly maxRetryBackoffMs: number + private readonly endpointValidationTtlMs: number private readonly webSocketConstructor?: WebSocketConstructor private readonly webSocketFactory?: WebSocketFactory private readonly validateEndpoint?: EndpointValidator - private readonly validatedEndpoints = new Set() + private readonly validatedEndpoints = new Map() private endpointIndex = 0 private id = 1 private socket?: WebSocketLike @@ -381,6 +398,10 @@ export class JsonRpcTransport { this.maxRequestRetries = nonNegativeInteger(options.maxRequestRetries, DEFAULT_MAX_REQUEST_RETRIES) this.retryBackoffMs = nonNegativeNumber(options.retryBackoffMs, DEFAULT_RETRY_BACKOFF_MS) this.maxRetryBackoffMs = nonNegativeNumber(options.maxRetryBackoffMs, DEFAULT_MAX_RETRY_BACKOFF_MS) + this.endpointValidationTtlMs = nonNegativeNumber( + options.endpointValidationTtlMs, + DEFAULT_ENDPOINT_VALIDATION_TTL_MS, + ) this.webSocketConstructor = options.webSocketConstructor ?? options.webSocket this.webSocketFactory = options.webSocketFactory this.validateEndpoint = options.validateEndpoint @@ -421,7 +442,9 @@ export class JsonRpcTransport { private async ensureEndpointValidated(options: RpcRequestOptions): Promise { if (this.validateEndpoint == null) return const endpoint = this.endpoint - if (this.validatedEndpoints.has(endpoint)) return + const validUntil = this.validatedEndpoints.get(endpoint) + const now = Date.now() + if (validUntil != null && validUntil > now) return try { await this.validateEndpoint(endpoint, (method, params = []) => this.isHttpEndpoint() @@ -432,7 +455,9 @@ export class JsonRpcTransport { if (error instanceof EndpointValidationError) throw error throw error } - this.validatedEndpoints.add(endpoint) + if (this.endpointValidationTtlMs > 0) { + this.validatedEndpoints.set(endpoint, Date.now() + this.endpointValidationTtlMs) + } } async subscribe( @@ -655,6 +680,7 @@ export class JsonRpcTransport { } private handleSocketClose(error: Error): void { + this.validatedEndpoints.delete(this.endpoint) this.failPending(error) this.subscriptionsById.clear() if (this.closed) return @@ -702,6 +728,7 @@ export class JsonRpcTransport { } private rotateEndpoint(): void { + this.validatedEndpoints.delete(this.endpoint) const socket = this.socket this.socket = undefined this.connecting = undefined @@ -725,7 +752,7 @@ export class Client { private readonly headRuntimeTtlMs: number private readonly historicalRuntimeCacheSize: number - private readonly endpointValidationOptions: JsonRpcTransportOptions + private readonly expectedGenesisHash?: string private headRuntimeCache?: HeadRuntimeCacheEntry private runtimesBySpecVersion = new Map() private historicalRuntimeCache = new Map() @@ -734,18 +761,20 @@ export class Client { constructor(network: string = 'finney', options: ClientOptions = {}) { const [label, endpoint] = resolveEndpoint(options.endpoint ?? network) + const fallbackEndpoints = options.fallbackEndpoints ?? [] + const expectedGenesisHash = normalizeGenesisHash( + options.expectedGenesisHash ?? trustedGenesisHash(label), + ) + if (fallbackEndpoints.length > 0 && expectedGenesisHash == null) { + throw new ChainError( + 'fallbackEndpoints require expectedGenesisHash for custom or untrusted networks', + ) + } this.network = label this.endpoint = endpoint - this.endpointValidationOptions = { - webSocket: options.webSocket, - webSocketConstructor: options.webSocketConstructor, - webSocketFactory: options.webSocketFactory, - requestTimeoutMs: options.requestTimeoutMs, - maxRequestRetries: 0, - retryBackoffMs: options.retryBackoffMs, - maxRetryBackoffMs: options.maxRetryBackoffMs, - } - this.transport = new JsonRpcTransport(endpoint, options.fallbackEndpoints, options.retryForever, { + this.expectedGenesisHash = expectedGenesisHash + this.genesis = expectedGenesisHash + this.transport = new JsonRpcTransport(endpoint, fallbackEndpoints, options.retryForever, { webSocket: options.webSocket, webSocketConstructor: options.webSocketConstructor, webSocketFactory: options.webSocketFactory, @@ -754,6 +783,7 @@ export class Client { maxRequestRetries: options.maxRequestRetries, retryBackoffMs: options.retryBackoffMs, maxRetryBackoffMs: options.maxRetryBackoffMs, + endpointValidationTtlMs: options.endpointValidationTtlMs, }) this.balances = new BalancesNamespace(this) this.subnets = new SubnetsNamespace(this) @@ -815,31 +845,18 @@ export class Client { } private async validateEndpointGenesis(endpoint: string, request: EndpointRequest): Promise { - if (this.genesis == null && endpoint !== this.endpoint) { - this.genesis = await this.fetchGenesisFromEndpoint(this.endpoint) - } const genesis = String(await request('chain_getBlockHash', [0])) - if (this.genesis == null) { + const expected = this.expectedGenesisHash ?? this.genesis + if (expected == null) { this.genesis = genesis return } - if (!sameHex(genesis, this.genesis)) { + if (!sameHex(genesis, expected)) { throw new EndpointValidationError( - `endpoint ${endpoint} genesis ${genesis} does not match primary genesis ${this.genesis}`, + `endpoint ${endpoint} genesis ${genesis} does not match expected genesis ${expected}`, ) } - } - - private async fetchGenesisFromEndpoint(endpoint: string): Promise { - const transport = new JsonRpcTransport(endpoint, [], false, this.endpointValidationOptions) - try { - return String(await transport.request('chain_getBlockHash', [0], { - maxRetries: 0, - retryForever: false, - })) - } finally { - transport.close() - } + this.genesis = expected } async runtimeAt(block?: number | string | null): Promise { @@ -1269,11 +1286,20 @@ export class Client { signer: SignerLike, options: SubmitOptions, manageNonce: boolean, + ): Promise { + return this.signExtrinsicWithSnapshot(call, signer, options, manageNonce, await this.signingSnapshot()) + } + + private async signExtrinsicWithSnapshot( + call: CallLike, + signer: SignerLike, + options: SubmitOptions, + manageNonce: boolean, + snapshot: SigningSnapshot, ): Promise { let resolved: ResolvedSigner | undefined let reservation: NonceReservation | undefined try { - const snapshot = await this.signingSnapshot() const { runtime } = snapshot const callData = this.callDataWithRuntime(call, runtime) resolved = await this.resolveSigner(signer, runtime) @@ -1283,8 +1309,8 @@ export class Client { const nonce = options.nonce ?? reservation?.nonce ?? await this.peekNextIndex(resolved.ss58Address) const period = options.period === undefined ? DEFAULT_ERA_PERIOD : options.period const { era, eraBlockHash } = await this.normalizeEra(period, snapshot) - const tip = transactionAmountRao(options.tip ?? 0n) - const tipAssetId = options.tipAssetId == null ? null : transactionAmountRao(options.tipAssetId) + const tip = taoTransactionAmountRao(options.tip ?? 0n, 'tip') + const tipAssetId = options.tipAssetId == null ? null : assetIdValue(options.tipAssetId, 'tipAssetId') const chainInfo = resolved.requiresMetadataProof ? await this.chainInfo(runtime, snapshot.blockHash) : undefined @@ -1675,15 +1701,20 @@ export class Client { } async estimateFee(call: CallLike, signer: SignerLike): Promise { - const runtime = await this.runtimeAt() + const snapshot = await this.signingSnapshot() + const { runtime } = snapshot const account = await this.resolveSigner(signer, runtime) - const signed = await this.signExtrinsic(call, signer, { + const signed = await this.signExtrinsicWithSnapshot(call, signer, { nonce: await this.peekNextIndex(account.ss58Address), period: null, - }) + }, false, snapshot) const length = Buffer.alloc(4) length.writeUInt32LE(signed.bytes.length, 0) - const raw = await this.rpc('state_call', ['TransactionPaymentApi_query_info', hex(Buffer.concat([signed.bytes, length])), null]) + const raw = await this.rpc('state_call', [ + 'TransactionPaymentApi_query_info', + hex(Buffer.concat([signed.bytes, length])), + snapshot.blockHash, + ]) const info = runtime.decodeTypeId>( runtime.runtimeApis().TransactionPaymentApi.query_info.outputTypeId, hexToBuffer(String(raw)), @@ -2402,15 +2433,15 @@ export const runtimeApis = runtimeApi export const calls = Object.freeze({ balances: Object.freeze({ transferKeepAlive(dest: string, value: TransactionAmount) { - return call('Balances', 'transfer_keep_alive', { dest, value: transactionAmountRao(value) }) + return call('Balances', 'transfer_keep_alive', { dest, value: taoTransactionAmountRao(value, 'transfer amount') }) }, transferAllowDeath(dest: string, value: TransactionAmount) { - return call('Balances', 'transfer_allow_death', { dest, value: transactionAmountRao(value) }) + return call('Balances', 'transfer_allow_death', { dest, value: taoTransactionAmountRao(value, 'transfer amount') }) }, }), subtensor: Object.freeze({ addStake(hotkey: string, netuid: number, amount: TransactionAmount) { - return call('SubtensorModule', 'add_stake', { hotkey, netuid, amount_staked: transactionAmountRao(amount) }) + return call('SubtensorModule', 'add_stake', { hotkey, netuid, amount_staked: taoTransactionAmountRao(amount, 'stake amount') }) }, burnedRegister(netuid: number, hotkey: string) { return call('SubtensorModule', 'burned_register', { netuid, hotkey }) @@ -2472,15 +2503,15 @@ export const calls = Object.freeze({ }), Balances: Object.freeze({ transfer_keep_alive(dest: string, value: TransactionAmount) { - return call('Balances', 'transfer_keep_alive', { dest, value: transactionAmountRao(value) }) + return call('Balances', 'transfer_keep_alive', { dest, value: taoTransactionAmountRao(value, 'transfer amount') }) }, transfer_allow_death(dest: string, value: TransactionAmount) { - return call('Balances', 'transfer_allow_death', { dest, value: transactionAmountRao(value) }) + return call('Balances', 'transfer_allow_death', { dest, value: taoTransactionAmountRao(value, 'transfer amount') }) }, }), SubtensorModule: Object.freeze({ add_stake(hotkey: string, netuid: number, amountStaked: TransactionAmount) { - return call('SubtensorModule', 'add_stake', { hotkey, netuid, amount_staked: transactionAmountRao(amountStaked) }) + return call('SubtensorModule', 'add_stake', { hotkey, netuid, amount_staked: taoTransactionAmountRao(amountStaked, 'stake amount') }) }, burned_register(netuid: number, hotkey: string) { return call('SubtensorModule', 'burned_register', { netuid, hotkey }) @@ -2838,6 +2869,21 @@ function resolveEndpoint(network: string): [string, string] { throw new Error(`Unknown network ${network}`) } +function trustedGenesisHash(network: string): string | undefined { + return Object.prototype.hasOwnProperty.call(NETWORK_GENESIS_HASHES, network) + ? NETWORK_GENESIS_HASHES[network as keyof typeof NETWORK_GENESIS_HASHES] + : undefined +} + +function normalizeGenesisHash(value: string | null | undefined): string | undefined { + if (value == null) return undefined + const normalized = value.startsWith('0x') ? value : `0x${value}` + if (!/^0x[0-9a-fA-F]{64}$/.test(normalized)) { + throw new ChainError('expectedGenesisHash must be a 32-byte hex string') + } + return normalized.toLowerCase() +} + function normalizeStorageArgs( pallet: string | Descriptor, storageFunction?: string | ScaleValue[], diff --git a/sdk/bittensor-ts/src/keys.ts b/sdk/bittensor-ts/src/keys.ts index d230a18a20..f3f958d120 100644 --- a/sdk/bittensor-ts/src/keys.ts +++ b/sdk/bittensor-ts/src/keys.ts @@ -289,6 +289,27 @@ export class Keypair implements PolkadotCompatibleKeypair { } } + static writeKeyfilePair( + privateKeypair: Keypair, + privatePath: string, + publicKeypair: Keypair, + publicPath: string, + options: WriteKeyfileOptions = {}, + ): Promise { + const normalized = normalizeWriteKeyfileOptions(options) + return nativeAsync(() => + native.writeKeypairPairKeyfile( + privateKeypair.handle, + privatePath, + normalized.password ?? undefined, + publicKeypair.handle, + publicPath, + normalized.overwrite, + normalized.allowPlaintext, + ), + ) + } + static encryptFor( ss58Address: string, message: string | ByteLike, @@ -652,6 +673,18 @@ export function readKeypairKeyfile(path: string, password?: string | null): Prom export const read_keypair_keyfile = readKeypairKeyfile +export function writeKeypairPairKeyfile( + privateKeypair: Keypair, + privatePath: string, + publicKeypair: Keypair, + publicPath: string, + options: WriteKeyfileOptions = {}, +): Promise { + return Keypair.writeKeyfilePair(privateKeypair, privatePath, publicKeypair, publicPath, options) +} + +export const write_keypair_pair_keyfile = writeKeypairPairKeyfile + export function encryptKeyfileData(keyfileData: ByteLike, password: string): Promise { return nativeAsync(() => native.encryptKeyfileData(toBuffer(keyfileData, 'keyfileData'), password), @@ -660,7 +693,7 @@ export function encryptKeyfileData(keyfileData: ByteLike, password: string): Pro export const encrypt_keyfile_data = encryptKeyfileData -export function decryptKeyfileData( +export function dangerouslyDecryptKeyfileData( keyfileData: ByteLike, password?: string | null, ): Promise { @@ -669,7 +702,7 @@ export function decryptKeyfileData( ) } -export const decrypt_keyfile_data = decryptKeyfileData +export const dangerously_decrypt_keyfile_data = dangerouslyDecryptKeyfileData export function keyfileDataIsEncrypted(keyfileData: ByteLike): boolean { return native.keyfileDataIsEncrypted(toBuffer(keyfileData, 'keyfileData')) @@ -701,14 +734,23 @@ export function keyfileDataEncryptionMethod(keyfileData: ByteLike): string { export const keyfile_data_encryption_method = keyfileDataEncryptionMethod -export function getPasswordFromEnvironment(name: string): string | null { +export function legacyGetPasswordFromEnvironment(name: string): string | null { return nativeCall(() => native.getPasswordFromEnvironment(name) ?? null) } -export const get_password_from_environment = getPasswordFromEnvironment +export const legacy_get_password_from_environment = legacyGetPasswordFromEnvironment -export function savePasswordToEnvironment(name: string, password: string): string { +export function legacySavePasswordToEnvironment(name: string, password: string): string { return nativeCall(() => native.savePasswordToEnvironment(name, password)) } -export const save_password_to_environment = savePasswordToEnvironment +export const legacy_save_password_to_environment = legacySavePasswordToEnvironment + +export const dangerousKeyfiles = Object.freeze({ + dangerouslyDecryptKeyfileData, + dangerously_decrypt_keyfile_data, + legacySavePasswordToEnvironment, + legacy_save_password_to_environment, + legacyGetPasswordFromEnvironment, + legacy_get_password_from_environment, +}) diff --git a/sdk/bittensor-ts/src/modules.ts b/sdk/bittensor-ts/src/modules.ts index 46b0f961a2..268b9125ed 100644 --- a/sdk/bittensor-ts/src/modules.ts +++ b/sdk/bittensor-ts/src/modules.ts @@ -57,10 +57,10 @@ export const rustCore = Object.freeze({ deserialize_keypair_from_keyfile: keys.deserialize_keypair_from_keyfile, readKeypairKeyfile: keys.readKeypairKeyfile, read_keypair_keyfile: keys.read_keypair_keyfile, + writeKeypairPairKeyfile: keys.writeKeypairPairKeyfile, + write_keypair_pair_keyfile: keys.write_keypair_pair_keyfile, encryptKeyfileData: keys.encryptKeyfileData, encrypt_keyfile_data: keys.encrypt_keyfile_data, - decryptKeyfileData: keys.decryptKeyfileData, - decrypt_keyfile_data: keys.decrypt_keyfile_data, keyfileDataIsEncrypted: keys.keyfileDataIsEncrypted, keyfile_data_is_encrypted: keys.keyfile_data_is_encrypted, keyfileDataIsEncryptedNacl: keys.keyfileDataIsEncryptedNacl, @@ -71,10 +71,7 @@ export const rustCore = Object.freeze({ keyfile_data_is_encrypted_legacy: keys.keyfile_data_is_encrypted_legacy, keyfileDataEncryptionMethod: keys.keyfileDataEncryptionMethod, keyfile_data_encryption_method: keys.keyfile_data_encryption_method, - getPasswordFromEnvironment: keys.getPasswordFromEnvironment, - get_password_from_environment: keys.get_password_from_environment, - savePasswordToEnvironment: keys.savePasswordToEnvironment, - save_password_to_environment: keys.save_password_to_environment, + dangerous: keys.dangerousKeyfiles, }), codec: Object.freeze({ value: Object.freeze({ diff --git a/sdk/bittensor-ts/src/native.ts b/sdk/bittensor-ts/src/native.ts index fffb1889ca..5eff8cec60 100644 --- a/sdk/bittensor-ts/src/native.ts +++ b/sdk/bittensor-ts/src/native.ts @@ -312,6 +312,15 @@ export interface NativeBinding { overwrite: boolean, allowPlaintext: boolean, ): Promise + writeKeypairPairKeyfile( + privateKeypair: NativeKeypairHandle, + privatePath: string, + privatePassword: string | null | undefined, + publicKeypair: NativeKeypairHandle, + publicPath: string, + overwrite: boolean, + allowPlaintext: boolean, + ): Promise encryptKeyfileData(keyfileData: Buffer, password: string): Promise decryptKeyfileData(keyfileData: Buffer, password?: string | null): Promise keyfileDataIsEncrypted(keyfileData: Buffer): boolean diff --git a/sdk/bittensor-ts/src/wallet.ts b/sdk/bittensor-ts/src/wallet.ts index eb23df9fbc..65a4ae759f 100644 --- a/sdk/bittensor-ts/src/wallet.ts +++ b/sdk/bittensor-ts/src/wallet.ts @@ -7,6 +7,7 @@ import { isAbsolute, join, relative, resolve, sep } from 'node:path' import { CRYPTO_SR25519, Keypair, + writeKeypairPairKeyfile, } from './keys' export const DEFAULT_WALLET_PATH = join(homedir(), '.bittensor', 'wallets') @@ -26,11 +27,40 @@ export interface SaveKeyOptions { password?: string | null } -export interface RegenerateKeyOptions extends SaveKeyOptions { +export type PrivateKeySaveOptions = + | { + keyfilePassword: string + allowPlaintext?: false + overwrite?: boolean + encrypt?: true + password?: never + } + | { + allowPlaintext: true + keyfilePassword?: never + overwrite?: boolean + encrypt?: false + password?: never + } + +export interface GenerateWalletKeyOptions { + nWords?: number + cryptoType?: number + mnemonicPassword?: string | null +} + +export type CreateWalletKeyOptions = GenerateWalletKeyOptions & PrivateKeySaveOptions + +export type RegenerateKeyOptions = PrivateKeySaveOptions & { cryptoType?: number mnemonicPassword?: string | null } +export interface GeneratedWalletKey { + keypair: Keypair + mnemonic: string +} + export interface CreatedWalletKey { wallet: Wallet keypair: Keypair @@ -60,8 +90,9 @@ export class Keyfile { } async setKeypair(keypair: Keypair, options: SaveKeyOptions = {}): Promise { - const { encrypt = false, overwrite = false } = options const password = keyfilePassword(options) + const encrypt = options.encrypt ?? password != null + const overwrite = options.overwrite ?? false if (encrypt && password == null) { throw new Error(`Password is required to encrypt ${this.path}`) } @@ -151,77 +182,77 @@ export class Wallet { return this.hotkeyCache } - async setColdkey(keypair: Keypair, options: SaveKeyOptions = {}): Promise { + async setColdkey(keypair: Keypair, options: PrivateKeySaveOptions): Promise { + const saveOptions = requirePrivateKeySaveOptions(options, 'setColdkey') const coldkeypub = publicOnly(keypair) - await this.coldkeyFile.setKeypair(keypair, { - ...options, - encrypt: options.encrypt ?? keyfilePassword(options) != null, + await writeKeypairPairKeyfile(keypair, this.coldkeyFile.path, coldkeypub, this.coldkeypubFile.path, { + password: keyfilePassword(saveOptions) ?? undefined, + overwrite: saveOptions.overwrite ?? false, + allowPlaintext: saveOptions.allowPlaintext === true, }) - await this.coldkeypubFile.setKeypair(coldkeypub, { overwrite: options.overwrite ?? true }) this.coldkeyCache = Promise.resolve(keypair) this.coldkeypubCache = Promise.resolve(coldkeypub) return this } - async setHotkey(keypair: Keypair, options: SaveKeyOptions = {}): Promise { + async setHotkey(keypair: Keypair, options: PrivateKeySaveOptions): Promise { + const saveOptions = requirePrivateKeySaveOptions(options, 'setHotkey') const hotkeypub = publicOnly(keypair) - await this.hotkeyFile.setKeypair(keypair, { - ...options, - encrypt: options.encrypt ?? keyfilePassword(options) != null, + await writeKeypairPairKeyfile(keypair, this.hotkeyFile.path, hotkeypub, this.hotkeypubFile.path, { + password: keyfilePassword(saveOptions) ?? undefined, + overwrite: saveOptions.overwrite ?? false, + allowPlaintext: saveOptions.allowPlaintext === true, }) - await this.hotkeypubFile.setKeypair(hotkeypub, { overwrite: options.overwrite ?? true }) this.hotkeyCache = Promise.resolve(keypair) this.hotkeypubCache = Promise.resolve(hotkeypub) return this } - async createNewColdkey( - options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}, - ): Promise { - const mnemonic = Keypair.generateMnemonic(options.nWords ?? 12) - const keypair = Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519) - await this.setColdkey(keypair, { - ...options, - encrypt: options.encrypt ?? keyfilePassword(options) != null, - }) - return { wallet: this, keypair, mnemonic } + static generateColdkey(options: GenerateWalletKeyOptions = {}): GeneratedWalletKey { + return generateWalletKey(options) } - async createNewHotkey( - options: SaveKeyOptions & { nWords?: number; cryptoType?: number } = {}, - ): Promise { - const mnemonic = Keypair.generateMnemonic(options.nWords ?? 12) - const keypair = Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519) - await this.setHotkey(keypair, { - ...options, - encrypt: options.encrypt ?? keyfilePassword(options) != null, - }) - return { wallet: this, keypair, mnemonic } + static generateHotkey(options: GenerateWalletKeyOptions = {}): GeneratedWalletKey { + return generateWalletKey(options) + } + + generateColdkey(options: GenerateWalletKeyOptions = {}): GeneratedWalletKey { + return Wallet.generateColdkey(options) + } + + generateHotkey(options: GenerateWalletKeyOptions = {}): GeneratedWalletKey { + return Wallet.generateHotkey(options) + } + + async createNewColdkey(options: CreateWalletKeyOptions): Promise { + const generated = Wallet.generateColdkey(options) + await this.setColdkey(generated.keypair, options) + return { wallet: this, ...generated } + } + + async createNewHotkey(options: CreateWalletKeyOptions): Promise { + const generated = Wallet.generateHotkey(options) + await this.setHotkey(generated.keypair, options) + return { wallet: this, ...generated } } async regenerateColdkey( mnemonic: string, - options: RegenerateKeyOptions = {}, + options: RegenerateKeyOptions, ): Promise { return this.setColdkey( Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519, options.mnemonicPassword), - { - ...options, - encrypt: options.encrypt ?? keyfilePassword(options) != null, - }, + options, ) } async regenerateHotkey( mnemonic: string, - options: RegenerateKeyOptions = {}, + options: RegenerateKeyOptions, ): Promise { return this.setHotkey( Keypair.fromMnemonic(mnemonic, options.cryptoType ?? CRYPTO_SR25519, options.mnemonicPassword), - { - ...options, - encrypt: options.encrypt ?? keyfilePassword(options) != null, - }, + options, ) } } @@ -260,6 +291,36 @@ function keyfilePassword(options: SaveKeyOptions): string | null | undefined { return options.keyfilePassword ?? options.password } +function requirePrivateKeySaveOptions( + options: SaveKeyOptions | undefined, + operation: string, +): SaveKeyOptions { + if (options == null) { + throw new Error(`${operation} requires keyfilePassword or allowPlaintext: true`) + } + const password = keyfilePassword(options) + if (password === '') { + throw new Error(`${operation} requires a non-empty keyfilePassword`) + } + if (password != null && options.allowPlaintext === true) { + throw new Error(`${operation} accepts either keyfilePassword or allowPlaintext: true, not both`) + } + if (password == null && options.allowPlaintext !== true) { + throw new Error(`${operation} requires keyfilePassword or allowPlaintext: true`) + } + return options +} + +function generateWalletKey(options: GenerateWalletKeyOptions = {}): GeneratedWalletKey { + const mnemonic = Keypair.generateMnemonic(options.nWords ?? 12) + const keypair = Keypair.fromMnemonic( + mnemonic, + options.cryptoType ?? CRYPTO_SR25519, + options.mnemonicPassword, + ) + return { keypair, mnemonic } +} + function publicOnly(keypair: Keypair): Keypair { return new Keypair(keypair.ss58Address, keypair.publicKey, keypair.cryptoType, keypair.ss58Format) } diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index e48ec1f201..227f85955c 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -925,13 +925,40 @@ test('transaction amounts require explicit units', () => { 'transfer_keep_alive', { dest: '5F', value: 500_000_000n }, ]) + assert.throws( + () => core.calls.balances.transferKeepAlive('5F', -1n), + /transfer amount must be non-negative/, + ) + assert.throws( + () => core.calls.balances.transferKeepAlive('5F', core.raoAmount('-1')), + /transfer amount must be non-negative/, + ) + assert.throws( + () => core.calls.balances.transferKeepAlive('5F', core.Balance.fromAlpha('1', 7)), + /must be a TAO balance/, + ) assert.throws( () => core.calls.balances.transferKeepAlive('5F', '1'), - /transaction amount must be/, + /transfer amount must be/, ) assert.throws( () => core.calls.balances.transferKeepAlive('5F', 1), - /transaction amount must be/, + /transfer amount must be/, + ) + assert.equal(core.assetIdValue('1'), 1n) + assert.equal(core.assetIdValue(1), 1n) + assert.equal(core.assetIdValue(1n), 1n) + assert.throws( + () => core.assetIdValue('-1'), + /must be non-negative/, + ) + assert.throws( + () => core.assetIdValue('1.0'), + /must be an integer/, + ) + assert.throws( + () => core.assetIdValue(core.taoAmount('1')), + /must be a bigint/, ) }) @@ -1158,6 +1185,7 @@ test('Client validates fallback endpoint genesis before use', async (t) => { const client = new core.Client('local', { endpoint: 'http://primary', + expectedGenesisHash: primaryGenesis, fallbackEndpoints: ['http://fallback'], maxRequestRetries: 1, requestTimeoutMs: 100, @@ -1168,7 +1196,52 @@ test('Client validates fallback endpoint genesis before use', async (t) => { await assert.rejects( () => client.rpc('state_getMetadata'), (error) => error.name === 'EndpointValidationError' && - /does not match primary genesis/.test(error.message), + /does not match expected genesis/.test(error.message), + ) +}) + +test('Client fallback can validate from expected genesis when primary is unavailable', async (t) => { + const originalFetch = globalThis.fetch + t.after(() => { + globalThis.fetch = originalFetch + }) + const expectedGenesis = `0x${'cc'.repeat(32)}` + globalThis.fetch = async (url, init) => { + const endpoint = String(url) + if (endpoint.includes('primary')) throw new Error('primary unavailable') + const request = JSON.parse(String(init.body)) + if (request.method === 'chain_getBlockHash') { + return { + ok: true, + json: async () => ({ jsonrpc: '2.0', id: request.id, result: expectedGenesis }), + } + } + return { + ok: true, + json: async () => ({ jsonrpc: '2.0', id: request.id, result: '0x1234' }), + } + } + + const client = new core.Client('local', { + endpoint: 'http://primary', + expectedGenesisHash: expectedGenesis, + fallbackEndpoints: ['http://fallback'], + maxRequestRetries: 1, + requestTimeoutMs: 100, + retryBackoffMs: 0, + maxRetryBackoffMs: 0, + }) + + assert.equal(await client.rpc('state_getMetadata'), '0x1234') +}) + +test('Client requires expected genesis for custom fallback endpoints', () => { + assert.throws( + () => new core.Client('local', { + endpoint: 'http://primary', + fallbackEndpoints: ['http://fallback'], + }), + /expectedGenesisHash/, ) }) @@ -1297,6 +1370,18 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => assert.equal(captures.encoded.params.metadataHashEnabled, false) assert.equal(await client.accountNextIndex(address), 12) assert.equal(await client.accountNextIndex(address), 12) + await assert.rejects( + () => client.signExtrinsic(callData, signer, { period: null, tip: core.Balance.fromAlpha('1', 7) }), + /tip must be a TAO balance/, + ) + await assert.rejects( + () => client.signExtrinsic(callData, signer, { period: null, tipAssetId: core.taoAmount('1') }), + /tipAssetId must be a bigint/, + ) + await assert.rejects( + () => client.signExtrinsic(callData, signer, { period: null, tipAssetId: -1 }), + /tipAssetId must be non-negative/, + ) }) test('Client estimateFee peeks the chain nonce without reserving it', async () => { @@ -1323,6 +1408,7 @@ test('Client estimateFee peeks the chain nonce without reserving it', async () = Buffer.alloc(64, 4), ]) const nonceReads = [] + let stateCallParams client.rpc = async (method, params = []) => { if (method === 'system_accountNextIndex') { nonceReads.push(params[0]) @@ -1338,7 +1424,10 @@ test('Client estimateFee peeks the chain nonce without reserving it', async () = if (method === 'system_properties') { return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } } - if (method === 'state_call') return '0x00' + if (method === 'state_call') { + stateCallParams = params + return '0x00' + } throw new Error(`unexpected RPC ${method}`) } const signer = { @@ -1358,6 +1447,8 @@ test('Client estimateFee peeks the chain nonce without reserving it', async () = assert.equal(firstRealNonce, 7) assert.equal(secondRealNonce, 7) assert.deepEqual(nonceReads, [address, address, address]) + assert.equal(stateCallParams[0], 'TransactionPaymentApi_query_info') + assert.equal(stateCallParams[2], `0x${'42'.repeat(32)}`) }) test('Client serializes concurrent initial nonce reservations during submit', async () => { @@ -2245,10 +2336,22 @@ test('wallet helpers keep mnemonic and keyfile passwords separate', async (t) => core.keyfileDataIsEncrypted(fs.readFileSync(created.hotkeyFile.path)), true, ) + const generated = core.Wallet.generateHotkey() + assert.equal(generated.mnemonic.split(/\s+/).length, 12) + const generatedWallet = new core.Wallet({ name: 'generated', hotkey: 'default', path: root }) + await generatedWallet.setHotkey(generated.keypair, { keyfilePassword }) + assert.deepEqual( + (await generatedWallet.getHotkey(keyfilePassword)).publicKey, + generated.keypair.publicKey, + ) + await assert.rejects( + () => generatedWallet.setHotkey(core.Keypair.fromUri('//Bob')), + /requires keyfilePassword or allowPlaintext/, + ) const plaintextDefault = new core.Wallet({ name: 'plaintext-default', hotkey: 'default', path: root }) await assert.rejects( () => plaintextDefault.createNewHotkey(), - /allowPlaintext/, + /keyfilePassword or allowPlaintext/, ) const plaintextAllowed = new core.Wallet({ name: 'plaintext-allowed', hotkey: 'default', path: root }) const plaintextHotkey = await plaintextAllowed.createNewHotkey({ allowPlaintext: true }) diff --git a/ts-tests/suites/dev/sdk/test-bittensor-ts.ts b/ts-tests/suites/dev/sdk/test-bittensor-ts.ts index 7650883548..1bf78b6f5a 100644 --- a/ts-tests/suites/dev/sdk/test-bittensor-ts.ts +++ b/ts-tests/suites/dev/sdk/test-bittensor-ts.ts @@ -34,6 +34,7 @@ describeSuite({ const call = await client.composeCall("System", "remark", { remark }); try { + await client.assertDescriptorSchema(); const signed = await client.signExtrinsic(call, alice); const watcher = await client.watchSigned(signed); From 4eeb1b621489b9c5599734c7012332b618cc1bf4 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 09:54:22 -0700 Subject: [PATCH 44/72] Fix CI --- sdk/bittensor-ts/src/client.ts | 67 +++++++++++++++++++++++++++- sdk/bittensor-ts/test/basic.test.cjs | 65 ++++++++++++++++++++++----- 2 files changed, 119 insertions(+), 13 deletions(-) diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 529f9af0be..60b0ec4796 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -24,6 +24,8 @@ export const DEFAULT_RETRY_BACKOFF_MS = 250 export const DEFAULT_MAX_RETRY_BACKOFF_MS = 5_000 export const DEFAULT_NONCE_RECONCILE_BLOCKS = 8 export const DEFAULT_ENDPOINT_VALIDATION_TTL_MS = 60_000 +const V15_METADATA_VERSION_HEX = '0x0f000000' +const V15_METADATA_MISSING_NEEDLE = 'Exported method Metadata_metadata_at_version is not found' export const NETWORKS = Object.freeze({ finney: 'wss://entrypoint-finney.opentensor.ai:443', test: 'wss://test.finney.opentensor.ai:443', @@ -920,9 +922,9 @@ export class Client { return cached } - const metadataHex = await this.rpc('state_getMetadata', blockHash == null ? [] : [blockHash]) + const metadataBytes = await this.runtimeMetadata(blockHash) const runtime = new Runtime( - hexToBuffer(String(metadataHex)), + metadataBytes, version.specVersion, version.transactionVersion, ss58Format, @@ -932,6 +934,29 @@ export class Client { return entry } + private async runtimeMetadata(blockHash: string | null): Promise { + let v15Result: unknown + try { + v15Result = await this.rpc('state_call', [ + 'Metadata_metadata_at_version', + V15_METADATA_VERSION_HEX, + blockHash, + ]) + } catch (error) { + if (!isMetadataAtVersionUnavailable(error)) throw error + v15Result = null + } + + if (v15Result != null) { + const metadata = stripOptionOpaqueMetadata(hexToBuffer(String(v15Result))) + if (metadata != null) return metadata + } + + const legacy = await this.rpc('state_getMetadata', blockHash == null ? [] : [blockHash]) + if (legacy == null) throw new ChainError(`no metadata for block ${blockHash ?? 'head'}`) + return hexToBuffer(String(legacy)) + } + private cacheRuntimeBySpecVersion(entry: RuntimeCacheEntry): void { this.runtimesBySpecVersion.delete(entry.specVersion) this.runtimesBySpecVersion.set(entry.specVersion, entry) @@ -2830,6 +2855,14 @@ function sameHex(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase() } +function isMetadataAtVersionUnavailable(error: unknown): boolean { + const message = String(error instanceof Error ? error.message : error) + const data = String(error instanceof JsonRpcError && error.data != null ? error.data : '') + const text = `${message} ${data}` + return text.includes(V15_METADATA_MISSING_NEEDLE) || + (text.includes('Metadata_metadata_at_version') && /not found|unknown function/i.test(text)) +} + function hasOwn(value: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key) } @@ -2948,6 +2981,36 @@ function hexToBuffer(value: string): Buffer { return Buffer.from(text, 'hex') } +function stripOptionOpaqueMetadata(data: Buffer): Buffer | null { + if (data.length === 0 || data[0] === 0) return null + if (data.length < 2) throw new ChainError('invalid Metadata_metadata_at_version response') + const { length, offset } = decodeCompactLength(data, 1) + const end = offset + length + if (end > data.length) throw new ChainError('truncated Metadata_metadata_at_version response') + return data.subarray(offset, end) +} + +function decodeCompactLength(data: Buffer, offset: number): { length: number; offset: number } { + const first = data[offset] + if (first == null) throw new ChainError('truncated compact length') + const mode = first & 0b11 + if (mode === 0) return { length: first >> 2, offset: offset + 1 } + if (mode === 1) { + if (offset + 2 > data.length) throw new ChainError('truncated compact length') + return { length: data.readUInt16LE(offset) >> 2, offset: offset + 2 } + } + if (mode === 2) { + if (offset + 4 > data.length) throw new ChainError('truncated compact length') + return { length: data.readUInt32LE(offset) >>> 2, offset: offset + 4 } + } + const byteCount = (first >> 2) + 4 + if (offset + 1 + byteCount > data.length) throw new ChainError('truncated compact length') + let length = 0 + for (let i = 0; i < byteCount; i += 1) length += data[offset + 1 + i] * (256 ** i) + if (!Number.isSafeInteger(length)) throw new ChainError('compact length exceeds safe integer range') + return { length, offset: offset + 1 + byteCount } +} + function hexNumber(value: string): number { return Number.parseInt(value, 16) } diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 227f85955c..1f239a4cbe 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -23,16 +23,30 @@ function compactLength(buffer, offset) { } function goldenMetadataBytes() { + const raw = Buffer.from(goldenMetadataResponseHex().slice(2), 'hex') + assert.equal(raw[0], 1) + const decoded = compactLength(raw, 1) + return raw.subarray(decoded.offset, decoded.offset + decoded.length) +} + +function goldenMetadataResponseHex() { const golden = JSON.parse( fs.readFileSync( path.join(__dirname, '..', '..', 'python', 'tests', 'fixtures', 'golden.json'), 'utf8', ), ) - const raw = Buffer.from(golden.metadata.v15_hex.slice(2), 'hex') - assert.equal(raw[0], 1) - const decoded = compactLength(raw, 1) - return raw.subarray(decoded.offset, decoded.offset + decoded.length) + return golden.metadata.v15_hex +} + +function goldenLegacyMetadataHex() { + const golden = JSON.parse( + fs.readFileSync( + path.join(__dirname, '..', '..', 'python', 'tests', 'fixtures', 'golden.json'), + 'utf8', + ), + ) + return golden.metadata.v14_hex } function ledgerProofVector() { @@ -110,9 +124,11 @@ function fakeSigningClient(runtime, callData) { } function fakeRuntimeCacheClient(options = {}) { - const metadataHex = `0x${goldenMetadataBytes().toString('hex')}` + const metadataV15Hex = options.metadataV15Hex ?? goldenMetadataResponseHex() + const legacyMetadataHex = options.legacyMetadataHex ?? goldenLegacyMetadataHex() const calls = { metadata: [], + metadataAtVersion: [], version: [], properties: [], } @@ -139,9 +155,14 @@ function fakeRuntimeCacheClient(options = {}) { transactionVersion: version.transactionVersion, } } + if (method === 'state_call' && params[0] === 'Metadata_metadata_at_version') { + calls.metadataAtVersion.push({ versionHex: params[1], blockHash: params[2] ?? null }) + if (options.metadataAtVersionError != null) throw options.metadataAtVersionError + return metadataV15Hex + } if (method === 'state_getMetadata') { calls.metadata.push(blockHash) - return metadataHex + return legacyMetadataHex } if (method === 'system_properties') { calls.properties.push(blockHash) @@ -1254,13 +1275,15 @@ test('Client expires head runtime metadata and invalidates it on runtime upgrade const cached = await client.runtimeAt() assert.equal(cached, first) assert.equal(calls.version.length, 1) - assert.equal(calls.metadata.length, 1) + assert.deepEqual(calls.metadataAtVersion, [{ versionHex: '0x0f000000', blockHash: null }]) + assert.equal(calls.metadata.length, 0) client.headRuntimeCache.expiresAtMs = 0 const sameVersion = await client.runtimeAt() assert.equal(sameVersion, first) assert.equal(calls.version.length, 2) - assert.equal(calls.metadata.length, 1) + assert.deepEqual(calls.metadataAtVersion, [{ versionHex: '0x0f000000', blockHash: null }]) + assert.equal(calls.metadata.length, 0) setHeadVersion(420, 2) client.headRuntimeCache.expiresAtMs = 0 @@ -1269,7 +1292,21 @@ test('Client expires head runtime metadata and invalidates it on runtime upgrade assert.equal(upgraded.specVersion, 420) assert.equal(upgraded.transactionVersion, 2) assert.equal(calls.version.length, 3) - assert.deepEqual(calls.metadata, [null, null]) + assert.deepEqual(calls.metadataAtVersion, [ + { versionHex: '0x0f000000', blockHash: null }, + { versionHex: '0x0f000000', blockHash: null }, + ]) + assert.equal(calls.metadata.length, 0) +}) + +test('Client falls back to legacy metadata when V15 metadata runtime API is unavailable', async () => { + const { client, calls } = fakeRuntimeCacheClient({ + metadataAtVersionError: new Error('Execution failed: Exported method Metadata_metadata_at_version is not found'), + }) + + await client.runtimeAt() + assert.deepEqual(calls.metadataAtVersion, [{ versionHex: '0x0f000000', blockHash: null }]) + assert.deepEqual(calls.metadata, [null]) }) test('Client caches historical runtimes by block hash with LRU eviction', async () => { @@ -1286,7 +1323,8 @@ test('Client caches historical runtimes by block hash with LRU eviction', async const runtimeA = await client.runtimeAt(blockA) assert.equal(await client.runtimeAt(blockA), runtimeA) assert.deepEqual(calls.version, [blockA]) - assert.deepEqual(calls.metadata, [blockA]) + assert.deepEqual(calls.metadataAtVersion, [{ versionHex: '0x0f000000', blockHash: blockA }]) + assert.equal(calls.metadata.length, 0) await client.runtimeAt(blockB) await client.runtimeAt(blockC) @@ -1296,7 +1334,12 @@ test('Client caches historical runtimes by block hash with LRU eviction', async assert.equal(await client.runtimeAt(blockA), runtimeA) assert.deepEqual(calls.version, [blockA, blockB, blockC, blockA]) - assert.deepEqual(calls.metadata, [blockA, blockB, blockC]) + assert.deepEqual(calls.metadataAtVersion, [ + { versionHex: '0x0f000000', blockHash: blockA }, + { versionHex: '0x0f000000', blockHash: blockB }, + { versionHex: '0x0f000000', blockHash: blockC }, + ]) + assert.equal(calls.metadata.length, 0) }) test('Client queryBatch decodes metadata defaults for missing storage values', async () => { From 0e9679b02a0f607dc50723df5a85d2ba23d7537b Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 10:34:10 -0700 Subject: [PATCH 45/72] fix rpc stuff --- sdk/bittensor-ts/src/client.ts | 875 +++++++++++++++++++++------ sdk/bittensor-ts/src/ledger.ts | 12 +- sdk/bittensor-ts/test/basic.test.cjs | 268 +++++++- 3 files changed, 961 insertions(+), 194 deletions(-) diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 60b0ec4796..1b59081ef3 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -1,7 +1,7 @@ -import { blake2_256, generateExtrinsicProof, metadataDigest } from './crypto' -import { CRYPTO_SR25519, Keypair, publicKeyFromSs58, ss58FromPublic } from './keys' +import { blake2_256, generateExtrinsicProof, hexToBytes, metadataDigest } from './crypto' +import { CRYPTO_ED25519, CRYPTO_SR25519, Keypair, publicKeyFromSs58, ss58FromPublic } from './keys' import { LedgerDevice } from './ledger' -import { Runtime, eraBirth } from './runtime' +import { Runtime, decodeCompactLength as decodeCompactLengthNative, encodeCompact, eraBirth } from './runtime' import { toBuffer } from './wire' import { Balance, @@ -24,6 +24,8 @@ export const DEFAULT_RETRY_BACKOFF_MS = 250 export const DEFAULT_MAX_RETRY_BACKOFF_MS = 5_000 export const DEFAULT_NONCE_RECONCILE_BLOCKS = 8 export const DEFAULT_ENDPOINT_VALIDATION_TTL_MS = 60_000 +export const DEFAULT_MAX_SUBSCRIPTION_QUEUE = 1024 +export const DEFAULT_MAX_WS_MESSAGE_BYTES = 16 * 1024 * 1024 const V15_METADATA_VERSION_HEX = '0x0f000000' const V15_METADATA_MISSING_NEEDLE = 'Exported method Metadata_metadata_at_version is not found' export const NETWORKS = Object.freeze({ @@ -39,6 +41,7 @@ export const NETWORK_GENESIS_HASHES = Object.freeze({ export type NetworkName = keyof typeof NETWORKS export type Descriptor = readonly [string, string] +export type ServeIp = bigint | number | string export type CallLike = | readonly [string, string, ScaleValue?] | { pallet?: string; module?: string; call?: string; function?: string; params?: ScaleValue } @@ -81,6 +84,8 @@ export interface JsonRpcTransportOptions { retryBackoffMs?: number maxRetryBackoffMs?: number endpointValidationTtlMs?: number + maxSubscriptionQueue?: number + maxMessageBytes?: number } export interface SubmitOptions { @@ -95,6 +100,13 @@ export interface SubmitOptions { signal?: AbortSignal } +export interface QueryMapOptions { + pageSize?: number + maxPages?: number + maxResults?: number + signal?: AbortSignal +} + export interface SignerAccountContext { client: Client runtime: Runtime @@ -138,10 +150,28 @@ export interface ExtensionSignRawRequest { chainInfo?: ChainInfo } +export interface ExtensionSignPayloadRequest { + address: string + blockHash: string + blockNumber: string + era: string + genesisHash: string + method: string + nonce: string + signedExtensions: string[] + specVersion: string + tip: string + transactionVersion: string + version: number + assetId?: string | null + metadataHash?: string + mode?: number +} + export type SignerSignature = | ByteLike | string - | { signature: ByteLike | string } + | { signature: ByteLike | string; signedTransaction?: unknown } export interface ChainSigner { readonly ss58Address?: string @@ -151,17 +181,21 @@ export interface ChainSigner { readonly requiresMetadataProof?: boolean readonly requiresMetadataHash?: boolean getAccount?(context: SignerAccountContext): SignerAccount | Promise - sign?( + signBytes?( payload: ByteLike, - context?: SignerPayloadContext, + context: SignerPayloadContext, ): SignerSignature | Promise - signPayload?( + sign?( payload: ByteLike, - context: SignerPayloadContext, + context?: SignerPayloadContext, ): SignerSignature | Promise signRaw?( request: ExtensionSignRawRequest, ): SignerSignature | Promise + signPayload?( + payload: ExtensionSignPayloadRequest, + context: SignerPayloadContext, + ): SignerSignature | Promise } export type SignerLike = Keypair | ChainSigner | LedgerDevice @@ -217,6 +251,7 @@ export interface DescriptorSchemaIssue { } interface RpcRequest { + generation: number resolve(value: unknown): void reject(error: Error): void cleanup(): void @@ -237,9 +272,25 @@ interface SubscriptionState { unsubscribeMethod: string requestOptions: RpcRequestOptions subscription?: string + subscriptionGeneration?: number + error?: Error resubscribing?: Promise } +interface EndpointAttempt { + endpoint: string + index: number + generation: number +} + +interface ActiveConnection extends EndpointAttempt { + socket: WebSocketLike +} + +interface ConnectingAttempt extends EndpointAttempt { + promise: Promise +} + interface ResolvedSigner { signer: Keypair | ChainSigner ss58Address: string @@ -375,17 +426,20 @@ export class JsonRpcTransport { private readonly retryBackoffMs: number private readonly maxRetryBackoffMs: number private readonly endpointValidationTtlMs: number + private readonly maxSubscriptionQueue: number + private readonly maxMessageBytes: number private readonly webSocketConstructor?: WebSocketConstructor private readonly webSocketFactory?: WebSocketFactory private readonly validateEndpoint?: EndpointValidator private readonly validatedEndpoints = new Map() private endpointIndex = 0 + private generation = 0 private id = 1 - private socket?: WebSocketLike - private connecting?: Promise + private socket?: ActiveConnection + private connecting?: ConnectingAttempt private pending = new Map() private subscriptions = new Set() - private subscriptionsById = new Map() + private subscriptionsById = new Map() private closed = false constructor( @@ -404,6 +458,11 @@ export class JsonRpcTransport { options.endpointValidationTtlMs, DEFAULT_ENDPOINT_VALIDATION_TTL_MS, ) + this.maxSubscriptionQueue = nonNegativeInteger( + options.maxSubscriptionQueue, + DEFAULT_MAX_SUBSCRIPTION_QUEUE, + ) + this.maxMessageBytes = nonNegativeInteger(options.maxMessageBytes, DEFAULT_MAX_WS_MESSAGE_BYTES) this.webSocketConstructor = options.webSocketConstructor ?? options.webSocket this.webSocketFactory = options.webSocketFactory this.validateEndpoint = options.validateEndpoint @@ -414,6 +473,7 @@ export class JsonRpcTransport { } async request(method: string, params: unknown[] = [], options: RpcRequestOptions = {}): Promise { + if (this.closed) throw new ChainError('transport closed') const requestOptions = { ...options, timeoutMs: options.timeoutMs ?? this.requestTimeoutMs, @@ -424,41 +484,51 @@ export class JsonRpcTransport { const retryBackoffMs = requestOptions.retryBackoffMs ?? this.retryBackoffMs const maxRetryBackoffMs = requestOptions.maxRetryBackoffMs ?? this.maxRetryBackoffMs let attempt = 0 + const attemptedEndpoints = new Set() for (;;) { + const endpointAttempt = this.currentAttempt() + attemptedEndpoints.add(endpointAttempt.endpoint) try { - await this.ensureEndpointValidated(requestOptions) - return this.isHttpEndpoint() - ? await this.httpRequest(method, params, requestOptions) - : await this.wsRequest(method, params, requestOptions) + await this.ensureEndpointValidated(endpointAttempt, requestOptions) + return this.isHttpEndpoint(endpointAttempt.endpoint) + ? await this.httpRequest(endpointAttempt, method, params, requestOptions) + : await this.wsRequest(endpointAttempt, method, params, requestOptions) } catch (error) { - if (error instanceof JsonRpcError || error instanceof RequestAbortedError || error instanceof EndpointValidationError) throw error + if (error instanceof RequestAbortedError || error instanceof JsonRpcError) throw error + if (error instanceof EndpointValidationError) { + this.validatedEndpoints.delete(endpointAttempt.endpoint) + if (attemptedEndpoints.size >= this.endpoints.length || !this.rotateEndpoint(endpointAttempt)) throw error + continue + } if (!retryForever && attempt >= maxRetries) throw error attempt += 1 - this.rotateEndpoint() + this.rotateEndpoint(endpointAttempt) const capped = Math.min(retryBackoffMs * (2 ** Math.max(0, attempt - 1)), maxRetryBackoffMs) await delay(capped, requestOptions.signal) } } } - private async ensureEndpointValidated(options: RpcRequestOptions): Promise { + private currentAttempt(): EndpointAttempt { + return { + endpoint: this.endpoint, + index: this.endpointIndex, + generation: this.generation, + } + } + + private async ensureEndpointValidated(attempt: EndpointAttempt, options: RpcRequestOptions): Promise { if (this.validateEndpoint == null) return - const endpoint = this.endpoint - const validUntil = this.validatedEndpoints.get(endpoint) + const validUntil = this.validatedEndpoints.get(attempt.endpoint) const now = Date.now() if (validUntil != null && validUntil > now) return - try { - await this.validateEndpoint(endpoint, (method, params = []) => - this.isHttpEndpoint() - ? this.httpRequest(method, params, options) - : this.wsRequest(method, params, options), - ) - } catch (error) { - if (error instanceof EndpointValidationError) throw error - throw error - } + await this.validateEndpoint(attempt.endpoint, (method, params = []) => + this.isHttpEndpoint(attempt.endpoint) + ? this.httpRequest(attempt, method, params, options) + : this.wsRequest(attempt, method, params, options), + ) if (this.endpointValidationTtlMs > 0) { - this.validatedEndpoints.set(endpoint, Date.now() + this.endpointValidationTtlMs) + this.validatedEndpoints.set(attempt.endpoint, Date.now() + this.endpointValidationTtlMs) } } @@ -468,7 +538,7 @@ export class JsonRpcTransport { unsubscribeMethod: string, options: SubscriptionOptions = {}, ): Promise & { unsubscribe(): Promise }> { - if (this.isHttpEndpoint()) throw new ChainError('subscriptions require a WebSocket endpoint') + if (this.isHttpEndpoint(this.endpoint)) throw new ChainError('subscriptions require a WebSocket endpoint') const state: SubscriptionState = { queue: [], waiters: [], @@ -517,27 +587,33 @@ export class JsonRpcTransport { close(): void { this.closed = true - this.socket?.close() + this.generation += 1 + this.socket?.socket.close() this.socket = undefined this.connecting = undefined this.failPending(new ChainError('transport closed')) for (const state of [...this.subscriptions]) this.closeSubscription(state) } - private isHttpEndpoint(): boolean { - return this.endpoint.startsWith('http://') || this.endpoint.startsWith('https://') + private isHttpEndpoint(endpoint: string): boolean { + return endpoint.startsWith('http://') || endpoint.startsWith('https://') } - private async httpRequest(method: string, params: unknown[], options: RpcRequestOptions): Promise { + private async httpRequest( + attempt: EndpointAttempt, + method: string, + params: unknown[], + options: RpcRequestOptions, + ): Promise { const request = withRequestSignal(options) try { - const response = await fetch(this.endpoint, { + const response = await fetch(attempt.endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: this.id++, method, params }), signal: request.signal, }) - if (!response.ok) throw new JsonRpcError(`HTTP ${response.status} from ${this.endpoint}`) + if (!response.ok) throw new JsonRpcError(`HTTP ${response.status} from ${attempt.endpoint}`) const payload = await response.json() if (payload.error) throw new JsonRpcError(payload.error.message, payload.error.code, payload.error.data) return payload.result @@ -548,8 +624,13 @@ export class JsonRpcTransport { } } - private async wsRequest(method: string, params: unknown[], options: RpcRequestOptions): Promise { - const socket = await this.connect(options) + private async wsRequest( + attempt: EndpointAttempt, + method: string, + params: unknown[], + options: RpcRequestOptions, + ): Promise { + const connection = await this.connect(attempt, options) const id = this.id++ const promise = new Promise((resolve, reject) => { const request = withRequestSignal(options) @@ -560,6 +641,7 @@ export class JsonRpcTransport { reject(error) } this.pending.set(id, { + generation: connection.generation, resolve(value) { cleanup() resolve(value) @@ -573,7 +655,7 @@ export class JsonRpcTransport { request.onAbort((error) => fail(error)) }) try { - socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })) + connection.socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })) } catch (error) { const pending = this.pending.get(id) this.pending.delete(id) @@ -583,19 +665,34 @@ export class JsonRpcTransport { return promise } - private async connect(options: RpcRequestOptions = {}): Promise { + private async connect(attempt: EndpointAttempt, options: RpcRequestOptions = {}): Promise { if (this.closed) throw new ChainError('transport closed') - if (this.socket?.readyState === 1) return this.socket - if (this.connecting != null) return this.connecting + if ( + this.socket?.generation === attempt.generation && + this.socket.endpoint === attempt.endpoint && + this.socket.socket.readyState === 1 + ) return this.socket + if ( + this.connecting?.generation === attempt.generation && + this.connecting.endpoint === attempt.endpoint + ) return this.connecting.promise + if (this.generation !== attempt.generation || this.endpoint !== attempt.endpoint) { + throw new ChainError(`stale endpoint attempt for ${attempt.endpoint}`) + } - this.connecting = new Promise((resolve, reject) => { + const connecting: ConnectingAttempt = { + ...attempt, + promise: undefined as unknown as Promise, + } + this.connecting = connecting + connecting.promise = new Promise((resolve, reject) => { const request = withRequestSignal(options) let socket: WebSocketLike try { - socket = this.createWebSocket(this.endpoint) + socket = this.createWebSocket(attempt.endpoint) } catch (error) { request.cleanup() - this.connecting = undefined + if (this.connecting === connecting) this.connecting = undefined reject(error) return } @@ -605,8 +702,10 @@ export class JsonRpcTransport { if (settled) return settled = true cleanup() - this.socket = undefined - this.connecting = undefined + if (this.generation === attempt.generation) { + this.socket = undefined + if (this.connecting === connecting) this.connecting = undefined + } try { socket.close() } catch { @@ -619,15 +718,30 @@ export class JsonRpcTransport { if (settled) return settled = true cleanup() - this.socket = socket - this.connecting = undefined - resolve(socket) + if (this.generation !== attempt.generation || this.endpoint !== attempt.endpoint) { + if (this.connecting === connecting) this.connecting = undefined + try { + socket.close() + } catch { + // Ignore close errors while discarding a stale connection. + } + reject(new ChainError(`stale connection opened for ${attempt.endpoint}`)) + return + } + const connection = { ...attempt, socket } + this.socket = connection + if (this.connecting === connecting) this.connecting = undefined + resolve(connection) + }) + socket.addEventListener('error', () => fail(new ChainError(`could not connect to ${attempt.endpoint}`))) + socket.addEventListener('close', () => { + const error = new ChainError(`connection closed: ${attempt.endpoint}`) + if (!settled) fail(error) + else this.handleSocketClose({ ...attempt, socket }, error) }) - socket.addEventListener('error', () => fail(new ChainError(`could not connect to ${this.endpoint}`))) - socket.addEventListener('close', () => this.handleSocketClose(new ChainError(`connection closed: ${this.endpoint}`))) - socket.addEventListener('message', (event) => this.handleMessage(event.data)) + socket.addEventListener('message', (event) => this.handleMessage({ ...attempt, socket }, event.data)) }) - return this.connecting + return connecting.promise } private createWebSocket(url: string): WebSocketLike { @@ -643,51 +757,94 @@ export class JsonRpcTransport { return new WebSocketImpl(url) } - private handleMessage(data: unknown): void { - const message = JSON.parse(String(data)) - if (typeof message.id === 'number') { - const pending = this.pending.get(message.id) - if (pending == null) return - this.pending.delete(message.id) - if (message.error) pending.reject(new JsonRpcError(message.error.message, message.error.code, message.error.data)) - else pending.resolve(message.result) + private handleMessage(connection: ActiveConnection, data: unknown): void { + if (!this.isCurrentConnection(connection)) return + const raw = typeof data === 'string' + ? data + : Buffer.isBuffer(data) || data instanceof Uint8Array + ? Buffer.from(data).toString('utf8') + : String(data) + if (Buffer.byteLength(raw, 'utf8') > this.maxMessageBytes) { + this.handleSocketClose(connection, new ChainError('JSON-RPC message exceeded size limit')) + return + } + let message: unknown + try { + message = JSON.parse(raw) + } catch { + this.handleSocketClose(connection, new ChainError('invalid JSON-RPC message')) return } - const subscription = message.params?.subscription + if (typeof message !== 'object' || message == null) return + const rpcMessage = message as { + id?: unknown + error?: { message?: string; code?: number; data?: unknown } + result?: unknown + params?: { subscription?: unknown; result?: unknown } + } + if (typeof rpcMessage.id === 'number') { + const pending = this.pending.get(rpcMessage.id) + if (pending == null || pending.generation !== connection.generation) return + this.pending.delete(rpcMessage.id) + if (rpcMessage.error) pending.reject(new JsonRpcError(String(rpcMessage.error.message ?? 'JSON-RPC error'), rpcMessage.error.code, rpcMessage.error.data)) + else pending.resolve(rpcMessage.result) + return + } + const subscription = rpcMessage.params?.subscription if (subscription == null) return - const state = this.subscriptionsById.get(subscription) - if (state == null || state.closed) return - const result = message.params?.result + const entry = this.subscriptionsById.get(String(subscription)) + if (entry == null || entry.generation !== connection.generation || entry.state.closed) return + const state = entry.state + const result = rpcMessage.params?.result const waiter = state.waiters.shift() if (waiter != null) waiter.resolve({ done: false, value: result }) - else state.queue.push(result) + else if (state.queue.length >= this.maxSubscriptionQueue) { + this.closeSubscription(state, new ChainError('subscription notification queue exceeded limit')) + } else { + state.queue.push(result) + } } private subscriptionNext(state: SubscriptionState): Promise> { if (state.queue.length > 0) return Promise.resolve({ done: false, value: state.queue.shift() }) - if (state.closed) return Promise.resolve({ done: true, value: undefined }) + if (state.closed) { + return state.error == null + ? Promise.resolve({ done: true, value: undefined }) + : Promise.reject(state.error) + } return new Promise((resolve, reject) => { state.waiters.push({ resolve, reject }) }) } - private failPending(error: Error): void { - this.socket = undefined - this.connecting = undefined - for (const pending of this.pending.values()) { + private failPending(error: Error, generation?: number): void { + if (generation == null || this.socket?.generation === generation) this.socket = undefined + if (generation == null || this.connecting?.generation === generation) this.connecting = undefined + for (const [id, pending] of this.pending) { + if (generation != null && pending.generation !== generation) continue + this.pending.delete(id) pending.cleanup() pending.reject(error) } - this.pending.clear() } - private handleSocketClose(error: Error): void { - this.validatedEndpoints.delete(this.endpoint) - this.failPending(error) - this.subscriptionsById.clear() + private handleSocketClose(connection: ActiveConnection, error: Error): void { + if (!this.isCurrentConnection(connection)) return + this.validatedEndpoints.delete(connection.endpoint) + this.failPending(error, connection.generation) + try { + connection.socket.close() + } catch { + // Ignore close errors while tearing down a failed connection. + } + for (const [subscription, entry] of this.subscriptionsById) { + if (entry.generation === connection.generation) this.subscriptionsById.delete(subscription) + } if (this.closed) return for (const state of this.subscriptions) { + if (state.subscriptionGeneration !== connection.generation) continue state.subscription = undefined + state.subscriptionGeneration = undefined if (state.resubscribe) void this.resubscribe(state) else this.closeSubscription(state, error) } @@ -702,7 +859,8 @@ export class JsonRpcTransport { return } state.subscription = subscription - this.subscriptionsById.set(subscription, state) + state.subscriptionGeneration = this.generation + this.subscriptionsById.set(subscription, { state, generation: this.generation }) } private resubscribe(state: SubscriptionState): Promise { @@ -720,26 +878,41 @@ export class JsonRpcTransport { private closeSubscription(state: SubscriptionState, error?: Error): void { if (state.closed) return state.closed = true + state.error = error this.subscriptions.delete(state) if (state.subscription != null) this.subscriptionsById.delete(state.subscription) state.subscription = undefined + state.subscriptionGeneration = undefined for (const waiter of state.waiters.splice(0)) { if (error == null) waiter.resolve({ done: true, value: undefined }) else waiter.reject(error) } } - private rotateEndpoint(): void { - this.validatedEndpoints.delete(this.endpoint) + private rotateEndpoint(attempt: EndpointAttempt = this.currentAttempt()): boolean { + if ( + attempt.generation !== this.generation || + attempt.index !== this.endpointIndex || + attempt.endpoint !== this.endpoint + ) return true + this.validatedEndpoints.delete(attempt.endpoint) const socket = this.socket + this.generation += 1 + if (this.endpoints.length > 1) this.endpointIndex = (this.endpointIndex + 1) % this.endpoints.length this.socket = undefined this.connecting = undefined try { - socket?.close() + socket?.socket.close() } catch { // Ignore close errors while rotating to another endpoint. } - if (this.endpoints.length > 1) this.endpointIndex = (this.endpointIndex + 1) % this.endpoints.length + return this.endpointIndex !== attempt.index || this.endpoints.length > 1 + } + + private isCurrentConnection(connection: ActiveConnection): boolean { + return this.socket?.socket === connection.socket && + this.socket.generation === connection.generation && + this.socket.endpoint === connection.endpoint } } @@ -1054,25 +1227,40 @@ export class Client { storageFunction?: string | ScaleValue[], paramsOrBlock?: ScaleValue[] | number | string | null, block?: number | string | null, - pageSize = 512, + pageSizeOrOptions: number | QueryMapOptions = 512, ): Promise> { const [moduleName, itemName, itemParams, blockRef] = normalizeStorageArgs(pallet, storageFunction, paramsOrBlock, block) - const blockHash = await this.resolveBlockHash(blockRef) + const blockHash = await this.resolveReadBlockHash(blockRef) const runtime = await this.runtimeAt(blockHash) const prefix = runtime.storageKey(moduleName, itemName, itemParams) const entry = runtime.storageEntry(moduleName, itemName) + const options = normalizeQueryMapOptions(pageSizeOrOptions) const out: Array<[K, V]> = [] let startKey: string | null = null + let pages = 0 + const seenPageStarts = new Set() for (;;) { - const keys = (await this.rpc('state_getKeysPaged', [ + throwIfAborted(options.signal) + if (options.maxPages != null && pages >= options.maxPages) { + throw new ChainError(`queryMap exceeded maxPages ${options.maxPages}`) + } + const pageStart = startKey ?? '' + if (seenPageStarts.has(pageStart)) throw new ChainError('queryMap pagination did not advance') + seenPageStarts.add(pageStart) + const keys = (await this.transport.request('state_getKeysPaged', [ hex(prefix), - pageSize, + options.pageSize, startKey, ...(blockHash == null ? [] : [blockHash]), - ])) as string[] + ], { signal: options.signal })) as string[] if (keys.length === 0) break - const raw = await this.rpc('state_queryStorageAt', [keys, ...(blockHash == null ? [] : [blockHash])]) + pages += 1 + const lastKey = keys[keys.length - 1] + if (lastKey == null || (startKey != null && lastKey.toLowerCase() === startKey.toLowerCase())) { + throw new ChainError('queryMap pagination did not advance') + } + const raw = await this.transport.request('state_queryStorageAt', [keys, ...(blockHash == null ? [] : [blockHash])], { signal: options.signal }) const changes = ((raw as Array<{ changes?: Array<[string, string | null]> }>)[0]?.changes ?? []) const valueByKey = new Map(changes.map(([key, value]) => [key.toLowerCase(), value])) for (const key of keys) { @@ -1081,8 +1269,9 @@ export class Client { const decodedKey = runtime.decodeStorageKeyParams(moduleName, itemName, hexToBuffer(key), itemParams.length) const normalizedKey = (decodedKey.length === 1 ? decodedKey[0] : decodedKey) as K out.push([normalizedKey, runtime.decode(entry.valueType, hexToBuffer(value), false)]) + if (options.maxResults != null && out.length >= options.maxResults) return out } - startKey = keys[keys.length - 1] + startKey = lastKey } return out } @@ -1336,15 +1525,19 @@ export class Client { const { era, eraBlockHash } = await this.normalizeEra(period, snapshot) const tip = taoTransactionAmountRao(options.tip ?? 0n, 'tip') const tipAssetId = options.tipAssetId == null ? null : assetIdValue(options.tipAssetId, 'tipAssetId') - const chainInfo = resolved.requiresMetadataProof + const metadataHashOptionProvided = hasOwn(options, 'metadataHash') + const defaultMetadataHash = runtimeSupportsMetadataHash(runtime) || resolved.requiresMetadataProof + const chainInfo = resolved.requiresMetadataProof || (!metadataHashOptionProvided && defaultMetadataHash) ? await this.chainInfo(runtime, snapshot.blockHash) : undefined const metadataHash = - options.metadataHash == null - ? resolved.requiresMetadataProof + metadataHashOptionProvided + ? options.metadataHash == null + ? null + : toBuffer(options.metadataHash, 'metadataHash') + : defaultMetadataHash ? metadataDigest(runtime.metadataBytes, chainInfo!) : null - : toBuffer(options.metadataHash, 'metadataHash') const txParams = { era, nonce, @@ -1430,21 +1623,28 @@ export class Client { }) return await watcher.result } + let hash: string try { - const hash = String(await this.transport.request('author_submitExtrinsic', [hex(bytes)], { + hash = String(await this.transport.request('author_submitExtrinsic', [hex(bytes)], { timeoutMs: options.timeoutMs, signal: options.signal, maxRetries: 0, retryForever: false, })) - if (reservation != null) await this.submitNonce(reservation) - else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) - return { status: 'submitted', message: 'Submitted', extrinsicHash: hash, events: [] } } catch (error) { if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) throw error } + const returnedHash = normalizeHash32(hash, 'author_submitExtrinsic hash') + if (reservation != null) await this.submitNonce(reservation) + else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) + if (!sameHex(returnedHash, extrinsicHash)) { + throw new ChainError( + `author_submitExtrinsic returned hash ${returnedHash}, expected ${extrinsicHash}`, + ) + } + return { status: 'submitted', message: 'Submitted', extrinsicHash, events: [] } } async watchSigned( @@ -1506,8 +1706,7 @@ export class Client { } async peekNextIndex(address: string): Promise { - const nonce = Number(await this.rpc('system_accountNextIndex', [address])) - return nonce + return parseNonce(await this.rpc('system_accountNextIndex', [address]), 'system_accountNextIndex') } async accountNextIndex(address: string, _useCache = false): Promise { @@ -1733,15 +1932,22 @@ export class Client { nonce: await this.peekNextIndex(account.ss58Address), period: null, }, false, snapshot) - const length = Buffer.alloc(4) - length.writeUInt32LE(signed.bytes.length, 0) + const queryInfo = runtime.runtimeApis().TransactionPaymentApi?.query_info + const inputDetails = queryInfo?.inputDetails ?? [] + if (queryInfo == null || inputDetails.length !== 2) { + throw new ChainError('TransactionPaymentApi.query_info metadata is unavailable') + } + const encodedInput = Buffer.concat([ + runtime.encodeId(inputDetails[0].typeId, signed.bytes), + runtime.encodeId(inputDetails[1].typeId, signed.bytes.length), + ]) const raw = await this.rpc('state_call', [ 'TransactionPaymentApi_query_info', - hex(Buffer.concat([signed.bytes, length])), + hex(encodedInput), snapshot.blockHash, ]) const info = runtime.decodeTypeId>( - runtime.runtimeApis().TransactionPaymentApi.query_info.outputTypeId, + queryInfo.outputTypeId, hexToBuffer(String(raw)), false, ) @@ -1813,7 +2019,7 @@ export class Client { serveAxon( signer: SignerLike, - args: { netuid: number; ip: number; port: number; version?: number; ipType?: number; protocol?: number }, + args: { netuid: number; ip: ServeIp; port: number; version?: number; ipType?: number; protocol?: number }, options: SubmitOptions = {}, ): Promise { return this.submit(calls.subtensor.serveAxon(args.netuid, args.ip, args.port, args.version, args.ipType, args.protocol), signer, { @@ -1824,7 +2030,7 @@ export class Client { serve_axon( signer: SignerLike, - args: { netuid: number; ip: number; port: number; version?: number; ipType?: number; protocol?: number }, + args: { netuid: number; ip: ServeIp; port: number; version?: number; ipType?: number; protocol?: number }, options: SubmitOptions = {}, ): Promise { return this.serveAxon(signer, args, options) @@ -1924,6 +2130,11 @@ export class Client { return this.blockHash(block) } + async resolveReadBlockHash(block?: number | string | null): Promise { + const blockHash = await this.resolveBlockHash(block) + return blockHash ?? await this.finalizedHead() + } + private async resolveSigner(signer: SignerLike, runtime: Runtime): Promise { const normalizedSigner: Keypair | ChainSigner = signer instanceof LedgerDevice ? signer.signer() : signer @@ -1939,7 +2150,14 @@ export class Client { throw new ChainError('signer must expose an address, ss58Address, or getAccount()') } const publicKey = signerPublicKey(account?.publicKey ?? signerShape.publicKey, ss58Address) + const addressPublicKey = publicKeyFromSs58(ss58Address) + if (!publicKey.equals(addressPublicKey)) { + throw new ChainError('signer publicKey does not match signer address') + } const cryptoType = account?.cryptoType ?? signerShape.cryptoType ?? CRYPTO_SR25519 + if (cryptoType !== CRYPTO_ED25519 && cryptoType !== CRYPTO_SR25519) { + throw new ChainError(`unsupported signer cryptoType ${cryptoType}`) + } return { signer: normalizedSigner, ss58Address, @@ -1960,9 +2178,9 @@ export class Client { context: SignerPayloadContext, ): Promise { const signerShape = signer as ChainSigner - if (signerShape.signPayload != null) { + if (signerShape.signBytes != null) { return normalizeSignature( - await signerShape.signPayload(payload, context), + await signerShape.signBytes(payload, context), context.cryptoType, ) } @@ -1984,7 +2202,13 @@ export class Client { if (signerShape.sign != null) { return normalizeSignature(await signerShape.sign(payload, context), context.cryptoType) } - throw new ChainError('signer must implement sign(), signPayload(), or signRaw()') + if (signerShape.signPayload != null) { + return normalizeSignature( + await signerShape.signPayload(extensionSignPayload(context), context), + context.cryptoType, + ) + } + throw new ChainError('signer must implement signBytes(), signRaw(), sign(), or extension-style signPayload()') } private async normalizeEra( @@ -2052,6 +2276,9 @@ export class Client { const failed = triggered.find((event) => eventName(event) === 'System.ExtrinsicFailed') const success = triggered.some((event) => eventName(event) === 'System.ExtrinsicSuccess') const feeEvent = triggered.find((event) => eventName(event) === 'TransactionPayment.TransactionFeePaid') + const failureMessage = failed == null + ? undefined + : dispatchFailureMessage(await this.runtimeAt(blockHash), failed) if (!success && failed == null) { return { status: 'unknown', @@ -2071,7 +2298,7 @@ export class Client { return { status: dispatchSuccess ? finalized ? 'finalized' : 'inBlock' : 'failed', success: dispatchSuccess, - message: failed == null ? 'Success' : 'Extrinsic failed', + message: failed == null ? 'Success' : failureMessage ?? 'Extrinsic failed', extrinsicHash, blockHash, blockNumber, @@ -2172,10 +2399,11 @@ export class SubnetsNamespace { constructor(private readonly client: Client) {} async subnet(netuid: number, block?: number | string | null): Promise { + const blockHash = await this.client.resolveReadBlockHash(block) const [tempo, burn, count] = await Promise.all([ - this.client.query(storage.SubtensorModule.Tempo, [netuid], block), - this.client.query(storage.SubtensorModule.Burn, [netuid], block), - this.client.query(storage.SubtensorModule.SubnetworkN, [netuid], block), + this.client.query(storage.SubtensorModule.Tempo, [netuid], blockHash), + this.client.query(storage.SubtensorModule.Burn, [netuid], blockHash), + this.client.query(storage.SubtensorModule.SubnetworkN, [netuid], blockHash), ]) return { netuid, tempo: Number(tempo ?? 0), burn: Balance.fromRao(String(burn ?? 0)), neuronCount: Number(count ?? 0) } } @@ -2185,11 +2413,12 @@ export class SubnetsNamespace { } async all(block?: number | string | null): Promise { + const blockHash = await this.client.resolveReadBlockHash(block) const [added, tempos, burns, counts] = await Promise.all([ - this.client.queryMap(storage.SubtensorModule.NetworksAdded, [], block), - this.client.queryMap(storage.SubtensorModule.Tempo, [], block), - this.client.queryMap(storage.SubtensorModule.Burn, [], block), - this.client.queryMap(storage.SubtensorModule.SubnetworkN, [], block), + this.client.queryMap(storage.SubtensorModule.NetworksAdded, [], blockHash), + this.client.queryMap(storage.SubtensorModule.Tempo, [], blockHash), + this.client.queryMap(storage.SubtensorModule.Burn, [], blockHash), + this.client.queryMap(storage.SubtensorModule.SubnetworkN, [], blockHash), ]) const tempoByNetuid = new Map(tempos.map(([key, value]) => [Number(key), Number(value)])) const burnByNetuid = new Map(burns.map(([key, value]) => [Number(key), String(value)])) @@ -2480,17 +2709,17 @@ export const calls = Object.freeze({ destination_hotkey: destinationHotkey, origin_netuid: originNetuid, destination_netuid: destinationNetuid, - alpha_amount: transactionAmountRao(amount), + alpha_amount: alphaTransactionAmountForNetuid(amount, originNetuid, 'alpha amount'), }) }, register(netuid: number, blockNumber: bigint | number | string, nonce: bigint | number | string, work: ByteLike, hotkey: string, coldkey: string) { - return call('SubtensorModule', 'register', { netuid, block_number: BigInt(blockNumber), nonce: BigInt(nonce), work, hotkey, coldkey }) + return call('SubtensorModule', 'register', { netuid, _block_number: BigInt(blockNumber), _nonce: BigInt(nonce), _work: work, hotkey, _coldkey: coldkey }) }, registerNetwork(hotkey: string) { return call('SubtensorModule', 'register_network', { hotkey }) }, removeStake(hotkey: string, netuid: number, amount: TransactionAmount) { - return call('SubtensorModule', 'remove_stake', { hotkey, netuid, amount_unstaked: transactionAmountRao(amount) }) + return call('SubtensorModule', 'remove_stake', { hotkey, netuid, amount_unstaked: alphaTransactionAmountForNetuid(amount, netuid, 'unstake amount') }) }, revealWeights(netuid: number, uids: number[], values: number[], salt: number[], versionKey: bigint | number | string) { return call('SubtensorModule', 'reveal_weights', { netuid, uids, values, salt, version_key: BigInt(versionKey) }) @@ -2498,11 +2727,13 @@ export const calls = Object.freeze({ rootRegister(hotkey: string) { return call('SubtensorModule', 'root_register', { hotkey }) }, - serveAxon(netuid: number, ip: number, port: number, version = 0, ipType = 4, protocol = 4) { - return call('SubtensorModule', 'serve_axon', { netuid, version, ip, port, ip_type: ipType, protocol, placeholder1: 0, placeholder2: 0 }) + serveAxon(netuid: number, ip: ServeIp, port: number, version = 0, ipType?: number, protocol = 4) { + const normalizedIp = normalizeServeIp(ip, ipType) + return call('SubtensorModule', 'serve_axon', { netuid, version, ip: normalizedIp.value, port, ip_type: normalizedIp.type, protocol, placeholder1: 0, placeholder2: 0 }) }, - servePrometheus(netuid: number, ip: number, port: number, version = 0, ipType = 4) { - return call('SubtensorModule', 'serve_prometheus', { netuid, version, ip, port, ip_type: ipType }) + servePrometheus(netuid: number, ip: ServeIp, port: number, version = 0, ipType?: number) { + const normalizedIp = normalizeServeIp(ip, ipType) + return call('SubtensorModule', 'serve_prometheus', { netuid, version, ip: normalizedIp.value, port, ip_type: normalizedIp.type }) }, setChildren(hotkey: string, netuid: number, children: ScaleValue) { return call('SubtensorModule', 'set_children', { hotkey, netuid, children }) @@ -2519,7 +2750,7 @@ export const calls = Object.freeze({ hotkey, origin_netuid: originNetuid, destination_netuid: destinationNetuid, - alpha_amount: transactionAmountRao(amount), + alpha_amount: alphaTransactionAmountForNetuid(amount, originNetuid, 'alpha amount'), }) }, unstakeAll(hotkey: string) { @@ -2550,17 +2781,17 @@ export const calls = Object.freeze({ destination_hotkey: destinationHotkey, origin_netuid: originNetuid, destination_netuid: destinationNetuid, - alpha_amount: transactionAmountRao(alphaAmount), + alpha_amount: alphaTransactionAmountForNetuid(alphaAmount, originNetuid, 'alpha amount'), }) }, register(netuid: number, blockNumber: bigint | number | string, nonce: bigint | number | string, work: ByteLike, hotkey: string, coldkey: string) { - return call('SubtensorModule', 'register', { netuid, block_number: BigInt(blockNumber), nonce: BigInt(nonce), work, hotkey, coldkey }) + return call('SubtensorModule', 'register', { netuid, _block_number: BigInt(blockNumber), _nonce: BigInt(nonce), _work: work, hotkey, _coldkey: coldkey }) }, register_network(hotkey: string) { return call('SubtensorModule', 'register_network', { hotkey }) }, remove_stake(hotkey: string, netuid: number, amountUnstaked: TransactionAmount) { - return call('SubtensorModule', 'remove_stake', { hotkey, netuid, amount_unstaked: transactionAmountRao(amountUnstaked) }) + return call('SubtensorModule', 'remove_stake', { hotkey, netuid, amount_unstaked: alphaTransactionAmountForNetuid(amountUnstaked, netuid, 'unstake amount') }) }, reveal_weights(netuid: number, uids: number[], values: number[], salt: number[], versionKey: bigint | number | string) { return call('SubtensorModule', 'reveal_weights', { netuid, uids, values, salt, version_key: BigInt(versionKey) }) @@ -2568,11 +2799,13 @@ export const calls = Object.freeze({ root_register(hotkey: string) { return call('SubtensorModule', 'root_register', { hotkey }) }, - serve_axon(netuid: number, ip: number, port: number, version = 0, ipType = 4, protocol = 4) { - return call('SubtensorModule', 'serve_axon', { netuid, version, ip, port, ip_type: ipType, protocol, placeholder1: 0, placeholder2: 0 }) + serve_axon(netuid: number, ip: ServeIp, port: number, version = 0, ipType?: number, protocol = 4) { + const normalizedIp = normalizeServeIp(ip, ipType) + return call('SubtensorModule', 'serve_axon', { netuid, version, ip: normalizedIp.value, port, ip_type: normalizedIp.type, protocol, placeholder1: 0, placeholder2: 0 }) }, - serve_prometheus(netuid: number, ip: number, port: number, version = 0, ipType = 4) { - return call('SubtensorModule', 'serve_prometheus', { netuid, version, ip, port, ip_type: ipType }) + serve_prometheus(netuid: number, ip: ServeIp, port: number, version = 0, ipType?: number) { + const normalizedIp = normalizeServeIp(ip, ipType) + return call('SubtensorModule', 'serve_prometheus', { netuid, version, ip: normalizedIp.value, port, ip_type: normalizedIp.type }) }, set_children(hotkey: string, netuid: number, children: ScaleValue) { return call('SubtensorModule', 'set_children', { hotkey, netuid, children }) @@ -2589,7 +2822,7 @@ export const calls = Object.freeze({ hotkey, origin_netuid: originNetuid, destination_netuid: destinationNetuid, - alpha_amount: transactionAmountRao(alphaAmount), + alpha_amount: alphaTransactionAmountForNetuid(alphaAmount, originNetuid, 'alpha amount'), }) }, unstake_all(hotkey: string) { @@ -2598,25 +2831,31 @@ export const calls = Object.freeze({ }), }) -const CALL_DESCRIPTOR_ENTRIES: Array<{ path: string; descriptor: Descriptor }> = [ - { path: 'calls.balances.transferKeepAlive', descriptor: descriptor('Balances', 'transfer_keep_alive') }, - { path: 'calls.balances.transferAllowDeath', descriptor: descriptor('Balances', 'transfer_allow_death') }, - { path: 'calls.subtensor.addStake', descriptor: descriptor('SubtensorModule', 'add_stake') }, - { path: 'calls.subtensor.burnedRegister', descriptor: descriptor('SubtensorModule', 'burned_register') }, - { path: 'calls.subtensor.commitWeights', descriptor: descriptor('SubtensorModule', 'commit_weights') }, - { path: 'calls.subtensor.moveStake', descriptor: descriptor('SubtensorModule', 'move_stake') }, - { path: 'calls.subtensor.register', descriptor: descriptor('SubtensorModule', 'register') }, - { path: 'calls.subtensor.registerNetwork', descriptor: descriptor('SubtensorModule', 'register_network') }, - { path: 'calls.subtensor.removeStake', descriptor: descriptor('SubtensorModule', 'remove_stake') }, - { path: 'calls.subtensor.revealWeights', descriptor: descriptor('SubtensorModule', 'reveal_weights') }, - { path: 'calls.subtensor.rootRegister', descriptor: descriptor('SubtensorModule', 'root_register') }, - { path: 'calls.subtensor.serveAxon', descriptor: descriptor('SubtensorModule', 'serve_axon') }, - { path: 'calls.subtensor.servePrometheus', descriptor: descriptor('SubtensorModule', 'serve_prometheus') }, - { path: 'calls.subtensor.setChildren', descriptor: descriptor('SubtensorModule', 'set_children') }, - { path: 'calls.subtensor.setWeights', descriptor: descriptor('SubtensorModule', 'set_weights') }, - { path: 'calls.subtensor.startCall', descriptor: descriptor('SubtensorModule', 'start_call') }, - { path: 'calls.subtensor.transferStake', descriptor: descriptor('SubtensorModule', 'transfer_stake') }, - { path: 'calls.subtensor.unstakeAll', descriptor: descriptor('SubtensorModule', 'unstake_all') }, +interface CallDescriptorEntry { + path: string + descriptor: Descriptor + args: readonly string[] +} + +const CALL_DESCRIPTOR_ENTRIES: CallDescriptorEntry[] = [ + { path: 'calls.balances.transferKeepAlive', descriptor: descriptor('Balances', 'transfer_keep_alive'), args: ['dest', 'value'] }, + { path: 'calls.balances.transferAllowDeath', descriptor: descriptor('Balances', 'transfer_allow_death'), args: ['dest', 'value'] }, + { path: 'calls.subtensor.addStake', descriptor: descriptor('SubtensorModule', 'add_stake'), args: ['hotkey', 'netuid', 'amount_staked'] }, + { path: 'calls.subtensor.burnedRegister', descriptor: descriptor('SubtensorModule', 'burned_register'), args: ['netuid', 'hotkey'] }, + { path: 'calls.subtensor.commitWeights', descriptor: descriptor('SubtensorModule', 'commit_weights'), args: ['netuid', 'commit_hash'] }, + { path: 'calls.subtensor.moveStake', descriptor: descriptor('SubtensorModule', 'move_stake'), args: ['origin_hotkey', 'destination_hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'] }, + { path: 'calls.subtensor.register', descriptor: descriptor('SubtensorModule', 'register'), args: ['netuid', '_block_number', '_nonce', '_work', 'hotkey', '_coldkey'] }, + { path: 'calls.subtensor.registerNetwork', descriptor: descriptor('SubtensorModule', 'register_network'), args: ['hotkey'] }, + { path: 'calls.subtensor.removeStake', descriptor: descriptor('SubtensorModule', 'remove_stake'), args: ['hotkey', 'netuid', 'amount_unstaked'] }, + { path: 'calls.subtensor.revealWeights', descriptor: descriptor('SubtensorModule', 'reveal_weights'), args: ['netuid', 'uids', 'values', 'salt', 'version_key'] }, + { path: 'calls.subtensor.rootRegister', descriptor: descriptor('SubtensorModule', 'root_register'), args: ['hotkey'] }, + { path: 'calls.subtensor.serveAxon', descriptor: descriptor('SubtensorModule', 'serve_axon'), args: ['netuid', 'version', 'ip', 'port', 'ip_type', 'protocol', 'placeholder1', 'placeholder2'] }, + { path: 'calls.subtensor.servePrometheus', descriptor: descriptor('SubtensorModule', 'serve_prometheus'), args: ['netuid', 'version', 'ip', 'port', 'ip_type'] }, + { path: 'calls.subtensor.setChildren', descriptor: descriptor('SubtensorModule', 'set_children'), args: ['hotkey', 'netuid', 'children'] }, + { path: 'calls.subtensor.setWeights', descriptor: descriptor('SubtensorModule', 'set_weights'), args: ['netuid', 'dests', 'weights', 'version_key'] }, + { path: 'calls.subtensor.startCall', descriptor: descriptor('SubtensorModule', 'start_call'), args: ['netuid'] }, + { path: 'calls.subtensor.transferStake', descriptor: descriptor('SubtensorModule', 'transfer_stake'), args: ['destination_coldkey', 'hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'] }, + { path: 'calls.subtensor.unstakeAll', descriptor: descriptor('SubtensorModule', 'unstake_all'), args: ['hotkey'] }, ] export function validateDescriptorSchema(runtime: Runtime): DescriptorSchemaIssue[] { @@ -2646,6 +2885,28 @@ export function validateDescriptorSchema(runtime: Runtime): DescriptorSchemaIssu issues.push({ ...entry, kind: 'runtimeApi', message: `runtime API ${api} not found` }) } else if (apiMap[api][method] == null) { issues.push({ ...entry, kind: 'runtimeApi', message: `runtime API method ${api}.${method} not found` }) + } else { + const methodInfo = apiMap[api][method] + const inputs = methodInfo.inputs ?? [] + const inputDetails = methodInfo.inputDetails ?? [] + if (inputDetails.length > 0 && inputs.length !== inputDetails.length) { + issues.push({ + ...entry, + kind: 'runtimeApi', + message: `runtime API method ${api}.${method} input metadata disagrees: inputs=${inputs.length}, inputDetails=${inputDetails.length}`, + }) + } + for (let index = 0; index < Math.min(inputs.length, inputDetails.length); index += 1) { + const [inputName, inputType] = inputs[index] + const detail = inputDetails[index] + if (inputName !== detail.name || inputType !== detail.type) { + issues.push({ + ...entry, + kind: 'runtimeApi', + message: `runtime API method ${api}.${method} input ${index} drifted: expected ${inputName}: ${inputType}, got ${detail.name}: ${detail.type}`, + }) + } + } } } const metadata = runtime.metadataIr() @@ -2654,8 +2915,30 @@ export function validateDescriptorSchema(runtime: Runtime): DescriptorSchemaIssu const palletInfo = metadata.pallets.find((candidate) => candidate.name === pallet) if (palletInfo == null) { issues.push({ ...entry, kind: 'call', message: `pallet ${pallet} not found` }) - } else if (!palletInfo.calls.some((callInfo) => callInfo.name === item)) { - issues.push({ ...entry, kind: 'call', message: `call ${pallet}.${item} not found` }) + } else { + const callInfo = palletInfo.calls.find((candidate) => candidate.name === item) + if (callInfo == null) { + issues.push({ ...entry, kind: 'call', message: `call ${pallet}.${item} not found` }) + continue + } + const actualArgs = callInfo.args ?? [] + if (actualArgs.length !== entry.args.length) { + issues.push({ + ...entry, + kind: 'call', + message: `call ${pallet}.${item} argument count drifted: expected ${entry.args.length}, got ${actualArgs.length}`, + }) + continue + } + for (let index = 0; index < entry.args.length; index += 1) { + if (actualArgs[index] !== entry.args[index]) { + issues.push({ + ...entry, + kind: 'call', + message: `call ${pallet}.${item} argument ${index} drifted: expected ${entry.args[index]}, got ${actualArgs[index]}`, + }) + } + } } } return issues @@ -2776,6 +3059,93 @@ function nonNegativeInteger(value: unknown, fallback: number): number { return Math.floor(nonNegativeNumber(value, fallback)) } +function normalizeQueryMapOptions(value: number | QueryMapOptions): Required> & Omit { + if (typeof value === 'number') return { pageSize: positiveInteger(value, 'queryMap pageSize') } + return { + ...value, + pageSize: positiveInteger(value.pageSize ?? 512, 'queryMap pageSize'), + maxPages: value.maxPages == null ? undefined : positiveInteger(value.maxPages, 'queryMap maxPages'), + maxResults: value.maxResults == null ? undefined : positiveInteger(value.maxResults, 'queryMap maxResults'), + } +} + +function positiveInteger(value: unknown, name: string): number { + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new RangeError(`${name} must be a positive safe integer`) + return parsed +} + +function alphaTransactionAmountForNetuid(value: TransactionAmount, netuid: number, name: string): bigint { + if (value instanceof Balance && value.netuid !== netuid) { + throw new RangeError(`${name} must be subnet-${netuid} alpha, not ${value.netuid === 0 ? 'TAO' : `subnet-${value.netuid} alpha`}`) + } + return transactionAmountRao(value, { name }) +} + +function normalizeServeIp(value: ServeIp, ipType?: number): { value: bigint | number; type: number } { + let parsed: bigint + let inferredType = ipType ?? 4 + if (typeof value === 'bigint') { + parsed = value + } else if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) throw new RangeError('ip must be a safe integer, bigint, or string') + parsed = BigInt(value) + } else { + const text = value.trim() + if (/^\d+$/.test(text)) parsed = BigInt(text) + else if (/^0x[0-9a-fA-F]+$/.test(text)) parsed = BigInt(text) + else if (text.includes(':')) { + parsed = parseIpv6(text) + inferredType = ipType ?? 6 + } else if (text.includes('.')) { + parsed = parseIpv4(text) + inferredType = ipType ?? 4 + } else { + throw new RangeError('ip string must be decimal, hex, IPv4, or IPv6') + } + } + if (parsed < 0n || parsed >= (1n << 128n)) throw new RangeError('ip must fit in u128') + return { + value: parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : parsed, + type: inferredType, + } +} + +function parseIpv4(value: string): bigint { + const parts = value.split('.') + if (parts.length !== 4) throw new RangeError('invalid IPv4 address') + let out = 0n + for (const part of parts) { + if (!/^\d+$/.test(part)) throw new RangeError('invalid IPv4 address') + const byte = Number(part) + if (!Number.isInteger(byte) || byte < 0 || byte > 255) throw new RangeError('invalid IPv4 address') + out = (out << 8n) + BigInt(byte) + } + return out +} + +function parseIpv6(value: string): bigint { + const zoneFree = value.split('%', 1)[0] + const doubleColon = zoneFree.indexOf('::') + if (doubleColon !== zoneFree.lastIndexOf('::')) throw new RangeError('invalid IPv6 address') + const parseSide = (side: string): number[] => side === '' + ? [] + : side.split(':').flatMap((part) => { + if (part.includes('.')) { + const ipv4 = parseIpv4(part) + return [Number((ipv4 >> 16n) & 0xffffn), Number(ipv4 & 0xffffn)] + } + if (!/^[0-9a-fA-F]{1,4}$/.test(part)) throw new RangeError('invalid IPv6 address') + return [Number.parseInt(part, 16)] + }) + const left = parseSide(doubleColon >= 0 ? zoneFree.slice(0, doubleColon) : zoneFree) + const right = doubleColon >= 0 ? parseSide(zoneFree.slice(doubleColon + 2)) : [] + const missing = doubleColon >= 0 ? 8 - left.length - right.length : 0 + const groups = doubleColon >= 0 ? [...left, ...Array(missing).fill(0), ...right] : left + if (missing < 0 || groups.length !== 8) throw new RangeError('invalid IPv6 address') + return groups.reduce((out, group) => (out << 16n) + BigInt(group), 0n) +} + function accountAddress(account?: SignerAccount | null): string | undefined { return stringValue(account?.ss58Address) ?? stringValue(account?.address) } @@ -2855,6 +3225,25 @@ function sameHex(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase() } +function normalizeHash32(value: string, name: string): string { + const bytes = hexToBuffer(value) + if (bytes.length !== 32) throw new ChainError(`${name} must be a 32-byte hex string`) + return hex(bytes) +} + +function parseNonce(value: unknown, name: string): number { + const nonce = typeof value === 'bigint' ? Number(value) : Number(value) + if (!Number.isSafeInteger(nonce) || nonce < 0) { + throw new ChainError(`${name} returned invalid nonce ${String(value)}`) + } + return nonce +} + +function runtimeSupportsMetadataHash(runtime: Runtime): boolean { + const identifiers = runtime.signedExtensionIdentifiers?.() ?? [] + return identifiers.includes('CheckMetadataHash') +} + function isMetadataAtVersionUnavailable(error: unknown): boolean { const message = String(error instanceof Error ? error.message : error) const data = String(error instanceof JsonRpcError && error.data != null ? error.data : '') @@ -2877,10 +3266,62 @@ function signerPublicKey(value: ByteLike | undefined, ss58Address: string): Buff : toBuffer(value, 'signer.publicKey') } +function extensionSignPayload(context: SignerPayloadContext): ExtensionSignPayloadRequest { + return { + address: context.address, + blockHash: hex(context.txParams.eraBlockHash), + blockNumber: u32Hex(eraCurrentBlock(context.txParams.era)), + era: eraPayloadHex(context.runtime, context.txParams.era), + genesisHash: hex(context.txParams.genesisHash), + method: hex(context.callData), + nonce: compactIntegerHex(context.txParams.nonce), + signedExtensions: context.runtime.signedExtensionIdentifiers(), + specVersion: u32Hex(context.runtime.specVersion), + tip: compactIntegerHex(context.txParams.tip ?? 0n), + transactionVersion: u32Hex(context.runtime.transactionVersion), + version: 4, + assetId: context.txParams.tipAssetId == null ? null : compactIntegerHex(context.txParams.tipAssetId), + metadataHash: context.metadataHash == null ? undefined : hex(context.metadataHash), + mode: context.metadataHash == null ? 0 : 1, + } +} + +function eraPayloadHex(runtime: Runtime, era: ScaleValue): string { + if (typeof era === 'string') return era.startsWith('0x') ? era : `0x${era}` + return hex(runtime.encodeEra(era)) +} + +function eraCurrentBlock(era: ScaleValue): number { + const value = recordValue(era) + return Number(value?.current ?? 0) +} + +function compactIntegerHex(value: bigint | number): string { + return hex(encodeCompact(value)) +} + +function u32Hex(value: bigint | number): string { + const numeric = typeof value === 'bigint' ? Number(value) : value + if (!Number.isInteger(numeric) || numeric < 0 || numeric > 0xffffffff) { + throw new ChainError(`value ${String(value)} cannot be encoded as u32`) + } + const out = Buffer.alloc(4) + out.writeUInt32LE(numeric, 0) + return hex(out) +} + function normalizeSignature( result: SignerSignature, fallbackCryptoType: number, ): NormalizedSignature { + if ( + result != null && + typeof result === 'object' && + !(Buffer.isBuffer(result) || result instanceof Uint8Array) && + (result as { signedTransaction?: unknown }).signedTransaction != null + ) { + throw new ChainError('signer returned signedTransaction, which is not supported by this signing path') + } const raw = signatureBytes(result) if (raw.length === 65 && raw[0] <= CRYPTO_SR25519) { return { signature: Buffer.from(raw.subarray(1)), cryptoType: raw[0] } @@ -2977,40 +3418,33 @@ function hex(bytes: ByteLike): string { } function hexToBuffer(value: string): Buffer { - const text = value.startsWith('0x') ? value.slice(2) : value - return Buffer.from(text, 'hex') + return hexToBytes(value) } function stripOptionOpaqueMetadata(data: Buffer): Buffer | null { - if (data.length === 0 || data[0] === 0) return null - if (data.length < 2) throw new ChainError('invalid Metadata_metadata_at_version response') - const { length, offset } = decodeCompactLength(data, 1) + if (data.length === 0) throw new ChainError('invalid Metadata_metadata_at_version response') + if (data[0] === 0) { + if (data.length !== 1) throw new ChainError('invalid Metadata_metadata_at_version response') + return null + } + if (data[0] !== 1 || data.length < 2) throw new ChainError('invalid Metadata_metadata_at_version response') + let decoded + try { + decoded = decodeCompactLengthNative(data.subarray(1), false) + } catch (error) { + throw new ChainError('invalid Metadata_metadata_at_version compact length', error) + } + if (decoded.value < 0n || decoded.value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new ChainError('Metadata_metadata_at_version response is too large') + } + const length = Number(decoded.value) + const offset = 1 + decoded.offset const end = offset + length if (end > data.length) throw new ChainError('truncated Metadata_metadata_at_version response') + if (end !== data.length) throw new ChainError('invalid Metadata_metadata_at_version response') return data.subarray(offset, end) } -function decodeCompactLength(data: Buffer, offset: number): { length: number; offset: number } { - const first = data[offset] - if (first == null) throw new ChainError('truncated compact length') - const mode = first & 0b11 - if (mode === 0) return { length: first >> 2, offset: offset + 1 } - if (mode === 1) { - if (offset + 2 > data.length) throw new ChainError('truncated compact length') - return { length: data.readUInt16LE(offset) >> 2, offset: offset + 2 } - } - if (mode === 2) { - if (offset + 4 > data.length) throw new ChainError('truncated compact length') - return { length: data.readUInt32LE(offset) >>> 2, offset: offset + 4 } - } - const byteCount = (first >> 2) + 4 - if (offset + 1 + byteCount > data.length) throw new ChainError('truncated compact length') - let length = 0 - for (let i = 0; i < byteCount; i += 1) length += data[offset + 1 + i] * (256 ** i) - if (!Number.isSafeInteger(length)) throw new ChainError('compact length exceeds safe integer range') - return { length, offset: offset + 1 + byteCount } -} - function hexNumber(value: string): number { return Number.parseInt(value, 16) } @@ -3053,6 +3487,85 @@ function feeFromEvent(event: unknown): Balance | undefined { return amount == null ? undefined : Balance.fromRao(String(amount)) } +function dispatchFailureMessage(runtime: Runtime, event: unknown): string { + const message = formatDispatchError(runtime, dispatchErrorFromEvent(event)) + return message == null ? 'Extrinsic failed' : `Extrinsic failed: ${message}` +} + +function dispatchErrorFromEvent(event: unknown): unknown { + const outer = recordValue(event) + if (outer == null) return undefined + const attrs = recordValue(outer.attributes) + if (attrs != null) { + return attrs.dispatch_error ?? attrs.dispatchError ?? attrs.error + } + const nested = recordValue(outer.ExtrinsicFailed) + return nested?.dispatch_error ?? nested?.dispatchError ?? nested +} + +function formatDispatchError(runtime: Runtime, error: unknown): string | undefined { + if (typeof error === 'string') return error + const value = recordValue(error) + if (value == null) return undefined + const moduleError = recordValue(value.Module ?? value.module) + if (moduleError != null) return formatModuleDispatchError(runtime, moduleError) + const err = value.Err ?? value.err + if (err != null) return formatDispatchError(runtime, err) + const entries = Object.entries(value) + if (entries.length !== 1) return undefined + const [variant, payload] = entries[0] + const nested = formatDispatchError(runtime, payload) + return nested == null ? variant : `${variant}.${nested}` +} + +function formatModuleDispatchError(runtime: Runtime, value: Record): string | undefined { + const moduleIndex = unsignedByteValue(value.index) + const errorIndex = dispatchModuleErrorIndex(value.error) + if (moduleIndex == null || errorIndex == null) return undefined + try { + const resolved = runtime.moduleError(moduleIndex, errorIndex) + const name = Array.isArray(resolved) ? resolved[0] : resolved.name + const rawDocs = Array.isArray(resolved) ? resolved[1] : resolved.docs + const docs = rawDocs.map((doc) => doc.trim()).filter(Boolean).join(' ') + return docs.length === 0 ? name : `${name}: ${docs}` + } catch { + return `Module(${moduleIndex}, ${errorIndex})` + } +} + +function dispatchModuleErrorIndex(value: unknown): number | undefined { + if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value[0] + if (typeof value === 'string' && value.startsWith('0x')) { + try { + return hexToBuffer(value)[0] + } catch { + return undefined + } + } + if (Array.isArray(value)) return unsignedByteValue(value[0]) + return unsignedByteValue(value) +} + +function unsignedByteValue(value: unknown): number | undefined { + let numeric: number + if (typeof value === 'number') numeric = value + else if (typeof value === 'bigint') { + if (value < 0n || value > 255n) return undefined + numeric = Number(value) + } else if (typeof value === 'string') { + numeric = value.startsWith('0x') ? Number.parseInt(value.slice(2), 16) : Number(value) + } else { + return undefined + } + return Number.isInteger(numeric) && numeric >= 0 && numeric <= 255 ? numeric : undefined +} + +function recordValue(value: unknown): Record | undefined { + return value != null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined +} + interface RequestSignal { signal?: AbortSignal cleanup(): void diff --git a/sdk/bittensor-ts/src/ledger.ts b/sdk/bittensor-ts/src/ledger.ts index 9d3d94b6f7..6866ce251b 100644 --- a/sdk/bittensor-ts/src/ledger.ts +++ b/sdk/bittensor-ts/src/ledger.ts @@ -27,7 +27,7 @@ export class LedgerSigner { ) } - signPayload( + signBytes( payload: ByteLike, context: { proof?: ByteLike; metadataProof?: ByteLike } = {}, ): Promise { @@ -42,6 +42,16 @@ export class LedgerSigner { proof, ) } + + signPayload( + payload: unknown, + context: { proof?: ByteLike; metadataProof?: ByteLike } = {}, + ): Promise { + if (!(Buffer.isBuffer(payload) || payload instanceof Uint8Array)) { + throw new Error('LedgerSigner.signPayload does not accept structured payloads; use signBytes') + } + return this.signBytes(payload, context) + } } export class LedgerDevice { diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 1f239a4cbe..fd967a032d 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -10,6 +10,10 @@ const { pathToFileURL } = require('node:url') const core = require('../dist/index.js') +function submittedExtrinsicHash(extrinsicHex) { + return `0x${core.blake2_256(Buffer.from(String(extrinsicHex).slice(2), 'hex')).toString('hex')}` +} + function compactLength(buffer, offset) { const first = buffer[offset] const mode = first & 0b11 @@ -981,6 +985,29 @@ test('transaction amounts require explicit units', () => { () => core.assetIdValue(core.taoAmount('1')), /must be a bigint/, ) + assert.throws( + () => core.calls.subtensor.removeStake('5F', 8, core.Balance.fromAlpha('1', 7)), + /subnet-8 alpha/, + ) + assert.deepEqual(core.calls.subtensor.removeStake('5F', 8, core.Balance.fromAlpha('1', 8)), [ + 'SubtensorModule', + 'remove_stake', + { hotkey: '5F', netuid: 8, amount_unstaked: 1_000_000_000n }, + ]) + assert.deepEqual(core.calls.subtensor.serveAxon(1, '2001:db8::1', 30333), [ + 'SubtensorModule', + 'serve_axon', + { + netuid: 1, + version: 0, + ip: 0x20010db8000000000000000000000001n, + port: 30333, + ip_type: 6, + protocol: 4, + placeholder1: 0, + placeholder2: 0, + }, + ]) }) test('descriptor schema validation reports metadata drift', () => { @@ -1002,6 +1029,7 @@ test('descriptor schema validation reports metadata drift', () => { const issues = core.validateDescriptorSchema(runtime) assert.ok(issues.some((issue) => issue.path === 'storage.Balances.TotalIssuance')) assert.ok(issues.some((issue) => issue.path === 'runtimeApi.StakeInfoRuntimeApi.get_stake_fee')) + assert.ok(issues.some((issue) => issue.path === 'calls.balances.transferKeepAlive' && /argument count/.test(issue.message))) assert.ok(issues.some((issue) => issue.path === 'calls.balances.transferAllowDeath')) }) @@ -1053,6 +1081,55 @@ test('JsonRpcTransport restores websocket subscriptions after reconnect', async await subscription.unsubscribe() }) +test('JsonRpcTransport rejects pending requests on malformed websocket JSON', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + const transport = new core.JsonRpcTransport('ws://node-a', [], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + }) + + const pending = transport.request('state_getMetadata') + await waitFor(() => FakeWebSocket.sockets[0]?.sent.length === 1, 'websocket request send') + FakeWebSocket.sockets[0].emit('message', { data: '{not-json' }) + + await assert.rejects(pending, /invalid JSON-RPC message/) +}) + +test('JsonRpcTransport caps subscription notification queues', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + FakeWebSocket.onSend = (socket, message) => { + if (message.method === 'chain_subscribeNewHeads') { + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: 'sub-1' })) + } + } + const transport = new core.JsonRpcTransport('ws://node-a', [], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + maxSubscriptionQueue: 1, + }) + const subscription = await transport.subscribe( + 'chain_subscribeNewHeads', + [], + 'chain_unsubscribeNewHeads', + ) + const iterator = subscription[Symbol.asyncIterator]() + FakeWebSocket.sockets[0].serverMessage({ + jsonrpc: '2.0', + method: 'chain_subscription', + params: { subscription: 'sub-1', result: { number: 1 } }, + }) + FakeWebSocket.sockets[0].serverMessage({ + jsonrpc: '2.0', + method: 'chain_subscription', + params: { subscription: 'sub-1', result: { number: 2 } }, + }) + + assert.deepEqual(await iterator.next(), { done: false, value: { number: 1 } }) + await assert.rejects(() => iterator.next(), /subscription notification queue exceeded limit/) +}) + test('JsonRpcTransport does not resubmit submit-and-watch subscriptions after reconnect', async (t) => { const { FakeWebSocket, restore } = installFakeWebSocket() t.after(restore) @@ -1256,6 +1333,45 @@ test('Client fallback can validate from expected genesis when primary is unavail assert.equal(await client.rpc('state_getMetadata'), '0x1234') }) +test('Client rotates past a wrong-genesis primary to a valid fallback', async (t) => { + const originalFetch = globalThis.fetch + t.after(() => { + globalThis.fetch = originalFetch + }) + const expectedGenesis = `0x${'dd'.repeat(32)}` + const wrongGenesis = `0x${'ee'.repeat(32)}` + globalThis.fetch = async (url, init) => { + const request = JSON.parse(String(init.body)) + const endpoint = String(url) + if (request.method === 'chain_getBlockHash') { + return { + ok: true, + json: async () => ({ + jsonrpc: '2.0', + id: request.id, + result: endpoint.includes('primary') ? wrongGenesis : expectedGenesis, + }), + } + } + return { + ok: true, + json: async () => ({ jsonrpc: '2.0', id: request.id, result: '0x1234' }), + } + } + + const client = new core.Client('local', { + endpoint: 'http://primary', + expectedGenesisHash: expectedGenesis, + fallbackEndpoints: ['http://fallback'], + maxRequestRetries: 1, + requestTimeoutMs: 100, + retryBackoffMs: 0, + maxRetryBackoffMs: 0, + }) + + assert.equal(await client.rpc('state_getMetadata'), '0x1234') +}) + test('Client requires expected genesis for custom fallback endpoints', () => { assert.throws( () => new core.Client('local', { @@ -1369,7 +1485,7 @@ test('Client queryBatch decodes metadata defaults for missing storage values', a } client.runtimeAt = async () => runtime client.resolveBlockHash = async () => null - client.rpc = async (method) => { + client.rpc = async (method, params = []) => { assert.equal(method, 'state_queryStorageAt') return [{ changes: [['0x01', '0x05']] }] } @@ -1377,6 +1493,40 @@ test('Client queryBatch decodes metadata defaults for missing storage values', a assert.deepEqual(await client.queryBatch('Example', 'Value', [[], []]), [5, 9]) }) +test('Client queryMap pins reads and rejects pagination without progress', async () => { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const blockHash = `0x${'44'.repeat(32)}` + const calls = [] + const runtime = { + storageKey() { + return Buffer.from([0]) + }, + storageEntry() { + return { valueType: 'u8' } + }, + decodeStorageKeyParams(_pallet, _name, key) { + return [key[0]] + }, + decode(_type, bytes) { + return bytes[0] + }, + } + client.finalizedHead = async () => blockHash + client.runtimeAt = async (block) => { + assert.equal(block, blockHash) + return runtime + } + client.transport.request = async (method, params = []) => { + calls.push({ method, params }) + if (method === 'state_getKeysPaged') return ['0x01'] + if (method === 'state_queryStorageAt') return [{ changes: [['0x01', '0x05']] }] + throw new Error(`unexpected ${method}`) + } + + await assert.rejects(() => client.queryMap('Example', 'Map'), /pagination did not advance/) + assert.ok(calls.every((call) => call.params.at(-1) === blockHash)) +}) + test('Client signs extrinsics with extension-style signRaw signers', async () => { const callData = Buffer.from([5, 6, 7]) const { runtime, captures } = fakeSigningRuntime() @@ -1425,6 +1575,87 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => () => client.signExtrinsic(callData, signer, { period: null, tipAssetId: -1 }), /tipAssetId must be non-negative/, ) + await assert.rejects( + () => client.signExtrinsic(callData, { ...signer, publicKey: Buffer.alloc(32, 7) }, { period: null }), + /publicKey does not match/, + ) +}) + +test('Client enables metadata hash by default for software signers when supported', async () => { + const callData = Buffer.from([5, 6, 7]) + const { runtime, captures } = fakeSigningRuntime({ + metadataBytes: Buffer.from([1, 2, 3, 4]), + signedExtensionIdentifiers() { + return ['CheckNonce', 'CheckMetadataHash'] + }, + }) + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 6) + const address = core.ss58FromPublic(publicKey, 42) + let request + const signer = { + address, + publicKey, + signRaw(req) { + request = req + return { signature: `0x${Buffer.alloc(64, 9).toString('hex')}` } + }, + } + + await client.signExtrinsic(callData, signer, { period: null }) + + assert.equal(captures.encoded.params.metadataHashEnabled, true) + assert.equal(captures.payloadParams.metadataHash.length, 32) + assert.equal(typeof request.metadataHash, 'string') + assert.equal(request.metadataHash.length, 66) +}) + +test('Client passes structured payloads to extension signPayload signers', async () => { + const callData = Buffer.from([5, 6, 7]) + const { runtime, captures } = fakeSigningRuntime({ + signedExtensionIdentifiers() { + return ['CheckNonce'] + }, + encodeEra() { + return Buffer.from([0]) + }, + }) + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 6) + const address = core.ss58FromPublic(publicKey, 42) + let payload + const signer = { + address, + publicKey, + signPayload(value) { + payload = value + return { signature: `0x${Buffer.alloc(64, 9).toString('hex')}` } + }, + } + + await client.signExtrinsic(callData, signer, { period: null }) + + assert.equal(payload.address, address) + assert.equal(payload.method, '0x050607') + assert.equal(payload.version, 4) + assert.deepEqual(payload.signedExtensions, ['CheckNonce']) + assert.equal(captures.encoded.params.metadataHashEnabled, false) +}) + +test('Client rejects invalid chain nonce values', async () => { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + client.rpc = async () => 'NaN' + await assert.rejects(() => client.accountNextIndex('5F'), /invalid nonce/) +}) + +test('Client rejects mismatched submit hashes and keeps local hash authoritative', async () => { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + client.signedExtrinsicNonceTracking = async () => ({}) + client.transport.request = async (method) => { + assert.equal(method, 'author_submitExtrinsic') + return `0x${'00'.repeat(32)}` + } + await assert.rejects(() => client.submitSigned(Buffer.from([1, 2])), /returned hash/) }) test('Client estimateFee peeks the chain nonce without reserving it', async () => { @@ -1434,11 +1665,24 @@ test('Client estimateFee peeks the chain nonce without reserving it', async () = return { TransactionPaymentApi: { query_info: { + inputDetails: [ + { name: 'uxt', typeId: 10, type: 'Extrinsic' }, + { name: 'len', typeId: 11, type: 'u32' }, + ], outputTypeId: 1, }, }, } }, + encodeId(typeId, value) { + if (typeId === 10) return Buffer.from(value) + if (typeId === 11) { + const out = Buffer.alloc(4) + out.writeUInt32LE(value, 0) + return out + } + throw new Error(`unexpected type ${typeId}`) + }, decodeTypeId() { return { partial_fee: 123n } }, @@ -1538,7 +1782,7 @@ test('Client serializes concurrent initial nonce reservations during submit', as if (method === 'system_properties') { return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } } - if (method === 'author_submitExtrinsic') return `0x${'cd'.repeat(32)}` + if (method === 'author_submitExtrinsic') return submittedExtrinsicHash(params[0]) throw new Error(`unexpected RPC ${method}`) } const signer = { @@ -1602,7 +1846,7 @@ test('Client releases only the failed reserved nonce', async () => { if (method === 'system_properties') { return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } } - if (method === 'author_submitExtrinsic') return `0x${'ef'.repeat(32)}` + if (method === 'author_submitExtrinsic') return submittedExtrinsicHash(params[0]) throw new Error(`unexpected RPC ${method}`) } const failingSigner = { @@ -1675,7 +1919,7 @@ test('Client quarantines an ambiguous submit nonce even when a fallback node rep if (method === 'author_submitExtrinsic') { submitAttempts += 1 if (submitAttempts === 1) throw new core.JsonRpcError('lost response') - return `0x${'aa'.repeat(32)}` + return submittedExtrinsicHash(params[0]) } if (method === 'author_pendingExtrinsics') return [] if (method === 'chain_getHeader') return { number: '0x0' } @@ -1745,7 +1989,7 @@ test('Client invalidates nonce state after unknown ambiguous submission reconcil if (submitAttempts === 1) { throw new core.JsonRpcError('lost response') } - return `0x${'bb'.repeat(32)}` + return submittedExtrinsicHash(params[0]) } if (method === 'author_pendingExtrinsics') throw new Error('network still unavailable') throw new Error(`unexpected RPC ${method}`) @@ -1809,7 +2053,7 @@ test('Client protects an ambiguous submit nonce when the extrinsic is still pend submitAttempts += 1 submittedHex = params[0] if (submitAttempts === 1) throw new core.JsonRpcError('lost response') - return `0x${'bc'.repeat(32)}` + return submittedExtrinsicHash(params[0]) } if (method === 'author_pendingExtrinsics') return [submittedHex] throw new Error(`unexpected RPC ${method}`) @@ -1838,7 +2082,7 @@ test('Client submit without inclusion reports pool submission, not execution suc Buffer.from([core.CRYPTO_SR25519]), Buffer.alloc(64, 6), ]) - client.rpc = async (method) => { + client.rpc = async (method, params = []) => { if (method === 'system_accountNextIndex') return 30 if (method === 'state_getRuntimeVersion') { return { @@ -1850,7 +2094,7 @@ test('Client submit without inclusion reports pool submission, not execution suc if (method === 'system_properties') { return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } } - if (method === 'author_submitExtrinsic') return `0x${'ab'.repeat(32)}` + if (method === 'author_submitExtrinsic') return submittedExtrinsicHash(params[0]) throw new Error(`unexpected RPC ${method}`) } const signer = { @@ -1955,7 +2199,7 @@ test('Client records detached submitSigned nonces before the next managed submit if (method === 'author_submitExtrinsic') { submitMaxRetries.push(options.maxRetries) submitRetryForever.push(options.retryForever) - return `0x${'dd'.repeat(32)}` + return submittedExtrinsicHash(params[0]) } throw new Error(`unexpected RPC ${method}`) } @@ -2019,7 +2263,7 @@ test('Client decodes detached signed nonce instead of trusting mutable public fi if (method === 'system_properties') { return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } } - if (method === 'author_submitExtrinsic') return `0x${'df'.repeat(32)}` + if (method === 'author_submitExtrinsic') return submittedExtrinsicHash(params[0]) throw new Error(`unexpected RPC ${method}`) } const signer = { @@ -2078,7 +2322,7 @@ test('Client invalidates nonce state for opaque externally signed submissions', if (method === 'system_properties') { return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } } - if (method === 'author_submitExtrinsic') return `0x${'ce'.repeat(32)}` + if (method === 'author_submitExtrinsic') return submittedExtrinsicHash(params[0]) throw new Error(`unexpected RPC ${method}`) } const signer = { @@ -2254,7 +2498,7 @@ test('Client submission watches support timeout and reconcile managed nonces', a if (method === 'chain_getHeader') return { number: '0x0' } if (method === 'chain_getBlockHash') return `0x${'02'.repeat(32)}` if (method === 'chain_getBlock') return { block: { extrinsics: [] } } - if (method === 'author_submitExtrinsic') return `0x${'cc'.repeat(32)}` + if (method === 'author_submitExtrinsic') return submittedExtrinsicHash(params[0]) throw new Error(`unexpected RPC ${method}`) } const signer = { From 3557d6b63838806d3ebce0fe1f319a9165ac3634 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 10:38:37 -0700 Subject: [PATCH 46/72] fix typecheck --- sdk/bittensor-ts/src/client.ts | 4 ++-- sdk/bittensor-ts/test/basic.test.cjs | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 1b59081ef3..651146e061 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -3525,8 +3525,8 @@ function formatModuleDispatchError(runtime: Runtime, value: Record doc.trim()).filter(Boolean).join(' ') + const rawDocs: string[] = Array.isArray(resolved) ? resolved[1] : resolved.docs + const docs = rawDocs.map((doc: string) => doc.trim()).filter(Boolean).join(' ') return docs.length === 0 ? name : `${name}: ${docs}` } catch { return `Module(${moduleIndex}, ${errorIndex})` diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index fd967a032d..3755f490ed 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -1584,7 +1584,7 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => test('Client enables metadata hash by default for software signers when supported', async () => { const callData = Buffer.from([5, 6, 7]) const { runtime, captures } = fakeSigningRuntime({ - metadataBytes: Buffer.from([1, 2, 3, 4]), + metadataBytes: goldenMetadataBytes(), signedExtensionIdentifiers() { return ['CheckNonce', 'CheckMetadataHash'] }, @@ -2355,7 +2355,11 @@ test('Client records detached watchSigned nonces before the next managed submit' return } if (message.method === 'author_submitExtrinsic') { - queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: `0x${'de'.repeat(32)}` })) + queueMicrotask(() => socket.serverMessage({ + jsonrpc: '2.0', + id: message.id, + result: submittedExtrinsicHash(message.params[0]), + })) } } From 1ae69ccbf9b8086f97d98e5075d61534ec52db6f Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 10:49:20 -0700 Subject: [PATCH 47/72] cleanup --- sdk/bittensor-ts/native/src/runtime.rs | 67 ++++++++++++++ sdk/bittensor-ts/src/client.ts | 122 +++++++++++++------------ sdk/bittensor-ts/src/native.ts | 2 + sdk/bittensor-ts/src/runtime.ts | 8 ++ sdk/bittensor-ts/src/types.ts | 1 + sdk/bittensor-ts/test/basic.test.cjs | 60 +++++++----- 6 files changed, 178 insertions(+), 82 deletions(-) diff --git a/sdk/bittensor-ts/native/src/runtime.rs b/sdk/bittensor-ts/native/src/runtime.rs index 2eafd41ccc..8c6e33cad3 100644 --- a/sdk/bittensor-ts/native/src/runtime.rs +++ b/sdk/bittensor-ts/native/src/runtime.rs @@ -15,6 +15,7 @@ use bittensor_core::codec::Value; use bittensor_core::runtime::type_string::{Primitive, TypeSpec}; use bittensor_core::runtime::{PalletInfo, Runtime, StorageInfo}; use bittensor_core::CoreError; +use codec::Decode; use napi::bindgen_prelude::{BigInt, Buffer}; use napi_derive::napi; use scale_info::TypeDef; @@ -754,6 +755,50 @@ impl NativeRuntime { runtime_api_map_json(&self.inner) } + #[napi(js_name = "encodeRuntimeApiInput")] + pub fn encode_runtime_api_input( + &self, + api_name: String, + method_name: String, + params: JsonValue, + ) -> NapiResult { + let api = self + .inner + .apis + .iter() + .find(|api| api.name == api_name) + .ok_or_else(|| invalid_arg(format!("runtime API {api_name} not found")))?; + let method = api + .methods + .iter() + .find(|method| method.name == method_name) + .ok_or_else(|| { + invalid_arg(format!( + "runtime API method {api_name}.{method_name} not found" + )) + })?; + let values = match from_wire(params)? { + Value::List(values) | Value::Tuple(values) => values, + other => { + return Err(invalid_arg(format!( + "runtime API params must be an array, got {other}" + ))); + } + }; + if values.len() != method.inputs.len() { + return Err(invalid_arg(format!( + "runtime API {api_name}.{method_name} expects {} params, got {}", + method.inputs.len(), + values.len() + ))); + } + let mut output = Vec::new(); + for (param, value) in method.inputs.iter().zip(values.iter()) { + self.inner.encode_id(param.ty, value, &mut output).napi()?; + } + Ok(output.into()) + } + #[napi] pub fn runtime_api_infos(&self) -> JsonValue { runtime_api_infos_json(&self.inner) @@ -1311,6 +1356,11 @@ fn runtime_api_map_json(runtime: &Runtime) -> JsonValue { param.name, format!("scale_info::{}", param.ty), ])).collect::>(), + "inputDetails": method.inputs.iter().map(|param| json!({ + "name": param.name, + "typeId": param.ty, + "type": format!("scale_info::{}", param.ty), + })).collect::>(), "output": format!("scale_info::{}", method.output), "outputTypeId": method.output, "docs": method.docs, @@ -1373,6 +1423,7 @@ fn metadata_ir_json(runtime: &Runtime) -> NapiResult { "index": call.index, "args": call.fields.iter().map(|field| field.name.clone().unwrap_or_default()).collect::>(), "argTypes": call.fields.iter().map(|field| format!("scale_info::{}", field.ty.id)).collect::>(), + "argTypeIds": call.fields.iter().map(|field| field.ty.id).collect::>(), "docs": join_docs(&call.docs), })); } @@ -1507,6 +1558,22 @@ pub fn decode_compact_length(data: Buffer, strict: bool) -> NapiResult NapiResult> { + let mut input = data.as_ref(); + let metadata = Option::>::decode(&mut input).map_err(|error| { + invalid_arg(format!( + "invalid Metadata_metadata_at_version response: {error}" + )) + })?; + if !input.is_empty() { + return Err(invalid_arg( + "invalid Metadata_metadata_at_version response: trailing bytes", + )); + } + Ok(metadata.map(Into::into)) +} + #[napi(js_name = "hashStorageParam")] pub fn hash_storage_param(hasher: String, data: Buffer) -> NapiResult { hash_param(&hasher, data.as_ref()).napi().map(Into::into) diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 651146e061..e750395c2f 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -1,7 +1,7 @@ import { blake2_256, generateExtrinsicProof, hexToBytes, metadataDigest } from './crypto' import { CRYPTO_ED25519, CRYPTO_SR25519, Keypair, publicKeyFromSs58, ss58FromPublic } from './keys' import { LedgerDevice } from './ledger' -import { Runtime, decodeCompactLength as decodeCompactLengthNative, encodeCompact, eraBirth } from './runtime' +import { Runtime, decodeOptionalOpaqueMetadata, encodeCompact, eraBirth } from './runtime' import { toBuffer } from './wire' import { Balance, @@ -1121,7 +1121,7 @@ export class Client { } if (v15Result != null) { - const metadata = stripOptionOpaqueMetadata(hexToBuffer(String(v15Result))) + const metadata = decodeOptionalOpaqueMetadata(hexToBuffer(String(v15Result))) if (metadata != null) return metadata } @@ -1321,11 +1321,7 @@ export class Client { const runtime = await this.runtimeAt(blockHash) const info = runtime.runtimeApis()[apiName]?.[methodName] if (info == null) throw new ChainError(`runtime API ${apiName}.${methodName} not found`) - const inputDetails = info.inputDetails ?? [] - if (inputDetails.length !== callParams.length) { - throw new ChainError(`${apiName}.${methodName} expects ${inputDetails.length} params`) - } - const encoded = Buffer.concat(callParams.map((value, index) => runtime.encodeId(inputDetails[index].typeId, value))) + const encoded = runtime.encodeRuntimeApiInput(apiName, methodName, callParams) const raw = await this.rpc('state_call', [`${apiName}_${methodName}`, hex(encoded), blockHash ?? null]) return runtime.decodeTypeId(info.outputTypeId, hexToBuffer(String(raw)), false) } @@ -1933,14 +1929,10 @@ export class Client { period: null, }, false, snapshot) const queryInfo = runtime.runtimeApis().TransactionPaymentApi?.query_info - const inputDetails = queryInfo?.inputDetails ?? [] - if (queryInfo == null || inputDetails.length !== 2) { + if (queryInfo == null) { throw new ChainError('TransactionPaymentApi.query_info metadata is unavailable') } - const encodedInput = Buffer.concat([ - runtime.encodeId(inputDetails[0].typeId, signed.bytes), - runtime.encodeId(inputDetails[1].typeId, signed.bytes.length), - ]) + const encodedInput = runtime.encodeRuntimeApiInput('TransactionPaymentApi', 'query_info', [signed.bytes, signed.bytes.length]) const raw = await this.rpc('state_call', [ 'TransactionPaymentApi_query_info', hex(encodedInput), @@ -2713,7 +2705,7 @@ export const calls = Object.freeze({ }) }, register(netuid: number, blockNumber: bigint | number | string, nonce: bigint | number | string, work: ByteLike, hotkey: string, coldkey: string) { - return call('SubtensorModule', 'register', { netuid, _block_number: BigInt(blockNumber), _nonce: BigInt(nonce), _work: work, hotkey, _coldkey: coldkey }) + return call('SubtensorModule', 'register', { netuid, block_number: BigInt(blockNumber), nonce: BigInt(nonce), work, hotkey, coldkey }) }, registerNetwork(hotkey: string) { return call('SubtensorModule', 'register_network', { hotkey }) @@ -2785,7 +2777,7 @@ export const calls = Object.freeze({ }) }, register(netuid: number, blockNumber: bigint | number | string, nonce: bigint | number | string, work: ByteLike, hotkey: string, coldkey: string) { - return call('SubtensorModule', 'register', { netuid, _block_number: BigInt(blockNumber), _nonce: BigInt(nonce), _work: work, hotkey, _coldkey: coldkey }) + return call('SubtensorModule', 'register', { netuid, block_number: BigInt(blockNumber), nonce: BigInt(nonce), work, hotkey, coldkey }) }, register_network(hotkey: string) { return call('SubtensorModule', 'register_network', { hotkey }) @@ -2835,27 +2827,28 @@ interface CallDescriptorEntry { path: string descriptor: Descriptor args: readonly string[] + argTypeIds: readonly number[] } const CALL_DESCRIPTOR_ENTRIES: CallDescriptorEntry[] = [ - { path: 'calls.balances.transferKeepAlive', descriptor: descriptor('Balances', 'transfer_keep_alive'), args: ['dest', 'value'] }, - { path: 'calls.balances.transferAllowDeath', descriptor: descriptor('Balances', 'transfer_allow_death'), args: ['dest', 'value'] }, - { path: 'calls.subtensor.addStake', descriptor: descriptor('SubtensorModule', 'add_stake'), args: ['hotkey', 'netuid', 'amount_staked'] }, - { path: 'calls.subtensor.burnedRegister', descriptor: descriptor('SubtensorModule', 'burned_register'), args: ['netuid', 'hotkey'] }, - { path: 'calls.subtensor.commitWeights', descriptor: descriptor('SubtensorModule', 'commit_weights'), args: ['netuid', 'commit_hash'] }, - { path: 'calls.subtensor.moveStake', descriptor: descriptor('SubtensorModule', 'move_stake'), args: ['origin_hotkey', 'destination_hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'] }, - { path: 'calls.subtensor.register', descriptor: descriptor('SubtensorModule', 'register'), args: ['netuid', '_block_number', '_nonce', '_work', 'hotkey', '_coldkey'] }, - { path: 'calls.subtensor.registerNetwork', descriptor: descriptor('SubtensorModule', 'register_network'), args: ['hotkey'] }, - { path: 'calls.subtensor.removeStake', descriptor: descriptor('SubtensorModule', 'remove_stake'), args: ['hotkey', 'netuid', 'amount_unstaked'] }, - { path: 'calls.subtensor.revealWeights', descriptor: descriptor('SubtensorModule', 'reveal_weights'), args: ['netuid', 'uids', 'values', 'salt', 'version_key'] }, - { path: 'calls.subtensor.rootRegister', descriptor: descriptor('SubtensorModule', 'root_register'), args: ['hotkey'] }, - { path: 'calls.subtensor.serveAxon', descriptor: descriptor('SubtensorModule', 'serve_axon'), args: ['netuid', 'version', 'ip', 'port', 'ip_type', 'protocol', 'placeholder1', 'placeholder2'] }, - { path: 'calls.subtensor.servePrometheus', descriptor: descriptor('SubtensorModule', 'serve_prometheus'), args: ['netuid', 'version', 'ip', 'port', 'ip_type'] }, - { path: 'calls.subtensor.setChildren', descriptor: descriptor('SubtensorModule', 'set_children'), args: ['hotkey', 'netuid', 'children'] }, - { path: 'calls.subtensor.setWeights', descriptor: descriptor('SubtensorModule', 'set_weights'), args: ['netuid', 'dests', 'weights', 'version_key'] }, - { path: 'calls.subtensor.startCall', descriptor: descriptor('SubtensorModule', 'start_call'), args: ['netuid'] }, - { path: 'calls.subtensor.transferStake', descriptor: descriptor('SubtensorModule', 'transfer_stake'), args: ['destination_coldkey', 'hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'] }, - { path: 'calls.subtensor.unstakeAll', descriptor: descriptor('SubtensorModule', 'unstake_all'), args: ['hotkey'] }, + { path: 'calls.balances.transferKeepAlive', descriptor: descriptor('Balances', 'transfer_keep_alive'), args: ['dest', 'value'], argTypeIds: [174, 176] }, + { path: 'calls.balances.transferAllowDeath', descriptor: descriptor('Balances', 'transfer_allow_death'), args: ['dest', 'value'], argTypeIds: [174, 176] }, + { path: 'calls.subtensor.addStake', descriptor: descriptor('SubtensorModule', 'add_stake'), args: ['hotkey', 'netuid', 'amount_staked'], argTypeIds: [0, 40, 6] }, + { path: 'calls.subtensor.burnedRegister', descriptor: descriptor('SubtensorModule', 'burned_register'), args: ['netuid', 'hotkey'], argTypeIds: [40, 0] }, + { path: 'calls.subtensor.commitWeights', descriptor: descriptor('SubtensorModule', 'commit_weights'), args: ['netuid', 'commit_hash'], argTypeIds: [40, 13] }, + { path: 'calls.subtensor.moveStake', descriptor: descriptor('SubtensorModule', 'move_stake'), args: ['origin_hotkey', 'destination_hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypeIds: [0, 0, 40, 40, 6] }, + { path: 'calls.subtensor.register', descriptor: descriptor('SubtensorModule', 'register'), args: ['netuid', 'block_number', 'nonce', 'work', 'hotkey', 'coldkey'], argTypeIds: [40, 6, 6, 14, 0, 0] }, + { path: 'calls.subtensor.registerNetwork', descriptor: descriptor('SubtensorModule', 'register_network'), args: ['hotkey'], argTypeIds: [0] }, + { path: 'calls.subtensor.removeStake', descriptor: descriptor('SubtensorModule', 'remove_stake'), args: ['hotkey', 'netuid', 'amount_unstaked'], argTypeIds: [0, 40, 6] }, + { path: 'calls.subtensor.revealWeights', descriptor: descriptor('SubtensorModule', 'reveal_weights'), args: ['netuid', 'uids', 'values', 'salt', 'version_key'], argTypeIds: [40, 206, 206, 206, 6] }, + { path: 'calls.subtensor.rootRegister', descriptor: descriptor('SubtensorModule', 'root_register'), args: ['hotkey'], argTypeIds: [0] }, + { path: 'calls.subtensor.serveAxon', descriptor: descriptor('SubtensorModule', 'serve_axon'), args: ['netuid', 'version', 'ip', 'port', 'ip_type', 'protocol', 'placeholder1', 'placeholder2'], argTypeIds: [40, 4, 8, 40, 2, 2, 2, 2] }, + { path: 'calls.subtensor.servePrometheus', descriptor: descriptor('SubtensorModule', 'serve_prometheus'), args: ['netuid', 'version', 'ip', 'port', 'ip_type'], argTypeIds: [40, 4, 8, 40, 2] }, + { path: 'calls.subtensor.setChildren', descriptor: descriptor('SubtensorModule', 'set_children'), args: ['hotkey', 'netuid', 'children'], argTypeIds: [0, 40, 44] }, + { path: 'calls.subtensor.setWeights', descriptor: descriptor('SubtensorModule', 'set_weights'), args: ['netuid', 'dests', 'weights', 'version_key'], argTypeIds: [40, 206, 206, 6] }, + { path: 'calls.subtensor.startCall', descriptor: descriptor('SubtensorModule', 'start_call'), args: ['netuid'], argTypeIds: [40] }, + { path: 'calls.subtensor.transferStake', descriptor: descriptor('SubtensorModule', 'transfer_stake'), args: ['destination_coldkey', 'hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypeIds: [0, 0, 40, 40, 6] }, + { path: 'calls.subtensor.unstakeAll', descriptor: descriptor('SubtensorModule', 'unstake_all'), args: ['hotkey'], argTypeIds: [0] }, ] export function validateDescriptorSchema(runtime: Runtime): DescriptorSchemaIssue[] { @@ -2939,11 +2932,50 @@ export function validateDescriptorSchema(runtime: Runtime): DescriptorSchemaIssu }) } } + const actualArgTypeIds = callArgumentTypeIds(callInfo) + if (actualArgTypeIds == null) { + issues.push({ + ...entry, + kind: 'call', + message: `call ${pallet}.${item} argument type ID metadata is unavailable`, + }) + } else if (actualArgTypeIds.length !== entry.argTypeIds.length) { + issues.push({ + ...entry, + kind: 'call', + message: `call ${pallet}.${item} argument type ID count drifted: expected ${entry.argTypeIds.length}, got ${actualArgTypeIds.length}`, + }) + } else { + for (let index = 0; index < entry.argTypeIds.length; index += 1) { + if (actualArgTypeIds[index] !== entry.argTypeIds[index]) { + issues.push({ + ...entry, + kind: 'call', + message: `call ${pallet}.${item} argument ${index} type ID drifted: expected ${entry.argTypeIds[index]}, got ${actualArgTypeIds[index]}`, + }) + } + } + } } } return issues } +function callArgumentTypeIds(callInfo: { argTypeIds?: unknown; argTypes?: unknown }): number[] | null { + if (Array.isArray(callInfo.argTypeIds)) { + const ids = callInfo.argTypeIds.map((value) => Number(value)) + return ids.every((value) => Number.isSafeInteger(value) && value >= 0) ? ids : null + } + if (Array.isArray(callInfo.argTypes)) { + const ids = callInfo.argTypes.map((value) => { + const match = /^scale_info::(\d+)$/.exec(String(value)) + return match == null ? NaN : Number(match[1]) + }) + return ids.every((value) => Number.isSafeInteger(value) && value >= 0) ? ids : null + } + return null +} + function descriptorEntries(value: unknown, prefix: string): Array<{ path: string; descriptor: Descriptor }> { if (isDescriptor(value)) return [{ path: prefix, descriptor: value }] if (typeof value !== 'object' || value == null) return [] @@ -3421,30 +3453,6 @@ function hexToBuffer(value: string): Buffer { return hexToBytes(value) } -function stripOptionOpaqueMetadata(data: Buffer): Buffer | null { - if (data.length === 0) throw new ChainError('invalid Metadata_metadata_at_version response') - if (data[0] === 0) { - if (data.length !== 1) throw new ChainError('invalid Metadata_metadata_at_version response') - return null - } - if (data[0] !== 1 || data.length < 2) throw new ChainError('invalid Metadata_metadata_at_version response') - let decoded - try { - decoded = decodeCompactLengthNative(data.subarray(1), false) - } catch (error) { - throw new ChainError('invalid Metadata_metadata_at_version compact length', error) - } - if (decoded.value < 0n || decoded.value > BigInt(Number.MAX_SAFE_INTEGER)) { - throw new ChainError('Metadata_metadata_at_version response is too large') - } - const length = Number(decoded.value) - const offset = 1 + decoded.offset - const end = offset + length - if (end > data.length) throw new ChainError('truncated Metadata_metadata_at_version response') - if (end !== data.length) throw new ChainError('invalid Metadata_metadata_at_version response') - return data.subarray(offset, end) -} - function hexNumber(value: string): number { return Number.parseInt(value, 16) } diff --git a/sdk/bittensor-ts/src/native.ts b/sdk/bittensor-ts/src/native.ts index 5eff8cec60..df24ea7c59 100644 --- a/sdk/bittensor-ts/src/native.ts +++ b/sdk/bittensor-ts/src/native.ts @@ -143,6 +143,7 @@ export interface NativeRuntimeHandle { pallets(): unknown[] extrinsicInfo(): unknown runtimeApis(): unknown + encodeRuntimeApiInput(api: string, method: string, params: unknown): Buffer runtimeApiInfos(): unknown runtimeSnapshot(): unknown composeCall(pallet: string, fn: string, params: unknown): Buffer @@ -352,6 +353,7 @@ export interface NativeBinding { data: Buffer, strict: boolean, ): { value: bigint; offset: number; remaining: number } + decodeOptionalOpaqueMetadata(data: Buffer): Buffer | null | undefined hashStorageParam(hasher: string, data: Buffer): Buffer storagePrefixFor(prefix: string, name: string): Buffer concatHashLength(hasher: string): number diff --git a/sdk/bittensor-ts/src/runtime.ts b/sdk/bittensor-ts/src/runtime.ts index 722d40036d..c341c3ba46 100644 --- a/sdk/bittensor-ts/src/runtime.ts +++ b/sdk/bittensor-ts/src/runtime.ts @@ -561,6 +561,10 @@ export class Runtime { return this.handle.runtimeApis() as RuntimeApiMap } + encodeRuntimeApiInput(api: string, method: string, params: ScaleValue[]): Buffer { + return nativeCall(() => this.handle.encodeRuntimeApiInput(api, method, toWire(params))) + } + runtimeApiInfos(): RuntimeApiInfo[] { return this.handle.runtimeApiInfos() as RuntimeApiInfo[] } @@ -980,6 +984,10 @@ export function decodeCompactLength(data: ByteLike, strict = true): CompactDecod return nativeCall(() => native.decodeCompactLength(toBuffer(data, 'data'), strict)) } +export function decodeOptionalOpaqueMetadata(data: ByteLike): Buffer | null { + return nativeCall(() => native.decodeOptionalOpaqueMetadata(toBuffer(data, 'data')) ?? null) +} + export function hashStorageParam(hasher: string, data: ByteLike): Buffer { return nativeCall(() => native.hashStorageParam(hasher, toBuffer(data, 'data'))) } diff --git a/sdk/bittensor-ts/src/types.ts b/sdk/bittensor-ts/src/types.ts index f4fe673edd..c63afa0a6a 100644 --- a/sdk/bittensor-ts/src/types.ts +++ b/sdk/bittensor-ts/src/types.ts @@ -222,6 +222,7 @@ export interface MetadataIrCall { index: number args: string[] argTypes: string[] + argTypeIds: number[] docs: string } diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 3755f490ed..f8a48334b3 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -14,23 +14,11 @@ function submittedExtrinsicHash(extrinsicHex) { return `0x${core.blake2_256(Buffer.from(String(extrinsicHex).slice(2), 'hex')).toString('hex')}` } -function compactLength(buffer, offset) { - const first = buffer[offset] - const mode = first & 0b11 - if (mode === 0) return { length: first >> 2, offset: offset + 1 } - if (mode === 1) return { length: buffer.readUInt16LE(offset) >> 2, offset: offset + 2 } - if (mode === 2) return { length: buffer.readUInt32LE(offset) >>> 2, offset: offset + 4 } - const bytes = (first >> 2) + 4 - let length = 0 - for (let i = 0; i < bytes; i += 1) length += buffer[offset + 1 + i] * (256 ** i) - return { length, offset: offset + 1 + bytes } -} - function goldenMetadataBytes() { const raw = Buffer.from(goldenMetadataResponseHex().slice(2), 'hex') - assert.equal(raw[0], 1) - const decoded = compactLength(raw, 1) - return raw.subarray(decoded.offset, decoded.offset + decoded.length) + const metadata = core.decodeOptionalOpaqueMetadata(raw) + assert.ok(metadata) + return metadata } function goldenMetadataResponseHex() { @@ -1023,14 +1011,36 @@ test('descriptor schema validation reports metadata drift', () => { return {} }, metadataIr() { - return { pallets: [{ name: 'Balances', calls: [{ name: 'transfer_keep_alive' }] }] } + return { + pallets: [{ + name: 'Balances', + calls: [ + { name: 'transfer_keep_alive' }, + { name: 'transfer_allow_death', args: ['dest', 'value'], argTypeIds: [174, 999] }, + ], + }], + } }, } const issues = core.validateDescriptorSchema(runtime) assert.ok(issues.some((issue) => issue.path === 'storage.Balances.TotalIssuance')) assert.ok(issues.some((issue) => issue.path === 'runtimeApi.StakeInfoRuntimeApi.get_stake_fee')) assert.ok(issues.some((issue) => issue.path === 'calls.balances.transferKeepAlive' && /argument count/.test(issue.message))) - assert.ok(issues.some((issue) => issue.path === 'calls.balances.transferAllowDeath')) + assert.ok(issues.some((issue) => issue.path === 'calls.balances.transferAllowDeath' && /type ID drifted/.test(issue.message))) +}) + +test('Rust decoder unwraps Metadata_metadata_at_version responses strictly', () => { + const response = Buffer.from(goldenMetadataResponseHex().slice(2), 'hex') + assert.deepEqual(core.decodeOptionalOpaqueMetadata(response), goldenMetadataBytes()) + assert.equal(core.decodeOptionalOpaqueMetadata(Buffer.from([0])), null) + assert.throws( + () => core.decodeOptionalOpaqueMetadata(Buffer.from([2])), + /invalid Metadata_metadata_at_version response/, + ) + assert.throws( + () => core.decodeOptionalOpaqueMetadata(Buffer.concat([response, Buffer.from([0])])), + /trailing bytes/, + ) }) test('JsonRpcTransport restores websocket subscriptions after reconnect', async (t) => { @@ -1674,14 +1684,14 @@ test('Client estimateFee peeks the chain nonce without reserving it', async () = }, } }, - encodeId(typeId, value) { - if (typeId === 10) return Buffer.from(value) - if (typeId === 11) { - const out = Buffer.alloc(4) - out.writeUInt32LE(value, 0) - return out - } - throw new Error(`unexpected type ${typeId}`) + encodeRuntimeApiInput(api, method, params) { + assert.equal(api, 'TransactionPaymentApi') + assert.equal(method, 'query_info') + assert.equal(params.length, 2) + const [uxt, len] = params + const encodedLen = Buffer.alloc(4) + encodedLen.writeUInt32LE(len, 0) + return Buffer.concat([Buffer.from(uxt), encodedLen]) }, decodeTypeId() { return { partial_fee: 123n } From 5a285e131b844fc22afaea1e343bcac79d2b6aee Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 11:09:15 -0700 Subject: [PATCH 48/72] fix CI again --- sdk/bittensor-ts/src/client.ts | 64 ++++++++++--------- sdk/bittensor-ts/test/basic.test.cjs | 92 ++++++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 35 deletions(-) diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index e750395c2f..a9578e80aa 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -1522,7 +1522,7 @@ export class Client { const tip = taoTransactionAmountRao(options.tip ?? 0n, 'tip') const tipAssetId = options.tipAssetId == null ? null : assetIdValue(options.tipAssetId, 'tipAssetId') const metadataHashOptionProvided = hasOwn(options, 'metadataHash') - const defaultMetadataHash = runtimeSupportsMetadataHash(runtime) || resolved.requiresMetadataProof + const defaultMetadataHash = resolved.requiresMetadataProof const chainInfo = resolved.requiresMetadataProof || (!metadataHashOptionProvided && defaultMetadataHash) ? await this.chainInfo(runtime, snapshot.blockHash) : undefined @@ -2827,28 +2827,28 @@ interface CallDescriptorEntry { path: string descriptor: Descriptor args: readonly string[] - argTypeIds: readonly number[] + argTypes: readonly string[] } const CALL_DESCRIPTOR_ENTRIES: CallDescriptorEntry[] = [ - { path: 'calls.balances.transferKeepAlive', descriptor: descriptor('Balances', 'transfer_keep_alive'), args: ['dest', 'value'], argTypeIds: [174, 176] }, - { path: 'calls.balances.transferAllowDeath', descriptor: descriptor('Balances', 'transfer_allow_death'), args: ['dest', 'value'], argTypeIds: [174, 176] }, - { path: 'calls.subtensor.addStake', descriptor: descriptor('SubtensorModule', 'add_stake'), args: ['hotkey', 'netuid', 'amount_staked'], argTypeIds: [0, 40, 6] }, - { path: 'calls.subtensor.burnedRegister', descriptor: descriptor('SubtensorModule', 'burned_register'), args: ['netuid', 'hotkey'], argTypeIds: [40, 0] }, - { path: 'calls.subtensor.commitWeights', descriptor: descriptor('SubtensorModule', 'commit_weights'), args: ['netuid', 'commit_hash'], argTypeIds: [40, 13] }, - { path: 'calls.subtensor.moveStake', descriptor: descriptor('SubtensorModule', 'move_stake'), args: ['origin_hotkey', 'destination_hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypeIds: [0, 0, 40, 40, 6] }, - { path: 'calls.subtensor.register', descriptor: descriptor('SubtensorModule', 'register'), args: ['netuid', 'block_number', 'nonce', 'work', 'hotkey', 'coldkey'], argTypeIds: [40, 6, 6, 14, 0, 0] }, - { path: 'calls.subtensor.registerNetwork', descriptor: descriptor('SubtensorModule', 'register_network'), args: ['hotkey'], argTypeIds: [0] }, - { path: 'calls.subtensor.removeStake', descriptor: descriptor('SubtensorModule', 'remove_stake'), args: ['hotkey', 'netuid', 'amount_unstaked'], argTypeIds: [0, 40, 6] }, - { path: 'calls.subtensor.revealWeights', descriptor: descriptor('SubtensorModule', 'reveal_weights'), args: ['netuid', 'uids', 'values', 'salt', 'version_key'], argTypeIds: [40, 206, 206, 206, 6] }, - { path: 'calls.subtensor.rootRegister', descriptor: descriptor('SubtensorModule', 'root_register'), args: ['hotkey'], argTypeIds: [0] }, - { path: 'calls.subtensor.serveAxon', descriptor: descriptor('SubtensorModule', 'serve_axon'), args: ['netuid', 'version', 'ip', 'port', 'ip_type', 'protocol', 'placeholder1', 'placeholder2'], argTypeIds: [40, 4, 8, 40, 2, 2, 2, 2] }, - { path: 'calls.subtensor.servePrometheus', descriptor: descriptor('SubtensorModule', 'serve_prometheus'), args: ['netuid', 'version', 'ip', 'port', 'ip_type'], argTypeIds: [40, 4, 8, 40, 2] }, - { path: 'calls.subtensor.setChildren', descriptor: descriptor('SubtensorModule', 'set_children'), args: ['hotkey', 'netuid', 'children'], argTypeIds: [0, 40, 44] }, - { path: 'calls.subtensor.setWeights', descriptor: descriptor('SubtensorModule', 'set_weights'), args: ['netuid', 'dests', 'weights', 'version_key'], argTypeIds: [40, 206, 206, 6] }, - { path: 'calls.subtensor.startCall', descriptor: descriptor('SubtensorModule', 'start_call'), args: ['netuid'], argTypeIds: [40] }, - { path: 'calls.subtensor.transferStake', descriptor: descriptor('SubtensorModule', 'transfer_stake'), args: ['destination_coldkey', 'hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypeIds: [0, 0, 40, 40, 6] }, - { path: 'calls.subtensor.unstakeAll', descriptor: descriptor('SubtensorModule', 'unstake_all'), args: ['hotkey'], argTypeIds: [0] }, + { path: 'calls.balances.transferKeepAlive', descriptor: descriptor('Balances', 'transfer_keep_alive'), args: ['dest', 'value'], argTypes: ['MultiAddress', 'Compact'] }, + { path: 'calls.balances.transferAllowDeath', descriptor: descriptor('Balances', 'transfer_allow_death'), args: ['dest', 'value'], argTypes: ['MultiAddress', 'Compact'] }, + { path: 'calls.subtensor.addStake', descriptor: descriptor('SubtensorModule', 'add_stake'), args: ['hotkey', 'netuid', 'amount_staked'], argTypes: ['AccountId32', 'u16', 'u64'] }, + { path: 'calls.subtensor.burnedRegister', descriptor: descriptor('SubtensorModule', 'burned_register'), args: ['netuid', 'hotkey'], argTypes: ['u16', 'AccountId32'] }, + { path: 'calls.subtensor.commitWeights', descriptor: descriptor('SubtensorModule', 'commit_weights'), args: ['netuid', 'commit_hash'], argTypes: ['u16', 'H256'] }, + { path: 'calls.subtensor.moveStake', descriptor: descriptor('SubtensorModule', 'move_stake'), args: ['origin_hotkey', 'destination_hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypes: ['AccountId32', 'AccountId32', 'u16', 'u16', 'u64'] }, + { path: 'calls.subtensor.register', descriptor: descriptor('SubtensorModule', 'register'), args: ['netuid', 'block_number', 'nonce', 'work', 'hotkey', 'coldkey'], argTypes: ['u16', 'u64', 'u64', 'Vec', 'AccountId32', 'AccountId32'] }, + { path: 'calls.subtensor.registerNetwork', descriptor: descriptor('SubtensorModule', 'register_network'), args: ['hotkey'], argTypes: ['AccountId32'] }, + { path: 'calls.subtensor.removeStake', descriptor: descriptor('SubtensorModule', 'remove_stake'), args: ['hotkey', 'netuid', 'amount_unstaked'], argTypes: ['AccountId32', 'u16', 'u64'] }, + { path: 'calls.subtensor.revealWeights', descriptor: descriptor('SubtensorModule', 'reveal_weights'), args: ['netuid', 'uids', 'values', 'salt', 'version_key'], argTypes: ['u16', 'Vec', 'Vec', 'Vec', 'u64'] }, + { path: 'calls.subtensor.rootRegister', descriptor: descriptor('SubtensorModule', 'root_register'), args: ['hotkey'], argTypes: ['AccountId32'] }, + { path: 'calls.subtensor.serveAxon', descriptor: descriptor('SubtensorModule', 'serve_axon'), args: ['netuid', 'version', 'ip', 'port', 'ip_type', 'protocol', 'placeholder1', 'placeholder2'], argTypes: ['u16', 'u32', 'u128', 'u16', 'u8', 'u8', 'u8', 'u8'] }, + { path: 'calls.subtensor.servePrometheus', descriptor: descriptor('SubtensorModule', 'serve_prometheus'), args: ['netuid', 'version', 'ip', 'port', 'ip_type'], argTypes: ['u16', 'u32', 'u128', 'u16', 'u8'] }, + { path: 'calls.subtensor.setChildren', descriptor: descriptor('SubtensorModule', 'set_children'), args: ['hotkey', 'netuid', 'children'], argTypes: ['AccountId32', 'u16', 'Vec<(u64, AccountId32)>'] }, + { path: 'calls.subtensor.setWeights', descriptor: descriptor('SubtensorModule', 'set_weights'), args: ['netuid', 'dests', 'weights', 'version_key'], argTypes: ['u16', 'Vec', 'Vec', 'u64'] }, + { path: 'calls.subtensor.startCall', descriptor: descriptor('SubtensorModule', 'start_call'), args: ['netuid'], argTypes: ['u16'] }, + { path: 'calls.subtensor.transferStake', descriptor: descriptor('SubtensorModule', 'transfer_stake'), args: ['destination_coldkey', 'hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypes: ['AccountId32', 'AccountId32', 'u16', 'u16', 'u64'] }, + { path: 'calls.subtensor.unstakeAll', descriptor: descriptor('SubtensorModule', 'unstake_all'), args: ['hotkey'], argTypes: ['AccountId32'] }, ] export function validateDescriptorSchema(runtime: Runtime): DescriptorSchemaIssue[] { @@ -2939,19 +2939,20 @@ export function validateDescriptorSchema(runtime: Runtime): DescriptorSchemaIssu kind: 'call', message: `call ${pallet}.${item} argument type ID metadata is unavailable`, }) - } else if (actualArgTypeIds.length !== entry.argTypeIds.length) { + } else if (actualArgTypeIds.length !== entry.argTypes.length) { issues.push({ ...entry, kind: 'call', - message: `call ${pallet}.${item} argument type ID count drifted: expected ${entry.argTypeIds.length}, got ${actualArgTypeIds.length}`, + message: `call ${pallet}.${item} argument type count drifted: expected ${entry.argTypes.length}, got ${actualArgTypeIds.length}`, }) } else { - for (let index = 0; index < entry.argTypeIds.length; index += 1) { - if (actualArgTypeIds[index] !== entry.argTypeIds[index]) { + const actualArgTypes = actualArgTypeIds.map((typeId) => runtimeTypeName(runtime, typeId)) + for (let index = 0; index < entry.argTypes.length; index += 1) { + if (actualArgTypes[index] !== entry.argTypes[index]) { issues.push({ ...entry, kind: 'call', - message: `call ${pallet}.${item} argument ${index} type ID drifted: expected ${entry.argTypeIds[index]}, got ${actualArgTypeIds[index]}`, + message: `call ${pallet}.${item} argument ${index} type drifted: expected ${entry.argTypes[index]}, got ${actualArgTypes[index]} (type ID ${actualArgTypeIds[index]})`, }) } } @@ -2976,6 +2977,14 @@ function callArgumentTypeIds(callInfo: { argTypeIds?: unknown; argTypes?: unknow return null } +function runtimeTypeName(runtime: Runtime, typeId: number): string { + try { + return runtime.typeNameOf(typeId) ?? `scale_info::${typeId}` + } catch { + return `scale_info::${typeId}` + } +} + function descriptorEntries(value: unknown, prefix: string): Array<{ path: string; descriptor: Descriptor }> { if (isDescriptor(value)) return [{ path: prefix, descriptor: value }] if (typeof value !== 'object' || value == null) return [] @@ -3271,11 +3280,6 @@ function parseNonce(value: unknown, name: string): number { return nonce } -function runtimeSupportsMetadataHash(runtime: Runtime): boolean { - const identifiers = runtime.signedExtensionIdentifiers?.() ?? [] - return identifiers.includes('CheckMetadataHash') -} - function isMetadataAtVersionUnavailable(error: unknown): boolean { const message = String(error instanceof Error ? error.message : error) const data = String(error instanceof JsonRpcError && error.data != null ? error.data : '') diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index f8a48334b3..a5a297ea52 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -1026,7 +1026,84 @@ test('descriptor schema validation reports metadata drift', () => { assert.ok(issues.some((issue) => issue.path === 'storage.Balances.TotalIssuance')) assert.ok(issues.some((issue) => issue.path === 'runtimeApi.StakeInfoRuntimeApi.get_stake_fee')) assert.ok(issues.some((issue) => issue.path === 'calls.balances.transferKeepAlive' && /argument count/.test(issue.message))) - assert.ok(issues.some((issue) => issue.path === 'calls.balances.transferAllowDeath' && /type ID drifted/.test(issue.message))) + assert.ok(issues.some((issue) => issue.path === 'calls.balances.transferAllowDeath' && /type drifted/.test(issue.message))) +}) + +test('descriptor schema validation accepts metadata-local type ID shifts', () => { + const runtime = { + pallet(name) { + if (name === 'Balances') return { storage: Object.values(core.storage.Balances).map(([, item]) => ({ name: item })) } + if (name === 'SubtensorModule') return { storage: Object.values(core.storage.SubtensorModule).map(([, item]) => ({ name: item })) } + if (name === 'System') return { storage: Object.values(core.storage.System).map(([, item]) => ({ name: item })) } + if (name === 'Timestamp') return { storage: Object.values(core.storage.Timestamp).map(([, item]) => ({ name: item })) } + if (name === 'Multisig') return { storage: Object.values(core.storage.Multisig).map(([, item]) => ({ name: item })) } + if (name === 'Proxy') return { storage: Object.values(core.storage.Proxy).map(([, item]) => ({ name: item })) } + return null + }, + constantInfo() { + return {} + }, + runtimeApis() { + return Object.fromEntries( + Object.entries(core.runtimeApi).map(([api, methods]) => [ + api, + Object.fromEntries(Object.keys(methods).map((method) => [method, { inputs: [], inputDetails: [], outputTypeId: 0 }])), + ]), + ) + }, + metadataIr() { + return { + pallets: [ + { + name: 'Balances', + calls: [ + { name: 'transfer_keep_alive', args: ['dest', 'value'], argTypeIds: [176, 178] }, + { name: 'transfer_allow_death', args: ['dest', 'value'], argTypeIds: [176, 178] }, + ], + }, + { + name: 'SubtensorModule', + calls: [ + { name: 'add_stake', args: ['hotkey', 'netuid', 'amount_staked'], argTypeIds: [0, 40, 6] }, + { name: 'burned_register', args: ['netuid', 'hotkey'], argTypeIds: [40, 0] }, + { name: 'commit_weights', args: ['netuid', 'commit_hash'], argTypeIds: [40, 13] }, + { name: 'move_stake', args: ['origin_hotkey', 'destination_hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypeIds: [0, 0, 40, 40, 6] }, + { name: 'register', args: ['netuid', 'block_number', 'nonce', 'work', 'hotkey', 'coldkey'], argTypeIds: [40, 6, 6, 14, 0, 0] }, + { name: 'register_network', args: ['hotkey'], argTypeIds: [0] }, + { name: 'remove_stake', args: ['hotkey', 'netuid', 'amount_unstaked'], argTypeIds: [0, 40, 6] }, + { name: 'reveal_weights', args: ['netuid', 'uids', 'values', 'salt', 'version_key'], argTypeIds: [40, 209, 209, 209, 6] }, + { name: 'root_register', args: ['hotkey'], argTypeIds: [0] }, + { name: 'serve_axon', args: ['netuid', 'version', 'ip', 'port', 'ip_type', 'protocol', 'placeholder1', 'placeholder2'], argTypeIds: [40, 4, 8, 40, 2, 2, 2, 2] }, + { name: 'serve_prometheus', args: ['netuid', 'version', 'ip', 'port', 'ip_type'], argTypeIds: [40, 4, 8, 40, 2] }, + { name: 'set_children', args: ['hotkey', 'netuid', 'children'], argTypeIds: [0, 40, 44] }, + { name: 'set_weights', args: ['netuid', 'dests', 'weights', 'version_key'], argTypeIds: [40, 209, 209, 6] }, + { name: 'start_call', args: ['netuid'], argTypeIds: [40] }, + { name: 'transfer_stake', args: ['destination_coldkey', 'hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypeIds: [0, 0, 40, 40, 6] }, + { name: 'unstake_all', args: ['hotkey'], argTypeIds: [0] }, + ], + }, + ], + } + }, + typeNameOf(typeId) { + return { + 0: 'AccountId32', + 2: 'u8', + 4: 'u32', + 6: 'u64', + 8: 'u128', + 13: 'H256', + 14: 'Vec', + 40: 'u16', + 44: 'Vec<(u64, AccountId32)>', + 176: 'MultiAddress', + 178: 'Compact', + 209: 'Vec', + }[typeId] ?? null + }, + } + const issues = core.validateDescriptorSchema(runtime) + assert.deepEqual(issues, []) }) test('Rust decoder unwraps Metadata_metadata_at_version responses strictly', () => { @@ -1591,7 +1668,7 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => ) }) -test('Client enables metadata hash by default for software signers when supported', async () => { +test('Client leaves metadata hash disabled by default for software signers', async () => { const callData = Buffer.from([5, 6, 7]) const { runtime, captures } = fakeSigningRuntime({ metadataBytes: goldenMetadataBytes(), @@ -1614,10 +1691,15 @@ test('Client enables metadata hash by default for software signers when supporte await client.signExtrinsic(callData, signer, { period: null }) + assert.equal(captures.encoded.params.metadataHashEnabled, false) + assert.equal(captures.payloadParams.metadataHash, null) + assert.equal(request.metadataHash, undefined) + + const explicitMetadataHash = Buffer.alloc(32, 3) + await client.signExtrinsic(callData, signer, { period: null, metadataHash: explicitMetadataHash }) assert.equal(captures.encoded.params.metadataHashEnabled, true) - assert.equal(captures.payloadParams.metadataHash.length, 32) - assert.equal(typeof request.metadataHash, 'string') - assert.equal(request.metadataHash.length, 66) + assert.deepEqual(captures.payloadParams.metadataHash, explicitMetadataHash) + assert.equal(request.metadataHash, `0x${explicitMetadataHash.toString('hex')}`) }) test('Client passes structured payloads to extension signPayload signers', async () => { From d713c32e4325536ae292131a66d60fc03bc35c24 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 11:23:33 -0700 Subject: [PATCH 49/72] address auditor review --- .github/workflows/bittensor-ts-e2e.yml | 6 +++--- sdk/bittensor-ts/src/client.ts | 15 ++++++++++++++- sdk/bittensor-ts/test/basic.test.cjs | 22 +++++++++++++++++----- ts-tests/moonwall.config.json | 1 + 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/workflows/bittensor-ts-e2e.yml b/.github/workflows/bittensor-ts-e2e.yml index b203d3f281..af9ac21054 100644 --- a/.github/workflows/bittensor-ts-e2e.yml +++ b/.github/workflows/bittensor-ts-e2e.yml @@ -255,10 +255,10 @@ jobs: strategy: matrix: include: - - variant: release - flags: "" + - variant: release + flags: "--features metadata-hash" - variant: fast - flags: "--features fast-runtime" + flags: "--features fast-runtime,metadata-hash" env: RUST_BACKTRACE: full steps: diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index a9578e80aa..0754a922b9 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -1522,7 +1522,15 @@ export class Client { const tip = taoTransactionAmountRao(options.tip ?? 0n, 'tip') const tipAssetId = options.tipAssetId == null ? null : assetIdValue(options.tipAssetId, 'tipAssetId') const metadataHashOptionProvided = hasOwn(options, 'metadataHash') - const defaultMetadataHash = resolved.requiresMetadataProof + const supportsMetadataHash = runtimeSupportsMetadataHash(runtime) + const defaultMetadataHash = supportsMetadataHash || resolved.requiresMetadataProof + if (metadataHashOptionProvided && options.metadataHash == null && defaultMetadataHash) { + throw new ChainError( + supportsMetadataHash + ? 'metadataHash cannot be disabled when the runtime declares CheckMetadataHash' + : 'metadataHash cannot be disabled for a signer that requires metadata proof', + ) + } const chainInfo = resolved.requiresMetadataProof || (!metadataHashOptionProvided && defaultMetadataHash) ? await this.chainInfo(runtime, snapshot.blockHash) : undefined @@ -3280,6 +3288,11 @@ function parseNonce(value: unknown, name: string): number { return nonce } +function runtimeSupportsMetadataHash(runtime: Runtime): boolean { + const identifiers = runtime.signedExtensionIdentifiers?.() ?? [] + return identifiers.includes('CheckMetadataHash') +} + function isMetadataAtVersionUnavailable(error: unknown): boolean { const message = String(error instanceof Error ? error.message : error) const data = String(error instanceof JsonRpcError && error.data != null ? error.data : '') diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index a5a297ea52..db16d51439 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -1668,10 +1668,11 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => ) }) -test('Client leaves metadata hash disabled by default for software signers', async () => { +test('Client enables metadata hash by default for software signers when supported', async () => { const callData = Buffer.from([5, 6, 7]) + const metadataBytes = goldenMetadataBytes() const { runtime, captures } = fakeSigningRuntime({ - metadataBytes: goldenMetadataBytes(), + metadataBytes, signedExtensionIdentifiers() { return ['CheckNonce', 'CheckMetadataHash'] }, @@ -1691,9 +1692,20 @@ test('Client leaves metadata hash disabled by default for software signers', asy await client.signExtrinsic(callData, signer, { period: null }) - assert.equal(captures.encoded.params.metadataHashEnabled, false) - assert.equal(captures.payloadParams.metadataHash, null) - assert.equal(request.metadataHash, undefined) + const expectedMetadataHash = core.metadataDigest(metadataBytes, { + specVersion: runtime.specVersion, + specName: 'node-subtensor', + base58Prefix: 42, + decimals: 9, + tokenSymbol: 'TAO', + }) + assert.equal(captures.encoded.params.metadataHashEnabled, true) + assert.deepEqual(captures.payloadParams.metadataHash, expectedMetadataHash) + assert.equal(request.metadataHash, `0x${expectedMetadataHash.toString('hex')}`) + await assert.rejects( + () => client.signExtrinsic(callData, signer, { period: null, metadataHash: null }), + /metadataHash cannot be disabled/, + ) const explicitMetadataHash = Buffer.alloc(32, 3) await client.signExtrinsic(callData, signer, { period: null, metadataHash: explicitMetadataHash }) diff --git a/ts-tests/moonwall.config.json b/ts-tests/moonwall.config.json index 951661ef9e..9d859ae744 100644 --- a/ts-tests/moonwall.config.json +++ b/ts-tests/moonwall.config.json @@ -31,6 +31,7 @@ "--no-telemetry", "--reserved-only", "--tmp", + "--execution=wasm", "--sealing=manual" ], "disableDefaultEthProviders": true, From dc718f7a361661fe2a4ad101708c4fbcfe0da828 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 11:24:52 -0700 Subject: [PATCH 50/72] fix inaccurate job names --- .github/workflows/bittensor-ts-e2e.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/bittensor-ts-e2e.yml b/.github/workflows/bittensor-ts-e2e.yml index af9ac21054..ddfa803c7c 100644 --- a/.github/workflows/bittensor-ts-e2e.yml +++ b/.github/workflows/bittensor-ts-e2e.yml @@ -1,10 +1,10 @@ -name: Bittensor TS E2E Tests +name: Bittensor TS Checks on: pull_request: concurrency: - group: bittensor-ts-e2e-${{ github.ref }} + group: bittensor-ts-checks-${{ github.ref }} cancel-in-progress: true env: @@ -27,10 +27,10 @@ jobs: # typescript-formatting is a required merge check, so the workflow must # always trigger and that job always runs (it is cheap). The expensive node - # builds and zombienet suites skip themselves when the PR touches nothing + # builds and Moonwall suites skip themselves when the PR touches nothing # they exercise; `skipped` satisfies branch protection. changes: - name: detect e2e-relevant changes + name: Detect Bittensor TS check changes runs-on: ubuntu-latest permissions: pull-requests: read @@ -51,11 +51,12 @@ jobs: if grep -qE "$pattern" <<< "$files"; then echo "e2e=true" >> "$GITHUB_OUTPUT" else - echo "no e2e-relevant paths changed" + echo "no Bittensor TS check-relevant paths changed" echo "e2e=false" >> "$GITHUB_OUTPUT" fi typescript-formatting: + name: TypeScript formatting and static checks runs-on: ubuntu-latest steps: - name: Check-out repository @@ -94,6 +95,7 @@ jobs: pnpm run fmt build-bittensor-ts: + name: Build and test bittensor-ts package runs-on: [self-hosted, fireactions-turbo-8] environment: name: sccache-writer @@ -243,8 +245,9 @@ jobs: node -e "if (typeof globalThis.WebSocket !== 'function') throw new Error('globalThis.WebSocket is required by the default WSS client path')" node --test sdk/bittensor-ts/test/*.test.cjs - # Build the node binary in both variants and share as artifacts. + # Build the node binary in both variants and share as artifacts for Moonwall. build: + name: Build node-subtensor (${{ matrix.variant }}) runs-on: [self-hosted, fireactions-turbo-8] environment: name: sccache-writer From 6bfcd6137ca27938419bbaaf0e251a0886d20195 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 12:41:32 -0700 Subject: [PATCH 51/72] another round of fixes --- sdk/bittensor-ts/src/browser.ts | 10 + sdk/bittensor-ts/src/client.ts | 120 +++++++++-- sdk/bittensor-ts/src/keys.ts | 1 + sdk/bittensor-ts/test/basic.test.cjs | 289 ++++++++++++++++++++++++++- 4 files changed, 403 insertions(+), 17 deletions(-) diff --git a/sdk/bittensor-ts/src/browser.ts b/sdk/bittensor-ts/src/browser.ts index e567a9e1d6..5350dd2bdb 100644 --- a/sdk/bittensor-ts/src/browser.ts +++ b/sdk/bittensor-ts/src/browser.ts @@ -822,6 +822,7 @@ export class Keypair { const rawSignature = hasType ? suppliedSignature.subarray(1) : suppliedSignature if (signerPublic == null) { + if (hasType && cryptoType !== this.cryptoType) return false return this.handle.verify(typeof message === 'string' ? message : toBytes(message, 'message'), rawSignature) } if (typeof signerPublic === 'string' && !signerPublic.startsWith('0x')) { @@ -1026,6 +1027,13 @@ export function revealRound(encryptedData: BrowserByteLike): number { export function sealMevShieldTransaction( publicKey: BrowserByteLike, plaintext: BrowserByteLike, +): Uint8Array { + return encryptMlkem768(publicKey, plaintext, true) +} + +export function encryptMlkem768( + publicKey: BrowserByteLike, + plaintext: BrowserByteLike, includeKeyHash = false, ): Uint8Array { return copyBytes( @@ -1037,6 +1045,8 @@ export function sealMevShieldTransaction( ) } +export const encrypt_mlkem768 = encryptMlkem768 + export function mlkemKdfId(): Uint8Array { return copyBytes(wasmSync().mlkemKdfId()) } diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 0754a922b9..a7d04ef6f4 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -63,6 +63,7 @@ export interface ClientOptions { retryBackoffMs?: number maxRetryBackoffMs?: number endpointValidationTtlMs?: number + maxMessageBytes?: number } export interface RpcRequestOptions { @@ -606,17 +607,16 @@ export class JsonRpcTransport { options: RpcRequestOptions, ): Promise { const request = withRequestSignal(options) + const id = this.id++ try { const response = await fetch(attempt.endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: this.id++, method, params }), + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), signal: request.signal, }) if (!response.ok) throw new JsonRpcError(`HTTP ${response.status} from ${attempt.endpoint}`) - const payload = await response.json() - if (payload.error) throw new JsonRpcError(payload.error.message, payload.error.code, payload.error.data) - return payload.result + return jsonRpcResponseResult(await parseJsonRpcResponse(response, this.maxMessageBytes), id) } catch (error) { throw normalizeAbortError(error, request) } finally { @@ -959,6 +959,7 @@ export class Client { retryBackoffMs: options.retryBackoffMs, maxRetryBackoffMs: options.maxRetryBackoffMs, endpointValidationTtlMs: options.endpointValidationTtlMs, + maxMessageBytes: options.maxMessageBytes, }) this.balances = new BalancesNamespace(this) this.subnets = new SubnetsNamespace(this) @@ -1192,10 +1193,10 @@ export class Client { ): Promise { const [moduleName, itemName, itemParams, blockRef] = normalizeStorageArgs(pallet, storageFunction, paramsOrBlock, block) - const blockHash = await this.resolveBlockHash(blockRef) + const blockHash = await this.resolveReadBlockHash(blockRef) const runtime = await this.runtimeAt(blockHash) const key = runtime.storageKey(moduleName, itemName, itemParams) - const raw = await this.rpc('state_getStorage', [hex(key), ...(blockHash == null ? [] : [blockHash])]) + const raw = await this.rpc('state_getStorage', [hex(key), blockHash]) const entry = runtime.storageEntry(moduleName, itemName) return decodeStorageValue(runtime, entry, raw) } @@ -1209,10 +1210,10 @@ export class Client { const [moduleName, itemName, sets, blockRef] = normalizeBatchArgs(pallet, storageFunction, paramSetsOrBlock, block) if (sets.length === 0) return [] - const blockHash = await this.resolveBlockHash(blockRef) + const blockHash = await this.resolveReadBlockHash(blockRef) const runtime = await this.runtimeAt(blockHash) const keys = runtime.storageKeyBatch(moduleName, itemName, sets) - const raw = await this.rpc('state_queryStorageAt', [keys.map(hex), ...(blockHash == null ? [] : [blockHash])]) + const raw = await this.rpc('state_queryStorageAt', [keys.map(hex), blockHash]) const changes = ((raw as Array<{ changes?: Array<[string, string | null]> }>)[0]?.changes ?? []) const valueByKey = new Map(changes.map(([key, value]) => [key.toLowerCase(), value])) const entry = runtime.storageEntry(moduleName, itemName) @@ -1317,12 +1318,12 @@ export class Client { block?: number | string | null, ): Promise { const [apiName, methodName, callParams, blockRef] = normalizeRuntimeArgs(api, method, paramsOrBlock, block) - const blockHash = await this.resolveBlockHash(blockRef) + const blockHash = await this.resolveReadBlockHash(blockRef) const runtime = await this.runtimeAt(blockHash) const info = runtime.runtimeApis()[apiName]?.[methodName] if (info == null) throw new ChainError(`runtime API ${apiName}.${methodName} not found`) const encoded = runtime.encodeRuntimeApiInput(apiName, methodName, callParams) - const raw = await this.rpc('state_call', [`${apiName}_${methodName}`, hex(encoded), blockHash ?? null]) + const raw = await this.rpc('state_call', [`${apiName}_${methodName}`, hex(encoded), blockHash]) return runtime.decodeTypeId(info.outputTypeId, hexToBuffer(String(raw)), false) } @@ -1640,14 +1641,23 @@ export class Client { else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) throw error } - const returnedHash = normalizeHash32(hash, 'author_submitExtrinsic hash') - if (reservation != null) await this.submitNonce(reservation) - else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) + let returnedHash: string + try { + returnedHash = normalizeHash32(hash, 'author_submitExtrinsic hash') + } catch (error) { + if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) + else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) + throw error + } if (!sameHex(returnedHash, extrinsicHash)) { + if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) + else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) throw new ChainError( `author_submitExtrinsic returned hash ${returnedHash}, expected ${extrinsicHash}`, ) } + if (reservation != null) await this.submitNonce(reservation) + else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) return { status: 'submitted', message: 'Submitted', extrinsicHash, events: [] } } @@ -3315,6 +3325,88 @@ function signerPublicKey(value: ByteLike | undefined, ss58Address: string): Buff : toBuffer(value, 'signer.publicKey') } +async function parseJsonRpcResponse(response: Response, maxBytes: number): Promise { + const responseLike = response as Response & { json?: () => Promise } + if (response.body != null || typeof response.text === 'function') { + const raw = await readResponseText(response, maxBytes) + try { + return JSON.parse(raw) as unknown + } catch { + throw new JsonRpcError('invalid JSON-RPC response') + } + } + if (typeof responseLike.json === 'function') return responseLike.json() + throw new JsonRpcError('HTTP JSON-RPC response body is not readable') +} + +async function readResponseText(response: Response, maxBytes: number): Promise { + const body = response.body + if (body == null || typeof body.getReader !== 'function') { + const text = await response.text() + if (Buffer.byteLength(text, 'utf8') > maxBytes) { + throw new JsonRpcError('JSON-RPC response exceeded size limit') + } + return text + } + + const reader = body.getReader() + const chunks: Buffer[] = [] + let total = 0 + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (value == null) continue + const chunk = Buffer.from(value) + total += chunk.byteLength + if (total > maxBytes) { + await reader.cancel().catch(() => undefined) + throw new JsonRpcError('JSON-RPC response exceeded size limit') + } + chunks.push(chunk) + } + } finally { + reader.releaseLock() + } + return Buffer.concat(chunks, total).toString('utf8') +} + +function jsonRpcResponseResult(payload: unknown, expectedId: number): unknown { + if (payload == null || typeof payload !== 'object' || Array.isArray(payload)) { + throw new JsonRpcError('invalid JSON-RPC response envelope') + } + const envelope = payload as { + jsonrpc?: unknown + id?: unknown + result?: unknown + error?: unknown + } + if (envelope.jsonrpc !== '2.0') { + throw new JsonRpcError('invalid JSON-RPC response version') + } + if (envelope.id !== expectedId) { + throw new JsonRpcError('JSON-RPC response id did not match request id') + } + const hasResult = hasOwn(envelope, 'result') + const hasError = hasOwn(envelope, 'error') + if (hasResult === hasError) { + throw new JsonRpcError('JSON-RPC response must contain exactly one of result or error') + } + if (hasError) { + const error = envelope.error + if (error == null || typeof error !== 'object') { + throw new JsonRpcError('invalid JSON-RPC error response') + } + const rpcError = error as { message?: unknown; code?: unknown; data?: unknown } + throw new JsonRpcError( + String(rpcError.message ?? 'JSON-RPC error'), + typeof rpcError.code === 'number' ? rpcError.code : undefined, + rpcError.data, + ) + } + return envelope.result +} + function extensionSignPayload(context: SignerPayloadContext): ExtensionSignPayloadRequest { return { address: context.address, @@ -3328,7 +3420,7 @@ function extensionSignPayload(context: SignerPayloadContext): ExtensionSignPaylo specVersion: u32Hex(context.runtime.specVersion), tip: compactIntegerHex(context.txParams.tip ?? 0n), transactionVersion: u32Hex(context.runtime.transactionVersion), - version: 4, + version: context.runtime.extrinsicVersion, assetId: context.txParams.tipAssetId == null ? null : compactIntegerHex(context.txParams.tipAssetId), metadataHash: context.metadataHash == null ? undefined : hex(context.metadataHash), mode: context.metadataHash == null ? 0 : 1, diff --git a/sdk/bittensor-ts/src/keys.ts b/sdk/bittensor-ts/src/keys.ts index f3f958d120..dd49bb992a 100644 --- a/sdk/bittensor-ts/src/keys.ts +++ b/sdk/bittensor-ts/src/keys.ts @@ -427,6 +427,7 @@ export class Keypair implements PolkadotCompatibleKeypair { const rawSignature = hasType ? suppliedSignature.subarray(1) : suppliedSignature if (signerPublic == null) { + if (hasType && cryptoType !== this.cryptoType) return false return nativeCall(() => this.handle.verify(coerceMessage(message), rawSignature)) } if (typeof signerPublic === 'string' && !signerPublic.startsWith('0x')) { diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index db16d51439..a409c6939f 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -61,6 +61,7 @@ function fakeSigningRuntime(overrides = {}) { const runtime = { specVersion: 419, transactionVersion: 1, + extrinsicVersion: 4, ss58Format: 42, metadataBytes: Buffer.alloc(0), signaturePayloadParts(params) { @@ -620,6 +621,19 @@ test('Rust keypair is compatible with Polkadot.js and Moonwall signer expectatio // for the same payload are both valid but are not required to be identical. assert.equal(alice.verify(payload, raw, alice.publicKey), true) assert.equal(alice.verify(payload, typed, alice.publicKey), true) + const srSignatureLabelledEd25519 = Buffer.concat([ + Buffer.from([core.CRYPTO_ED25519]), + raw, + ]) + assert.equal(alice.verify(payload, srSignatureLabelledEd25519), false) + + const aliceEd25519 = core.Keypair.fromUri('//Alice', core.CRYPTO_ED25519) + const edRaw = aliceEd25519.sign(payload) + const edSignatureLabelledSr25519 = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + edRaw, + ]) + assert.equal(aliceEd25519.verify(payload, edSignatureLabelledSr25519), false) }) test('mnemonics and secret URIs never appear in public keypair metadata', () => { @@ -722,6 +736,84 @@ test('public constants and low-level hashes are exposed', () => { assert.ok(core.PARALLEL_DECODE_THRESHOLD > 0) }) +test('MEV shield high-level helper prefixes ciphertext with key hash', () => { + const publicKey = Buffer.alloc(1184, 0x42) + const ciphertext = core.sealMevShieldTransaction(publicKey, Buffer.from('payload')) + + assert.deepEqual(ciphertext.subarray(0, 16), core.twox_128(publicKey)) + assert.equal(ciphertext.readUInt16LE(16), 1088) +}) + +test('browser MEV shield wrapper keeps high-level prefix and low-level option', async () => { + const browser = await import(`${pathToFileURL(path.join(__dirname, '..', 'dist', 'browser.mjs')).href}?shield=${Date.now()}`) + const calls = [] + browser.configureBrowserWasm(async () => ({ + default: async () => undefined, + encryptMlkem768(publicKey, plaintext, includeKeyHash = false) { + calls.push({ + publicKey: Array.from(publicKey), + plaintext: Array.from(plaintext), + includeKeyHash, + }) + return Uint8Array.of(includeKeyHash ? 1 : 0, publicKey[0] ?? 0, plaintext[0] ?? 0) + }, + })) + await browser.initBrowser() + + assert.deepEqual( + Array.from(browser.sealMevShieldTransaction(Uint8Array.of(7), Uint8Array.of(8))), + [1, 7, 8], + ) + assert.equal(calls.at(-1).includeKeyHash, true) + assert.deepEqual( + Array.from(browser.encryptMlkem768(Uint8Array.of(7), Uint8Array.of(8), false)), + [0, 7, 8], + ) + assert.equal(calls.at(-1).includeKeyHash, false) +}) + +test('browser Keypair.verify rejects wrong typed signature scheme before native verify', async () => { + const browser = await import(`${pathToFileURL(path.join(__dirname, '..', 'dist', 'browser.mjs')).href}?verify=${Date.now()}`) + let verifyCalls = 0 + class FakeKeypair { + constructor(ss58Address, publicKey, cryptoType = browser.CRYPTO_SR25519, ss58Format = 42) { + this.ss58Address = ss58Address ?? '5Fake' + this.publicKey = publicKey ?? new Uint8Array(32) + this.cryptoType = cryptoType + this.ss58Format = ss58Format + this.kind = cryptoType === browser.CRYPTO_ED25519 ? 'Ed25519' : 'Sr25519' + } + + sign() { + return new Uint8Array(64) + } + + verify() { + verifyCalls += 1 + return true + } + + derive() { + return this + } + } + browser.configureBrowserWasm(async () => ({ + default: async () => undefined, + Keypair: FakeKeypair, + })) + await browser.initBrowser() + const keypair = new browser.Keypair('5Fake', new Uint8Array(32), browser.CRYPTO_SR25519, 42) + const wrong = new Uint8Array(65) + wrong[0] = browser.CRYPTO_ED25519 + const right = new Uint8Array(65) + right[0] = browser.CRYPTO_SR25519 + + assert.equal(keypair.verify(Uint8Array.of(1), wrong), false) + assert.equal(verifyCalls, 0) + assert.equal(keypair.verify(Uint8Array.of(1), right), true) + assert.equal(verifyCalls, 1) +}) + test('epoch schedule functions stay in Rust', () => { const state = { lastEpochBlock: 0n, @@ -1183,6 +1275,62 @@ test('JsonRpcTransport rejects pending requests on malformed websocket JSON', as await assert.rejects(pending, /invalid JSON-RPC message/) }) +test('JsonRpcTransport validates HTTP JSON-RPC envelopes and response size', async (t) => { + const originalFetch = globalThis.fetch + t.after(() => { + globalThis.fetch = originalFetch + }) + + let mode = 'id' + globalThis.fetch = async (_url, init) => { + const request = JSON.parse(String(init.body)) + let body + if (mode === 'id') { + body = JSON.stringify({ jsonrpc: '2.0', id: request.id + 1, result: '0x00' }) + } else if (mode === 'both') { + body = JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: '0x00', + error: { message: 'also bad' }, + }) + } else { + body = JSON.stringify({ jsonrpc: '2.0', id: request.id, result: 'x'.repeat(128) }) + } + return new Response(body, { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + + const transport = new core.JsonRpcTransport('http://node-a', [], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + maxMessageBytes: 256, + }) + await assert.rejects( + () => transport.request('state_getMetadata'), + /id did not match/, + ) + + mode = 'both' + await assert.rejects( + () => transport.request('state_getMetadata'), + /exactly one of result or error/, + ) + + mode = 'large' + const cappedTransport = new core.JsonRpcTransport('http://node-a', [], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + maxMessageBytes: 32, + }) + await assert.rejects( + () => cappedTransport.request('state_getMetadata'), + /exceeded size limit/, + ) +}) + test('JsonRpcTransport caps subscription notification queues', async (t) => { const { FakeWebSocket, restore } = installFakeWebSocket() t.after(restore) @@ -1547,6 +1695,7 @@ test('Client caches historical runtimes by block hash with LRU eviction', async test('Client queryBatch decodes metadata defaults for missing storage values', async () => { const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const blockHash = `0x${'33'.repeat(32)}` const keys = [Buffer.from([1]), Buffer.from([2])] const runtime = { storageKeyBatch() { @@ -1570,16 +1719,84 @@ test('Client queryBatch decodes metadata defaults for missing storage values', a return bytes[0] }, } - client.runtimeAt = async () => runtime - client.resolveBlockHash = async () => null + client.finalizedHead = async () => blockHash + client.runtimeAt = async (block) => { + assert.equal(block, blockHash) + return runtime + } client.rpc = async (method, params = []) => { assert.equal(method, 'state_queryStorageAt') + assert.deepEqual(params, [['0x01', '0x02'], blockHash]) return [{ changes: [['0x01', '0x05']] }] } assert.deepEqual(await client.queryBatch('Example', 'Value', [[], []]), [5, 9]) }) +test('Client query and runtimeCall pin default reads to one finalized block', async () => { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const blockHash = `0x${'34'.repeat(32)}` + const calls = [] + const runtime = { + storageKey() { + return Buffer.from([3]) + }, + storageEntry() { + return { + pallet: 'Example', + name: 'Value', + prefix: 'Example', + modifier: 'Default', + valueType: 'u8', + valueTypeId: 0, + paramTypes: [], + paramTypeIds: [], + paramHashers: [], + defaultBytes: Buffer.from([0]), + } + }, + decode(_type, bytes) { + return bytes[0] + }, + runtimeApis() { + return { + ExampleApi: { + thing: { + inputDetails: [], + outputTypeId: 7, + outputType: 'u8', + }, + }, + } + }, + encodeRuntimeApiInput() { + return Buffer.from([9, 9]) + }, + decodeTypeId(typeId, bytes) { + assert.equal(typeId, 7) + return bytes[0] + }, + } + client.finalizedHead = async () => blockHash + client.runtimeAt = async (block) => { + assert.equal(block, blockHash) + return runtime + } + client.rpc = async (method, params = []) => { + calls.push({ method, params }) + if (method === 'state_getStorage') return '0x05' + if (method === 'state_call') return '0x07' + throw new Error(`unexpected RPC ${method}`) + } + + assert.equal(await client.query('Example', 'Value'), 5) + assert.equal(await client.runtimeCall('ExampleApi', 'thing'), 7) + assert.deepEqual(calls, [ + { method: 'state_getStorage', params: ['0x03', blockHash] }, + { method: 'state_call', params: ['ExampleApi_thing', '0x0909', blockHash] }, + ]) +}) + test('Client queryMap pins reads and rejects pagination without progress', async () => { const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) const blockHash = `0x${'44'.repeat(32)}` @@ -1717,6 +1934,7 @@ test('Client enables metadata hash by default for software signers when supporte test('Client passes structured payloads to extension signPayload signers', async () => { const callData = Buffer.from([5, 6, 7]) const { runtime, captures } = fakeSigningRuntime({ + extrinsicVersion: 5, signedExtensionIdentifiers() { return ['CheckNonce'] }, @@ -1741,7 +1959,7 @@ test('Client passes structured payloads to extension signPayload signers', async assert.equal(payload.address, address) assert.equal(payload.method, '0x050607') - assert.equal(payload.version, 4) + assert.equal(payload.version, 5) assert.deepEqual(payload.signedExtensions, ['CheckNonce']) assert.equal(captures.encoded.params.metadataHashEnabled, false) }) @@ -1762,6 +1980,71 @@ test('Client rejects mismatched submit hashes and keeps local hash authoritative await assert.rejects(() => client.submitSigned(Buffer.from([1, 2])), /returned hash/) }) +test('Client reconciles managed nonce before rejecting mismatched submit hash', async () => { + const callData = Buffer.from([7, 5, 3]) + const capturedNonces = [] + const { runtime } = fakeSigningRuntime({ + signaturePayload(_callData, params) { + capturedNonces.push(params.nonce) + return Buffer.from([Number(params.nonce)]) + }, + encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { + return { + bytes: Buffer.from([signatureVersion, Number(params.nonce)]), + hash: Buffer.alloc(32, Number(params.nonce)), + } + }, + }) + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 22) + const address = core.ss58FromPublic(publicKey, 42) + const typedSignature = Buffer.concat([ + Buffer.from([core.CRYPTO_SR25519]), + Buffer.alloc(64, 4), + ]) + const nonceReads = [] + client.rpc = async (method, params = []) => { + if (method === 'system_accountNextIndex') { + nonceReads.push(params[0]) + return 30 + } + if (method === 'state_getRuntimeVersion') { + return { + specName: 'node-subtensor', + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion, + } + } + if (method === 'system_properties') { + return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } + } + if (method === 'author_submitExtrinsic') return `0x${'ff'.repeat(32)}` + if (method === 'author_pendingExtrinsics') return [] + if (method === 'chain_getHeader') return { number: '0x0' } + if (method === 'chain_getBlockHash') return `0x${'02'.repeat(32)}` + if (method === 'chain_getBlock') return { block: { extrinsics: [] } } + throw new Error(`unexpected RPC ${method}`) + } + const signer = { + address, + publicKey, + signRaw() { + return { signature: `0x${typedSignature.toString('hex')}` } + }, + } + + await assert.rejects( + () => client.submit(callData, signer, { period: null }), + /returned hash/, + ) + await assert.rejects( + () => client.submit(callData, signer, { period: null }), + /nonce 30 .* ambiguous/, + ) + assert.deepEqual(capturedNonces, [30]) + assert.deepEqual(nonceReads, [address, address, address]) +}) + test('Client estimateFee peeks the chain nonce without reserving it', async () => { const callData = Buffer.from([9, 8, 7]) const { runtime, captures } = fakeSigningRuntime({ From b4308287d5db7640bac7f9b0bcffc283ad4a9c54 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 12:59:04 -0700 Subject: [PATCH 52/72] fix e2e --- sdk/bittensor-ts/scripts/check-native-parity.cjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/bittensor-ts/scripts/check-native-parity.cjs b/sdk/bittensor-ts/scripts/check-native-parity.cjs index 161dca5f84..1937e287ed 100755 --- a/sdk/bittensor-ts/scripts/check-native-parity.cjs +++ b/sdk/bittensor-ts/scripts/check-native-parity.cjs @@ -515,6 +515,8 @@ const browserWrapperExpected = allowlistedSurface( 'decryptWithSignature', 'encrypt', 'encryptAtRound', + 'encryptMlkem768', + 'encrypt_mlkem768', 'eraBirth', 'generateCommitV2', 'generateExtrinsicProof', From 7ab5ca4ecd824d40330b54072b7dff519f5fc809 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 14:19:31 -0700 Subject: [PATCH 53/72] another review round --- sdk/bittensor-core/src/codec/extrinsic.rs | 234 ++++++++++++++++++++-- sdk/bittensor-core/src/keyfiles/mod.rs | 77 ++++++- sdk/bittensor-ts/native/src/keys.rs | 7 + sdk/bittensor-ts/native/src/runtime.rs | 59 +++++- sdk/bittensor-ts/src/balance.ts | 65 ++++-- sdk/bittensor-ts/src/client.ts | 125 +++++------- sdk/bittensor-ts/src/keys.ts | 32 +++ sdk/bittensor-ts/src/modules.ts | 4 + sdk/bittensor-ts/src/native.ts | 24 +++ sdk/bittensor-ts/src/runtime.ts | 17 ++ sdk/bittensor-ts/test/basic.test.cjs | 181 ++++++++++++++++- 11 files changed, 720 insertions(+), 105 deletions(-) diff --git a/sdk/bittensor-core/src/codec/extrinsic.rs b/sdk/bittensor-core/src/codec/extrinsic.rs index 2323274bca..23b52a6848 100644 --- a/sdk/bittensor-core/src/codec/extrinsic.rs +++ b/sdk/bittensor-core/src/codec/extrinsic.rs @@ -13,7 +13,7 @@ use crate::codec::encode::compact; use crate::codec::value::Value; use crate::error::CoreError; use crate::keys::ss58_from_public; -use crate::runtime::Runtime; +use crate::runtime::{Runtime, SignedExtensionInfo}; /// Everything one signature payload / signed extrinsic needs beyond the call. pub struct TxParams { @@ -29,6 +29,28 @@ pub struct TxParams { pub metadata_hash: Option<[u8; 32]>, } +/// Polkadot-compatible JSON payload for extension/browser signers. Each +/// signed-extension field is encoded through the runtime metadata type that +/// also drives [`Runtime::signature_payload`], so the display payload and the +/// bytes Rust assembles for the final extrinsic cannot drift. +pub struct SignerPayload { + pub address: String, + pub block_hash: String, + pub block_number: String, + pub era: String, + pub genesis_hash: String, + pub method: String, + pub nonce: String, + pub signed_extensions: Vec, + pub spec_version: String, + pub tip: String, + pub transaction_version: String, + pub version: u8, + pub asset_id: Option, + pub metadata_hash: Option, + pub mode: Option, +} + /// The signature payload is ``call ++ extra ++ additional``: each signed /// extension the runtime declares contributes its "extra" bytes (signed /// alongside the call, `ty`) and then its "additional" bytes (implied data @@ -61,6 +83,168 @@ enum Slot { } impl Runtime { + fn ensure_payload_params_supported(&self, params: &TxParams) -> Result<(), CoreError> { + if params.metadata_hash.is_some() + && !self + .extrinsic + .signed_extensions + .iter() + .any(|e| e.identifier == "CheckMetadataHash") + { + return Err(CoreError::Codec( + "this runtime does not declare CheckMetadataHash".into(), + )); + } + if params.tip_asset_id.is_some() + && !self + .extrinsic + .signed_extensions + .iter() + .any(|e| e.identifier == "ChargeAssetTxPayment") + { + return Err(CoreError::Codec( + "this runtime does not declare ChargeAssetTxPayment".into(), + )); + } + Ok(()) + } + + fn hex_prefixed(data: &[u8]) -> String { + format!("0x{}", hex::encode(data)) + } + + fn u32_le_hex(value: u32) -> String { + Self::hex_prefixed(&value.to_le_bytes()) + } + + fn record_u64_field(value: &Value, name: &str) -> Option { + match value { + Value::Dict(entries) => entries.iter().find_map(|(k, v)| match (k, v) { + (Value::Str(key), Value::Int(i)) if key == name => u64::try_from(*i).ok(), + (Value::Str(key), Value::Uint(u)) if key == name => u64::try_from(*u).ok(), + _ => None, + }), + _ => None, + } + } + + fn signer_payload_block_number(era: &Value) -> Result { + let current = Self::record_u64_field(era, "current").unwrap_or(0); + let current = u32::try_from(current) + .map_err(|_| CoreError::Codec("mortal era current block does not fit u32".into()))?; + Ok(Self::u32_le_hex(current)) + } + + fn signed_extension(&self, identifier: &str) -> Option<&SignedExtensionInfo> { + self.extrinsic + .signed_extensions + .iter() + .find(|e| e.identifier == identifier) + } + + fn named_field_type( + &self, + composite_ty: u32, + field_name: &str, + ) -> Result, CoreError> { + let TypeDef::Composite(composite) = &self.resolve(composite_ty)?.type_def else { + return Ok(None); + }; + if !composite.fields.iter().any(|field| field.name.is_some()) { + return Ok(None); + } + composite + .fields + .iter() + .find(|field| field.name.as_deref() == Some(field_name)) + .map(|field| Ok(Some(field.ty.id))) + .unwrap_or_else(|| { + Err(CoreError::Codec(format!( + "signed extension field {field_name:?} is not present in metadata type {composite_ty}" + ))) + }) + } + + fn extension_field_type( + &self, + extension: &SignedExtensionInfo, + field_name: &str, + ) -> Result { + self.named_field_type(extension.ty, field_name)? + .map_or(Ok(extension.ty), Ok) + } + + fn encode_value_hex(&self, ty: u32, value: &Value) -> Result { + let mut out = Vec::new(); + self.encode_id(ty, value, &mut out)?; + Ok(Self::hex_prefixed(&out)) + } + + fn encode_extension_field_hex( + &self, + extension: &SignedExtensionInfo, + field_name: &str, + value: &Value, + ) -> Result { + let ty = self.extension_field_type(extension, field_name)?; + self.encode_value_hex(ty, value) + } + + fn signer_payload_nonce(&self, params: &TxParams) -> Result { + match self.signed_extension("CheckNonce") { + Some(extension) => self.encode_extension_field_hex( + extension, + "nonce", + &Value::Uint(u128::from(params.nonce)), + ), + None => Ok("0x00".into()), + } + } + + fn signer_payload_tip(&self, params: &TxParams) -> Result { + for extension in &self.extrinsic.signed_extensions { + match extension.identifier.as_str() { + "ChargeTransactionPayment" | "ChargeAssetTxPayment" => { + return self.encode_extension_field_hex( + extension, + "tip", + &Value::Uint(params.tip), + ); + } + _ => {} + } + } + Ok("0x00".into()) + } + + fn signer_payload_asset_id(&self, params: &TxParams) -> Result, CoreError> { + let Some(id) = params.tip_asset_id else { + return Ok(None); + }; + let Some(extension) = self.signed_extension("ChargeAssetTxPayment") else { + return Ok(None); + }; + self.encode_extension_field_hex(extension, "asset_id", &Value::Uint(id)) + .map(Some) + } + + fn signer_payload_mode(&self, params: &TxParams) -> Result, CoreError> { + let Some(extension) = self.signed_extension("CheckMetadataHash") else { + return Ok(None); + }; + let ty = self.extension_field_type(extension, "mode")?; + let value = self.payload_field_value("mode", ty, params)?; + let mut out = Vec::new(); + self.encode_id(ty, &value, &mut out)?; + match out.as_slice() { + [mode] => Ok(Some(*mode)), + _ => Err(CoreError::Codec(format!( + "CheckMetadataHash mode encoded to {} bytes, expected one byte", + out.len() + ))), + } + } + /// The fixed byte length a signature variant carries, when it wraps a /// single `[u8; N]` (the MultiSignature shape). `None` for variants whose /// payload is not a fixed byte array, where a length check does not apply. @@ -218,23 +402,49 @@ impl Runtime { &self, params: &TxParams, ) -> Result<(Vec, Vec), CoreError> { - if params.metadata_hash.is_some() - && !self - .extrinsic - .signed_extensions - .iter() - .any(|e| e.identifier == "CheckMetadataHash") - { - return Err(CoreError::Codec( - "this runtime does not declare CheckMetadataHash".into(), - )); - } + self.ensure_payload_params_supported(params)?; Ok(( self.encode_payload_section(Slot::Extrinsic, params)?, self.encode_payload_section(Slot::AdditionalSigned, params)?, )) } + /// Build the structured signer payload expected by browser/extension + /// signers. The same metadata-driven encoders as the raw payload path are + /// used for era, nonce, tip, asset id, and metadata-hash mode. + pub fn signer_payload( + &self, + address: &str, + call_data: &[u8], + params: &TxParams, + ) -> Result { + self.ensure_payload_params_supported(params)?; + let mut era = Vec::new(); + self.encode_era_value(¶ms.era, &mut era)?; + Ok(SignerPayload { + address: address.into(), + block_hash: Self::hex_prefixed(¶ms.era_block_hash), + block_number: Self::signer_payload_block_number(¶ms.era)?, + era: Self::hex_prefixed(&era), + genesis_hash: Self::hex_prefixed(¶ms.genesis_hash), + method: Self::hex_prefixed(call_data), + nonce: self.signer_payload_nonce(params)?, + signed_extensions: self + .extrinsic + .signed_extensions + .iter() + .map(|extension| extension.identifier.clone()) + .collect(), + spec_version: Self::u32_le_hex(self.spec_version), + tip: self.signer_payload_tip(params)?, + transaction_version: Self::u32_le_hex(self.transaction_version), + version: self.extrinsic.version, + asset_id: self.signer_payload_asset_id(params)?, + metadata_hash: params.metadata_hash.map(|hash| Self::hex_prefixed(&hash)), + mode: self.signer_payload_mode(params)?, + }) + } + /// The exact bytes a signer signs for the given raw call. Payloads longer /// than 256 bytes are blake2b-256 hashed, per the Substrate convention. pub fn signature_payload( diff --git a/sdk/bittensor-core/src/keyfiles/mod.rs b/sdk/bittensor-core/src/keyfiles/mod.rs index f58baea837..42793a67da 100644 --- a/sdk/bittensor-core/src/keyfiles/mod.rs +++ b/sdk/bittensor-core/src/keyfiles/mod.rs @@ -10,7 +10,7 @@ use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; #[cfg(unix)] use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use base64::{engine::general_purpose, Engine as _}; use fernet::Fernet; @@ -383,12 +383,14 @@ fn prepare_keyfile_target(path: &Path, overwrite: bool) -> Result<(), CoreError> } fn ensure_private_directory(path: &Path) -> Result<(), CoreError> { + reject_symlink_ancestors(path)?; fs::create_dir_all(path).map_err(|error| { key_err(format!( "failed to create wallet directory {}: {error}", path.display() )) })?; + reject_symlink_ancestors(path)?; let metadata = fs::symlink_metadata(path).map_err(|error| { key_err(format!( "failed to inspect wallet directory {}: {error}", @@ -404,6 +406,51 @@ fn ensure_private_directory(path: &Path) -> Result<(), CoreError> { set_private_directory_permissions(path) } +fn reject_symlink_ancestors(path: &Path) -> Result<(), CoreError> { + let mut current = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => continue, + Component::ParentDir => { + return Err(key_err(format!( + "wallet path {} must not contain parent directory components", + path.display() + ))); + } + Component::Prefix(prefix) => current.push(prefix.as_os_str()), + Component::RootDir | Component::Normal(_) => current.push(component.as_os_str()), + } + if current.as_os_str().is_empty() { + continue; + } + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(key_err(format!( + "wallet path {} must not contain symlink ancestor {}", + path.display(), + current.display() + ))); + } + if !metadata.is_dir() { + return Err(key_err(format!( + "wallet path ancestor {} must be a directory", + current.display() + ))); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(error) => { + return Err(key_err(format!( + "failed to inspect wallet path ancestor {}: {error}", + current.display() + ))); + } + } + } + Ok(()) +} + #[cfg(unix)] fn set_private_directory_permissions(path: &Path) -> Result<(), CoreError> { fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| { @@ -1012,4 +1059,32 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + + #[test] + #[cfg(unix)] + fn rejects_symlink_wallet_directory_ancestor() { + use std::os::unix::fs::symlink; + + let dir = temp_test_dir("symlink-ancestor"); + let real = dir.join("real"); + let link = dir.join("link"); + fs::create_dir(&real).unwrap(); + symlink(&real, &link).unwrap(); + let keypair = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_SR25519, None).unwrap(); + + let err = save_keypair_to_keyfile( + &keypair, + &link.join("wallet").join("hotkey"), + Some("test-password"), + false, + false, + ) + .expect_err("symlink ancestors must be rejected"); + assert!( + err.to_string().contains("symlink ancestor"), + "unexpected error: {err}" + ); + + fs::remove_dir_all(dir).unwrap(); + } } diff --git a/sdk/bittensor-ts/native/src/keys.rs b/sdk/bittensor-ts/native/src/keys.rs index 8c494851a0..f039500198 100644 --- a/sdk/bittensor-ts/native/src/keys.rs +++ b/sdk/bittensor-ts/native/src/keys.rs @@ -365,6 +365,13 @@ pub fn serialize_keypair(keypair: &NativeKeypair) -> NapiResult { .map(Into::into) } +#[napi(js_name = "dangerouslySerializeKeypair")] +pub fn dangerously_serialize_keypair(keypair: &NativeKeypair) -> NapiResult { + keyfiles::serialized_keypair_to_keyfile_data(&keypair.inner) + .napi() + .map(Into::into) +} + #[napi(js_name = "keypairToKeyfileData")] pub fn keypair_to_keyfile_data( keypair: &NativeKeypair, diff --git a/sdk/bittensor-ts/native/src/runtime.rs b/sdk/bittensor-ts/native/src/runtime.rs index 8c6e33cad3..918e9d2c5c 100644 --- a/sdk/bittensor-ts/native/src/runtime.rs +++ b/sdk/bittensor-ts/native/src/runtime.rs @@ -9,7 +9,9 @@ use std::sync::Arc; use bittensor_core::codec::batch::PARALLEL_THRESHOLD; use bittensor_core::codec::decode::{compact_len, compact_u128, convert_type_string, Cursor}; use bittensor_core::codec::encode::compact; -use bittensor_core::codec::extrinsic::{era_birth, multisig_account_id, multisig_ss58, TxParams}; +use bittensor_core::codec::extrinsic::{ + era_birth, multisig_account_id, multisig_ss58, SignerPayload, TxParams, +}; use bittensor_core::codec::storage::{concat_hash_len, hash_param, storage_prefix}; use bittensor_core::codec::Value; use bittensor_core::runtime::type_string::{Primitive, TypeSpec}; @@ -90,12 +92,53 @@ pub struct NativePayloadParts { pub included_in_signed_data: Buffer, } +#[napi(object)] +pub struct NativeSignerPayload { + pub address: String, + pub block_hash: String, + pub block_number: String, + pub era: String, + pub genesis_hash: String, + pub method: String, + pub nonce: String, + pub signed_extensions: Vec, + pub spec_version: String, + pub tip: String, + pub transaction_version: String, + pub version: u8, + pub asset_id: Option, + pub metadata_hash: Option, + pub mode: Option, +} + #[napi(object)] pub struct NativeSignedExtrinsic { pub bytes: Buffer, pub hash: Buffer, } +impl From for NativeSignerPayload { + fn from(value: SignerPayload) -> Self { + Self { + address: value.address, + block_hash: value.block_hash, + block_number: value.block_number, + era: value.era, + genesis_hash: value.genesis_hash, + method: value.method, + nonce: value.nonce, + signed_extensions: value.signed_extensions, + spec_version: value.spec_version, + tip: value.tip, + transaction_version: value.transaction_version, + version: value.version, + asset_id: value.asset_id, + metadata_hash: value.metadata_hash, + mode: value.mode, + } + } +} + #[napi(object)] pub struct NativeMultisigAccount { pub account_id: Buffer, @@ -1065,6 +1108,20 @@ impl NativeRuntime { .map(Into::into) } + #[napi(js_name = "signerPayload")] + pub fn signer_payload( + &self, + address: String, + call_data: Buffer, + params: NativeTxParams, + ) -> NapiResult { + let params = self.tx_params(params)?; + self.inner + .signer_payload(&address, call_data.as_ref(), ¶ms) + .napi() + .map(Into::into) + } + #[napi] pub fn encode_signed_extrinsic( &self, diff --git a/sdk/bittensor-ts/src/balance.ts b/sdk/bittensor-ts/src/balance.ts index 34e0504c72..93d05a301e 100644 --- a/sdk/bittensor-ts/src/balance.ts +++ b/sdk/bittensor-ts/src/balance.ts @@ -3,7 +3,8 @@ const MAX_SAFE_RAO = BigInt(Number.MAX_SAFE_INTEGER) const AMOUNT_UNIT = '__bittensorAmountUnit' export type BalanceLike = Balance | bigint | number | string -export type TransactionAmount = Balance | bigint | RaoAmount | TaoAmount +export type AmountUnit = 'rao' | 'tao' | 'alpha' +export type TransactionAmount = Balance | bigint | RaoAmount | TaoAmount | AlphaAmount export type AssetId = bigint | number | string export interface RaoAmount { @@ -16,6 +17,12 @@ export interface TaoAmount { readonly rao: bigint } +export interface AlphaAmount { + readonly [AMOUNT_UNIT]: 'alpha' + readonly netuid: number + readonly rao: bigint +} + export class UnitMismatchError extends Error { constructor(message: string) { super(message) @@ -168,7 +175,7 @@ export function balanceRao(value: BalanceLike): bigint { export function transactionAmountRao( value: TransactionAmount, - options: { name?: string; taoOnly?: boolean } = {}, + options: { name?: string; taoOnly?: boolean; alphaOnly?: boolean } = {}, ): bigint { const name = options.name ?? 'transaction amount' let rao: bigint @@ -176,14 +183,24 @@ export function transactionAmountRao( if (options.taoOnly && value.netuid !== 0) { throw new UnitMismatchError(`${name} must be a TAO balance, not subnet-${value.netuid} alpha`) } + if (options.alphaOnly && value.netuid === 0) { + throw new UnitMismatchError(`${name} must be an alpha balance, not TAO`) + } rao = value.rao } else if (typeof value === 'bigint') { rao = value } else if (isBrandedAmount(value)) { + const unit = brandedAmountUnit(value) + if (options.taoOnly && unit === 'alpha') { + throw new UnitMismatchError(`${name} must be a TAO amount, not subnet-${brandedAmountNetuid(value) ?? 'unknown'} alpha`) + } + if (options.alphaOnly && unit === 'tao') { + throw new UnitMismatchError(`${name} must be an alpha amount, not TAO`) + } rao = value.rao } else { throw new TypeError( - `${name} must be a Balance, bigint rao amount, raoAmount(...), or taoAmount(...)`, + `${name} must be a Balance, bigint rao amount, raoAmount(...), taoAmount(...), or alphaAmount(...)`, ) } if (rao < 0n) throw new RangeError(`${name} must be non-negative`) @@ -209,7 +226,7 @@ export function tao_transaction_amount_rao(value: TransactionAmount, name = 'tra } export function alphaTransactionAmountRao(value: TransactionAmount, name = 'transaction amount'): bigint { - return transactionAmountRao(value, { name }) + return transactionAmountRao(value, { name, alphaOnly: true }) } export function alpha_transaction_amount_rao(value: TransactionAmount, name = 'transaction amount'): bigint { @@ -230,6 +247,15 @@ export function taoAmount(value: number | string): TaoAmount { }) as TaoAmount } +export function alphaAmount(value: number | string, netuid: number): AlphaAmount { + const normalizedNetuid = alphaAmountNetuid(netuid) + return Object.freeze({ + [AMOUNT_UNIT]: 'alpha', + netuid: normalizedNetuid, + rao: Balance.fromAlpha(value, normalizedNetuid).rao, + }) as AlphaAmount +} + function parseRao(value: bigint | number | string): bigint { return parseInteger(value, 'balance rao') } @@ -248,14 +274,28 @@ function parseInteger(value: bigint | number | string, name: string): bigint { throw new RangeError(`${name} must be an integer`) } -function isBrandedAmount(value: unknown): value is RaoAmount | TaoAmount { - return ( - typeof value === 'object' && - value != null && - ((value as Record)[AMOUNT_UNIT] === 'rao' || - (value as Record)[AMOUNT_UNIT] === 'tao') && - typeof (value as { rao?: unknown }).rao === 'bigint' - ) +function alphaAmountNetuid(netuid: number): number { + if (!Number.isSafeInteger(netuid) || netuid <= 0) { + throw new RangeError('alphaAmount requires a positive safe-integer netuid') + } + return netuid +} + +export function brandedAmountUnit(value: unknown): AmountUnit | undefined { + if (typeof value !== 'object' || value == null) return undefined + if (typeof (value as { rao?: unknown }).rao !== 'bigint') return undefined + const unit = (value as Record)[AMOUNT_UNIT] + return unit === 'rao' || unit === 'tao' || unit === 'alpha' ? unit : undefined +} + +export function brandedAmountNetuid(value: unknown): number | undefined { + if (brandedAmountUnit(value) !== 'alpha') return undefined + const netuid = (value as { netuid?: unknown }).netuid + return Number.isSafeInteger(netuid) && Number(netuid) > 0 ? Number(netuid) : undefined +} + +function isBrandedAmount(value: unknown): value is RaoAmount | TaoAmount | AlphaAmount { + return brandedAmountUnit(value) != null } export const tao = Balance.fromTao @@ -263,4 +303,5 @@ export const alpha = Balance.fromAlpha export const rao = Balance.fromRao export const rao_amount = raoAmount export const tao_amount = taoAmount +export const alpha_amount = alphaAmount export const transaction_amount_rao = transactionAmountRao diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index a7d04ef6f4..30c690e466 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -1,14 +1,17 @@ import { blake2_256, generateExtrinsicProof, hexToBytes, metadataDigest } from './crypto' import { CRYPTO_ED25519, CRYPTO_SR25519, Keypair, publicKeyFromSs58, ss58FromPublic } from './keys' import { LedgerDevice } from './ledger' -import { Runtime, decodeOptionalOpaqueMetadata, encodeCompact, eraBirth } from './runtime' +import { Runtime, decodeOptionalOpaqueMetadata, eraBirth, type RuntimeSignerPayload } from './runtime' import { toBuffer } from './wire' import { Balance, + UnitMismatchError, type AssetId, type BalanceLike, type TransactionAmount, assetIdValue, + brandedAmountNetuid, + brandedAmountUnit, taoTransactionAmountRao, transactionAmountRao, } from './balance' @@ -28,6 +31,33 @@ export const DEFAULT_MAX_SUBSCRIPTION_QUEUE = 1024 export const DEFAULT_MAX_WS_MESSAGE_BYTES = 16 * 1024 * 1024 const V15_METADATA_VERSION_HEX = '0x0f000000' const V15_METADATA_MISSING_NEEDLE = 'Exported method Metadata_metadata_at_version is not found' +const IDEMPOTENT_RPC_METHODS = new Set([ + 'chain_getBlock', + 'chain_getBlockHash', + 'chain_getFinalizedHead', + 'chain_getHeader', + 'chain_subscribeFinalizedHeads', + 'chain_subscribeNewHeads', + 'chain_unsubscribeFinalizedHeads', + 'chain_unsubscribeNewHeads', + 'state_call', + 'state_getKeysPaged', + 'state_getMetadata', + 'state_getRuntimeVersion', + 'state_getStorage', + 'state_queryStorageAt', + 'state_subscribeRuntimeVersion', + 'state_subscribeStorage', + 'state_unsubscribeRuntimeVersion', + 'state_unsubscribeStorage', + 'system_accountNextIndex', + 'system_chain', + 'system_health', + 'system_name', + 'system_properties', + 'system_version', + 'author_pendingExtrinsics', +]) export const NETWORKS = Object.freeze({ finney: 'wss://entrypoint-finney.opentensor.ai:443', test: 'wss://test.finney.opentensor.ai:443', @@ -143,7 +173,7 @@ export interface SignerPayloadContext { export interface ExtensionSignRawRequest { address: string data: string - type: 'bytes' + type: 'payload' payload: Buffer metadataHash?: string metadataProof?: Buffer @@ -151,23 +181,7 @@ export interface ExtensionSignRawRequest { chainInfo?: ChainInfo } -export interface ExtensionSignPayloadRequest { - address: string - blockHash: string - blockNumber: string - era: string - genesisHash: string - method: string - nonce: string - signedExtensions: string[] - specVersion: string - tip: string - transactionVersion: string - version: number - assetId?: string | null - metadataHash?: string - mode?: number -} +export interface ExtensionSignPayloadRequest extends RuntimeSignerPayload {} export type SignerSignature = | ByteLike @@ -480,8 +494,9 @@ export class JsonRpcTransport { timeoutMs: options.timeoutMs ?? this.requestTimeoutMs, } throwIfAborted(requestOptions.signal) - const maxRetries = requestOptions.maxRetries ?? this.maxRequestRetries - const retryForever = requestOptions.retryForever ?? this.retryForever + const retryByDefault = IDEMPOTENT_RPC_METHODS.has(method) + const maxRetries = requestOptions.maxRetries ?? (retryByDefault ? this.maxRequestRetries : 0) + const retryForever = requestOptions.retryForever ?? (retryByDefault ? this.retryForever : false) const retryBackoffMs = requestOptions.retryBackoffMs ?? this.retryBackoffMs const maxRetryBackoffMs = requestOptions.maxRetryBackoffMs ?? this.maxRetryBackoffMs let attempt = 0 @@ -2194,12 +2209,18 @@ export class Client { context.cryptoType, ) } + if (signerShape.signPayload != null) { + return normalizeSignature( + await signerShape.signPayload(extensionSignPayload(context), context), + context.cryptoType, + ) + } if (signerShape.signRaw != null) { return normalizeSignature( await signerShape.signRaw({ address: context.address, data: hex(payload), - type: 'bytes', + type: 'payload', payload, metadataHash: context.metadataHash == null ? undefined : hex(context.metadataHash), metadataProof: context.metadataProof, @@ -2212,12 +2233,6 @@ export class Client { if (signerShape.sign != null) { return normalizeSignature(await signerShape.sign(payload, context), context.cryptoType) } - if (signerShape.signPayload != null) { - return normalizeSignature( - await signerShape.signPayload(extensionSignPayload(context), context), - context.cryptoType, - ) - } throw new ChainError('signer must implement signBytes(), signRaw(), sign(), or extension-style signPayload()') } @@ -3136,7 +3151,17 @@ function positiveInteger(value: unknown, name: string): number { function alphaTransactionAmountForNetuid(value: TransactionAmount, netuid: number, name: string): bigint { if (value instanceof Balance && value.netuid !== netuid) { - throw new RangeError(`${name} must be subnet-${netuid} alpha, not ${value.netuid === 0 ? 'TAO' : `subnet-${value.netuid} alpha`}`) + throw new UnitMismatchError(`${name} must be subnet-${netuid} alpha, not ${value.netuid === 0 ? 'TAO' : `subnet-${value.netuid} alpha`}`) + } + const unit = brandedAmountUnit(value) + if (unit === 'tao') { + throw new UnitMismatchError(`${name} must be subnet-${netuid} alpha, not TAO`) + } + if (unit === 'alpha') { + const amountNetuid = brandedAmountNetuid(value) + if (amountNetuid !== netuid) { + throw new UnitMismatchError(`${name} must be subnet-${netuid} alpha, not subnet-${amountNetuid ?? 'unknown'} alpha`) + } } return transactionAmountRao(value, { name }) } @@ -3408,47 +3433,7 @@ function jsonRpcResponseResult(payload: unknown, expectedId: number): unknown { } function extensionSignPayload(context: SignerPayloadContext): ExtensionSignPayloadRequest { - return { - address: context.address, - blockHash: hex(context.txParams.eraBlockHash), - blockNumber: u32Hex(eraCurrentBlock(context.txParams.era)), - era: eraPayloadHex(context.runtime, context.txParams.era), - genesisHash: hex(context.txParams.genesisHash), - method: hex(context.callData), - nonce: compactIntegerHex(context.txParams.nonce), - signedExtensions: context.runtime.signedExtensionIdentifiers(), - specVersion: u32Hex(context.runtime.specVersion), - tip: compactIntegerHex(context.txParams.tip ?? 0n), - transactionVersion: u32Hex(context.runtime.transactionVersion), - version: context.runtime.extrinsicVersion, - assetId: context.txParams.tipAssetId == null ? null : compactIntegerHex(context.txParams.tipAssetId), - metadataHash: context.metadataHash == null ? undefined : hex(context.metadataHash), - mode: context.metadataHash == null ? 0 : 1, - } -} - -function eraPayloadHex(runtime: Runtime, era: ScaleValue): string { - if (typeof era === 'string') return era.startsWith('0x') ? era : `0x${era}` - return hex(runtime.encodeEra(era)) -} - -function eraCurrentBlock(era: ScaleValue): number { - const value = recordValue(era) - return Number(value?.current ?? 0) -} - -function compactIntegerHex(value: bigint | number): string { - return hex(encodeCompact(value)) -} - -function u32Hex(value: bigint | number): string { - const numeric = typeof value === 'bigint' ? Number(value) : value - if (!Number.isInteger(numeric) || numeric < 0 || numeric > 0xffffffff) { - throw new ChainError(`value ${String(value)} cannot be encoded as u32`) - } - const out = Buffer.alloc(4) - out.writeUInt32LE(numeric, 0) - return hex(out) + return context.runtime.signerPayload(context.address, context.callData, context.txParams) } function normalizeSignature( diff --git a/sdk/bittensor-ts/src/keys.ts b/sdk/bittensor-ts/src/keys.ts index dd49bb992a..1e9b831ad7 100644 --- a/sdk/bittensor-ts/src/keys.ts +++ b/sdk/bittensor-ts/src/keys.ts @@ -506,9 +506,30 @@ export class Keypair implements PolkadotCompatibleKeypair { } serialize(): Buffer { + return this.serializePublic() + } + + serializePublic(): Buffer { + if (this.kind !== 'PublicOnly') { + throw new Error( + 'Keypair.serialize() only supports public-only keypairs; use toKeyfileData(password) or dangerouslySerializePrivateKeypair()', + ) + } return nativeCall(() => native.serializeKeypair(this.handle)) } + serialize_public(): Buffer { + return this.serializePublic() + } + + dangerouslySerializePrivateKeypair(): Buffer { + return nativeCall(() => native.dangerouslySerializeKeypair(this.handle)) + } + + dangerously_serialize_private_keypair(): Buffer { + return this.dangerouslySerializePrivateKeypair() + } + toKeyfileData(password?: string | null): Promise { return nativeAsync(() => native.keypairToKeyfileData(this.handle, password ?? undefined), @@ -640,9 +661,20 @@ export function serializeKeypair(keypair: Keypair): Buffer { return keypair.serialize() } +export function serializePublicKeypair(keypair: Keypair): Buffer { + return keypair.serializePublic() +} + +export const serialize_public_keypair = serializePublicKeypair export const serializedKeypairToKeyfileData = serializeKeypair export const serialized_keypair_to_keyfile_data = serializedKeypairToKeyfileData +export function dangerouslySerializePrivateKeypair(keypair: Keypair): Buffer { + return keypair.dangerouslySerializePrivateKeypair() +} + +export const dangerously_serialize_private_keypair = dangerouslySerializePrivateKeypair + export function deserializeKeypair(keyfileData: ByteLike): Keypair { return Keypair.deserialize(keyfileData) } diff --git a/sdk/bittensor-ts/src/modules.ts b/sdk/bittensor-ts/src/modules.ts index 268b9125ed..8b052711bc 100644 --- a/sdk/bittensor-ts/src/modules.ts +++ b/sdk/bittensor-ts/src/modules.ts @@ -46,8 +46,12 @@ export const rustCore = Object.freeze({ }), keyfiles: Object.freeze({ serializeKeypair: keys.serializeKeypair, + serializePublicKeypair: keys.serializePublicKeypair, + serialize_public_keypair: keys.serialize_public_keypair, serializedKeypairToKeyfileData: keys.serializedKeypairToKeyfileData, serialized_keypair_to_keyfile_data: keys.serialized_keypair_to_keyfile_data, + dangerouslySerializePrivateKeypair: keys.dangerouslySerializePrivateKeypair, + dangerously_serialize_private_keypair: keys.dangerously_serialize_private_keypair, keypairToKeyfileData: keys.keypairToKeyfileData, keypair_to_keyfile_data: keys.keypair_to_keyfile_data, deserializeKeypair: keys.deserializeKeypair, diff --git a/sdk/bittensor-ts/src/native.ts b/sdk/bittensor-ts/src/native.ts index df24ea7c59..0cd7a585f9 100644 --- a/sdk/bittensor-ts/src/native.ts +++ b/sdk/bittensor-ts/src/native.ts @@ -46,6 +46,24 @@ export interface NativeTxParams { metadataHash?: Buffer | null } +export interface NativeSignerPayload { + address: string + blockHash: string + blockNumber: string + era: string + genesisHash: string + method: string + nonce: string + signedExtensions: string[] + specVersion: string + tip: string + transactionVersion: string + version: number + assetId?: string | null + metadataHash?: string | null + mode?: number | null +} + export interface NativeExtrinsicParams { era: unknown nonce: bigint @@ -195,6 +213,11 @@ export interface NativeRuntimeHandle { includedInSignedData: Buffer } signaturePayload(callData: Buffer, params: NativeTxParams): Buffer + signerPayload( + address: string, + callData: Buffer, + params: NativeTxParams, + ): NativeSignerPayload encodeSignedExtrinsic( callData: Buffer, publicKey: Buffer, @@ -296,6 +319,7 @@ export interface NativeBinding { publicKeyFromSs58(ss58Address: string): Buffer ss58FromPublic(publicKey: Buffer, ss58Format: number): string serializeKeypair(keypair: NativeKeypairHandle): Buffer + dangerouslySerializeKeypair(keypair: NativeKeypairHandle): Buffer keypairToKeyfileData( keypair: NativeKeypairHandle, password?: string | null, diff --git a/sdk/bittensor-ts/src/runtime.ts b/sdk/bittensor-ts/src/runtime.ts index c341c3ba46..2fb3ec8bd6 100644 --- a/sdk/bittensor-ts/src/runtime.ts +++ b/sdk/bittensor-ts/src/runtime.ts @@ -2,6 +2,7 @@ import native, { type NativeCursorHandle, type NativeExtrinsicParams, type NativeRuntimeHandle, + type NativeSignerPayload, type NativeStorageChange, type NativeTxParams, } from './native' @@ -38,6 +39,12 @@ import type { export type PayloadPartsTuple = [Buffer, Buffer] export type SignedExtrinsicTuple = [Buffer, Buffer] +export interface RuntimeSignerPayload extends NativeSignerPayload { + assetId?: string | null + metadataHash?: string | null + mode?: number | null +} + function nativeTxParams(params: TransactionParams): NativeTxParams { return { era: toWire(params.era), @@ -840,6 +847,16 @@ export class Runtime { ) } + signerPayload( + address: string, + callData: ByteLike, + params: TransactionParams, + ): RuntimeSignerPayload { + return nativeCall(() => + this.handle.signerPayload(address, toBuffer(callData, 'callData'), nativeTxParams(params)), + ) + } + signature_payload( call_data: ByteLike, era: ScaleValue, diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index a409c6939f..d589097cf1 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -75,6 +75,30 @@ function fakeSigningRuntime(overrides = {}) { captures.payloadParams = params return Buffer.from([9, 9, 9]) }, + signerPayload(address, callData, params) { + captures.signerPayload = { address, callData: Buffer.from(callData), params } + return { + address, + blockHash: `0x${Buffer.from(params.eraBlockHash).toString('hex')}`, + blockNumber: '0x00000000', + era: '0x00', + genesisHash: `0x${Buffer.from(params.genesisHash).toString('hex')}`, + method: `0x${Buffer.from(callData).toString('hex')}`, + nonce: '0x30', + signedExtensions: runtime.signedExtensionIdentifiers(), + specVersion: '0xa3010000', + tip: '0x00', + transactionVersion: '0x01000000', + version: runtime.extrinsicVersion, + assetId: params.tipAssetId == null ? null : '0x010000', + metadataHash: + params.metadataHash == null ? undefined : `0x${Buffer.from(params.metadataHash).toString('hex')}`, + mode: params.metadataHash == null ? 0 : 1, + } + }, + signedExtensionIdentifiers() { + return ['CheckNonce'] + }, encodeSignedExtrinsic(callData, publicKey, signature, signatureVersion, params) { captures.encoded = { callData, publicKey, signature, signatureVersion, params } return { @@ -582,10 +606,17 @@ test('Python-compatible bittensor_core names are exported', () => { ) const publicOnly = new core.Keypair(alice.ss58_address) + assert.throws(() => alice.serialize(), /public-only keypairs/) const publicKeyfile = JSON.parse( core.serialized_keypair_to_keyfile_data(publicOnly).toString('utf8'), ) assert.equal(publicKeyfile.ss58Address, alice.ss58_address) + assert.deepEqual( + JSON.parse(core.serializePublicKeypair(publicOnly).toString('utf8')), + JSON.parse(publicOnly.serialize().toString('utf8')), + ) + const privatePlaintext = core.dangerouslySerializePrivateKeypair(alice) + assert.equal(core.deserializeKeypair(privatePlaintext).ss58_address, alice.ss58_address) assert.equal(core.keyfile_data_is_encrypted(Buffer.from('plain')), false) assert.deepEqual(core.mlkem_kdf_id(), core.MLKEM_KDF_ID) assert.equal(typeof core.metadata_digest, 'function') @@ -672,13 +703,17 @@ test('private key bytes are not exported to JavaScript', async (t) => { Object.prototype.hasOwnProperty.call(core.native.NativeKeypair.prototype, 'privateKey'), false, ) - assert.throws(() => alice.serialize(), /plaintext private key serialization is disabled/) - assert.throws(() => core.serializeKeypair(alice), /plaintext private key serialization is disabled/) + assert.throws(() => alice.serialize(), /public-only keypairs/) + assert.throws(() => core.serializeKeypair(alice), /public-only keypairs/) const rawNativeSerialize = core.native.serializeKeypair( core.native.keypairFromUri('//Alice', core.CRYPTO_SR25519), ) assert.equal(rawNativeSerialize instanceof Error, true) assert.match(rawNativeSerialize.message, /plaintext private key serialization is disabled/) + assert.equal( + core.deserializeKeypair(core.dangerouslySerializePrivateKeypair(alice)).ss58Address, + alice.ss58Address, + ) const publicOnly = new core.Keypair(alice.ss58Address) const publicKeyfile = JSON.parse(publicOnly.serialize().toString('utf8')) @@ -701,6 +736,45 @@ test('fallible Runtime construction uses the native factory', () => { ) }) +test('Runtime signer payload encodes signed extension fields through metadata', () => { + const runtime = new core.Runtime(goldenMetadataBytes(), 419, 1, 42) + const genesisHash = Buffer.alloc(32, 0) + const eraBlockHash = Buffer.alloc(32, 0x42) + const metadataHash = Buffer.alloc(32, 3) + const payload = runtime.signerPayload('5F', Buffer.from([5, 6, 7]), { + era: { period: 64, current: 70 }, + nonce: 12, + tip: 1, + tipAssetId: null, + genesisHash, + eraBlockHash, + metadataHash, + }) + + assert.equal(payload.address, '5F') + assert.equal(payload.method, '0x050607') + assert.equal(payload.blockHash, `0x${eraBlockHash.toString('hex')}`) + assert.equal(payload.blockNumber, '0x46000000') + assert.equal(payload.nonce, '0x30') + assert.equal(payload.tip, '0x04') + assert.equal(payload.assetId == null, true) + assert.equal(payload.metadataHash, `0x${metadataHash.toString('hex')}`) + assert.equal(payload.mode, 1) + assert.ok(payload.signedExtensions.includes('ChargeTransactionPayment')) + + const noAsset = runtime.signerPayload('5F', Buffer.from([5, 6, 7]), { + era: '00', + nonce: 12, + tip: 1, + tipAssetId: null, + genesisHash, + eraBlockHash, + metadataHash: null, + }) + assert.equal(noAsset.assetId == null, true) + assert.equal(noAsset.metadataHash == null, true) +}) + test('compact codec is the Rust implementation', () => { const values = [0n, 63n, 64n, 16383n, 16384n, 2n ** 64n, 2n ** 127n] for (const value of values) { @@ -1025,6 +1099,11 @@ test('transaction amounts require explicit units', () => { 'transfer_keep_alive', { dest: '5F', value: 2n }, ]) + assert.deepEqual(core.calls.subtensor.removeStake('5F', 8, core.alphaAmount('1.0', 8)), [ + 'SubtensorModule', + 'remove_stake', + { hotkey: '5F', netuid: 8, amount_unstaked: 1_000_000_000n }, + ]) assert.deepEqual(core.calls.balances.transferKeepAlive('5F', core.Balance.fromTao('0.5')), [ 'Balances', 'transfer_keep_alive', @@ -1042,6 +1121,10 @@ test('transaction amounts require explicit units', () => { () => core.calls.balances.transferKeepAlive('5F', core.Balance.fromAlpha('1', 7)), /must be a TAO balance/, ) + assert.throws( + () => core.calls.balances.transferKeepAlive('5F', core.alphaAmount('1', 7)), + /must be a TAO amount/, + ) assert.throws( () => core.calls.balances.transferKeepAlive('5F', '1'), /transfer amount must be/, @@ -1069,6 +1152,14 @@ test('transaction amounts require explicit units', () => { () => core.calls.subtensor.removeStake('5F', 8, core.Balance.fromAlpha('1', 7)), /subnet-8 alpha/, ) + assert.throws( + () => core.calls.subtensor.removeStake('5F', 8, core.taoAmount('1')), + /must be subnet-8 alpha, not TAO/, + ) + assert.throws( + () => core.calls.subtensor.removeStake('5F', 8, core.alphaAmount('1', 7)), + /subnet-8 alpha, not subnet-7 alpha/, + ) assert.deepEqual(core.calls.subtensor.removeStake('5F', 8, core.Balance.fromAlpha('1', 8)), [ 'SubtensorModule', 'remove_stake', @@ -1430,6 +1521,44 @@ test('JsonRpcTransport bounds retries and supports request cancellation', async ) }) +test('JsonRpcTransport only retries idempotent RPC methods by default', async (t) => { + const originalFetch = globalThis.fetch + t.after(() => { + globalThis.fetch = originalFetch + }) + let requests = 0 + globalThis.fetch = async () => { + requests += 1 + throw new Error('connection dropped') + } + const transport = new core.JsonRpcTransport('http://node-a', [], false, { + requestTimeoutMs: 5, + maxRequestRetries: 2, + retryBackoffMs: 1, + maxRetryBackoffMs: 1, + }) + + await assert.rejects( + () => transport.request('engine_createBlock'), + /connection dropped/, + ) + assert.equal(requests, 1) + + requests = 0 + await assert.rejects( + () => transport.request('state_getMetadata'), + /connection dropped/, + ) + assert.equal(requests, 3) + + requests = 0 + await assert.rejects( + () => transport.request('engine_createBlock', [], { maxRetries: 2 }), + /connection dropped/, + ) + assert.equal(requests, 3) +}) + test('JsonRpcTransport can disable retryForever for transaction submissions', async (t) => { const { FakeWebSocket, restore } = installFakeWebSocket() t.after(restore) @@ -1857,7 +1986,7 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => assert.equal(signed.nonce, 12) assert.equal(client.lastNonceAddress, address) assert.equal(request.address, address) - assert.equal(request.type, 'bytes') + assert.equal(request.type, 'payload') assert.equal(request.data, '0x090909') assert.equal(request.metadataProof, undefined) assert.deepEqual(captures.encoded.callData, callData) @@ -1936,16 +2065,34 @@ test('Client passes structured payloads to extension signPayload signers', async const { runtime, captures } = fakeSigningRuntime({ extrinsicVersion: 5, signedExtensionIdentifiers() { - return ['CheckNonce'] + return ['CheckNonce', 'ChargeAssetTxPayment', 'CheckMetadataHash'] }, - encodeEra() { - return Buffer.from([0]) + signerPayload(address, callData, params) { + captures.signerPayload = { address, callData: Buffer.from(callData), params } + return { + address, + blockHash: '0x4242424242424242424242424242424242424242424242424242424242424242', + blockNumber: '0x2a000000', + era: '0x2500', + genesisHash: '0x0000000000000000000000000000000000000000000000000000000000000000', + method: `0x${Buffer.from(callData).toString('hex')}`, + nonce: '0x3000', + signedExtensions: ['CheckNonce', 'ChargeAssetTxPayment', 'CheckMetadataHash'], + specVersion: '0xa3010000', + tip: '0x0400', + transactionVersion: '0x01000000', + version: 5, + assetId: '0x010000', + metadataHash: '0x0303030303030303030303030303030303030303030303030303030303030303', + mode: 1, + } }, }) const client = fakeSigningClient(runtime, callData) const publicKey = Buffer.alloc(32, 6) const address = core.ss58FromPublic(publicKey, 42) let payload + let rawCalls = 0 const signer = { address, publicKey, @@ -1953,15 +2100,31 @@ test('Client passes structured payloads to extension signPayload signers', async payload = value return { signature: `0x${Buffer.alloc(64, 9).toString('hex')}` } }, + signRaw() { + rawCalls += 1 + throw new Error('signRaw should not be used when signPayload exists') + }, } - await client.signExtrinsic(callData, signer, { period: null }) + await client.signExtrinsic(callData, signer, { + period: 64, + tip: core.raoAmount(1), + tipAssetId: 0n, + metadataHash: Buffer.alloc(32, 3), + }) assert.equal(payload.address, address) assert.equal(payload.method, '0x050607') assert.equal(payload.version, 5) - assert.deepEqual(payload.signedExtensions, ['CheckNonce']) - assert.equal(captures.encoded.params.metadataHashEnabled, false) + assert.equal(payload.assetId, '0x010000') + assert.equal(payload.nonce, '0x3000') + assert.equal(payload.tip, '0x0400') + assert.equal(payload.mode, 1) + assert.deepEqual(payload.signedExtensions, ['CheckNonce', 'ChargeAssetTxPayment', 'CheckMetadataHash']) + assert.equal(rawCalls, 0) + assert.deepEqual(captures.signerPayload.callData, callData) + assert.equal(captures.signerPayload.params.tipAssetId, 0n) + assert.equal(captures.encoded.params.metadataHashEnabled, true) }) test('Client rejects invalid chain nonce values', async () => { From 4625c5ced1da6c23538732fca5ae7d211eccdbc5 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 14:53:44 -0700 Subject: [PATCH 54/72] another review round --- Cargo.lock | 1 + sdk/bittensor-core/src/keyfiles/mod.rs | 244 ++++++++++++++++++++++--- sdk/bittensor-core/src/keys/mod.rs | 14 ++ sdk/bittensor-ts/README.md | 4 + sdk/bittensor-ts/native/Cargo.toml | 1 + sdk/bittensor-ts/native/src/keys.rs | 78 ++++---- sdk/bittensor-ts/src/client.ts | 127 +++++++++++-- sdk/bittensor-ts/test/basic.test.cjs | 133 +++++++++++++- 8 files changed, 524 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2cd8149a49..a898856d0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1700,6 +1700,7 @@ dependencies = [ "parity-scale-codec", "scale-info", "serde_json", + "zeroize", ] [[package]] diff --git a/sdk/bittensor-core/src/keyfiles/mod.rs b/sdk/bittensor-core/src/keyfiles/mod.rs index 42793a67da..26c946636f 100644 --- a/sdk/bittensor-core/src/keyfiles/mod.rs +++ b/sdk/bittensor-core/src/keyfiles/mod.rs @@ -31,6 +31,13 @@ fn key_err(msg: impl Into) -> CoreError { CoreError::Keyfile(msg.into()) } +fn require_non_empty_password(password: &str) -> Result<&str, CoreError> { + if password.is_empty() { + return Err(key_err("keyfile password must not be empty")); + } + Ok(password) +} + pub fn keyfile_data_is_encrypted_nacl(keyfile_data: &[u8]) -> bool { keyfile_data.starts_with(b"$NACL") } @@ -90,6 +97,7 @@ fn nacl_decrypt(keyfile_data: &[u8], key: &secretbox::Key) -> Result, Co pub fn encrypt_keyfile_data(keyfile_data: &[u8], password: &str) -> Result, CoreError> { ensure_sodium()?; + let password = require_non_empty_password(password)?; let key = derive_key(password.as_bytes())?; let nonce = secretbox::gen_nonce(); let encrypted_data = secretbox::seal(keyfile_data, &nonce, &key); @@ -383,27 +391,104 @@ fn prepare_keyfile_target(path: &Path, overwrite: bool) -> Result<(), CoreError> } fn ensure_private_directory(path: &Path) -> Result<(), CoreError> { + let path = normalize_directory_path(path); reject_symlink_ancestors(path)?; - fs::create_dir_all(path).map_err(|error| { - key_err(format!( - "failed to create wallet directory {}: {error}", - path.display() - )) - })?; + create_missing_private_directories(path)?; reject_symlink_ancestors(path)?; + validate_wallet_directory(path) +} + +fn normalize_directory_path(path: &Path) -> &Path { + if path.as_os_str().is_empty() { + Path::new(".") + } else { + path + } +} + +fn create_missing_private_directories(path: &Path) -> Result<(), CoreError> { + let mut current = PathBuf::new(); + let mut saw_component = false; + + for component in path.components() { + match component { + Component::CurDir => continue, + Component::ParentDir => { + return Err(key_err(format!( + "wallet path {} must not contain parent directory components", + path.display() + ))); + } + Component::Prefix(prefix) => current.push(prefix.as_os_str()), + Component::RootDir | Component::Normal(_) => current.push(component.as_os_str()), + } + saw_component = true; + if current.as_os_str().is_empty() { + continue; + } + match fs::symlink_metadata(¤t) { + Ok(metadata) => validate_wallet_directory_metadata(path, ¤t, &metadata)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match fs::create_dir(¤t) { + Ok(()) => set_private_directory_permissions(¤t)?, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let metadata = fs::symlink_metadata(¤t).map_err(|error| { + key_err(format!( + "failed to inspect wallet directory {}: {error}", + current.display() + )) + })?; + validate_wallet_directory_metadata(path, ¤t, &metadata)?; + } + Err(error) => { + return Err(key_err(format!( + "failed to create wallet directory {}: {error}", + current.display() + ))); + } + } + } + Err(error) => { + return Err(key_err(format!( + "failed to inspect wallet path ancestor {}: {error}", + current.display() + ))); + } + } + } + + if !saw_component { + validate_wallet_directory(Path::new("."))?; + } + Ok(()) +} + +fn validate_wallet_directory(path: &Path) -> Result<(), CoreError> { let metadata = fs::symlink_metadata(path).map_err(|error| { key_err(format!( "failed to inspect wallet directory {}: {error}", path.display() )) })?; + validate_wallet_directory_metadata(path, path, &metadata) +} + +fn validate_wallet_directory_metadata( + path: &Path, + current: &Path, + metadata: &fs::Metadata, +) -> Result<(), CoreError> { if metadata.file_type().is_symlink() || !metadata.is_dir() { return Err(key_err(format!( "wallet path {} must be a real directory", - path.display() + if current == path { + path.display().to_string() + } else { + current.display().to_string() + } ))); } - set_private_directory_permissions(path) + Ok(()) } fn reject_symlink_ancestors(path: &Path) -> Result<(), CoreError> { @@ -575,44 +660,74 @@ fn write_temp_keyfile(path: &Path, data: &[u8]) -> Result { } fn commit_one_keyfile(path: &Path, temp_path: &Path, overwrite: bool) -> Result<(), CoreError> { + if overwrite { + return commit_overwrite_keyfile(path, temp_path); + } + commit_new_keyfile(path, temp_path) +} + +fn commit_overwrite_keyfile(path: &Path, temp_path: &Path) -> Result<(), CoreError> { let dir = path .parent() .ok_or_else(|| key_err("keyfile path must have a parent directory"))?; - if overwrite { + let mut backup = None; + let mut committed = false; + let result = (|| -> Result<(), CoreError> { + backup = move_existing_to_backup(path)?; fs::rename(temp_path, path).map_err(|error| { key_err(format!( "failed to atomically replace keyfile {}: {error}", path.display() )) })?; + committed = true; + set_private_file_permissions(path)?; + fsync_directory(dir); + Ok(()) + })(); + + if result.is_err() { + if committed { + remove_file_if_exists(path); + } + restore_backup(path, backup.as_deref()); + fsync_directory(dir); } else { - commit_new_keyfile(path, temp_path)?; - return Ok(()); + if let Some(path) = backup { + let _ = fs::remove_file(path); + } } - set_private_file_permissions(path)?; - fsync_directory(dir); - Ok(()) + result } fn commit_new_keyfile(path: &Path, temp_path: &Path) -> Result<(), CoreError> { let dir = path .parent() .ok_or_else(|| key_err("keyfile path must have a parent directory"))?; - fs::hard_link(temp_path, path).map_err(|error| { - key_err(format!( - "failed to atomically create keyfile {}: {error}", - path.display() - )) - })?; - fs::remove_file(temp_path).map_err(|error| { - key_err(format!( - "failed to remove temporary keyfile {}: {error}", - temp_path.display() - )) - })?; - set_private_file_permissions(path)?; - fsync_directory(dir); - Ok(()) + let mut created = false; + let result = (|| -> Result<(), CoreError> { + fs::hard_link(temp_path, path).map_err(|error| { + key_err(format!( + "failed to atomically create keyfile {}: {error}", + path.display() + )) + })?; + created = true; + fs::remove_file(temp_path).map_err(|error| { + key_err(format!( + "failed to remove temporary keyfile {}: {error}", + temp_path.display() + )) + })?; + set_private_file_permissions(path)?; + fsync_directory(dir); + Ok(()) + })(); + if result.is_err() && created { + remove_file_if_exists(path); + fsync_directory(dir); + } + result } fn atomic_write_keyfile_pair( @@ -1060,6 +1175,77 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + #[test] + fn empty_passwords_are_rejected_by_low_level_keyfile_apis() { + let keypair = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_SR25519, None).unwrap(); + let public_keypair = Keypair::new( + Some(&keypair.ss58_address()), + None, + keypair.crypto_type(), + keypair.ss58_format(), + ) + .unwrap(); + let dir = temp_test_dir("empty-password"); + + assert!(encrypt_keyfile_data(b"{}", "").is_err()); + assert!(keypair_to_keyfile_data(&keypair, Some("")).is_err()); + assert!( + save_keypair_to_keyfile(&keypair, &dir.join("hotkey"), Some(""), false, false,) + .is_err() + ); + assert!(save_keypair_pair_to_keyfiles( + &keypair, + &dir.join("hotkey-pair"), + Some(""), + &public_keypair, + &dir.join("hotkeypub.txt"), + false, + false, + ) + .is_err()); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + #[cfg(unix)] + fn writing_keyfile_does_not_chmod_existing_parent_directory() { + use std::os::unix::fs::PermissionsExt; + + let dir = temp_test_dir("existing-parent-permissions"); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + let nested = dir.join("wallet"); + let keypair = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_SR25519, None).unwrap(); + + save_keypair_to_keyfile( + &keypair, + &nested.join("hotkey"), + Some("test-password"), + false, + false, + ) + .unwrap(); + + assert_eq!( + fs::metadata(&dir).unwrap().permissions().mode() & 0o777, + 0o755 + ); + assert_eq!( + fs::metadata(&nested).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(nested.join("hotkey")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + + fs::remove_dir_all(dir).unwrap(); + } + #[test] #[cfg(unix)] fn rejects_symlink_wallet_directory_ancestor() { diff --git a/sdk/bittensor-core/src/keys/mod.rs b/sdk/bittensor-core/src/keys/mod.rs index 241e7bf729..734cc8853a 100644 --- a/sdk/bittensor-core/src/keys/mod.rs +++ b/sdk/bittensor-core/src/keys/mod.rs @@ -29,6 +29,7 @@ pub const CRYPTO_ED25519: u8 = 0; pub const CRYPTO_SR25519: u8 = 1; pub const DEFAULT_SS58_FORMAT: u16 = 42; +const MAX_SS58_ACCOUNT_ADDRESS_LEN: usize = 64; fn crypto_err(msg: impl Into) -> CoreError { CoreError::Crypto(msg.into()) @@ -46,6 +47,9 @@ fn as_bytes>(value: &T) -> Vec { } pub fn public_key_from_ss58(ss58_address: &str) -> Result<[u8; 32], CoreError> { + if ss58_address.len() > MAX_SS58_ACCOUNT_ADDRESS_LEN { + return Err(crypto_err("invalid ss58 address length")); + } let decoded = base58::base58_decode(ss58_address) .ok_or_else(|| crypto_err("invalid ss58 address: invalid base58"))?; if decoded.len() != 35 && decoded.len() != 36 { @@ -648,6 +652,16 @@ mod tests { assert_eq!(public.public_key_bytes(), full.public_key_bytes()); } + #[test] + fn public_key_from_ss58_rejects_pathological_length_before_decode() { + let long_address = "1".repeat(MAX_SS58_ACCOUNT_ADDRESS_LEN + 1); + let error = public_key_from_ss58(&long_address).expect_err("long ss58 must reject"); + assert!( + error.to_string().contains("invalid ss58 address length"), + "unexpected error: {error}" + ); + } + #[test] fn sr25519_verify_roundtrip() { let kp = Keypair::from_uri("//Alice", CRYPTO_SR25519).unwrap(); diff --git a/sdk/bittensor-ts/README.md b/sdk/bittensor-ts/README.md index 5cf150f19f..ad1ad35db6 100644 --- a/sdk/bittensor-ts/README.md +++ b/sdk/bittensor-ts/README.md @@ -31,6 +31,10 @@ raw generated Node-API module as `@bittensor/sdk/native`, so every native entry point is callable even when an ergonomic wrapper has not yet been added. +This package is currently monorepo-internal and intentionally remains marked +`"private": true`. A public npm release needs a cross-platform native binary +layout and CI matrix for Linux, macOS, and Windows before that flag is removed. + Browser bundlers should import the explicit `@bittensor/sdk/browser` subpath. That entrypoint is a portable browser subset, not a method-for-method mirror of the Node API. It does not load `native.cjs`, `.node` binaries, Node `Buffer`, diff --git a/sdk/bittensor-ts/native/Cargo.toml b/sdk/bittensor-ts/native/Cargo.toml index 31ac5901cd..8eac91789f 100644 --- a/sdk/bittensor-ts/native/Cargo.toml +++ b/sdk/bittensor-ts/native/Cargo.toml @@ -21,6 +21,7 @@ napi = { version = "3.10.3", default-features = false, features = ["napi8", "ser napi-derive = "3.5.9" scale-info = { workspace = true, features = ["std", "serde"] } serde_json = { version = "1.0.132", features = ["arbitrary_precision"] } +zeroize = "1" [build-dependencies] napi-build = "2.3.2" diff --git a/sdk/bittensor-ts/native/src/keys.rs b/sdk/bittensor-ts/native/src/keys.rs index f039500198..560787b5da 100644 --- a/sdk/bittensor-ts/native/src/keys.rs +++ b/sdk/bittensor-ts/native/src/keys.rs @@ -4,6 +4,7 @@ use napi::bindgen_prelude::{AsyncTask, Buffer}; use napi::{Env, Task}; use napi_derive::napi; use std::path::PathBuf; +use zeroize::Zeroizing; use crate::errors::{invalid_arg, CoreResultExt, NapiResult}; @@ -20,7 +21,7 @@ impl NativeKeypair { pub struct KeypairFromEncryptedJsonTask { json_data: String, - passphrase: String, + passphrase: Zeroizing, } impl Task for KeypairFromEncryptedJsonTask { @@ -28,7 +29,7 @@ impl Task for KeypairFromEncryptedJsonTask { type JsValue = NativeKeypair; fn compute(&mut self) -> napi::Result { - Keypair::from_encrypted_json(&self.json_data, &self.passphrase).napi() + Keypair::from_encrypted_json(&self.json_data, self.passphrase.as_str()).napi() } fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { @@ -38,7 +39,7 @@ impl Task for KeypairFromEncryptedJsonTask { pub struct KeypairToKeyfileDataTask { keypair: Keypair, - password: Option, + password: Option>, } impl Task for KeypairToKeyfileDataTask { @@ -46,7 +47,11 @@ impl Task for KeypairToKeyfileDataTask { type JsValue = Buffer; fn compute(&mut self) -> napi::Result { - keyfiles::keypair_to_keyfile_data(&self.keypair, self.password.as_deref()).napi() + keyfiles::keypair_to_keyfile_data( + &self.keypair, + self.password.as_ref().map(|value| value.as_str()), + ) + .napi() } fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { @@ -55,8 +60,8 @@ impl Task for KeypairToKeyfileDataTask { } pub struct DeserializeKeypairFromKeyfileTask { - keyfile_data: Vec, - password: Option, + keyfile_data: Zeroizing>, + password: Option>, } impl Task for DeserializeKeypairFromKeyfileTask { @@ -64,8 +69,11 @@ impl Task for DeserializeKeypairFromKeyfileTask { type JsValue = NativeKeypair; fn compute(&mut self) -> napi::Result { - keyfiles::deserialize_keypair_from_keyfile(&self.keyfile_data, self.password.as_deref()) - .napi() + keyfiles::deserialize_keypair_from_keyfile( + &self.keyfile_data, + self.password.as_ref().map(|value| value.as_str()), + ) + .napi() } fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { @@ -75,7 +83,7 @@ impl Task for DeserializeKeypairFromKeyfileTask { pub struct ReadKeypairKeyfileTask { path: PathBuf, - password: Option, + password: Option>, } impl Task for ReadKeypairKeyfileTask { @@ -83,7 +91,11 @@ impl Task for ReadKeypairKeyfileTask { type JsValue = NativeKeypair; fn compute(&mut self) -> napi::Result { - keyfiles::read_keypair_from_keyfile(&self.path, self.password.as_deref()).napi() + keyfiles::read_keypair_from_keyfile( + &self.path, + self.password.as_ref().map(|value| value.as_str()), + ) + .napi() } fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { @@ -94,7 +106,7 @@ impl Task for ReadKeypairKeyfileTask { pub struct WriteKeypairKeyfileTask { keypair: Keypair, path: PathBuf, - password: Option, + password: Option>, overwrite: bool, allow_plaintext: bool, } @@ -107,7 +119,7 @@ impl Task for WriteKeypairKeyfileTask { keyfiles::save_keypair_to_keyfile( &self.keypair, &self.path, - self.password.as_deref(), + self.password.as_ref().map(|value| value.as_str()), self.overwrite, self.allow_plaintext, ) @@ -122,7 +134,7 @@ impl Task for WriteKeypairKeyfileTask { pub struct WriteKeypairPairKeyfileTask { private_keypair: Keypair, private_path: PathBuf, - private_password: Option, + private_password: Option>, public_keypair: Keypair, public_path: PathBuf, overwrite: bool, @@ -137,7 +149,7 @@ impl Task for WriteKeypairPairKeyfileTask { keyfiles::save_keypair_pair_to_keyfiles( &self.private_keypair, &self.private_path, - self.private_password.as_deref(), + self.private_password.as_ref().map(|value| value.as_str()), &self.public_keypair, &self.public_path, self.overwrite, @@ -152,8 +164,8 @@ impl Task for WriteKeypairPairKeyfileTask { } pub struct EncryptKeyfileDataTask { - keyfile_data: Vec, - password: String, + keyfile_data: Zeroizing>, + password: Zeroizing, } impl Task for EncryptKeyfileDataTask { @@ -161,7 +173,7 @@ impl Task for EncryptKeyfileDataTask { type JsValue = Buffer; fn compute(&mut self) -> napi::Result { - keyfiles::encrypt_keyfile_data(&self.keyfile_data, &self.password).napi() + keyfiles::encrypt_keyfile_data(&self.keyfile_data, self.password.as_str()).napi() } fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { @@ -170,8 +182,8 @@ impl Task for EncryptKeyfileDataTask { } pub struct DecryptKeyfileDataTask { - keyfile_data: Vec, - password: Option, + keyfile_data: Zeroizing>, + password: Option>, } impl Task for DecryptKeyfileDataTask { @@ -179,7 +191,11 @@ impl Task for DecryptKeyfileDataTask { type JsValue = Buffer; fn compute(&mut self) -> napi::Result { - keyfiles::decrypt_keyfile_data(&self.keyfile_data, self.password.as_deref()).napi() + keyfiles::decrypt_keyfile_data( + &self.keyfile_data, + self.password.as_ref().map(|value| value.as_str()), + ) + .napi() } fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { @@ -308,7 +324,7 @@ pub fn keypair_from_encrypted_json( ) -> AsyncTask { AsyncTask::new(KeypairFromEncryptedJsonTask { json_data, - passphrase, + passphrase: Zeroizing::new(passphrase), }) } @@ -379,7 +395,7 @@ pub fn keypair_to_keyfile_data( ) -> AsyncTask { AsyncTask::new(KeypairToKeyfileDataTask { keypair: keypair.inner.clone(), - password, + password: password.map(Zeroizing::new), }) } @@ -396,8 +412,8 @@ pub fn deserialize_keypair_from_keyfile( password: Option, ) -> AsyncTask { AsyncTask::new(DeserializeKeypairFromKeyfileTask { - keyfile_data: keyfile_data.to_vec(), - password, + keyfile_data: Zeroizing::new(keyfile_data.to_vec()), + password: password.map(Zeroizing::new), }) } @@ -408,7 +424,7 @@ pub fn read_keypair_keyfile( ) -> AsyncTask { AsyncTask::new(ReadKeypairKeyfileTask { path: PathBuf::from(path), - password, + password: password.map(Zeroizing::new), }) } @@ -423,7 +439,7 @@ pub fn write_keypair_keyfile( AsyncTask::new(WriteKeypairKeyfileTask { keypair: keypair.inner.clone(), path: PathBuf::from(path), - password, + password: password.map(Zeroizing::new), overwrite, allow_plaintext, }) @@ -442,7 +458,7 @@ pub fn write_keypair_pair_keyfile( AsyncTask::new(WriteKeypairPairKeyfileTask { private_keypair: private_keypair.inner.clone(), private_path: PathBuf::from(private_path), - private_password, + private_password: private_password.map(Zeroizing::new), public_keypair: public_keypair.inner.clone(), public_path: PathBuf::from(public_path), overwrite, @@ -456,8 +472,8 @@ pub fn encrypt_keyfile_data( password: String, ) -> AsyncTask { AsyncTask::new(EncryptKeyfileDataTask { - keyfile_data: keyfile_data.to_vec(), - password, + keyfile_data: Zeroizing::new(keyfile_data.to_vec()), + password: Zeroizing::new(password), }) } @@ -467,8 +483,8 @@ pub fn decrypt_keyfile_data( password: Option, ) -> AsyncTask { AsyncTask::new(DecryptKeyfileDataTask { - keyfile_data: keyfile_data.to_vec(), - password, + keyfile_data: Zeroizing::new(keyfile_data.to_vec()), + password: password.map(Zeroizing::new), }) } diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 30c690e466..e01170c334 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -26,6 +26,7 @@ export const DEFAULT_MAX_REQUEST_RETRIES = 2 export const DEFAULT_RETRY_BACKOFF_MS = 250 export const DEFAULT_MAX_RETRY_BACKOFF_MS = 5_000 export const DEFAULT_NONCE_RECONCILE_BLOCKS = 8 +export const DEFAULT_MAX_NONCE_STATUS_HISTORY = 256 export const DEFAULT_ENDPOINT_VALIDATION_TTL_MS = 60_000 export const DEFAULT_MAX_SUBSCRIPTION_QUEUE = 1024 export const DEFAULT_MAX_WS_MESSAGE_BYTES = 16 * 1024 * 1024 @@ -173,7 +174,7 @@ export interface SignerPayloadContext { export interface ExtensionSignRawRequest { address: string data: string - type: 'payload' + type: 'bytes' payload: Buffer metadataHash?: string metadataProof?: Buffer @@ -525,6 +526,41 @@ export class JsonRpcTransport { } } + private async requestWebSocketOnly( + method: string, + params: unknown[] = [], + options: RpcRequestOptions = {}, + ): Promise { + if (this.closed) throw new ChainError('transport closed') + const requestOptions = { + ...options, + timeoutMs: options.timeoutMs ?? this.requestTimeoutMs, + } + throwIfAborted(requestOptions.signal) + const maxRetries = requestOptions.maxRetries ?? this.maxRequestRetries + const retryForever = requestOptions.retryForever ?? this.retryForever + const retryBackoffMs = requestOptions.retryBackoffMs ?? this.retryBackoffMs + const maxRetryBackoffMs = requestOptions.maxRetryBackoffMs ?? this.maxRetryBackoffMs + let attempt = 0 + for (;;) { + const endpointAttempt = this.currentWebSocketAttempt() + try { + await this.ensureEndpointValidated(endpointAttempt, requestOptions) + return await this.wsRequest(endpointAttempt, method, params, requestOptions) + } catch (error) { + if (error instanceof RequestAbortedError || error instanceof JsonRpcError) throw error + if (error instanceof EndpointValidationError) this.validatedEndpoints.delete(endpointAttempt.endpoint) + if (!retryForever && attempt >= maxRetries) throw error + attempt += 1 + const nextIndex = this.nextWebSocketEndpointIndex(endpointAttempt.index) + if (nextIndex == null) throw error + this.rotateEndpoint(endpointAttempt, nextIndex) + const capped = Math.min(retryBackoffMs * (2 ** Math.max(0, attempt - 1)), maxRetryBackoffMs) + await delay(capped, requestOptions.signal) + } + } + } + private currentAttempt(): EndpointAttempt { return { endpoint: this.endpoint, @@ -533,6 +569,25 @@ export class JsonRpcTransport { } } + private currentWebSocketAttempt(): EndpointAttempt { + const current = this.currentAttempt() + if (!this.isHttpEndpoint(current.endpoint)) return current + const nextIndex = this.nextWebSocketEndpointIndex(current.index) + if (nextIndex == null) { + throw new ChainError('subscriptions require a WebSocket endpoint') + } + this.rotateEndpoint(current, nextIndex) + return this.currentAttempt() + } + + private nextWebSocketEndpointIndex(afterIndex: number): number | undefined { + for (let offset = 1; offset <= this.endpoints.length; offset += 1) { + const index = (afterIndex + offset) % this.endpoints.length + if (!this.isHttpEndpoint(this.endpoints[index])) return index + } + return undefined + } + private async ensureEndpointValidated(attempt: EndpointAttempt, options: RpcRequestOptions): Promise { if (this.validateEndpoint == null) return const validUntil = this.validatedEndpoints.get(attempt.endpoint) @@ -554,7 +609,7 @@ export class JsonRpcTransport { unsubscribeMethod: string, options: SubscriptionOptions = {}, ): Promise & { unsubscribe(): Promise }> { - if (this.isHttpEndpoint(this.endpoint)) throw new ChainError('subscriptions require a WebSocket endpoint') + this.currentWebSocketAttempt() const state: SubscriptionState = { queue: [], waiters: [], @@ -587,7 +642,9 @@ export class JsonRpcTransport { for (const waiter of state.waiters.splice(0)) waiter.resolve({ done: true, value: undefined }) const subscription = state.subscription state.subscription = undefined - if (subscription != null) await this.request(unsubscribeMethod, [subscription]).catch(() => undefined) + if (subscription != null) { + await this.requestWebSocketOnly(unsubscribeMethod, [subscription]).catch(() => undefined) + } } return { unsubscribe, @@ -852,12 +909,16 @@ export class JsonRpcTransport { } catch { // Ignore close errors while tearing down a failed connection. } + this.transitionDisconnectedSubscriptions(connection.generation, error) + } + + private transitionDisconnectedSubscriptions(generation: number, error: Error): void { for (const [subscription, entry] of this.subscriptionsById) { - if (entry.generation === connection.generation) this.subscriptionsById.delete(subscription) + if (entry.generation === generation) this.subscriptionsById.delete(subscription) } if (this.closed) return for (const state of this.subscriptions) { - if (state.subscriptionGeneration !== connection.generation) continue + if (state.subscriptionGeneration !== generation) continue state.subscription = undefined state.subscriptionGeneration = undefined if (state.resubscribe) void this.resubscribe(state) @@ -867,10 +928,10 @@ export class JsonRpcTransport { private async activateSubscription(state: SubscriptionState): Promise { const subscription = String( - await this.request(state.subscribeMethod, state.params, state.requestOptions), + await this.requestWebSocketOnly(state.subscribeMethod, state.params, state.requestOptions), ) if (state.closed) { - await this.request(state.unsubscribeMethod, [subscription]).catch(() => undefined) + await this.requestWebSocketOnly(state.unsubscribeMethod, [subscription]).catch(() => undefined) return } state.subscription = subscription @@ -904,7 +965,7 @@ export class JsonRpcTransport { } } - private rotateEndpoint(attempt: EndpointAttempt = this.currentAttempt()): boolean { + private rotateEndpoint(attempt: EndpointAttempt = this.currentAttempt(), nextIndex?: number): boolean { if ( attempt.generation !== this.generation || attempt.index !== this.endpointIndex || @@ -912,10 +973,14 @@ export class JsonRpcTransport { ) return true this.validatedEndpoints.delete(attempt.endpoint) const socket = this.socket + const error = new ChainError(`endpoint rotated from ${attempt.endpoint}`) + this.failPending(error, attempt.generation) this.generation += 1 - if (this.endpoints.length > 1) this.endpointIndex = (this.endpointIndex + 1) % this.endpoints.length + if (nextIndex != null) this.endpointIndex = nextIndex + else if (this.endpoints.length > 1) this.endpointIndex = (this.endpointIndex + 1) % this.endpoints.length this.socket = undefined this.connecting = undefined + this.transitionDisconnectedSubscriptions(attempt.generation, error) try { socket?.socket.close() } catch { @@ -939,6 +1004,7 @@ export class Client { readonly subnets: SubnetsNamespace readonly neurons: NeuronsNamespace readonly staking: StakingNamespace + readonly ready?: Promise private readonly headRuntimeTtlMs: number private readonly historicalRuntimeCacheSize: number @@ -985,7 +1051,10 @@ export class Client { options.historicalRuntimeCacheSize, DEFAULT_HISTORICAL_RUNTIME_CACHE_SIZE, ) - if (options.autoConnect) void this.connect() + if (options.autoConnect) { + this.ready = this.connect() + this.ready.catch(() => undefined) + } } async connect(): Promise { @@ -1777,6 +1846,7 @@ export class Client { if (state.next == null) { const chainNext = await this.peekNextIndex(address) await this.resolveAmbiguousNonce(address, state, chainNext) + advanceNonceStateToChainNext(state, chainNext) if (state.next == null) state.next = chainNext } state.reusable.sort((left, right) => left - right) @@ -1794,6 +1864,7 @@ export class Client { private async submitNonce(reservation: NonceReservation): Promise { await this.withNonceAccount(reservation.address, (state) => { state.statuses.set(reservation.nonce, 'submitted') + pruneNonceStatuses(state) }) } @@ -1853,11 +1924,12 @@ export class Client { return } - if (chainNext != null) state.next = chainNext + if (chainNext != null) advanceNonceStateToChainNext(state, chainNext) else if (state.next == null || state.next <= reservation.nonce) state.next = reservation.nonce + 1 if (state.ambiguous != null && reservation.nonce >= state.ambiguous.nonce) { state.ambiguous = undefined } + if (state.next == null || state.next <= reservation.nonce) state.next = reservation.nonce + 1 const minimumReusableNonce = Math.max(state.next, reservation.nonce + 1) state.reusable = state.reusable.filter( @@ -1869,7 +1941,6 @@ export class Client { } } - if (state.next <= reservation.nonce) state.next = reservation.nonce + 1 state.statuses.set(reservation.nonce, location === 'block' ? 'confirmed' : 'submitted') while (state.statuses.has(state.next) && state.statuses.get(state.next) !== 'reusable') { state.next += 1 @@ -2220,7 +2291,7 @@ export class Client { await signerShape.signRaw({ address: context.address, data: hex(payload), - type: 'payload', + type: 'bytes', payload, metadataHash: context.metadataHash == null ? undefined : hex(context.metadataHash), metadataProof: context.metadataProof, @@ -2271,7 +2342,7 @@ export class Client { for await (const status of subscription) { if (abortError != null) throw abortError const normalized = normalizeStatus(status) - const fatal = ['usurped', 'retracted', 'finalitytimeout', 'dropped', 'invalid'].find((name) => + const fatal = ['usurped', 'finalitytimeout', 'dropped', 'invalid'].find((name) => hasOwn(normalized, name), ) if (fatal != null) throw new ChainError(`Extrinsic ${fatal}`, status) @@ -3272,11 +3343,32 @@ function hashExtrinsicHex(extrinsic: unknown): string | undefined { } } +function advanceNonceStateToChainNext(state: NonceAccountState, chainNext: number): void { + if (state.next == null || state.next < chainNext) state.next = chainNext + state.reusable = state.reusable.filter((nonce) => nonce >= chainNext) + for (const [nonce, status] of state.statuses) { + if (nonce >= chainNext) continue + if (status === 'ambiguous' && state.ambiguous?.nonce === nonce) { + state.ambiguous = undefined + } + state.statuses.delete(nonce) + } + pruneNonceStatuses(state) +} + function pruneNonceStatuses(state: NonceAccountState): void { - if (state.statuses.size <= 512) return + if (state.next != null) { + for (const [nonce, status] of state.statuses) { + if (nonce >= state.next) continue + if (status === 'submitted' || status === 'confirmed' || status === 'failed') { + state.statuses.delete(nonce) + } + } + } + if (state.statuses.size <= DEFAULT_MAX_NONCE_STATUS_HISTORY * 2) return for (const [nonce, status] of state.statuses) { - if (state.statuses.size <= 256) break - if (status === 'confirmed' || status === 'failed') state.statuses.delete(nonce) + if (state.statuses.size <= DEFAULT_MAX_NONCE_STATUS_HISTORY) break + if (status !== 'reserved' && status !== 'ambiguous') state.statuses.delete(nonce) } } @@ -3303,6 +3395,7 @@ function clearAmbiguousNonceState(state: NonceAccountState, next: number): void state.ambiguous = undefined state.next = next state.reusable = state.reusable.filter((nonce) => nonce >= next) + pruneNonceStatuses(state) } function sameHex(left: string, right: string): boolean { diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index d589097cf1..51ad681b7b 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -727,6 +727,14 @@ test('private key bytes are not exported to JavaScript', async (t) => { const encrypted = await alice.toKeyfileData('review-password') assert.equal(core.keyfileDataIsEncrypted(encrypted), true) + await assert.rejects( + () => alice.toKeyfileData(''), + /keyfile password must not be empty/, + ) + await assert.rejects( + () => alice.writeKeyfile(path.join(root, 'empty-password'), { password: '' }), + /keyfile password must not be empty/, + ) }) test('fallible Runtime construction uses the native factory', () => { @@ -1351,6 +1359,81 @@ test('JsonRpcTransport restores websocket subscriptions after reconnect', async await subscription.unsubscribe() }) +test('JsonRpcTransport routes subscriptions only to WebSocket fallbacks', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + let nextSubscription = 1 + FakeWebSocket.onSend = (socket, message) => { + if (message.method === 'chain_subscribeNewHeads') { + const subscription = `sub-${nextSubscription++}` + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: subscription })) + return + } + if (message.method === 'chain_unsubscribeNewHeads') { + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: true })) + } + } + + const httpPrimary = new core.JsonRpcTransport('http://node-a', ['ws://node-b'], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + }) + const initialSubscription = await httpPrimary.subscribe( + 'chain_subscribeNewHeads', + [], + 'chain_unsubscribeNewHeads', + ) + assert.equal(FakeWebSocket.sockets.at(-1).url, 'ws://node-b') + await initialSubscription.unsubscribe() + + const mixedFallbacks = new core.JsonRpcTransport('ws://node-a', ['http://node-b', 'ws://node-c'], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + }) + const subscription = await mixedFallbacks.subscribe( + 'chain_subscribeNewHeads', + [], + 'chain_unsubscribeNewHeads', + ) + mixedFallbacks.endpointIndex = 1 + FakeWebSocket.sockets.at(-1).close() + await waitFor( + () => FakeWebSocket.sockets.some((socket) => + socket.url === 'ws://node-c' && + socket.sent.some((message) => message.method === 'chain_subscribeNewHeads'), + ), + 'resubscribe on websocket fallback', + ) + await subscription.unsubscribe() +}) + +test('JsonRpcTransport rejects old pending websocket requests on endpoint rotation', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + FakeWebSocket.onSend = () => undefined + const transport = new core.JsonRpcTransport('ws://node-a', ['ws://node-b'], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + retryBackoffMs: 1, + maxRetryBackoffMs: 1, + }) + + const oldPending = transport.request('state_getStorage', [], { + timeoutMs: 1_000, + maxRetries: 0, + }) + await waitFor(() => FakeWebSocket.sockets[0]?.sent.length === 1, 'first pending request') + const rotating = transport.request('state_getMetadata', [], { + timeoutMs: 5, + maxRetries: 1, + retryBackoffMs: 1, + maxRetryBackoffMs: 1, + }) + rotating.catch(() => undefined) + + await assert.rejects(oldPending, /endpoint rotated from ws:\/\/node-a/) +}) + test('JsonRpcTransport rejects pending requests on malformed websocket JSON', async (t) => { const { FakeWebSocket, restore } = installFakeWebSocket() t.after(restore) @@ -1616,6 +1699,24 @@ test('Client accepts an injected WebSocket factory when no global WebSocket exis assert.deepEqual(urls, ['ws://node-a']) }) +test('Client autoConnect exposes a handled readiness promise', async (t) => { + const originalRuntimeAt = core.Client.prototype.runtimeAt + t.after(() => { + core.Client.prototype.runtimeAt = originalRuntimeAt + }) + core.Client.prototype.runtimeAt = async () => { + throw new Error('startup failed') + } + + const client = new core.Client('local', { + endpoint: 'http://127.0.0.1:9944', + autoConnect: true, + }) + + assert.ok(client.ready instanceof Promise) + await assert.rejects(client.ready, /startup failed/) +}) + test('Client validates fallback endpoint genesis before use', async (t) => { const originalFetch = globalThis.fetch t.after(() => { @@ -1986,7 +2087,7 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => assert.equal(signed.nonce, 12) assert.equal(client.lastNonceAddress, address) assert.equal(request.address, address) - assert.equal(request.type, 'payload') + assert.equal(request.type, 'bytes') assert.equal(request.data, '0x090909') assert.equal(request.metadataProof, undefined) assert.deepEqual(captures.encoded.callData, callData) @@ -2660,6 +2761,7 @@ test('Client submit without inclusion reports pool submission, not execution suc assert.equal(result.status, 'submitted') assert.equal(result.success, undefined) assert.equal(result.message, 'Submitted') + assert.equal(client.nonceAccounts.get(address).statuses.size, 0) }) test('Client treats string and object fatal watch statuses as failures', async () => { @@ -2681,6 +2783,35 @@ test('Client treats string and object fatal watch statuses as failures', async ( ) }) +test('Client continues watching after retracted extrinsic status', async () => { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const blockHash = `0x${'33'.repeat(32)}` + const extrinsicHash = `0x${'44'.repeat(32)}` + const subscription = { + async *[Symbol.asyncIterator]() { + yield { retracted: `0x${'22'.repeat(32)}` } + yield { finalized: blockHash } + }, + async unsubscribe() {}, + } + client.resolveInclusion = async (hash, block, finalized) => ({ + status: 'finalized', + success: true, + message: 'Success', + extrinsicHash: hash, + blockHash: block, + finalized, + events: [], + }) + + const result = await client.resolveWatchedExtrinsic(subscription, extrinsicHash, true) + + assert.equal(result.status, 'finalized') + assert.equal(result.extrinsicHash, extrinsicHash) + assert.equal(result.blockHash, blockHash) + assert.equal(result.finalized, true) +}) + test('Client reports included extrinsics with missing dispatch outcome as unknown', async () => { const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) const extrinsic = '0x0102' From 1c5502389ee141d99f67924c3487fe0d92a61d4b Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 15:00:37 -0700 Subject: [PATCH 55/72] clippy --- sdk/bittensor-core/src/keyfiles/mod.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sdk/bittensor-core/src/keyfiles/mod.rs b/sdk/bittensor-core/src/keyfiles/mod.rs index 26c946636f..6cdf60972e 100644 --- a/sdk/bittensor-core/src/keyfiles/mod.rs +++ b/sdk/bittensor-core/src/keyfiles/mod.rs @@ -692,10 +692,8 @@ fn commit_overwrite_keyfile(path: &Path, temp_path: &Path) -> Result<(), CoreErr } restore_backup(path, backup.as_deref()); fsync_directory(dir); - } else { - if let Some(path) = backup { - let _ = fs::remove_file(path); - } + } else if let Some(path) = backup { + let _ = fs::remove_file(path); } result } From 47dceba06b06f0097d599489865f99d9bad032bc Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 15:49:17 -0700 Subject: [PATCH 56/72] use new rust from merge --- sdk/bittensor-core/Cargo.toml | 1 - sdk/bittensor-core/src/transaction.rs | 65 + sdk/bittensor-ts/README.md | 39 +- sdk/bittensor-ts/native/src/errors.rs | 2 + sdk/bittensor-ts/native/src/lib.rs | 1 + sdk/bittensor-ts/native/src/transaction.rs | 1073 +++++++++++++++++ .../scripts/check-native-parity.cjs | 21 + sdk/bittensor-ts/src/client.ts | 379 +++++- sdk/bittensor-ts/src/index.ts | 13 + sdk/bittensor-ts/src/keys.ts | 8 + sdk/bittensor-ts/src/native.ts | 245 ++++ sdk/bittensor-ts/src/transaction.ts | 598 +++++++++ sdk/bittensor-ts/test/basic.test.cjs | 139 ++- 13 files changed, 2525 insertions(+), 59 deletions(-) create mode 100644 sdk/bittensor-ts/native/src/transaction.rs create mode 100644 sdk/bittensor-ts/src/transaction.ts diff --git a/sdk/bittensor-core/Cargo.toml b/sdk/bittensor-core/Cargo.toml index 764ae7ae75..84e2b9d220 100644 --- a/sdk/bittensor-core/Cargo.toml +++ b/sdk/bittensor-core/Cargo.toml @@ -76,7 +76,6 @@ merkleized-metadata = "0.5.1" # workers (codec/batch.rs). rayon = { version = "1.10", optional = true } scale-info = { workspace = true, features = ["std", "serde"] } -subtensor-macros = { workspace = true } # signers (feature "ledger"): APDU types for the Polkadot generic app; the # HID transport itself lives in signers/ledger.rs on top of hidapi (below). diff --git a/sdk/bittensor-core/src/transaction.rs b/sdk/bittensor-core/src/transaction.rs index a8aea0f1a7..4f7ea32770 100644 --- a/sdk/bittensor-core/src/transaction.rs +++ b/sdk/bittensor-core/src/transaction.rs @@ -432,6 +432,71 @@ impl IntentCall { ) } + /// Publish validator weights for one subnet. + pub fn set_weights(netuid: u16, dests: Vec, weights: Vec, version_key: u64) -> Self { + Self::trusted( + "set_weights", + SignerRole::Hotkey, + "SubtensorModule", + "set_weights", + Value::record(vec![ + ("netuid".into(), Value::Uint(u128::from(netuid))), + ( + "dests".into(), + Value::List( + dests + .into_iter() + .map(|uid| Value::Uint(u128::from(uid))) + .collect(), + ), + ), + ( + "weights".into(), + Value::List( + weights + .into_iter() + .map(|weight| Value::Uint(u128::from(weight))) + .collect(), + ), + ), + ("version_key".into(), Value::Uint(u128::from(version_key))), + ]), + Spend::None, + [netuid], + false, + ) + } + + /// Serve an Axon endpoint for one subnet. + pub fn serve_axon( + netuid: u16, + version: u32, + ip: u128, + port: u16, + ip_type: u8, + protocol: u8, + ) -> Self { + Self::trusted( + "serve_axon", + SignerRole::Hotkey, + "SubtensorModule", + "serve_axon", + Value::record(vec![ + ("netuid".into(), Value::Uint(u128::from(netuid))), + ("version".into(), Value::Uint(u128::from(version))), + ("ip".into(), Value::Uint(ip)), + ("port".into(), Value::Uint(u128::from(port))), + ("ip_type".into(), Value::Uint(u128::from(ip_type))), + ("protocol".into(), Value::Uint(u128::from(protocol))), + ("placeholder1".into(), Value::Uint(0)), + ("placeholder2".into(), Value::Uint(0)), + ]), + Spend::None, + [netuid], + false, + ) + } + /// Register a hotkey by burning the subnet's live registration cost. pub fn burned_register(netuid: u16, hotkey: impl Into) -> Self { Self::trusted( diff --git a/sdk/bittensor-ts/README.md b/sdk/bittensor-ts/README.md index ad1ad35db6..24a4802614 100644 --- a/sdk/bittensor-ts/README.md +++ b/sdk/bittensor-ts/README.md @@ -18,13 +18,16 @@ Chain-defined work runs in Rust: The Node TypeScript layer is limited to JavaScript-friendly names and defaults, lossless `Buffer`/`bigint`/`Map` boundary conversion, error classes, signing-compatibility adapters for the signer objects expected by Polkadot.js, -Polkadot API, and Moonwall, plus the client responsibilities that deliberately -do not live in Rust: WebSocket/HTTP JSON-RPC transport, reconnect/fallback -handling, subscriptions, storage queries, runtime API calls, nonce tracking, -extrinsic submission and inclusion/finalization outcome handling, wallet -filesystem management, generated-style descriptors, and high-level Bittensor -operations for balances, subnets, metagraphs, staking, registration, serving, -transfers, and weights. +Polkadot API, and Moonwall, wallet filesystem management, generated-style +descriptors, and the client responsibilities that deliberately remain outside +the current blocking Rust client: async WebSocket transport, subscriptions, +endpoint fallback, browser support, and extension-signer interop. On Node, +chain reads, call composition, fee estimation, Rust-keypair signing, +inclusion/finalization receipts, dispatch-error interpretation, and high-level +transaction semantics prefer the native Rust client and transaction executor when +available. High-level TypeScript transaction helpers construct Rust +`IntentCall` values, and arbitrary pallet/function calls are classified as raw +by Rust policy before signing. The package exposes both CommonJS and ESM entrypoints. It also exports the raw generated Node-API module as `@bittensor/sdk/native`, so every native @@ -142,6 +145,28 @@ flows using `signExtrinsic()` followed by `submitSigned()` or `watchSigned()` record the signed nonce after submission, but callers producing multiple detached transactions concurrently should pass explicit `nonce` values. +High-level transaction helpers such as `transfer()`, `staking.addStake()`, +`setWeights()`, registration, and serving route through Rust trusted +constructors on `IntentCall`. The same constructors are exported directly for +advanced callers: + +```ts +import { Client, IntentCall, Policy } from '@bittensor/sdk' + +const client = await new Client('finney').connect() +await client.submit( + IntentCall.addStake(hotkey, 1, 1_000_000_000n), + coldkeySigner, + { policy: new Policy({ maxSpendRao: 1_000_000_000n, allowedNetuids: [1] }) }, +) +``` + +Raw pallet/function calls are an explicit escape hatch. They are treated by +Rust as unbounded spend with unknown/all-subnet scope, so they require +`allowRawCall: true` or a `Policy` with `allowRawCalls: true`; spend and subnet +caps still fail closed for raw calls. Opaque pre-composed call bytes are more +restricted because the SDK cannot prove their spend or subnet scope. + `client.assertDescriptorSchema()` checks the exported storage, call, constant, and runtime API descriptor tables against the chain metadata loaded for a block. Run it in CI or application startup when relying on the convenience descriptor diff --git a/sdk/bittensor-ts/native/src/errors.rs b/sdk/bittensor-ts/native/src/errors.rs index afc3b39b31..bee1eeb4fc 100644 --- a/sdk/bittensor-ts/native/src/errors.rs +++ b/sdk/bittensor-ts/native/src/errors.rs @@ -11,6 +11,8 @@ pub fn into_napi(error: CoreError) -> Error { CoreError::Codec(message) => ("CODEC", Status::InvalidArg, message), CoreError::Crypto(message) => ("CRYPTO", Status::InvalidArg, message), CoreError::Device(message) => ("DEVICE", Status::GenericFailure, message), + CoreError::Rpc(message) => ("RPC", Status::GenericFailure, message), + CoreError::Policy(message) => ("POLICY", Status::InvalidArg, message), }; Error::new(status, format!("[BITTENSOR_CORE:{code}] {message}")) } diff --git a/sdk/bittensor-ts/native/src/lib.rs b/sdk/bittensor-ts/native/src/lib.rs index c46a5beb3d..247d2f4119 100644 --- a/sdk/bittensor-ts/native/src/lib.rs +++ b/sdk/bittensor-ts/native/src/lib.rs @@ -8,6 +8,7 @@ mod ledger; mod mlkem; mod runtime; mod timelock; +mod transaction; mod values; use bittensor_core::codec::value::{to_corpus_json, u256_decimal}; diff --git a/sdk/bittensor-ts/native/src/transaction.rs b/sdk/bittensor-ts/native/src/transaction.rs new file mode 100644 index 0000000000..8747878832 --- /dev/null +++ b/sdk/bittensor-ts/native/src/transaction.rs @@ -0,0 +1,1073 @@ +use std::sync::Arc; + +use bittensor_core::client::{BlockHeader, DispatchError, SubnetInfo, SwapQuote, TxOutcome}; +use bittensor_core::transaction::{Executor, IntentCall, Plan, Policy, SignerRole, Spend, Wallet}; +use bittensor_core::Client; +use napi::bindgen_prelude::{BigInt, Buffer}; +use napi_derive::napi; +use serde_json::Value as JsonValue; + +use crate::errors::{invalid_arg, CoreResultExt, NapiResult}; +use crate::keys::NativeKeypair; +use crate::runtime::NativeMapPair; +use crate::values::{from_wire, to_wire}; + +#[napi(object)] +pub struct NativePolicyOptions { + pub max_fee_rao: Option, + pub max_spend_rao: Option, + pub allowed_netuids: Option>, + pub allow_raw_calls: Option, +} + +#[napi(object)] +pub struct NativePlan { + pub op: String, + pub summary: String, + pub signer_role: String, + pub signer_address: String, + pub fee_rao: Option, + pub warnings: Vec, + pub violations: Vec, + pub ok: bool, + pub call_data: Buffer, +} + +#[napi(object)] +pub struct NativeDispatchError { + pub pallet: Option, + pub name: String, + pub docs: Vec, + pub semantic_code: String, +} + +#[napi(object)] +pub struct NativeTxOutcome { + pub success: bool, + pub extrinsic_hash: String, + pub block_hash: Option, + pub block_number: Option, + pub extrinsic_index: Option, + pub fee_rao: Option, + pub events: Vec, + pub error: Option, + pub message: String, + pub data: JsonValue, +} + +#[napi(object)] +pub struct NativeBlockHeader { + pub hash: String, + pub parent_hash: String, + pub number: BigInt, +} + +#[napi(object)] +pub struct NativeSubnetInfo { + pub netuid: u16, + pub tempo: u16, + pub burn_rao: String, + pub neuron_count: u16, +} + +#[napi(object)] +pub struct NativeSwapQuote { + pub tao_amount: String, + pub alpha_amount: String, + pub tao_fee: String, + pub alpha_fee: String, + pub tao_slippage: String, + pub alpha_slippage: String, +} + +#[napi(object)] +pub struct NativeSignedExtrinsic { + pub bytes: Buffer, + pub hash: String, +} + +#[napi] +pub enum NativeSignerRole { + Coldkey, + Hotkey, +} + +#[napi] +pub enum NativeSpendKind { + None, + Bounded, + Unbounded, +} + +#[napi] +pub struct NativePolicy { + pub(crate) inner: Policy, +} + +#[napi] +impl NativePolicy { + #[napi(factory, js_name = "fromOptions")] + pub fn from_options(options: Option) -> napi::Result { + let Some(options) = options else { + return Ok(Self { + inner: Policy::default(), + }); + }; + Ok(Self { + inner: Policy { + max_fee_rao: options + .max_fee_rao + .as_ref() + .map(|value| bigint_u128("maxFeeRao", value)) + .transpose()?, + max_spend_rao: options + .max_spend_rao + .as_ref() + .map(|value| bigint_u128("maxSpendRao", value)) + .transpose()?, + allowed_netuids: options + .allowed_netuids + .map(|items| items.into_iter().collect()), + allow_raw_calls: options.allow_raw_calls.unwrap_or(false), + }, + }) + } + + #[napi(getter)] + pub fn allow_raw_calls(&self) -> bool { + self.inner.allow_raw_calls + } + + #[napi(js_name = "check")] + pub fn check( + &self, + intent: &NativeIntentCall, + fee_rao: Option, + ) -> NapiResult> { + let fee = fee_rao + .as_ref() + .map(|value| bigint_u128("feeRao", value)) + .transpose()?; + Ok(self.inner.check(&intent.inner, fee)) + } +} + +#[napi] +pub struct NativeIntentCall { + pub(crate) inner: IntentCall, +} + +#[napi] +impl NativeIntentCall { + #[napi(factory, js_name = "rawCall")] + pub fn raw_call( + op: String, + signer_role: NativeSignerRole, + pallet: String, + call_function: String, + params: JsonValue, + ) -> napi::Result { + Ok(Self { + inner: IntentCall::raw_call( + op, + signer_role.into(), + pallet, + call_function, + from_wire(params)?, + ), + }) + } + + #[napi(factory)] + pub fn transfer(dest: String, amount_rao: BigInt) -> napi::Result { + Ok(Self { + inner: IntentCall::transfer(dest, bigint_u128("amountRao", &amount_rao)?), + }) + } + + #[napi(factory, js_name = "fundEvmKey")] + pub fn fund_evm_key(mirror: String, amount_rao: BigInt) -> napi::Result { + Ok(Self { + inner: IntentCall::fund_evm_key(mirror, bigint_u128("amountRao", &amount_rao)?), + }) + } + + #[napi(factory, js_name = "transferAllowDeath")] + pub fn transfer_allow_death(dest: String, amount_rao: BigInt) -> napi::Result { + Ok(Self { + inner: IntentCall::transfer_allow_death(dest, bigint_u128("amountRao", &amount_rao)?), + }) + } + + #[napi(factory, js_name = "transferAll")] + pub fn transfer_all(dest: String, keep_alive: bool) -> Self { + Self { + inner: IntentCall::transfer_all(dest, keep_alive), + } + } + + #[napi(factory, js_name = "addStake")] + pub fn add_stake(hotkey: String, netuid: u16, amount_rao: BigInt) -> napi::Result { + Ok(Self { + inner: IntentCall::add_stake(hotkey, netuid, bigint_u128("amountRao", &amount_rao)?), + }) + } + + #[napi(factory, js_name = "addStakeLimit")] + pub fn add_stake_limit( + hotkey: String, + netuid: u16, + amount_rao: BigInt, + limit_price_rao: BigInt, + allow_partial: bool, + ) -> napi::Result { + Ok(Self { + inner: IntentCall::add_stake_limit( + hotkey, + netuid, + bigint_u128("amountRao", &amount_rao)?, + bigint_u128("limitPriceRao", &limit_price_rao)?, + allow_partial, + ), + }) + } + + #[napi(factory, js_name = "removeStake")] + pub fn remove_stake( + hotkey: String, + netuid: u16, + amount_alpha_rao: BigInt, + ) -> napi::Result { + Ok(Self { + inner: IntentCall::remove_stake( + hotkey, + netuid, + bigint_u128("amountAlphaRao", &amount_alpha_rao)?, + ), + }) + } + + #[napi(factory, js_name = "removeStakeLimit")] + pub fn remove_stake_limit( + hotkey: String, + netuid: u16, + amount_alpha_rao: BigInt, + limit_price_rao: BigInt, + allow_partial: bool, + ) -> napi::Result { + Ok(Self { + inner: IntentCall::remove_stake_limit( + hotkey, + netuid, + bigint_u128("amountAlphaRao", &amount_alpha_rao)?, + bigint_u128("limitPriceRao", &limit_price_rao)?, + allow_partial, + ), + }) + } + + #[napi(factory, js_name = "registerSubnet")] + pub fn register_subnet(hotkey: String) -> Self { + Self { + inner: IntentCall::register_subnet(hotkey), + } + } + + #[napi(factory, js_name = "startCall")] + pub fn start_call(netuid: u16) -> Self { + Self { + inner: IntentCall::start_call(netuid), + } + } + + #[napi(factory, js_name = "setWeights")] + pub fn set_weights( + netuid: u16, + dests: Vec, + weights: Vec, + version_key: BigInt, + ) -> napi::Result { + Ok(Self { + inner: IntentCall::set_weights( + netuid, + dests, + weights, + bigint_u64("versionKey", &version_key)?, + ), + }) + } + + #[napi(factory, js_name = "serveAxon")] + pub fn serve_axon( + netuid: u16, + version: u32, + ip: BigInt, + port: u16, + ip_type: u8, + protocol: u8, + ) -> napi::Result { + Ok(Self { + inner: IntentCall::serve_axon( + netuid, + version, + bigint_u128("ip", &ip)?, + port, + ip_type, + protocol, + ), + }) + } + + #[napi(factory, js_name = "burnedRegister")] + pub fn burned_register(netuid: u16, hotkey: String) -> Self { + Self { + inner: IntentCall::burned_register(netuid, hotkey), + } + } + + #[napi(factory, js_name = "rootRegister")] + pub fn root_register(hotkey: String) -> Self { + Self { + inner: IntentCall::root_register(hotkey), + } + } + + #[napi(factory, js_name = "moveStake")] + pub fn move_stake( + origin_hotkey: String, + origin_netuid: u16, + destination_hotkey: String, + destination_netuid: u16, + amount_alpha_rao: BigInt, + ) -> napi::Result { + Ok(Self { + inner: IntentCall::move_stake( + origin_hotkey, + origin_netuid, + destination_hotkey, + destination_netuid, + bigint_u128("amountAlphaRao", &amount_alpha_rao)?, + ), + }) + } + + #[napi(factory, js_name = "swapStake")] + pub fn swap_stake( + hotkey: String, + origin_netuid: u16, + destination_netuid: u16, + amount_alpha_rao: BigInt, + ) -> napi::Result { + Ok(Self { + inner: IntentCall::swap_stake( + hotkey, + origin_netuid, + destination_netuid, + bigint_u128("amountAlphaRao", &amount_alpha_rao)?, + ), + }) + } + + #[napi(factory, js_name = "transferStake")] + pub fn transfer_stake( + destination_coldkey: String, + hotkey: String, + origin_netuid: u16, + destination_netuid: u16, + amount_alpha_rao: BigInt, + ) -> napi::Result { + Ok(Self { + inner: IntentCall::transfer_stake( + destination_coldkey, + hotkey, + origin_netuid, + destination_netuid, + bigint_u128("amountAlphaRao", &amount_alpha_rao)?, + ), + }) + } + + #[napi(factory, js_name = "unstakeAll")] + pub fn unstake_all(hotkey: String) -> Self { + Self { + inner: IntentCall::unstake_all(hotkey), + } + } + + #[napi(factory, js_name = "unstakeAllAlpha")] + pub fn unstake_all_alpha(hotkey: String) -> Self { + Self { + inner: IntentCall::unstake_all_alpha(hotkey), + } + } + + #[napi(factory, js_name = "setHyperparameter")] + pub fn set_hyperparameter(netuid: u16, name: String, value: JsonValue) -> napi::Result { + IntentCall::set_hyperparameter(netuid, &name, from_wire(value)?) + .napi() + .map(|inner| Self { inner }) + } + + #[napi(factory, js_name = "setRootClaimType")] + pub fn set_root_claim_type( + claim_type: String, + subnets: Option>, + ) -> napi::Result { + IntentCall::set_root_claim_type(&claim_type, subnets) + .napi() + .map(|inner| Self { inner }) + } + + #[napi(getter)] + pub fn op(&self) -> String { + self.inner.op().to_owned() + } + + #[napi(getter)] + pub fn summary(&self) -> String { + self.inner.summary.clone() + } + + #[napi(getter, js_name = "signerRole")] + pub fn signer_role(&self) -> String { + signer_role_name(self.inner.signer_role()).to_owned() + } + + #[napi(getter)] + pub fn pallet(&self) -> String { + self.inner.pallet().to_owned() + } + + #[napi(getter, js_name = "callFunction")] + pub fn call_function(&self) -> String { + self.inner.function().to_owned() + } + + #[napi(getter)] + pub fn params(&self) -> NapiResult { + to_wire(self.inner.params()) + } + + #[napi(js_name = "withSummary")] + pub fn with_summary(&self, summary: String) -> Self { + Self { + inner: self.inner.clone().summary(summary), + } + } + + #[napi(js_name = "forceRaw")] + pub fn force_raw(&self) -> Self { + Self { + inner: self.inner.clone().raw(), + } + } + + #[napi(js_name = "asCallTuple")] + pub fn as_call_tuple(&self) -> NapiResult> { + Ok(vec![ + JsonValue::String(self.inner.pallet().to_owned()), + JsonValue::String(self.inner.function().to_owned()), + to_wire(self.inner.params())?, + ]) + } +} + +#[napi] +pub struct NativeClient { + pub(crate) inner: Arc, +} + +#[napi] +impl NativeClient { + #[napi(factory)] + pub fn connect(endpoint: String) -> napi::Result { + Client::connect(endpoint).napi().map(|inner| Self { + inner: Arc::new(inner), + }) + } + + #[napi(getter)] + pub fn endpoint(&self) -> String { + self.inner.endpoint().to_owned() + } + + #[napi(getter, js_name = "ss58Format")] + pub fn ss58_format(&self) -> u16 { + self.inner.ss58_format() + } + + #[napi(getter, js_name = "genesisHash")] + pub fn genesis_hash(&self) -> Buffer { + self.inner.genesis_hash().to_vec().into() + } + + #[napi(js_name = "blockHash")] + pub fn block_hash(&self, block: Option) -> NapiResult { + let block = block + .as_ref() + .map(|value| bigint_u64("block", value)) + .transpose()?; + self.inner.block_hash(block).napi() + } + + #[napi(js_name = "finalizedHead")] + pub fn finalized_head(&self) -> NapiResult { + self.inner.finalized_head().napi() + } + + #[napi(js_name = "blockNumber")] + pub fn block_number(&self, block_hash: Option) -> NapiResult { + let number = match block_hash { + Some(hash) => self.inner.header(Some(&hash)).napi()?.number, + None => self.inner.block_number().napi()?, + }; + Ok(BigInt::from(number)) + } + + #[napi(js_name = "header")] + pub fn header(&self, block_hash: Option) -> NapiResult { + self.inner + .header(block_hash.as_deref()) + .napi() + .map(header_to_native) + } + + #[napi(js_name = "readCatalog")] + pub fn read_catalog(&self) -> Vec { + self.inner + .read_catalog() + .iter() + .map(|item| (*item).to_owned()) + .collect() + } + + #[napi(js_name = "refreshRuntime")] + pub fn refresh_runtime(&self) -> NapiResult { + self.inner.refresh_runtime().napi() + } + + #[napi(js_name = "composeCall")] + pub fn compose_call( + &self, + pallet: String, + call_function: String, + params: JsonValue, + ) -> NapiResult { + self.inner + .compose_call(&pallet, &call_function, &from_wire(params)?) + .napi() + .map(Buffer::from) + } + + #[napi(js_name = "decodeScale")] + pub fn decode_scale(&self, type_name: String, data: Buffer) -> NapiResult { + self.inner + .decode_scale(&type_name, &data) + .napi() + .and_then(|value| to_wire(&value)) + } + + #[napi(js_name = "constant")] + pub fn constant(&self, pallet: String, name: String) -> NapiResult { + self.inner + .constant(&pallet, &name) + .napi() + .and_then(|value| to_wire(&value)) + } + + #[napi(js_name = "query")] + pub fn query( + &self, + pallet: String, + storage: String, + params: JsonValue, + block_hash: Option, + ) -> NapiResult { + let params = wire_value_list("params", params)?; + self.inner + .query(&pallet, &storage, ¶ms, block_hash.as_deref()) + .napi() + .and_then(|value| to_wire(&value)) + } + + #[napi(js_name = "queryBatch")] + pub fn query_batch( + &self, + pallet: String, + storage: String, + param_sets: JsonValue, + block_hash: Option, + ) -> NapiResult> { + let param_sets = wire_value_list_list("paramSets", param_sets)?; + self.inner + .query_batch(&pallet, &storage, ¶m_sets, block_hash.as_deref()) + .napi()? + .iter() + .map(to_wire) + .collect() + } + + #[napi(js_name = "queryMap")] + pub fn query_map( + &self, + pallet: String, + storage: String, + fixed_params: JsonValue, + block_hash: Option, + ) -> NapiResult> { + let fixed_params = wire_value_list("fixedParams", fixed_params)?; + self.inner + .query_map(&pallet, &storage, &fixed_params, block_hash.as_deref()) + .napi()? + .iter() + .map(|(key, value)| { + Ok(NativeMapPair { + key: to_wire(key)?, + value: to_wire(value)?, + }) + }) + .collect() + } + + #[napi(js_name = "runtimeCall")] + pub fn runtime_call( + &self, + api: String, + method: String, + params: JsonValue, + block_hash: Option, + ) -> NapiResult { + let params = wire_value_list("params", params)?; + self.inner + .runtime_call(&api, &method, ¶ms, block_hash.as_deref()) + .napi() + .and_then(|value| to_wire(&value)) + } + + #[napi(js_name = "accountNextIndex")] + pub fn account_next_index(&self, address: String) -> NapiResult { + self.inner.account_next_index(&address).napi().map(BigInt::from) + } + + #[napi(js_name = "signExtrinsic")] + pub fn sign_extrinsic( + &self, + call_data: Buffer, + signer: &NativeKeypair, + nonce: BigInt, + period: Option, + ) -> NapiResult { + let nonce = bigint_u64("nonce", &nonce)?; + let period = period + .as_ref() + .map(|value| bigint_u64("period", value)) + .transpose()?; + self.inner + .sign_extrinsic(&call_data, &signer.inner, nonce, period) + .napi() + .map(|(bytes, hash)| NativeSignedExtrinsic { + bytes: bytes.into(), + hash, + }) + } + + #[napi(js_name = "estimateFee")] + pub fn estimate_fee(&self, call_data: Buffer, signer: &NativeKeypair) -> NapiResult { + self.inner + .estimate_fee(&call_data, &signer.inner) + .napi() + .map(|value| value.to_string()) + } + + #[napi(js_name = "submit")] + pub fn submit( + &self, + call_data: Buffer, + signer: &NativeKeypair, + nonce: Option, + period: Option, + wait_for_finalization: Option, + ) -> NapiResult { + let nonce = nonce + .as_ref() + .map(|value| bigint_u64("nonce", value)) + .transpose()?; + let period = period + .as_ref() + .map(|value| bigint_u64("period", value)) + .transpose()?; + self.inner + .submit( + &call_data, + &signer.inner, + nonce, + period, + wait_for_finalization.unwrap_or(false), + ) + .napi() + .and_then(outcome_to_native) + } + + #[napi(js_name = "submitEncoded")] + pub fn submit_encoded( + &self, + extrinsic: Buffer, + expected_hash: String, + wait_for_finalization: Option, + ) -> NapiResult { + self.inner + .submit_encoded( + &extrinsic, + expected_hash, + wait_for_finalization.unwrap_or(false), + ) + .napi() + .and_then(outcome_to_native) + } + + #[napi(js_name = "balanceRao")] + pub fn balance_rao(&self, address: String) -> NapiResult { + self.inner + .balance_rao(&address) + .napi() + .map(|value| value.to_string()) + } + + #[napi(js_name = "existentialDepositRao")] + pub fn existential_deposit_rao(&self) -> NapiResult { + self.inner + .existential_deposit_rao() + .napi() + .map(|value| value.to_string()) + } + + #[napi(js_name = "subnets")] + pub fn subnets(&self, block_hash: Option) -> NapiResult> { + self.inner + .subnets(block_hash.as_deref()) + .napi() + .map(|items| items.into_iter().map(subnet_to_native).collect()) + } + + #[napi(js_name = "metagraph")] + pub fn metagraph(&self, netuid: u16, block_hash: Option) -> NapiResult { + self.inner + .metagraph(netuid, block_hash.as_deref()) + .napi() + .and_then(|value| to_wire(&value)) + } + + #[napi(js_name = "neurons")] + pub fn neurons(&self, netuid: u16, block_hash: Option) -> NapiResult> { + self.inner + .neurons(netuid, block_hash.as_deref()) + .napi()? + .iter() + .map(to_wire) + .collect() + } + + #[napi(js_name = "subnetHyperparameters")] + pub fn subnet_hyperparameters( + &self, + netuid: u16, + block_hash: Option, + ) -> NapiResult { + self.inner + .subnet_hyperparameters(netuid, block_hash.as_deref()) + .napi() + .and_then(|value| to_wire(&value)) + } + + #[napi(js_name = "stakeRao")] + pub fn stake_rao( + &self, + coldkey: String, + hotkey: String, + netuid: u16, + block_hash: Option, + ) -> NapiResult { + self.inner + .stake_rao(&coldkey, &hotkey, netuid, block_hash.as_deref()) + .napi() + .map(|value| value.to_string()) + } + + #[napi(js_name = "quoteStake")] + pub fn quote_stake( + &self, + netuid: u16, + amount_rao: BigInt, + block_hash: Option, + ) -> NapiResult { + self.inner + .quote_stake( + netuid, + bigint_u128("amountRao", &amount_rao)?, + block_hash.as_deref(), + ) + .napi() + .map(quote_to_native) + } + + #[napi(js_name = "composeIntent")] + pub fn compose_intent(&self, intent: &NativeIntentCall) -> NapiResult { + intent.inner.encode(&self.inner).napi().map(Buffer::from) + } +} + +#[napi] +pub struct NativeWallet { + pub(crate) inner: Wallet, +} + +#[napi] +impl NativeWallet { + #[napi(factory, js_name = "fromKeypairs")] + pub fn from_keypairs(coldkey: &NativeKeypair, hotkey: &NativeKeypair) -> Self { + Self { + inner: Wallet { + coldkey: coldkey.inner.clone(), + hotkey: hotkey.inner.clone(), + }, + } + } + + #[napi(factory, js_name = "fromUris")] + pub fn from_uris(coldkey_uri: String, hotkey_uri: String) -> napi::Result { + Wallet::from_uris(&coldkey_uri, &hotkey_uri) + .napi() + .map(|inner| Self { inner }) + } +} + +#[napi] +pub struct NativeExecutor { + client: Arc, + policy: Option, +} + +#[napi] +impl NativeExecutor { + #[napi(factory, js_name = "fromClient")] + pub fn from_client(client: &NativeClient) -> Self { + Self { + client: Arc::clone(&client.inner), + policy: None, + } + } + + #[napi(factory, js_name = "withPolicy")] + pub fn with_policy(client: &NativeClient, policy: &NativePolicy) -> Self { + Self { + client: Arc::clone(&client.inner), + policy: Some(policy.inner.clone()), + } + } + + #[napi(js_name = "plan")] + pub fn plan(&self, intent: &NativeIntentCall, wallet: &NativeWallet) -> NapiResult { + let executor = self.executor(); + executor + .plan(&intent.inner, &wallet.inner) + .napi() + .and_then(plan_to_native) + } + + #[napi(js_name = "planWithPolicy")] + pub fn plan_with_policy( + &self, + intent: &NativeIntentCall, + wallet: &NativeWallet, + policy: &NativePolicy, + ) -> NapiResult { + let executor = self.executor(); + executor + .plan_with_policy(&intent.inner, &wallet.inner, &policy.inner) + .napi() + .and_then(plan_to_native) + } + + #[napi(js_name = "execute")] + pub fn execute( + &self, + intent: &NativeIntentCall, + wallet: &NativeWallet, + wait_for_finalization: Option, + ) -> NapiResult { + let executor = self.executor(); + executor + .execute_with( + &intent.inner, + &wallet.inner, + None, + None, + None, + wait_for_finalization.unwrap_or(true), + ) + .napi() + .and_then(outcome_to_native) + } + + #[napi(js_name = "submitShielded")] + pub fn submit_shielded( + &self, + intent: &NativeIntentCall, + wallet: &NativeWallet, + ) -> NapiResult { + let executor = self.executor(); + executor + .submit_shielded(&intent.inner, &wallet.inner, None) + .napi() + .and_then(outcome_to_native) + } +} + +impl NativeExecutor { + fn executor(&self) -> Executor<'_> { + match &self.policy { + Some(policy) => Executor::with_policy(&self.client, policy.clone()), + None => Executor::new(&self.client), + } + } +} + +impl From for SignerRole { + fn from(value: NativeSignerRole) -> Self { + match value { + NativeSignerRole::Coldkey => SignerRole::Coldkey, + NativeSignerRole::Hotkey => SignerRole::Hotkey, + } + } +} + +fn signer_role_name(role: SignerRole) -> &'static str { + match role { + SignerRole::Coldkey => "coldkey", + SignerRole::Hotkey => "hotkey", + } +} + +fn plan_to_native(plan: Plan) -> NapiResult { + let ok = plan.ok(); + Ok(NativePlan { + op: plan.op, + summary: plan.summary, + signer_role: signer_role_name(plan.signer).to_owned(), + signer_address: plan.signer_address, + fee_rao: plan.fee_rao.map(|value| value.to_string()), + warnings: plan.warnings, + ok, + violations: plan.violations, + call_data: plan.call_data.into(), + }) +} + +fn outcome_to_native(outcome: TxOutcome) -> NapiResult { + let data = outcome + .data + .iter() + .map(|(key, value)| Ok((key.clone(), to_wire(value)?))) + .collect::>>()?; + let events = outcome + .events + .iter() + .map(to_wire) + .collect::>>()?; + let error = outcome.error.map(dispatch_error_to_native); + Ok(NativeTxOutcome { + success: outcome.success, + extrinsic_hash: outcome.extrinsic_hash, + block_hash: outcome.block_hash, + block_number: outcome.block_number.map(BigInt::from), + extrinsic_index: outcome.extrinsic_index, + fee_rao: outcome.fee_rao.map(|value| value.to_string()), + events, + error, + message: outcome.message, + data: JsonValue::Object(data), + }) +} + +fn dispatch_error_to_native(error: DispatchError) -> NativeDispatchError { + NativeDispatchError { + pallet: error.pallet, + name: error.name, + docs: error.docs, + semantic_code: error.semantic_code, + } +} + +fn header_to_native(header: BlockHeader) -> NativeBlockHeader { + NativeBlockHeader { + hash: header.hash, + parent_hash: header.parent_hash, + number: BigInt::from(header.number), + } +} + +fn subnet_to_native(subnet: SubnetInfo) -> NativeSubnetInfo { + NativeSubnetInfo { + netuid: subnet.netuid, + tempo: subnet.tempo, + burn_rao: subnet.burn_rao.to_string(), + neuron_count: subnet.neuron_count, + } +} + +fn quote_to_native(quote: SwapQuote) -> NativeSwapQuote { + NativeSwapQuote { + tao_amount: quote.tao_amount.to_string(), + alpha_amount: quote.alpha_amount.to_string(), + tao_fee: quote.tao_fee.to_string(), + alpha_fee: quote.alpha_fee.to_string(), + tao_slippage: quote.tao_slippage.to_string(), + alpha_slippage: quote.alpha_slippage.to_string(), + } +} + +fn wire_value_list(name: &str, value: JsonValue) -> NapiResult> { + match from_wire(value)? { + bittensor_core::codec::Value::List(values) | bittensor_core::codec::Value::Tuple(values) => { + Ok(values) + } + _ => Err(invalid_arg(format!("{name} must be an array"))), + } +} + +fn wire_value_list_list( + name: &str, + value: JsonValue, +) -> NapiResult>> { + wire_value_list(name, value)? + .into_iter() + .enumerate() + .map(|(index, value)| match value { + bittensor_core::codec::Value::List(values) + | bittensor_core::codec::Value::Tuple(values) => Ok(values), + _ => Err(invalid_arg(format!("{name}[{index}] must be an array"))), + }) + .collect() +} + +fn bigint_u128(name: &str, value: &BigInt) -> NapiResult { + let (negative, value, lossless) = value.get_u128(); + if negative || !lossless { + return Err(invalid_arg(format!("{name} must fit the Rust u128 range"))); + } + Ok(value) +} + +fn bigint_u64(name: &str, value: &BigInt) -> NapiResult { + let value = bigint_u128(name, value)?; + u64::try_from(value).map_err(|_| invalid_arg(format!("{name} must fit the Rust u64 range"))) +} + +#[allow(dead_code)] +fn spend_kind(spend: Spend) -> NativeSpendKind { + match spend { + Spend::None => NativeSpendKind::None, + Spend::Bounded(_) => NativeSpendKind::Bounded, + Spend::Unbounded => NativeSpendKind::Unbounded, + } +} diff --git a/sdk/bittensor-ts/scripts/check-native-parity.cjs b/sdk/bittensor-ts/scripts/check-native-parity.cjs index 1937e287ed..588b170393 100755 --- a/sdk/bittensor-ts/scripts/check-native-parity.cjs +++ b/sdk/bittensor-ts/scripts/check-native-parity.cjs @@ -405,6 +405,9 @@ function rustCorePublicFunctions(directory) { const coreCoveragePrivateFiles = new Set([ // Private implementation modules; their public helpers are not crate-public API. + // Host-only blocking HTTP SDK; the TS package documents selective native + // wrappers separately while browser/async transport remains TypeScript. + 'client.rs', 'keys/base58.rs', 'keys/encrypted_json.rs', 'signers/hid.rs', @@ -446,6 +449,9 @@ const coreCoverageAliases = new Map([ ['runtime/mod.rs#parse', ['fromMetadata', 'Runtime']], ['runtime/mod.rs#resolve', ['resolveType']], ['runtime/type_string.rs#from_name', ['primitiveFromName']], + ['transaction.rs#new', ['rawCall', 'fromClient']], + ['transaction.rs#function', ['callFunction']], + ['transaction.rs#raw', ['forceRaw']], ['timelock/constants.rs#max_simulation_blocks', ['timelockMaxSimulationBlocks', 'maxSimulationBlocks']], ['timelock/epoch_schedule.rs#should_run_epoch', ['epochShouldRun', 'shouldRunEpoch']], ['timelock/epoch_schedule.rs#current_epoch_pre_run_coinbase', ['epochCurrentPreRunCoinbase', 'currentEpochPreRunCoinbase']], @@ -468,6 +474,16 @@ const coreCoverageAliases = new Map([ const coreCoverageIntentionalCoreOnly = new Map([ ['keys/mod.rs#has_private_key', 'private-key presence is represented by Keypair.kind, not exported as a raw core method'], ['keys/mod.rs#private_key_bytes', 'secret key bytes must not be exportable to JavaScript'], + ['runtime/mod.rs#type_ident', 'internal runtime type-name helper; exposed surfaces use typeNameOf/typeSpec'], + ['transaction.rs#signer', 'Wallet signer selection is internal to the Rust executor wrapper'], + ['transaction.rs#spend', 'compatibility policy-floor mutator is intentionally not exposed to JS callers'], + ['transaction.rs#touches', 'compatibility subnet-scope mutator is intentionally not exposed to JS callers'], + ['transaction.rs#affects_all_subnets', 'scope can only broaden through trusted/raw Rust constructors exposed to JS'], + ['transaction.rs#set_take', 'state-dependent constructor remains Rust-native until NativeClient reads are fully lifted'], + ['transaction.rs#batch', 'batching requires live Rust Client encoding and will be exposed with the native-client lift'], + ['transaction.rs#ok', 'Plan.ok is exposed as the NativePlan.ok data field, not a callable method'], + ['transaction.rs#plan_with_proxy', 'proxy execution is an advanced Rust-only executor variant for now'], + ['transaction.rs#execute_with', 'proxy/finality executor variant is represented by execute plus policy in JS'], ]) function compareCoreCoverage(nativeExpected, wasmExpected, browserWrapperExpected) { @@ -496,6 +512,11 @@ const nativeClassInterfaces = { NativeRuntime: { instance: 'NativeRuntimeHandle', statics: 'NativeRuntimeConstructor' }, NativeCursor: { instance: 'NativeCursorHandle', statics: 'NativeCursorConstructor' }, NativeLedgerDevice: { instance: 'NativeLedgerHandle', statics: 'NativeLedgerConstructor' }, + NativePolicy: { instance: 'NativePolicyHandle', statics: 'NativePolicyConstructor' }, + NativeIntentCall: { instance: 'NativeIntentCallHandle', statics: 'NativeIntentCallConstructor' }, + NativeClient: { instance: 'NativeClientHandle', statics: 'NativeClientConstructor' }, + NativeWallet: { instance: 'NativeWalletHandle', statics: 'NativeWalletConstructor' }, + NativeExecutor: { instance: 'NativeExecutorHandle', statics: 'NativeExecutorConstructor' }, } const wasmClassInterfaces = { diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index e01170c334..e67488a24b 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -2,7 +2,9 @@ import { blake2_256, generateExtrinsicProof, hexToBytes, metadataDigest } from ' import { CRYPTO_ED25519, CRYPTO_SR25519, Keypair, publicKeyFromSs58, ss58FromPublic } from './keys' import { LedgerDevice } from './ledger' import { Runtime, decodeOptionalOpaqueMetadata, eraBirth, type RuntimeSignerPayload } from './runtime' +import { Executor, IntentCall, NativeChainClient, Policy, RustWallet, isIntentCall, rawCall, type PolicyOptions, type SignerRoleLike } from './transaction' import { toBuffer } from './wire' +import type { NativeTxOutcome } from './native' import { Balance, UnitMismatchError, @@ -74,6 +76,7 @@ export type NetworkName = keyof typeof NETWORKS export type Descriptor = readonly [string, string] export type ServeIp = bigint | number | string export type CallLike = + | IntentCall | readonly [string, string, ScaleValue?] | { pallet?: string; module?: string; call?: string; function?: string; params?: ScaleValue } | ByteLike @@ -126,6 +129,9 @@ export interface SubmitOptions { tip?: TransactionAmount tipAssetId?: AssetId | null metadataHash?: ByteLike | null + policy?: Policy | PolicyOptions | null + allowRawCall?: boolean + rawSignerRole?: SignerRoleLike waitForInclusion?: boolean waitForFinalization?: boolean timeoutMs?: number @@ -364,6 +370,12 @@ interface NonceTrackingInfo { type SubmittedExtrinsicLocation = 'pool' | 'block' | null +const nativeClientAccess = new WeakMap NativeChainClient | undefined>() + +function nativeRustClient(client: Client): NativeChainClient | undefined { + return nativeClientAccess.get(client)?.() +} + interface SubscriptionOptions extends RpcRequestOptions { resubscribe?: boolean } @@ -1014,6 +1026,9 @@ export class Client { private historicalRuntimeCache = new Map() private genesis?: string private nonceAccounts = new Map() + private readonly nativeEligible: boolean + private nativeClient?: NativeChainClient + private nativeUnavailable = false constructor(network: string = 'finney', options: ClientOptions = {}) { const [label, endpoint] = resolveEndpoint(options.endpoint ?? network) @@ -1030,6 +1045,10 @@ export class Client { this.endpoint = endpoint this.expectedGenesisHash = expectedGenesisHash this.genesis = expectedGenesisHash + this.nativeEligible = fallbackEndpoints.length === 0 && + options.webSocket == null && + options.webSocketConstructor == null && + options.webSocketFactory == null this.transport = new JsonRpcTransport(endpoint, fallbackEndpoints, options.retryForever, { webSocket: options.webSocket, webSocketConstructor: options.webSocketConstructor, @@ -1046,6 +1065,7 @@ export class Client { this.subnets = new SubnetsNamespace(this) this.neurons = new NeuronsNamespace(this) this.staking = new StakingNamespace(this) + nativeClientAccess.set(this, () => this.tryNativeClient()) this.headRuntimeTtlMs = nonNegativeNumber(options.headRuntimeTtlMs, DEFAULT_HEAD_RUNTIME_TTL_MS) this.historicalRuntimeCacheSize = nonNegativeInteger( options.historicalRuntimeCacheSize, @@ -1058,7 +1078,7 @@ export class Client { } async connect(): Promise { - await this.runtimeAt() + if (this.tryNativeClient() == null) await this.runtimeAt() return this } @@ -1066,6 +1086,21 @@ export class Client { this.transport.close() } + private tryNativeClient(): NativeChainClient | undefined { + if (!this.nativeEligible) return undefined + if (this.nativeUnavailable) return undefined + if (this.nativeClient != null) return this.nativeClient + try { + const client = NativeChainClient.connect(this.endpoint) + this.nativeClient = client + this.genesis = hex(client.genesisHash) + return client + } catch { + this.nativeUnavailable = true + return undefined + } + } + async block(): Promise { return this.blockNumber() } @@ -1079,11 +1114,15 @@ export class Client { } async blockNumber(blockHash?: string | null): Promise { + const native = this.tryNativeClient() + if (native != null) return native.blockNumber(blockHash) const header = await this.rpc('chain_getHeader', blockHash == null ? [] : [blockHash]) return headerNumber(header) } async blockHash(block?: number | null): Promise { + const native = this.tryNativeClient() + if (native != null) return native.blockHash(block == null ? undefined : block) return String(await this.rpc('chain_getBlockHash', block == null ? [] : [block])) } @@ -1096,10 +1135,17 @@ export class Client { } async finalizedHead(): Promise { + const native = this.tryNativeClient() + if (native != null) return native.finalizedHead() return String(await this.rpc('chain_getFinalizedHead')) } async genesisHash(): Promise { + const native = this.tryNativeClient() + if (native != null) { + this.genesis = hex(native.genesisHash) + return this.genesis + } this.genesis ??= await this.blockHash(0) return this.genesis } @@ -1278,6 +1324,8 @@ export class Client { const [moduleName, itemName, itemParams, blockRef] = normalizeStorageArgs(pallet, storageFunction, paramsOrBlock, block) const blockHash = await this.resolveReadBlockHash(blockRef) + const native = this.tryNativeClient() + if (native != null) return native.query(moduleName, itemName, itemParams, blockHash) as T const runtime = await this.runtimeAt(blockHash) const key = runtime.storageKey(moduleName, itemName, itemParams) const raw = await this.rpc('state_getStorage', [hex(key), blockHash]) @@ -1295,6 +1343,8 @@ export class Client { normalizeBatchArgs(pallet, storageFunction, paramSetsOrBlock, block) if (sets.length === 0) return [] const blockHash = await this.resolveReadBlockHash(blockRef) + const native = this.tryNativeClient() + if (native != null) return native.queryBatch(moduleName, itemName, sets, blockHash) as Array const runtime = await this.runtimeAt(blockHash) const keys = runtime.storageKeyBatch(moduleName, itemName, sets) const raw = await this.rpc('state_queryStorageAt', [keys.map(hex), blockHash]) @@ -1317,6 +1367,10 @@ export class Client { const [moduleName, itemName, itemParams, blockRef] = normalizeStorageArgs(pallet, storageFunction, paramsOrBlock, block) const blockHash = await this.resolveReadBlockHash(blockRef) + if (pageSizeOrOptions === 512) { + const native = this.tryNativeClient() + if (native != null) return native.queryMap(moduleName, itemName, itemParams, blockHash) as Array<[K, V]> + } const runtime = await this.runtimeAt(blockHash) const prefix = runtime.storageKey(moduleName, itemName, itemParams) const entry = runtime.storageEntry(moduleName, itemName) @@ -1403,6 +1457,8 @@ export class Client { ): Promise { const [apiName, methodName, callParams, blockRef] = normalizeRuntimeArgs(api, method, paramsOrBlock, block) const blockHash = await this.resolveReadBlockHash(blockRef) + const native = this.tryNativeClient() + if (native != null) return native.runtimeCall(apiName, methodName, callParams, blockHash) as T const runtime = await this.runtimeAt(blockHash) const info = runtime.runtimeApis()[apiName]?.[methodName] if (info == null) throw new ChainError(`runtime API ${apiName}.${methodName} not found`) @@ -1452,6 +1508,10 @@ export class Client { block?: number | string | null, ): Promise { const [moduleName, constantName] = typeof pallet === 'string' ? [pallet, name as string] : pallet + if (block == null) { + const native = this.tryNativeClient() + if (native != null) return native.constant(moduleName, constantName) as T + } return (await this.runtimeAt(block)).constant(moduleName, constantName) } @@ -1460,6 +1520,15 @@ export class Client { data: ByteLike | string, block?: number | string | null, ): Promise { + if (block == null) { + const native = this.tryNativeClient() + if (native != null) { + return Promise.resolve(native.decodeScale( + typeString, + typeof data === 'string' ? hexToBuffer(data) : toBuffer(data, 'data'), + ) as T) + } + } return this.runtimeAt(block).then((runtime) => runtime.decode(typeString, typeof data === 'string' ? hexToBuffer(data) : data, false), ) @@ -1474,6 +1543,10 @@ export class Client { } composeCall(pallet: string, fn: string, params: ScaleValue = {}, block?: number | string | null): Promise { + if (block == null) { + const native = this.tryNativeClient() + if (native != null) return Promise.resolve(native.composeCall(pallet, fn, params)) + } return this.runtimeAt(block).then((runtime) => runtime.composeCall(pallet, fn, params)) } @@ -1582,9 +1655,34 @@ export class Client { options: SubmitOptions, manageNonce: boolean, ): Promise { + if (!manageNonce) { + const nativeSigned = this.signExtrinsicWithNative(call, signer, options) + if (nativeSigned != null) return nativeSigned + } return this.signExtrinsicWithSnapshot(call, signer, options, manageNonce, await this.signingSnapshot()) } + private signExtrinsicWithNative( + call: CallLike, + signer: SignerLike, + options: SubmitOptions, + ): ManagedSignedExtrinsicResult | undefined { + if (!(signer instanceof Keypair) || !nativeSigningOptionsSupported(options)) return undefined + const native = this.tryNativeClient() + if (native == null) return undefined + const callData = this.composeNativeCallData(call, options, native) + const nonce = options.nonce ?? native.accountNextIndex(signer.ss58Address) + const period = options.period === undefined ? DEFAULT_ERA_PERIOD : options.period + const signed = native.signExtrinsic(callData, signer, BigInt(nonce), period == null ? null : BigInt(period)) + return { + bytes: Buffer.from(signed.bytes), + hash: hexToBuffer(signed.hash), + hex: hex(signed.bytes), + signerAddress: signer.ss58Address, + nonce, + } + } + private async signExtrinsicWithSnapshot( call: CallLike, signer: SignerLike, @@ -1596,7 +1694,8 @@ export class Client { let reservation: NonceReservation | undefined try { const { runtime } = snapshot - const callData = this.callDataWithRuntime(call, runtime) + const prepared = this.prepareCallForSigning(call, runtime, options) + const callData = prepared.callData resolved = await this.resolveSigner(signer, runtime) reservation = manageNonce && options.nonce == null ? await this.reserveNonce(resolved.ss58Address) @@ -1688,10 +1787,44 @@ export class Client { } async submit(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { + const nativeResult = this.submitWithNative(call, signer, options) + if (nativeResult != null) return nativeResult const signed = await this.signExtrinsicWithNonce(call, signer, options, true) return this.submitSigned(signed, signed.signerAddress, options) } + private submitWithNative( + call: CallLike, + signer: SignerLike, + options: SubmitOptions, + ): Promise | undefined { + if (!(signer instanceof Keypair) || !nativeSubmissionOptionsSupported(options)) return undefined + if (!options.waitForInclusion && !options.waitForFinalization) return undefined + const native = this.tryNativeClient() + if (native == null) return undefined + return Promise.resolve().then(() => { + if (isIntentCall(call) && options.nonce == null && options.period === undefined) { + const policy = policyForSubmitOptions(options) + const executor = new Executor(native, policy) + return nativeOutcomeToExtrinsicResult( + executor.execute(call, RustWallet.fromKeypair(signer), options.waitForFinalization === true), + options.waitForFinalization === true, + ) + } + const callData = this.composeNativeCallData(call, options, native) + return nativeOutcomeToExtrinsicResult( + native.submit( + callData, + signer, + options.nonce == null ? null : BigInt(options.nonce), + options.period === undefined || options.period == null ? null : BigInt(options.period), + options.waitForFinalization === true, + ), + options.waitForFinalization === true, + ) + }) + } + async submitSigned( extrinsic: SignedExtrinsicResult | SignedExtrinsic | ByteLike, signerAddress?: string, @@ -2024,11 +2157,23 @@ export class Client { return state } - async estimateFee(call: CallLike, signer: SignerLike): Promise { + async estimateFee( + call: CallLike, + signer: SignerLike, + options: Pick = {}, + ): Promise { + if (signer instanceof Keypair) { + const native = this.tryNativeClient() + if (native != null) { + const callData = this.composeNativeCallData(call, options, native) + return Balance.fromRao(native.estimateFee(callData, signer)) + } + } const snapshot = await this.signingSnapshot() const { runtime } = snapshot const account = await this.resolveSigner(signer, runtime) const signed = await this.signExtrinsicWithSnapshot(call, signer, { + ...options, nonce: await this.peekNextIndex(account.ss58Address), period: null, }, false, snapshot) @@ -2051,7 +2196,7 @@ export class Client { } submitCall(pallet: string, fn: string, params: ScaleValue, signer: SignerLike, options: SubmitOptions = {}): Promise { - return this.submit([pallet, fn, params], signer, options) + return this.submit(rawCall(pallet, fn, params, { signerRole: options.rawSignerRole ?? 'coldkey' }), signer, options) } submit_call(pallet: string, fn: string, params: ScaleValue, signer: SignerLike, options: SubmitOptions = {}): Promise { @@ -2059,7 +2204,11 @@ export class Client { } transfer(signer: SignerLike, dest: string, amount: TransactionAmount, options: SubmitOptions & { keepAlive?: boolean } = {}): Promise { - return this.submit(calls.balances[options.keepAlive === false ? 'transferAllowDeath' : 'transferKeepAlive'](dest, amount), signer, { + const amountRao = taoTransactionAmountRao(amount, 'transfer amount') + const intent = options.keepAlive === false + ? IntentCall.transferAllowDeath(dest, amountRao) + : IntentCall.transfer(dest, amountRao) + return this.submit(intent, signer, { waitForInclusion: true, ...options, }) @@ -2074,7 +2223,7 @@ export class Client { } setWeights(signer: SignerLike, netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string, options: SubmitOptions = {}): Promise { - return this.submit(calls.subtensor.setWeights(netuid, dests, weights, versionKey), signer, { waitForInclusion: true, ...options }) + return this.submit(IntentCall.setWeights(netuid, dests, weights, BigInt(versionKey)), signer, { waitForInclusion: true, ...options }) } set_weights(signer: SignerLike, netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string, options: SubmitOptions = {}): Promise { @@ -2082,7 +2231,7 @@ export class Client { } burnedRegister(signer: SignerLike, netuid: number, hotkey: string, options: SubmitOptions = {}): Promise { - return this.submit(calls.subtensor.burnedRegister(netuid, hotkey), signer, { waitForInclusion: true, ...options }) + return this.submit(IntentCall.burnedRegister(netuid, hotkey), signer, { waitForInclusion: true, ...options }) } burned_register(signer: SignerLike, netuid: number, hotkey: string, options: SubmitOptions = {}): Promise { @@ -2090,7 +2239,7 @@ export class Client { } rootRegister(signer: SignerLike, hotkey: string, options: SubmitOptions = {}): Promise { - return this.submit(calls.subtensor.rootRegister(hotkey), signer, { waitForInclusion: true, ...options }) + return this.submit(IntentCall.rootRegister(hotkey), signer, { waitForInclusion: true, ...options }) } root_register(signer: SignerLike, hotkey: string, options: SubmitOptions = {}): Promise { @@ -2098,7 +2247,7 @@ export class Client { } registerNetwork(signer: SignerLike, hotkey: string, options: SubmitOptions = {}): Promise { - return this.submit(calls.subtensor.registerNetwork(hotkey), signer, { waitForInclusion: true, ...options }) + return this.submit(IntentCall.registerSubnet(hotkey), signer, { waitForInclusion: true, ...options }) } register_network(signer: SignerLike, hotkey: string, options: SubmitOptions = {}): Promise { @@ -2118,7 +2267,15 @@ export class Client { args: { netuid: number; ip: ServeIp; port: number; version?: number; ipType?: number; protocol?: number }, options: SubmitOptions = {}, ): Promise { - return this.submit(calls.subtensor.serveAxon(args.netuid, args.ip, args.port, args.version, args.ipType, args.protocol), signer, { + const normalizedIp = normalizeServeIp(args.ip, args.ipType) + return this.submit(IntentCall.serveAxon( + args.netuid, + normalizedIp.value, + args.port, + args.version ?? 0, + normalizedIp.type, + args.protocol ?? 4, + ), signer, { waitForInclusion: true, ...options, }) @@ -2195,16 +2352,86 @@ export class Client { async callData(call: CallLike, block?: number | string | null): Promise { if (Buffer.isBuffer(call) || call instanceof Uint8Array) return toBuffer(call, 'call') + if (isIntentCall(call)) { + const [pallet, fn, params] = call.asCallTuple() + return this.composeCall(pallet, fn, params, block) + } const [pallet, fn, params] = normalizeCall(call) return this.composeCall(pallet, fn, params, block) } private callDataWithRuntime(call: CallLike, runtime: Runtime): Buffer { + if (isIntentCall(call)) { + const [pallet, fn, params] = call.asCallTuple() + return runtime.composeCall(pallet, fn, params) + } if (Buffer.isBuffer(call) || call instanceof Uint8Array) return toBuffer(call, 'call') const [pallet, fn, params] = normalizeCall(call) return runtime.composeCall(pallet, fn, params) } + private prepareCallForSigning( + call: CallLike, + runtime: Runtime, + options: SubmitOptions, + ): { callData: Buffer; intent?: IntentCall } { + if (isIntentCall(call)) { + this.enforceIntentPolicy(call, options) + return { callData: this.callDataWithRuntime(call, runtime), intent: call } + } + if (Buffer.isBuffer(call) || call instanceof Uint8Array) { + assertRawBytesAllowed(options) + return { callData: toBuffer(call, 'call') } + } + if (!rawCallsAllowed(options)) { + throw new ChainError( + 'raw metadata calls require explicit raw-call permission; use an IntentCall trusted constructor or pass allowRawCall: true', + ) + } + const [pallet, fn, params] = normalizeCall(call) + const intent = rawCall(pallet, fn, params, { + op: `${pallet}.${fn}`, + signerRole: options.rawSignerRole ?? 'coldkey', + }) + this.enforceIntentPolicy(intent, options) + return { callData: this.callDataWithRuntime(intent, runtime), intent } + } + + private composeNativeCallData( + call: CallLike, + options: Pick, + native: NativeChainClient, + ): Buffer { + if (isIntentCall(call)) { + this.enforceIntentPolicy(call, options as SubmitOptions) + return native.composeIntent(call) + } + if (Buffer.isBuffer(call) || call instanceof Uint8Array) { + assertRawBytesAllowed(options as SubmitOptions) + return toBuffer(call, 'call') + } + if (!rawCallsAllowed(options as SubmitOptions)) { + throw new ChainError( + 'raw metadata calls require explicit raw-call permission; use an IntentCall trusted constructor or pass allowRawCall: true', + ) + } + const [pallet, fn, params] = normalizeCall(call) + const intent = rawCall(pallet, fn, params, { + op: `${pallet}.${fn}`, + signerRole: options.rawSignerRole ?? 'coldkey', + }) + this.enforceIntentPolicy(intent, options as SubmitOptions) + return native.composeIntent(intent) + } + + private enforceIntentPolicy(intent: IntentCall, options: SubmitOptions): void { + const policy = policyForSubmitOptions(options) + const violations = policy.check(intent) + if (violations.length > 0) { + throw new ChainError(`transaction rejected by policy: ${violations.join('; ')}`) + } + } + private async signingSnapshot(): Promise { const blockHash = await this.finalizedHead() const [blockNumber, runtime, genesisHash] = await Promise.all([ @@ -2496,6 +2723,18 @@ export class SubnetsNamespace { async subnet(netuid: number, block?: number | string | null): Promise { const blockHash = await this.client.resolveReadBlockHash(block) + const native = nativeRustClient(this.client) + if (native != null) { + const subnet = native.subnets(blockHash).find((item) => item.netuid === netuid) + if (subnet != null) { + return { + netuid: subnet.netuid, + tempo: subnet.tempo, + burn: Balance.fromRao(subnet.burnRao), + neuronCount: subnet.neuronCount, + } + } + } const [tempo, burn, count] = await Promise.all([ this.client.query(storage.SubtensorModule.Tempo, [netuid], blockHash), this.client.query(storage.SubtensorModule.Burn, [netuid], blockHash), @@ -2510,6 +2749,15 @@ export class SubnetsNamespace { async all(block?: number | string | null): Promise { const blockHash = await this.client.resolveReadBlockHash(block) + const native = nativeRustClient(this.client) + if (native != null) { + return native.subnets(blockHash).map((subnet) => ({ + netuid: subnet.netuid, + tempo: subnet.tempo, + burn: Balance.fromRao(subnet.burnRao), + neuronCount: subnet.neuronCount, + })) + } const [added, tempos, burns, counts] = await Promise.all([ this.client.queryMap(storage.SubtensorModule.NetworksAdded, [], blockHash), this.client.queryMap(storage.SubtensorModule.Tempo, [], blockHash), @@ -2544,10 +2792,18 @@ export class SubnetsNamespace { } metagraph(netuid: number, block?: number | string | null): Promise { + const native = nativeRustClient(this.client) + if (native != null) { + return this.client.resolveReadBlockHash(block).then((blockHash) => native.metagraph(netuid, blockHash) as T) + } return this.client.runtime(runtimeApi.SubnetInfoRuntimeApi.get_metagraph, [netuid], block) } hyperparameters(netuid: number, block?: number | string | null): Promise { + const native = nativeRustClient(this.client) + if (native != null) { + return this.client.resolveReadBlockHash(block).then((blockHash) => native.subnetHyperparameters(netuid, blockHash) as T) + } return this.client.runtime(runtimeApi.SubnetInfoRuntimeApi.get_subnet_hyperparams_v3, [netuid], block) } @@ -2576,6 +2832,10 @@ export class NeuronsNamespace { constructor(private readonly client: Client) {} all(netuid: number, lite = true, block?: number | string | null): Promise { + const native = nativeRustClient(this.client) + if (native != null && lite) { + return this.client.resolveReadBlockHash(block).then((blockHash) => native.neurons(netuid, blockHash) as T) + } return this.client.runtime( lite ? runtimeApi.NeuronInfoRuntimeApi.get_neurons_lite : runtimeApi.NeuronInfoRuntimeApi.get_neurons, [netuid], @@ -2605,6 +2865,11 @@ export class StakingNamespace { constructor(private readonly client: Client) {} async get(coldkey: string, hotkey: string, netuid: number, block?: number | string | null): Promise { + const native = nativeRustClient(this.client) + if (native != null) { + const blockHash = await this.client.resolveReadBlockHash(block) + return Balance.fromRao(native.stakeRao(coldkey, hotkey, netuid, blockHash), netuid) + } const info = await this.client.runtime | null>( runtimeApi.StakeInfoRuntimeApi.get_stake_info_for_hotkey_coldkey_netuid, [hotkey, coldkey, netuid], @@ -2642,7 +2907,11 @@ export class StakingNamespace { } addStake(signer: SignerLike, hotkey: string, netuid: number, amount: TransactionAmount, options: SubmitOptions = {}): Promise { - return this.client.submit(calls.subtensor.addStake(hotkey, netuid, amount), signer, { waitForInclusion: true, ...options }) + return this.client.submit( + IntentCall.addStake(hotkey, netuid, taoTransactionAmountRao(amount, 'stake amount')), + signer, + { waitForInclusion: true, ...options }, + ) } add_stake(signer: SignerLike, hotkey: string, netuid: number, amount: TransactionAmount, options: SubmitOptions = {}): Promise { @@ -2650,7 +2919,11 @@ export class StakingNamespace { } removeStake(signer: SignerLike, hotkey: string, netuid: number, amount: TransactionAmount, options: SubmitOptions = {}): Promise { - return this.client.submit(calls.subtensor.removeStake(hotkey, netuid, amount), signer, { waitForInclusion: true, ...options }) + return this.client.submit( + IntentCall.removeStake(hotkey, netuid, alphaTransactionAmountForNetuid(amount, netuid, 'unstake amount')), + signer, + { waitForInclusion: true, ...options }, + ) } remove_stake(signer: SignerLike, hotkey: string, netuid: number, amount: TransactionAmount, options: SubmitOptions = {}): Promise { @@ -3623,13 +3896,87 @@ function blockFrom(value: unknown): number | string | null | undefined { return typeof value === 'number' || typeof value === 'string' || value == null ? value : undefined } -function normalizeCall(callLike: Exclude): [string, string, ScaleValue] { +function normalizeCall(callLike: Exclude): [string, string, ScaleValue] { if (isCallTuple(callLike)) return [callLike[0], callLike[1], callLike[2] ?? {}] - return [callLike.pallet ?? callLike.module ?? '', callLike.call ?? callLike.function ?? '', callLike.params ?? {}] + const pallet = callLike.pallet ?? callLike.module + const fn = callLike.call ?? callLike.function + if (typeof pallet !== 'string' || typeof fn !== 'string') { + throw new ChainError('call must include pallet/module and call/function') + } + return [pallet, fn, callLike.params ?? {}] +} + +function policyForSubmitOptions(options: SubmitOptions): Policy { + if (options.policy instanceof Policy) { + return options.allowRawCall === true ? options.policy.withRawCalls() : options.policy + } + const policy = options.policy ?? {} + return new Policy({ + ...policy, + allowRawCalls: policy.allowRawCalls === true || options.allowRawCall === true, + }) +} + +function nativeSigningOptionsSupported(options: SubmitOptions): boolean { + return options.tip == null && + options.tipAssetId == null && + !hasOwn(options, 'metadataHash') +} + +function nativeSubmissionOptionsSupported(options: SubmitOptions): boolean { + return nativeSigningOptionsSupported(options) && + options.signal == null && + options.timeoutMs == null +} + +function nativeOutcomeToExtrinsicResult(outcome: NativeTxOutcome, finalized: boolean): ExtrinsicResult { + const blockNumber = outcome.blockNumber == null ? undefined : Number(outcome.blockNumber) + const extrinsicIndex = outcome.extrinsicIndex ?? undefined + const included = outcome.blockHash != null && blockNumber != null && extrinsicIndex != null + return { + status: outcome.success + ? finalized ? 'finalized' : included ? 'inBlock' : 'submitted' + : included ? 'failed' : 'failed', + success: outcome.success, + message: outcome.message, + extrinsicHash: outcome.extrinsicHash, + blockHash: outcome.blockHash ?? undefined, + blockNumber, + extrinsicIndex, + extrinsicId: included ? `${blockNumber}-${String(extrinsicIndex).padStart(4, '0')}` : undefined, + finalized: included ? finalized : undefined, + fee: outcome.feeRao == null ? undefined : Balance.fromRao(outcome.feeRao), + events: outcome.events, + error: outcome.error ?? undefined, + } +} + +function rawCallsAllowed(options: SubmitOptions): boolean { + if (options.allowRawCall === true) return true + if (options.policy instanceof Policy) return options.policy.allowRawCalls + return options.policy?.allowRawCalls === true +} + +function assertRawBytesAllowed(options: SubmitOptions): void { + if (!rawCallsAllowed(options)) { + throw new ChainError( + 'opaque call bytes require explicit raw-call permission; pass allowRawCall: true only after constructing the call through a trusted path', + ) + } + const policy = options.policy instanceof Policy + ? options.policy + : options.policy == null + ? undefined + : new Policy(options.policy) + if (policy?.hasOpaqueByteRestrictions()) { + throw new ChainError( + 'opaque call bytes cannot prove fee, spend, or subnet policy; use an IntentCall or raw pallet/function call instead', + ) + } } -function isCallTuple(callLike: Exclude): callLike is readonly [string, string, ScaleValue?] { - return Array.isArray(callLike) +function isCallTuple(callLike: Exclude): callLike is readonly [string, string, ScaleValue?] { + return Array.isArray(callLike) && typeof callLike[0] === 'string' && typeof callLike[1] === 'string' } function hex(bytes: ByteLike): string { diff --git a/sdk/bittensor-ts/src/index.ts b/sdk/bittensor-ts/src/index.ts index e99c8b8a03..d060c2a36a 100644 --- a/sdk/bittensor-ts/src/index.ts +++ b/sdk/bittensor-ts/src/index.ts @@ -11,6 +11,19 @@ export * from './ledger' export * from './modules' export * from './runtime' export * from './timelock' +export { + IntentCall, + Policy, + SignerRole, + isIntentCall, + rawCall, +} from './transaction' +export type { + PolicyOptions, + RawCallOptions, + SignerRoleLike, + SignerRoleName, +} from './transaction' export * from './types' export * from './value' export * from './wallet' diff --git a/sdk/bittensor-ts/src/keys.ts b/sdk/bittensor-ts/src/keys.ts index 1e9b831ad7..310caf5170 100644 --- a/sdk/bittensor-ts/src/keys.ts +++ b/sdk/bittensor-ts/src/keys.ts @@ -560,6 +560,14 @@ export class Keypair implements PolkadotCompatibleKeypair { ), ) } + + nativeHandle(): NativeKeypairHandle { + return this.handle + } +} + +export function nativeKeypairHandle(keypair: Keypair): NativeKeypairHandle { + return keypair.nativeHandle() } export function createKeyringPairFromUri( diff --git a/sdk/bittensor-ts/src/native.ts b/sdk/bittensor-ts/src/native.ts index 0cd7a585f9..6f2852a084 100644 --- a/sdk/bittensor-ts/src/native.ts +++ b/sdk/bittensor-ts/src/native.ts @@ -36,6 +36,33 @@ export interface NativeMapPair { value: unknown } +export interface NativeBlockHeader { + hash: string + parentHash: string + number: bigint +} + +export interface NativeSubnetInfo { + netuid: number + tempo: number + burnRao: string + neuronCount: number +} + +export interface NativeSwapQuote { + taoAmount: string + alphaAmount: string + taoFee: string + alphaFee: string + taoSlippage: string + alphaSlippage: string +} + +export interface NativeSignedExtrinsic { + bytes: Buffer + hash: string +} + export interface NativeTxParams { era: unknown nonce: bigint @@ -263,11 +290,229 @@ export interface NativeLedgerConstructor { open(): Promise } +export interface NativePolicyOptions { + maxFeeRao?: bigint | null + maxSpendRao?: bigint | null + allowedNetuids?: number[] | null + allowRawCalls?: boolean | null +} + +export interface NativePlan { + op: string + summary: string + signerRole: string + signerAddress: string + feeRao?: string | null + warnings: string[] + violations: string[] + ok: boolean + callData: Buffer +} + +export interface NativeDispatchError { + pallet?: string | null + name: string + docs: string[] + semanticCode: string +} + +export interface NativeTxOutcome { + success: boolean + extrinsicHash: string + blockHash?: string | null + blockNumber?: bigint | null + extrinsicIndex?: number | null + feeRao?: string | null + events: unknown[] + error?: NativeDispatchError | null + message: string + data: unknown +} + +export interface NativePolicyHandle { + readonly allowRawCalls: boolean + check(intent: NativeIntentCallHandle, feeRao?: bigint | null): string[] +} + +export interface NativePolicyConstructor { + fromOptions(options?: NativePolicyOptions | null): NativePolicyHandle +} + +export interface NativeIntentCallHandle { + readonly op: string + readonly summary: string + readonly signerRole: string + readonly pallet: string + readonly callFunction: string + readonly params: unknown + withSummary(summary: string): NativeIntentCallHandle + forceRaw(): NativeIntentCallHandle + asCallTuple(): unknown[] +} + +export interface NativeIntentCallConstructor { + rawCall( + op: string, + signerRole: number, + pallet: string, + callFunction: string, + params: unknown, + ): NativeIntentCallHandle + transfer(dest: string, amountRao: bigint): NativeIntentCallHandle + fundEvmKey(mirror: string, amountRao: bigint): NativeIntentCallHandle + transferAllowDeath(dest: string, amountRao: bigint): NativeIntentCallHandle + transferAll(dest: string, keepAlive: boolean): NativeIntentCallHandle + addStake(hotkey: string, netuid: number, amountRao: bigint): NativeIntentCallHandle + addStakeLimit( + hotkey: string, + netuid: number, + amountRao: bigint, + limitPriceRao: bigint, + allowPartial: boolean, + ): NativeIntentCallHandle + removeStake(hotkey: string, netuid: number, amountAlphaRao: bigint): NativeIntentCallHandle + removeStakeLimit( + hotkey: string, + netuid: number, + amountAlphaRao: bigint, + limitPriceRao: bigint, + allowPartial: boolean, + ): NativeIntentCallHandle + registerSubnet(hotkey: string): NativeIntentCallHandle + startCall(netuid: number): NativeIntentCallHandle + setWeights( + netuid: number, + dests: number[], + weights: number[], + versionKey: bigint, + ): NativeIntentCallHandle + serveAxon( + netuid: number, + version: number, + ip: bigint, + port: number, + ipType: number, + protocol: number, + ): NativeIntentCallHandle + burnedRegister(netuid: number, hotkey: string): NativeIntentCallHandle + rootRegister(hotkey: string): NativeIntentCallHandle + moveStake( + originHotkey: string, + originNetuid: number, + destinationHotkey: string, + destinationNetuid: number, + amountAlphaRao: bigint, + ): NativeIntentCallHandle + swapStake( + hotkey: string, + originNetuid: number, + destinationNetuid: number, + amountAlphaRao: bigint, + ): NativeIntentCallHandle + transferStake( + destinationColdkey: string, + hotkey: string, + originNetuid: number, + destinationNetuid: number, + amountAlphaRao: bigint, + ): NativeIntentCallHandle + unstakeAll(hotkey: string): NativeIntentCallHandle + unstakeAllAlpha(hotkey: string): NativeIntentCallHandle + setHyperparameter(netuid: number, name: string, value: unknown): NativeIntentCallHandle + setRootClaimType(claimType: string, subnets?: number[] | null): NativeIntentCallHandle +} + +export interface NativeClientHandle { + readonly endpoint: string + readonly ss58Format: number + readonly genesisHash: Buffer + blockHash(block?: bigint | null): string + finalizedHead(): string + blockNumber(blockHash?: string | null): bigint + header(blockHash?: string | null): NativeBlockHeader + readCatalog(): string[] + refreshRuntime(): boolean + composeCall(pallet: string, callFunction: string, params: unknown): Buffer + decodeScale(typeName: string, data: Buffer): unknown + constant(pallet: string, name: string): unknown + query(pallet: string, storage: string, params: unknown, blockHash?: string | null): unknown + queryBatch(pallet: string, storage: string, paramSets: unknown, blockHash?: string | null): unknown[] + queryMap(pallet: string, storage: string, fixedParams: unknown, blockHash?: string | null): NativeMapPair[] + runtimeCall(api: string, method: string, params: unknown, blockHash?: string | null): unknown + accountNextIndex(address: string): bigint + signExtrinsic( + callData: Buffer, + signer: NativeKeypairHandle, + nonce: bigint, + period?: bigint | null, + ): NativeSignedExtrinsic + estimateFee(callData: Buffer, signer: NativeKeypairHandle): string + submit( + callData: Buffer, + signer: NativeKeypairHandle, + nonce?: bigint | null, + period?: bigint | null, + waitForFinalization?: boolean | null, + ): NativeTxOutcome + submitEncoded( + extrinsic: Buffer, + expectedHash: string, + waitForFinalization?: boolean | null, + ): NativeTxOutcome + balanceRao(address: string): string + existentialDepositRao(): string + subnets(blockHash?: string | null): NativeSubnetInfo[] + metagraph(netuid: number, blockHash?: string | null): unknown + neurons(netuid: number, blockHash?: string | null): unknown[] + subnetHyperparameters(netuid: number, blockHash?: string | null): unknown + stakeRao(coldkey: string, hotkey: string, netuid: number, blockHash?: string | null): string + quoteStake(netuid: number, amountRao: bigint, blockHash?: string | null): NativeSwapQuote + composeIntent(intent: NativeIntentCallHandle): Buffer +} + +export interface NativeClientConstructor { + connect(endpoint: string): NativeClientHandle +} + +export interface NativeWalletHandle {} + +export interface NativeWalletConstructor { + fromKeypairs(coldkey: NativeKeypairHandle, hotkey: NativeKeypairHandle): NativeWalletHandle + fromUris(coldkeyUri: string, hotkeyUri: string): NativeWalletHandle +} + +export interface NativeExecutorHandle { + plan(intent: NativeIntentCallHandle, wallet: NativeWalletHandle): NativePlan + planWithPolicy( + intent: NativeIntentCallHandle, + wallet: NativeWalletHandle, + policy: NativePolicyHandle, + ): NativePlan + execute( + intent: NativeIntentCallHandle, + wallet: NativeWalletHandle, + waitForFinalization?: boolean | null, + ): NativeTxOutcome + submitShielded(intent: NativeIntentCallHandle, wallet: NativeWalletHandle): NativeTxOutcome +} + +export interface NativeExecutorConstructor { + fromClient(client: NativeClientHandle): NativeExecutorHandle + withPolicy(client: NativeClientHandle, policy: NativePolicyHandle): NativeExecutorHandle +} + export interface NativeBinding { NativeKeypair: { readonly prototype: NativeKeypairHandle } NativeRuntime: NativeRuntimeConstructor NativeCursor: NativeCursorConstructor NativeLedgerDevice: NativeLedgerConstructor + NativeSignerRole: { readonly Coldkey: number; readonly Hotkey: number } + NativeSpendKind: { readonly None: number; readonly Bounded: number; readonly Unbounded: number } + NativePolicy: NativePolicyConstructor + NativeIntentCall: NativeIntentCallConstructor + NativeClient: NativeClientConstructor + NativeWallet: NativeWalletConstructor + NativeExecutor: NativeExecutorConstructor bindingVersion(): string ledgerEnabled(): boolean diff --git a/sdk/bittensor-ts/src/transaction.ts b/sdk/bittensor-ts/src/transaction.ts new file mode 100644 index 0000000000..9ed6c9394d --- /dev/null +++ b/sdk/bittensor-ts/src/transaction.ts @@ -0,0 +1,598 @@ +import native, { + type NativeBlockHeader, + type NativeClientHandle, + type NativeExecutorHandle, + type NativeIntentCallHandle, + type NativeMapPair, + type NativePlan, + type NativePolicyHandle, + type NativePolicyOptions, + type NativeSignedExtrinsic, + type NativeSubnetInfo, + type NativeSwapQuote, + type NativeTxOutcome, + type NativeWalletHandle, +} from './native' +import { nativeCall } from './errors' +import { fromWire, toWire } from './wire' +import type { ScaleValue } from './types' +import { Keypair, nativeKeypairHandle } from './keys' + +export type SignerRoleName = 'coldkey' | 'hotkey' +export type SignerRoleLike = SignerRoleName | number + +export const SignerRole = Object.freeze({ + Coldkey: native.NativeSignerRole.Coldkey, + Hotkey: native.NativeSignerRole.Hotkey, + coldkey: native.NativeSignerRole.Coldkey, + hotkey: native.NativeSignerRole.Hotkey, +}) + +export interface PolicyOptions { + maxFeeRao?: bigint | number | string | null + maxSpendRao?: bigint | number | string | null + allowedNetuids?: number[] | null + allowRawCalls?: boolean | null +} + +export interface RawCallOptions { + op?: string + signerRole?: SignerRoleLike +} + +export class Policy { + readonly native: NativePolicyHandle + private readonly options: PolicyOptions + + constructor(options: PolicyOptions = {}) { + this.options = normalizePolicyOptions(options) + this.native = nativeCall(() => native.NativePolicy.fromOptions(policyOptionsToNative(this.options))) + } + + static from(options: Policy | PolicyOptions | null | undefined): Policy { + return options instanceof Policy ? options : new Policy(options ?? {}) + } + + get allowRawCalls(): boolean { + return this.native.allowRawCalls + } + + withRawCalls(): Policy { + return this.allowRawCalls ? this : new Policy({ ...this.options, allowRawCalls: true }) + } + + hasOpaqueByteRestrictions(): boolean { + return ( + this.options.maxFeeRao != null || + this.options.maxSpendRao != null || + this.options.allowedNetuids != null + ) + } + + check(intent: IntentCall, feeRao?: bigint | number | string | null): string[] { + return nativeCall(() => + this.native.check(intent.native, feeRao == null ? undefined : bigintValue(feeRao, 'feeRao')), + ) + } +} + +export class IntentCall { + readonly native: NativeIntentCallHandle + + private constructor(nativeIntent: NativeIntentCallHandle) { + this.native = nativeIntent + } + + static fromNative(nativeIntent: NativeIntentCallHandle): IntentCall { + return new IntentCall(nativeIntent) + } + + static rawCall( + pallet: string, + fn: string, + params: ScaleValue = {}, + options: RawCallOptions = {}, + ): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.rawCall( + options.op ?? `${pallet}.${fn}`, + signerRoleValue(options.signerRole ?? 'coldkey'), + pallet, + fn, + toWire(params), + ), + )) + } + + static transfer(dest: string, amountRao: bigint | number | string): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.transfer(dest, bigintValue(amountRao, 'amountRao')), + )) + } + + static fundEvmKey(mirror: string, amountRao: bigint | number | string): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.fundEvmKey(mirror, bigintValue(amountRao, 'amountRao')), + )) + } + + static transferAllowDeath(dest: string, amountRao: bigint | number | string): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.transferAllowDeath(dest, bigintValue(amountRao, 'amountRao')), + )) + } + + static transferAll(dest: string, keepAlive: boolean): IntentCall { + return new IntentCall(nativeCall(() => native.NativeIntentCall.transferAll(dest, keepAlive))) + } + + static setWeights( + netuid: number, + dests: number[], + weights: number[], + versionKey: bigint | number | string, + ): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.setWeights(netuid, dests, weights, bigintValue(versionKey, 'versionKey')), + )) + } + + static addStake(hotkey: string, netuid: number, amountRao: bigint | number | string): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.addStake(hotkey, netuid, bigintValue(amountRao, 'amountRao')), + )) + } + + static addStakeLimit( + hotkey: string, + netuid: number, + amountRao: bigint | number | string, + limitPriceRao: bigint | number | string, + allowPartial: boolean, + ): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.addStakeLimit( + hotkey, + netuid, + bigintValue(amountRao, 'amountRao'), + bigintValue(limitPriceRao, 'limitPriceRao'), + allowPartial, + ), + )) + } + + static removeStake( + hotkey: string, + netuid: number, + amountAlphaRao: bigint | number | string, + ): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.removeStake( + hotkey, + netuid, + bigintValue(amountAlphaRao, 'amountAlphaRao'), + ), + )) + } + + static removeStakeLimit( + hotkey: string, + netuid: number, + amountAlphaRao: bigint | number | string, + limitPriceRao: bigint | number | string, + allowPartial: boolean, + ): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.removeStakeLimit( + hotkey, + netuid, + bigintValue(amountAlphaRao, 'amountAlphaRao'), + bigintValue(limitPriceRao, 'limitPriceRao'), + allowPartial, + ), + )) + } + + static burnedRegister(netuid: number, hotkey: string): IntentCall { + return new IntentCall(nativeCall(() => native.NativeIntentCall.burnedRegister(netuid, hotkey))) + } + + static rootRegister(hotkey: string): IntentCall { + return new IntentCall(nativeCall(() => native.NativeIntentCall.rootRegister(hotkey))) + } + + static registerSubnet(hotkey: string): IntentCall { + return new IntentCall(nativeCall(() => native.NativeIntentCall.registerSubnet(hotkey))) + } + + static startCall(netuid: number): IntentCall { + return new IntentCall(nativeCall(() => native.NativeIntentCall.startCall(netuid))) + } + + static serveAxon( + netuid: number, + ip: bigint | number | string, + port: number, + version = 0, + ipType = 4, + protocol = 4, + ): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.serveAxon( + netuid, + version, + bigintValue(ip, 'ip'), + port, + ipType, + protocol, + ), + )) + } + + static moveStake( + originHotkey: string, + originNetuid: number, + destinationHotkey: string, + destinationNetuid: number, + amountAlphaRao: bigint | number | string, + ): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.moveStake( + originHotkey, + originNetuid, + destinationHotkey, + destinationNetuid, + bigintValue(amountAlphaRao, 'amountAlphaRao'), + ), + )) + } + + static swapStake( + hotkey: string, + originNetuid: number, + destinationNetuid: number, + amountAlphaRao: bigint | number | string, + ): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.swapStake( + hotkey, + originNetuid, + destinationNetuid, + bigintValue(amountAlphaRao, 'amountAlphaRao'), + ), + )) + } + + static transferStake( + destinationColdkey: string, + hotkey: string, + originNetuid: number, + destinationNetuid: number, + amountAlphaRao: bigint | number | string, + ): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.transferStake( + destinationColdkey, + hotkey, + originNetuid, + destinationNetuid, + bigintValue(amountAlphaRao, 'amountAlphaRao'), + ), + )) + } + + static unstakeAll(hotkey: string): IntentCall { + return new IntentCall(nativeCall(() => native.NativeIntentCall.unstakeAll(hotkey))) + } + + static unstakeAllAlpha(hotkey: string): IntentCall { + return new IntentCall(nativeCall(() => native.NativeIntentCall.unstakeAllAlpha(hotkey))) + } + + static setHyperparameter(netuid: number, name: string, value: ScaleValue): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.setHyperparameter(netuid, name, toWire(value)), + )) + } + + static setRootClaimType(claimType: string, subnets?: number[] | null): IntentCall { + return new IntentCall(nativeCall(() => + native.NativeIntentCall.setRootClaimType(claimType, subnets ?? undefined), + )) + } + + get op(): string { return this.native.op } + get summary(): string { return this.native.summary } + get signerRole(): string { return this.native.signerRole } + get pallet(): string { return this.native.pallet } + get function(): string { return this.native.callFunction } + get params(): ScaleValue { return fromWire(this.native.params) } + + withSummary(summary: string): IntentCall { + return new IntentCall(nativeCall(() => this.native.withSummary(summary))) + } + + forceRaw(): IntentCall { + return new IntentCall(nativeCall(() => this.native.forceRaw())) + } + + asCallTuple(): [string, string, ScaleValue] { + const [pallet, fn, params] = nativeCall(() => this.native.asCallTuple()) + return [String(pallet), String(fn), fromWire(params)] + } +} + +export class NativeChainClient { + readonly native: NativeClientHandle + + private constructor(nativeClient: NativeClientHandle) { + this.native = nativeClient + } + + static connect(endpoint: string): NativeChainClient { + return new NativeChainClient(nativeCall(() => native.NativeClient.connect(endpoint))) + } + + get endpoint(): string { + return this.native.endpoint + } + + get ss58Format(): number { + return this.native.ss58Format + } + + get genesisHash(): Buffer { + return Buffer.from(this.native.genesisHash) + } + + readCatalog(): string[] { + return nativeCall(() => this.native.readCatalog()) + } + + refreshRuntime(): boolean { + return nativeCall(() => this.native.refreshRuntime()) + } + + blockHash(block?: bigint | number | null): string { + return nativeCall(() => this.native.blockHash(block == null ? undefined : bigintValue(block, 'block'))) + } + + finalizedHead(): string { + return nativeCall(() => this.native.finalizedHead()) + } + + blockNumber(blockHash?: string | null): number { + return Number(nativeCall(() => this.native.blockNumber(blockHash ?? undefined))) + } + + header(blockHash?: string | null): NativeBlockHeader { + return nativeCall(() => this.native.header(blockHash ?? undefined)) + } + + composeCall(pallet: string, fn: string, params: ScaleValue = {}): Buffer { + return nativeCall(() => this.native.composeCall(pallet, fn, toWire(params))) + } + + decodeScale(typeName: string, data: Buffer): ScaleValue { + return fromWire(nativeCall(() => this.native.decodeScale(typeName, data))) + } + + constant(pallet: string, name: string): ScaleValue { + return fromWire(nativeCall(() => this.native.constant(pallet, name))) + } + + query(pallet: string, storage: string, params: ScaleValue[] = [], blockHash?: string | null): ScaleValue { + return fromWire(nativeCall(() => + this.native.query(pallet, storage, toWire(params), blockHash ?? undefined), + )) + } + + queryBatch( + pallet: string, + storage: string, + paramSets: ScaleValue[][] = [], + blockHash?: string | null, + ): ScaleValue[] { + return nativeCall(() => + this.native.queryBatch(pallet, storage, toWire(paramSets), blockHash ?? undefined), + ).map(fromWire) + } + + queryMap( + pallet: string, + storage: string, + fixedParams: ScaleValue[] = [], + blockHash?: string | null, + ): Array<[ScaleValue, ScaleValue]> { + return nativeCall(() => + this.native.queryMap(pallet, storage, toWire(fixedParams), blockHash ?? undefined), + ).map((pair: NativeMapPair) => [fromWire(pair.key), fromWire(pair.value)]) + } + + runtimeCall(api: string, method: string, params: ScaleValue[] = [], blockHash?: string | null): ScaleValue { + return fromWire(nativeCall(() => + this.native.runtimeCall(api, method, toWire(params), blockHash ?? undefined), + )) + } + + accountNextIndex(address: string): number { + return Number(nativeCall(() => this.native.accountNextIndex(address))) + } + + signExtrinsic( + callData: Buffer, + signer: Keypair, + nonce: bigint | number, + period?: bigint | number | null, + ): NativeSignedExtrinsic { + return nativeCall(() => + this.native.signExtrinsic( + callData, + nativeKeypairHandle(signer), + bigintValue(nonce, 'nonce'), + period == null ? undefined : bigintValue(period, 'period'), + ), + ) + } + + estimateFee(callData: Buffer, signer: Keypair): bigint { + return BigInt(nativeCall(() => this.native.estimateFee(callData, nativeKeypairHandle(signer)))) + } + + submit( + callData: Buffer, + signer: Keypair, + nonce?: bigint | number | null, + period?: bigint | number | null, + waitForFinalization = false, + ): NativeTxOutcome { + return nativeCall(() => + this.native.submit( + callData, + nativeKeypairHandle(signer), + nonce == null ? undefined : bigintValue(nonce, 'nonce'), + period == null ? undefined : bigintValue(period, 'period'), + waitForFinalization, + ), + ) + } + + submitEncoded(extrinsic: Buffer, expectedHash: string, waitForFinalization = false): NativeTxOutcome { + return nativeCall(() => this.native.submitEncoded(extrinsic, expectedHash, waitForFinalization)) + } + + balanceRao(address: string): bigint { + return BigInt(nativeCall(() => this.native.balanceRao(address))) + } + + existentialDepositRao(): bigint { + return BigInt(nativeCall(() => this.native.existentialDepositRao())) + } + + subnets(blockHash?: string | null): NativeSubnetInfo[] { + return nativeCall(() => this.native.subnets(blockHash ?? undefined)) + } + + metagraph(netuid: number, blockHash?: string | null): ScaleValue { + return fromWire(nativeCall(() => this.native.metagraph(netuid, blockHash ?? undefined))) + } + + neurons(netuid: number, blockHash?: string | null): ScaleValue[] { + return nativeCall(() => this.native.neurons(netuid, blockHash ?? undefined)).map(fromWire) + } + + subnetHyperparameters(netuid: number, blockHash?: string | null): ScaleValue { + return fromWire(nativeCall(() => this.native.subnetHyperparameters(netuid, blockHash ?? undefined))) + } + + stakeRao(coldkey: string, hotkey: string, netuid: number, blockHash?: string | null): bigint { + return BigInt(nativeCall(() => this.native.stakeRao(coldkey, hotkey, netuid, blockHash ?? undefined))) + } + + quoteStake(netuid: number, amountRao: bigint | number | string, blockHash?: string | null): NativeSwapQuote { + return nativeCall(() => + this.native.quoteStake(netuid, bigintValue(amountRao, 'amountRao'), blockHash ?? undefined), + ) + } + + composeIntent(intent: IntentCall): Buffer { + return nativeCall(() => this.native.composeIntent(intent.native)) + } +} + +export class RustWallet { + readonly native: NativeWalletHandle + + private constructor(nativeWallet: NativeWalletHandle) { + this.native = nativeWallet + } + + static fromUris(coldkeyUri: string, hotkeyUri: string): RustWallet { + return new RustWallet(nativeCall(() => native.NativeWallet.fromUris(coldkeyUri, hotkeyUri))) + } + + static fromKeypair(signer: Keypair): RustWallet { + return RustWallet.fromKeypairs(signer, signer) + } + + static fromKeypairs(coldkey: Keypair, hotkey: Keypair): RustWallet { + return new RustWallet(nativeCall(() => + native.NativeWallet.fromKeypairs(nativeKeypairHandle(coldkey), nativeKeypairHandle(hotkey)), + )) + } +} + +export class Executor { + readonly native: NativeExecutorHandle + + constructor(client: NativeChainClient, policy?: Policy | PolicyOptions | null) { + this.native = nativeCall(() => + policy == null + ? native.NativeExecutor.fromClient(client.native) + : native.NativeExecutor.withPolicy(client.native, Policy.from(policy).native), + ) + } + + plan(intent: IntentCall, wallet: RustWallet): NativePlan { + return nativeCall(() => this.native.plan(intent.native, wallet.native)) + } + + execute(intent: IntentCall, wallet: RustWallet, waitForFinalization = true): NativeTxOutcome { + return nativeCall(() => this.native.execute(intent.native, wallet.native, waitForFinalization)) + } +} + +export function rawCall( + pallet: string, + fn: string, + params: ScaleValue = {}, + options: RawCallOptions = {}, +): IntentCall { + return IntentCall.rawCall(pallet, fn, params, options) +} + +export function isIntentCall(value: unknown): value is IntentCall { + return value instanceof IntentCall +} + +export function signerRoleValue(role: SignerRoleLike): number { + if (typeof role === 'number') return role + if (role === 'coldkey') return native.NativeSignerRole.Coldkey + if (role === 'hotkey') return native.NativeSignerRole.Hotkey + throw new TypeError(`unsupported signer role ${String(role)}`) +} + +function policyOptionsToNative(options: PolicyOptions): NativePolicyOptions { + return { + maxFeeRao: options.maxFeeRao == null ? undefined : bigintValue(options.maxFeeRao, 'maxFeeRao'), + maxSpendRao: options.maxSpendRao == null ? undefined : bigintValue(options.maxSpendRao, 'maxSpendRao'), + allowedNetuids: options.allowedNetuids ?? undefined, + allowRawCalls: options.allowRawCalls ?? undefined, + } +} + +function normalizePolicyOptions(options: PolicyOptions): PolicyOptions { + return { + maxFeeRao: options.maxFeeRao ?? undefined, + maxSpendRao: options.maxSpendRao ?? undefined, + allowedNetuids: options.allowedNetuids == null ? undefined : [...options.allowedNetuids], + allowRawCalls: options.allowRawCalls ?? undefined, + } +} + +function bigintValue(value: bigint | number | string, name: string): bigint { + if (typeof value === 'bigint') { + if (value < 0n) throw new RangeError(`${name} must be non-negative`) + return value + } + if (typeof value === 'number') { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + return BigInt(value) + } + if (!/^(0|[1-9][0-9]*)$/.test(value)) { + throw new RangeError(`${name} must be a non-negative integer decimal string`) + } + return BigInt(value) +} diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 51ad681b7b..07ab34b949 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -106,6 +106,10 @@ function fakeSigningRuntime(overrides = {}) { hash: Buffer.alloc(32, 7), } }, + composeCall(pallet, fn, params) { + captures.composeCall = { pallet, fn, params } + return Buffer.from([pallet.length, fn.length]) + }, ...overrides, } return { runtime, captures } @@ -1003,6 +1007,9 @@ test('module-shaped export mirrors the public Rust crate', () => { test('chain client surface is exported without Polkadot.js glue', () => { assert.equal(typeof core.Client, 'function') assert.equal(typeof core.Client.prototype.watchSigned, 'function') + assert.equal(Object.prototype.hasOwnProperty.call(core, 'NativeChainClient'), false) + assert.equal(Object.prototype.hasOwnProperty.call(core, 'RustWallet'), false) + assert.equal(Object.prototype.hasOwnProperty.call(core, 'Executor'), false) assert.equal(core.Subtensor, core.SubtensorClient) assert.equal(typeof core.subtensor, 'function') assert.equal(typeof core.Wallet, 'function') @@ -2081,7 +2088,7 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => }, } - const signed = await client.signExtrinsic(callData, signer, { period: null }) + const signed = await client.signExtrinsic(callData, signer, { period: null, allowRawCall: true }) assert.equal(signed.signerAddress, address) assert.equal(signed.nonce, 12) @@ -2098,23 +2105,84 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => assert.equal(await client.accountNextIndex(address), 12) assert.equal(await client.accountNextIndex(address), 12) await assert.rejects( - () => client.signExtrinsic(callData, signer, { period: null, tip: core.Balance.fromAlpha('1', 7) }), + () => client.signExtrinsic(callData, signer, { period: null, allowRawCall: true, tip: core.Balance.fromAlpha('1', 7) }), /tip must be a TAO balance/, ) await assert.rejects( - () => client.signExtrinsic(callData, signer, { period: null, tipAssetId: core.taoAmount('1') }), + () => client.signExtrinsic(callData, signer, { period: null, allowRawCall: true, tipAssetId: core.taoAmount('1') }), /tipAssetId must be a bigint/, ) await assert.rejects( - () => client.signExtrinsic(callData, signer, { period: null, tipAssetId: -1 }), + () => client.signExtrinsic(callData, signer, { period: null, allowRawCall: true, tipAssetId: -1 }), /tipAssetId must be non-negative/, ) await assert.rejects( - () => client.signExtrinsic(callData, { ...signer, publicKey: Buffer.alloc(32, 7) }, { period: null }), + () => client.signExtrinsic(callData, { ...signer, publicKey: Buffer.alloc(32, 7) }, { period: null, allowRawCall: true }), /publicKey does not match/, ) }) +test('Client rejects raw call shapes unless callers opt in', async () => { + const { runtime } = fakeSigningRuntime() + const callData = Buffer.from([5, 6, 7]) + const client = fakeSigningClient(runtime, callData) + const signer = core.Keypair.fromUri('//Alice') + + await assert.rejects( + () => client.signExtrinsic(callData, signer, { period: null }), + /opaque call bytes require explicit raw-call permission/, + ) + await assert.rejects( + () => client.signExtrinsic(['System', 'remark', { remark: Buffer.from('hello') }], signer, { period: null }), + /raw metadata calls require explicit raw-call permission/, + ) + await assert.rejects( + () => client.signExtrinsic(callData, signer, { + period: null, + policy: new core.Policy({ allowRawCalls: true, maxSpendRao: 1n }), + }), + /opaque call bytes cannot prove fee, spend, or subnet policy/, + ) +}) + +test('Client accepts raw metadata calls only with explicit raw permission', async () => { + const { runtime, captures } = fakeSigningRuntime() + const client = fakeSigningClient(runtime, Buffer.alloc(0)) + const signer = core.Keypair.fromUri('//Alice') + + const signed = await client.signExtrinsic( + ['System', 'remark', { remark: Buffer.from('hello') }], + signer, + { period: null, allowRawCall: true }, + ) + + assert.ok(signed.bytes.length > 0) + assert.equal(captures.composeCall.pallet, 'System') + assert.equal(captures.composeCall.fn, 'remark') + assert.deepEqual(captures.composeCall.params.remark, Buffer.from('hello')) +}) + +test('Client callData composes trusted Rust intent calls', async () => { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const captures = {} + client.composeCall = async (pallet, fn, params, block) => { + captures.composeCall = { pallet, fn, params, block } + return Buffer.from([1, 2, 3]) + } + + const bytes = await client.callData( + core.IntentCall.transfer('5F3sa2TJAWMqDhXG6jhV4N8ko9SxwGy8TpaNS1repo5EYjQX', 7n), + 99, + ) + + assert.deepEqual(bytes, Buffer.from([1, 2, 3])) + assert.equal(captures.composeCall.pallet, 'Balances') + assert.equal(captures.composeCall.fn, 'transfer_keep_alive') + assert.equal(captures.composeCall.params.dest, '5F3sa2TJAWMqDhXG6jhV4N8ko9SxwGy8TpaNS1repo5EYjQX') + assert.equal(captures.composeCall.params.value, 7) + assert.equal(captures.composeCall.block, 99) +}) + test('Client enables metadata hash by default for software signers when supported', async () => { const callData = Buffer.from([5, 6, 7]) const metadataBytes = goldenMetadataBytes() @@ -2137,7 +2205,7 @@ test('Client enables metadata hash by default for software signers when supporte }, } - await client.signExtrinsic(callData, signer, { period: null }) + await client.signExtrinsic(callData, signer, { period: null, allowRawCall: true }) const expectedMetadataHash = core.metadataDigest(metadataBytes, { specVersion: runtime.specVersion, @@ -2150,12 +2218,12 @@ test('Client enables metadata hash by default for software signers when supporte assert.deepEqual(captures.payloadParams.metadataHash, expectedMetadataHash) assert.equal(request.metadataHash, `0x${expectedMetadataHash.toString('hex')}`) await assert.rejects( - () => client.signExtrinsic(callData, signer, { period: null, metadataHash: null }), + () => client.signExtrinsic(callData, signer, { period: null, allowRawCall: true, metadataHash: null }), /metadataHash cannot be disabled/, ) const explicitMetadataHash = Buffer.alloc(32, 3) - await client.signExtrinsic(callData, signer, { period: null, metadataHash: explicitMetadataHash }) + await client.signExtrinsic(callData, signer, { period: null, allowRawCall: true, metadataHash: explicitMetadataHash }) assert.equal(captures.encoded.params.metadataHashEnabled, true) assert.deepEqual(captures.payloadParams.metadataHash, explicitMetadataHash) assert.equal(request.metadataHash, `0x${explicitMetadataHash.toString('hex')}`) @@ -2208,6 +2276,7 @@ test('Client passes structured payloads to extension signPayload signers', async } await client.signExtrinsic(callData, signer, { + allowRawCall: true, period: 64, tip: core.raoAmount(1), tipAssetId: 0n, @@ -2298,11 +2367,11 @@ test('Client reconciles managed nonce before rejecting mismatched submit hash', } await assert.rejects( - () => client.submit(callData, signer, { period: null }), + () => client.submit(callData, signer, { period: null, allowRawCall: true }), /returned hash/, ) await assert.rejects( - () => client.submit(callData, signer, { period: null }), + () => client.submit(callData, signer, { period: null, allowRawCall: true }), /nonce 30 .* ambiguous/, ) assert.deepEqual(capturedNonces, [30]) @@ -2376,7 +2445,7 @@ test('Client estimateFee peeks the chain nonce without reserving it', async () = }, } - const fee = await client.estimateFee(callData, signer) + const fee = await client.estimateFee(callData, signer, { allowRawCall: true }) const firstRealNonce = await client.accountNextIndex(address) const secondRealNonce = await client.accountNextIndex(address) @@ -2444,8 +2513,8 @@ test('Client serializes concurrent initial nonce reservations during submit', as }, } - const first = client.submit(callData, signer, { period: null }) - const second = client.submit(callData, signer, { period: null }) + const first = client.submit(callData, signer, { period: null, allowRawCall: true }) + const second = client.submit(callData, signer, { period: null, allowRawCall: true }) await new Promise((resolve) => setImmediate(resolve)) assert.equal(reads, 1) releaseRead() @@ -2517,13 +2586,13 @@ test('Client releases only the failed reserved nonce', async () => { }, } - const signing = client.submit(callData, failingSigner, { period: null }) + const signing = client.submit(callData, failingSigner, { period: null, allowRawCall: true }) await started - const independentlySubmitted = await client.submit(callData, succeedingSigner, { period: null }) + const independentlySubmitted = await client.submit(callData, succeedingSigner, { period: null, allowRawCall: true }) releaseSign() await assert.rejects(signing, /signer declined/) await independentlySubmitted - await client.submit(callData, succeedingSigner, { period: null }) + await client.submit(callData, succeedingSigner, { period: null, allowRawCall: true }) assert.deepEqual(capturedNonces, [12, 13, 12]) }) @@ -2586,9 +2655,9 @@ test('Client quarantines an ambiguous submit nonce even when a fallback node rep }, } - await assert.rejects(() => client.submit(callData, signer, { period: null }), /lost response/) + await assert.rejects(() => client.submit(callData, signer, { period: null, allowRawCall: true }), /lost response/) await assert.rejects( - () => client.submit(callData, signer, { period: null }), + () => client.submit(callData, signer, { period: null, allowRawCall: true }), /nonce 20 .* ambiguous/, ) assert.deepEqual(capturedNonces, [20]) @@ -2653,9 +2722,9 @@ test('Client invalidates nonce state after unknown ambiguous submission reconcil }, } - await assert.rejects(() => client.submit(callData, signer, { period: null }), /lost response/) + await assert.rejects(() => client.submit(callData, signer, { period: null, allowRawCall: true }), /lost response/) networkRestored = true - await client.submit(callData, signer, { period: null }) + await client.submit(callData, signer, { period: null, allowRawCall: true }) assert.deepEqual(capturedNonces, [50, 55]) }) @@ -2717,8 +2786,8 @@ test('Client protects an ambiguous submit nonce when the extrinsic is still pend }, } - await assert.rejects(() => client.submit(callData, signer, { period: null }), /lost response/) - await client.submit(callData, signer, { period: null }) + await assert.rejects(() => client.submit(callData, signer, { period: null, allowRawCall: true }), /lost response/) + await client.submit(callData, signer, { period: null, allowRawCall: true }) assert.deepEqual(capturedNonces, [40, 41]) assert.deepEqual(nonceReads, [address, address]) }) @@ -2756,7 +2825,7 @@ test('Client submit without inclusion reports pool submission, not execution suc }, } - const result = await client.submit(callData, signer, { period: null }) + const result = await client.submit(callData, signer, { period: null, allowRawCall: true }) assert.equal(result.status, 'submitted') assert.equal(result.success, undefined) @@ -2892,11 +2961,11 @@ test('Client records detached submitSigned nonces before the next managed submit }, } - await client.submit(callData, signer, { period: null }) - const detached = await client.signExtrinsic(callData, signer, { period: null }) + await client.submit(callData, signer, { period: null, allowRawCall: true }) + const detached = await client.signExtrinsic(callData, signer, { period: null, allowRawCall: true }) assert.equal(detached.nonce, 71) await client.submitSigned(detached) - await client.submit(callData, signer, { period: null }) + await client.submit(callData, signer, { period: null, allowRawCall: true }) assert.deepEqual(capturedNonces, [70, 71, 72]) assert.deepEqual(submitMaxRetries, [0, 0, 0]) @@ -2955,11 +3024,11 @@ test('Client decodes detached signed nonce instead of trusting mutable public fi }, } - const detached = await client.signExtrinsic(callData, signer, { period: null }) + const detached = await client.signExtrinsic(callData, signer, { period: null, allowRawCall: true }) detached.signerAddress = core.ss58FromPublic(Buffer.alloc(32, 99), 42) detached.nonce = 999 await client.submitSigned(detached) - await client.submit(callData, signer, { period: null }) + await client.submit(callData, signer, { period: null, allowRawCall: true }) assert.deepEqual(capturedNonces, [70, 71]) }) @@ -3014,9 +3083,9 @@ test('Client invalidates nonce state for opaque externally signed submissions', }, } - await client.submit(callData, signer, { period: null }) + await client.submit(callData, signer, { period: null, allowRawCall: true }) await client.submitSigned(Buffer.from([9, 9, 9]), address) - await client.submit(callData, signer, { period: null }) + await client.submit(callData, signer, { period: null, allowRawCall: true }) assert.deepEqual(capturedNonces, [90, 100]) assert.equal(nonceReads, 2) @@ -3107,7 +3176,7 @@ test('Client records detached watchSigned nonces before the next managed submit' }, } - const detached = await client.signExtrinsic(callData, signer, { period: null }) + const detached = await client.signExtrinsic(callData, signer, { period: null, allowRawCall: true }) const watcher = await client.watchSigned(detached, { timeoutMs: 100 }) await waitFor( () => FakeWebSocket.sockets[0].sent.some((message) => message.method === 'author_submitAndWatchExtrinsic'), @@ -3119,7 +3188,7 @@ test('Client records detached watchSigned nonces before the next managed submit' params: { subscription: 'watch-detached', result: { inBlock: `0x${'44'.repeat(32)}` } }, }) await watcher.result - await client.submit(callData, signer, { period: null }) + await client.submit(callData, signer, { period: null, allowRawCall: true }) assert.deepEqual(capturedNonces, [80, 81]) assert.equal(nonceReads, 2) @@ -3195,11 +3264,11 @@ test('Client submission watches support timeout and reconcile managed nonces', a } await assert.rejects( - () => client.submit(callData, signer, { period: null, waitForInclusion: true, timeoutMs: 5 }), + () => client.submit(callData, signer, { period: null, allowRawCall: true, waitForInclusion: true, timeoutMs: 5 }), (error) => error.name === 'RequestTimeoutError', ) await assert.rejects( - () => client.submit(callData, signer, { period: null }), + () => client.submit(callData, signer, { period: null, allowRawCall: true }), /nonce 60 .* ambiguous/, ) assert.deepEqual(capturedNonces, [60]) @@ -3267,7 +3336,7 @@ test('Client generates RFC-0078 proof for Ledger metadata-verifying signers', as tokenSymbol: 'TAO', } - const signed = await client.signExtrinsic(vector.callData, signer, { period: null }) + const signed = await client.signExtrinsic(vector.callData, signer, { period: null, allowRawCall: true }) const expectedMetadataHash = core.metadataDigest(metadataBytes, chainInfo) const expectedProof = core.generateExtrinsicProof( vector.callData, From 1f4e856b349acaf995dbe080916e474a77fbd8e6 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 16:12:14 -0700 Subject: [PATCH 57/72] fixes --- runtime/tests/evm_contract_value_transfer.rs | 166 ++++++++++++++++ sdk/bittensor-core/src/transaction.rs | 31 ++- sdk/bittensor-ts/native/src/transaction.rs | 30 +-- sdk/bittensor-ts/src/client.ts | 183 ++++++++++++++---- sdk/bittensor-ts/src/types.ts | 2 +- sdk/bittensor-ts/src/wire.ts | 13 +- sdk/bittensor-ts/test/basic.test.cjs | 159 +++++++++++++++ .../00-evm-substrate-transfer.test.ts | 15 +- 8 files changed, 532 insertions(+), 67 deletions(-) create mode 100644 runtime/tests/evm_contract_value_transfer.rs diff --git a/runtime/tests/evm_contract_value_transfer.rs b/runtime/tests/evm_contract_value_transfer.rs new file mode 100644 index 0000000000..ff53ff4e41 --- /dev/null +++ b/runtime/tests/evm_contract_value_transfer.rs @@ -0,0 +1,166 @@ +#![allow(clippy::expect_used)] +#![allow(clippy::unwrap_used)] + +use frame_support::traits::fungible::Inspect; +use node_subtensor_runtime::{Balances, BuildStorage, Runtime, RuntimeGenesisConfig, System}; +use pallet_evm::{AddressMapping, BalanceConverter, EvmBalance, Runner}; +use sp_core::{H160, U256}; +use subtensor_runtime_common::TaoBalance; + +const WITHDRAW_CONTRACT_BYTECODE: &str = + "6080604052348015600e575f80fd5b506101148061001c5f395ff3fe608060405260043610601e575f3560e01c80632e1a7d4d146028576024565b36602457005b5f80fd5b603e6004803603810190603a919060b8565b6040565b005b3373ffffffffffffffffffffffffffffffffffffffff166108fc8290811502906040515f60405180830381858888f193505050501580156082573d5f803e3d5ffd5b5050565b5f80fd5b5f819050919050565b609a81608a565b811460a3575f80fd5b50565b5f8135905060b2816093565b92915050565b5f6020828403121560ca5760c96086565b5b5f60d58482850160a6565b9150509291505056fea2646970667358221220f43400858bfe4fcc0bf3c1e2e06d3a9e6ced86454a00bd7e4866b3d4d64e46bb64736f6c634300081a0033"; + +fn new_test_ext() -> sp_io::TestExternalities { + let mut ext: sp_io::TestExternalities = RuntimeGenesisConfig::default() + .build_storage() + .unwrap() + .into(); + ext.execute_with(|| System::set_block_number(1)); + ext +} + +fn add_balance_to_evm_address(address: H160, tao: TaoBalance) { + let account_id = ::AddressMapping::into_account_id(address); + let credit = pallet_subtensor::Pallet::::mint_tao(tao); + let _ = pallet_subtensor::Pallet::::spend_tao(&account_id, credit, tao).unwrap(); +} + +fn evm_balance_from_substrate(amount: u64) -> U256 { + ::BalanceConverter::into_evm_balance(amount.into()) + .expect("test amount should convert to EVM balance") + .into() +} + +fn substrate_balance_from_evm(amount: U256) -> TaoBalance { + let substrate_balance = + ::BalanceConverter::into_substrate_balance(EvmBalance::new( + amount, + )) + .expect("test amount should convert to Substrate balance") + .into_u64_saturating(); + TaoBalance::new(substrate_balance) +} + +fn decode_hex(hex: &str) -> Vec { + assert_eq!(hex.len() % 2, 0, "hex bytecode must have even length"); + hex.as_bytes() + .chunks_exact(2) + .map(|chunk| { + let hi = (chunk[0] as char).to_digit(16).unwrap(); + let lo = (chunk[1] as char).to_digit(16).unwrap(); + ((hi << 4) | lo) as u8 + }) + .collect() +} + +fn withdraw_input(value: U256) -> Vec { + let mut input = sp_io::hashing::keccak_256(b"withdraw(uint256)")[0..4].to_vec(); + let encoded_value = value.to_big_endian(); + input.extend_from_slice(&encoded_value); + input +} + +#[test] +fn contract_withdraw_credits_caller_balance() { + new_test_ext().execute_with(|| { + let caller = H160::repeat_byte(0x11); + let caller_account = + ::AddressMapping::into_account_id(caller); + let one_tao = evm_balance_from_substrate(1_000_000_000); + let two_tao = evm_balance_from_substrate(2_000_000_000); + + add_balance_to_evm_address(caller, 10_000_000_000u64.into()); + + let create = ::Runner::create( + caller, + decode_hex(WITHDRAW_CONTRACT_BYTECODE), + U256::zero(), + 1_000_000, + None, + None, + None, + Vec::new(), + Vec::new(), + true, + Vec::new(), + false, + false, + None, + None, + ::config(), + ) + .expect("contract deployment should succeed"); + assert!(create.exit_reason.is_succeed()); + + let contract = create.value; + let fund = ::Runner::call( + caller, + contract, + Vec::new(), + two_tao, + 100_000, + None, + None, + None, + Vec::new(), + Vec::new(), + false, + false, + None, + None, + ::config(), + ) + .expect("funding call should succeed"); + assert!(fund.exit_reason.is_succeed()); + + let contract_account = + ::AddressMapping::into_account_id(contract); + assert_eq!( + Balances::total_balance(&contract_account), + substrate_balance_from_evm(two_tao) + ); + + let caller_balance_before = Balances::total_balance(&caller_account); + let withdraw = ::Runner::call( + caller, + contract, + withdraw_input(one_tao), + U256::zero(), + 1_000_000, + Some(U256::from(1_000_000_000u64)), + None, + None, + Vec::new(), + Vec::new(), + true, + false, + None, + None, + ::config(), + ) + .expect("withdraw call should succeed"); + assert!( + withdraw.exit_reason.is_succeed(), + "withdraw failed: {:?}", + withdraw.exit_reason + ); + + let caller_balance_after = Balances::total_balance(&caller_account); + let contract_balance_after = Balances::total_balance(&contract_account); + assert_eq!(contract_balance_after, substrate_balance_from_evm(one_tao)); + let one_tao_substrate = substrate_balance_from_evm(one_tao); + assert!( + caller_balance_after > caller_balance_before, + "caller balance should increase after receiving withdrawn value" + ); + assert!( + caller_balance_after <= caller_balance_before + one_tao_substrate, + "caller increase should be reduced only by transaction fees" + ); + assert!( + caller_balance_before + one_tao_substrate - caller_balance_after + <= TaoBalance::new(1_000_000), + "withdraw fee should stay below the test bound" + ); + }); +} diff --git a/sdk/bittensor-core/src/transaction.rs b/sdk/bittensor-core/src/transaction.rs index 4f7ea32770..7b129aaf5d 100644 --- a/sdk/bittensor-core/src/transaction.rs +++ b/sdk/bittensor-core/src/transaction.rs @@ -475,8 +475,9 @@ impl IntentCall { port: u16, ip_type: u8, protocol: u8, - ) -> Self { - Self::trusted( + ) -> Result { + validate_ip_family(ip, ip_type)?; + Ok(Self::trusted( "serve_axon", SignerRole::Hotkey, "SubtensorModule", @@ -494,7 +495,7 @@ impl IntentCall { Spend::None, [netuid], false, - ) + )) } /// Register a hotkey by burning the subnet's live registration cost. @@ -914,6 +915,16 @@ fn aggregate_spend(left: Spend, right: Spend) -> Spend { } } +fn validate_ip_family(ip: u128, ip_type: u8) -> Result<(), CoreError> { + match ip_type { + 4 if ip > u128::from(u32::MAX) => { + Err(CoreError::Codec("IPv4 address must fit in u32".into())) + } + 4 | 6 => Ok(()), + _ => Err(CoreError::Codec("ip_type must be 4 or 6".into())), + } +} + /// Transaction guardrails enforced before any signature is created. #[derive(Debug, Clone, Default)] pub struct Policy { @@ -1199,6 +1210,20 @@ mod tests { ); } + #[test] + fn serve_axon_rejects_contradictory_ip_family() { + assert!(IntentCall::serve_axon(1, 0, u128::from(u32::MAX), 30333, 4, 4).is_ok()); + assert!(IntentCall::serve_axon(1, 0, u128::from(u32::MAX) + 1, 30333, 6, 4).is_ok()); + + let too_large = IntentCall::serve_axon(1, 0, u128::from(u32::MAX) + 1, 30333, 4, 4); + assert!(matches!(too_large, Err(CoreError::Codec(message)) if message.contains("IPv4"))); + + let invalid_type = IntentCall::serve_axon(1, 0, 0, 30333, 5, 4); + assert!( + matches!(invalid_type, Err(CoreError::Codec(message)) if message.contains("ip_type")) + ); + } + #[test] fn compatibility_builders_cannot_downgrade_arbitrary_calls() { let intent = IntentCall::new( diff --git a/sdk/bittensor-ts/native/src/transaction.rs b/sdk/bittensor-ts/native/src/transaction.rs index 8747878832..100ad60d89 100644 --- a/sdk/bittensor-ts/native/src/transaction.rs +++ b/sdk/bittensor-ts/native/src/transaction.rs @@ -306,16 +306,16 @@ impl NativeIntentCall { ip_type: u8, protocol: u8, ) -> napi::Result { - Ok(Self { - inner: IntentCall::serve_axon( - netuid, - version, - bigint_u128("ip", &ip)?, - port, - ip_type, - protocol, - ), - }) + IntentCall::serve_axon( + netuid, + version, + bigint_u128("ip", &ip)?, + port, + ip_type, + protocol, + ) + .napi() + .map(|inner| Self { inner }) } #[napi(factory, js_name = "burnedRegister")] @@ -646,7 +646,10 @@ impl NativeClient { #[napi(js_name = "accountNextIndex")] pub fn account_next_index(&self, address: String) -> NapiResult { - self.inner.account_next_index(&address).napi().map(BigInt::from) + self.inner + .account_next_index(&address) + .napi() + .map(BigInt::from) } #[napi(js_name = "signExtrinsic")] @@ -1028,9 +1031,8 @@ fn quote_to_native(quote: SwapQuote) -> NativeSwapQuote { fn wire_value_list(name: &str, value: JsonValue) -> NapiResult> { match from_wire(value)? { - bittensor_core::codec::Value::List(values) | bittensor_core::codec::Value::Tuple(values) => { - Ok(values) - } + bittensor_core::codec::Value::List(values) + | bittensor_core::codec::Value::Tuple(values) => Ok(values), _ => Err(invalid_arg(format!("{name} must be an array"))), } } diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index e67488a24b..f4dd7420cf 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -3,7 +3,7 @@ import { CRYPTO_ED25519, CRYPTO_SR25519, Keypair, publicKeyFromSs58, ss58FromPub import { LedgerDevice } from './ledger' import { Runtime, decodeOptionalOpaqueMetadata, eraBirth, type RuntimeSignerPayload } from './runtime' import { Executor, IntentCall, NativeChainClient, Policy, RustWallet, isIntentCall, rawCall, type PolicyOptions, type SignerRoleLike } from './transaction' -import { toBuffer } from './wire' +import { toBigInt, toBuffer } from './wire' import type { NativeTxOutcome } from './native' import { Balance, @@ -97,7 +97,9 @@ export interface ClientOptions { retryBackoffMs?: number maxRetryBackoffMs?: number endpointValidationTtlMs?: number + maxSubscriptionQueue?: number maxMessageBytes?: number + validateDescriptorSchema?: boolean } export interface RpcRequestOptions { @@ -193,7 +195,7 @@ export interface ExtensionSignPayloadRequest extends RuntimeSignerPayload {} export type SignerSignature = | ByteLike | string - | { signature: ByteLike | string; signedTransaction?: unknown } + | { signature: ByteLike | string } export interface ChainSigner { readonly ss58Address?: string @@ -849,43 +851,56 @@ export class JsonRpcTransport { ? Buffer.from(data).toString('utf8') : String(data) if (Buffer.byteLength(raw, 'utf8') > this.maxMessageBytes) { - this.handleSocketClose(connection, new ChainError('JSON-RPC message exceeded size limit')) + this.handleProtocolError(connection, new ChainError('JSON-RPC message exceeded size limit')) return } let message: unknown try { message = JSON.parse(raw) } catch { - this.handleSocketClose(connection, new ChainError('invalid JSON-RPC message')) + this.handleProtocolError(connection, new ChainError('invalid JSON-RPC message')) return } - if (typeof message !== 'object' || message == null) return - const rpcMessage = message as { - id?: unknown - error?: { message?: string; code?: number; data?: unknown } - result?: unknown - params?: { subscription?: unknown; result?: unknown } + if (typeof message !== 'object' || message == null || Array.isArray(message)) { + this.handleProtocolError(connection, new JsonRpcError('invalid JSON-RPC message envelope')) + return } - if (typeof rpcMessage.id === 'number') { + const rpcMessage = message as { id?: unknown } + if (hasOwn(rpcMessage, 'id')) { + if (typeof rpcMessage.id !== 'number') { + this.handleProtocolError(connection, new JsonRpcError('invalid JSON-RPC response id')) + return + } const pending = this.pending.get(rpcMessage.id) if (pending == null || pending.generation !== connection.generation) return this.pending.delete(rpcMessage.id) - if (rpcMessage.error) pending.reject(new JsonRpcError(String(rpcMessage.error.message ?? 'JSON-RPC error'), rpcMessage.error.code, rpcMessage.error.data)) - else pending.resolve(rpcMessage.result) + try { + pending.resolve(jsonRpcResponseResult(message, rpcMessage.id)) + } catch (error) { + pending.reject(error instanceof Error ? error : new JsonRpcError(String(error))) + } return } - const subscription = rpcMessage.params?.subscription - if (subscription == null) return - const entry = this.subscriptionsById.get(String(subscription)) + let notification: { subscription: string; result: unknown } | undefined + try { + notification = jsonRpcSubscriptionNotification(message) + } catch (error) { + this.handleProtocolError( + connection, + error instanceof Error ? error : new JsonRpcError(String(error)), + ) + return + } + if (notification == null) return + const entry = this.subscriptionsById.get(notification.subscription) if (entry == null || entry.generation !== connection.generation || entry.state.closed) return const state = entry.state - const result = rpcMessage.params?.result const waiter = state.waiters.shift() - if (waiter != null) waiter.resolve({ done: false, value: result }) + if (waiter != null) waiter.resolve({ done: false, value: notification.result }) else if (state.queue.length >= this.maxSubscriptionQueue) { this.closeSubscription(state, new ChainError('subscription notification queue exceeded limit')) } else { - state.queue.push(result) + state.queue.push(notification.result) } } @@ -924,6 +939,23 @@ export class JsonRpcTransport { this.transitionDisconnectedSubscriptions(connection.generation, error) } + private handleProtocolError(connection: ActiveConnection, error: Error): void { + if (!this.isCurrentConnection(connection)) return + this.validatedEndpoints.delete(connection.endpoint) + this.failPending(error, connection.generation) + try { + connection.socket.close() + } catch { + // Ignore close errors while rejecting malformed protocol state. + } + for (const [subscription, entry] of this.subscriptionsById) { + if (entry.generation === connection.generation) this.subscriptionsById.delete(subscription) + } + for (const state of [...this.subscriptions]) { + if (state.subscriptionGeneration === connection.generation) this.closeSubscription(state, error) + } + } + private transitionDisconnectedSubscriptions(generation: number, error: Error): void { for (const [subscription, entry] of this.subscriptionsById) { if (entry.generation === generation) this.subscriptionsById.delete(subscription) @@ -1027,6 +1059,7 @@ export class Client { private genesis?: string private nonceAccounts = new Map() private readonly nativeEligible: boolean + private readonly validateDescriptorsOnLoad: boolean private nativeClient?: NativeChainClient private nativeUnavailable = false @@ -1045,6 +1078,7 @@ export class Client { this.endpoint = endpoint this.expectedGenesisHash = expectedGenesisHash this.genesis = expectedGenesisHash + this.validateDescriptorsOnLoad = options.validateDescriptorSchema ?? expectedGenesisHash != null this.nativeEligible = fallbackEndpoints.length === 0 && options.webSocket == null && options.webSocketConstructor == null && @@ -1059,6 +1093,7 @@ export class Client { retryBackoffMs: options.retryBackoffMs, maxRetryBackoffMs: options.maxRetryBackoffMs, endpointValidationTtlMs: options.endpointValidationTtlMs, + maxSubscriptionQueue: options.maxSubscriptionQueue, maxMessageBytes: options.maxMessageBytes, }) this.balances = new BalancesNamespace(this) @@ -1233,6 +1268,10 @@ export class Client { version.transactionVersion, ss58Format, ) + if (this.validateDescriptorsOnLoad) { + const descriptorIssues = validateDescriptorSchema(runtime) + if (descriptorIssues.length > 0) throw descriptorSchemaDriftError(descriptorIssues) + } const entry = { runtime, ss58Format, ...version } this.cacheRuntimeBySpecVersion(entry) return entry @@ -1634,11 +1673,7 @@ export class Client { async assertDescriptorSchema(block?: number | string | null): Promise { const issues = await this.validateDescriptorSchema(block) if (issues.length === 0) return - const sample = issues.slice(0, 8).map((issue) => `${issue.path}: ${issue.message}`) - throw new ChainError( - `descriptor schema drift detected (${issues.length} issue${issues.length === 1 ? '' : 's'}): ${sample.join('; ')}`, - issues, - ) + throw descriptorSchemaDriftError(issues) } assert_descriptor_schema(block?: number | string | null): Promise { @@ -1673,7 +1708,12 @@ export class Client { const callData = this.composeNativeCallData(call, options, native) const nonce = options.nonce ?? native.accountNextIndex(signer.ss58Address) const period = options.period === undefined ? DEFAULT_ERA_PERIOD : options.period - const signed = native.signExtrinsic(callData, signer, BigInt(nonce), period == null ? null : BigInt(period)) + const signed = native.signExtrinsic( + callData, + signer, + toBigInt(nonce, 'nonce'), + period == null ? null : toBigInt(period, 'period'), + ) return { bytes: Buffer.from(signed.bytes), hash: hexToBuffer(signed.hash), @@ -1816,8 +1856,8 @@ export class Client { native.submit( callData, signer, - options.nonce == null ? null : BigInt(options.nonce), - options.period === undefined || options.period == null ? null : BigInt(options.period), + options.nonce == null ? null : toBigInt(options.nonce, 'nonce'), + options.period === undefined || options.period == null ? null : toBigInt(options.period, 'period'), options.waitForFinalization === true, ), options.waitForFinalization === true, @@ -2223,7 +2263,7 @@ export class Client { } setWeights(signer: SignerLike, netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string, options: SubmitOptions = {}): Promise { - return this.submit(IntentCall.setWeights(netuid, dests, weights, BigInt(versionKey)), signer, { waitForInclusion: true, ...options }) + return this.submit(IntentCall.setWeights(netuid, dests, weights, toBigInt(versionKey, 'versionKey')), signer, { waitForInclusion: true, ...options }) } set_weights(signer: SignerLike, netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string, options: SubmitOptions = {}): Promise { @@ -3082,7 +3122,7 @@ export const calls = Object.freeze({ }) }, register(netuid: number, blockNumber: bigint | number | string, nonce: bigint | number | string, work: ByteLike, hotkey: string, coldkey: string) { - return call('SubtensorModule', 'register', { netuid, block_number: BigInt(blockNumber), nonce: BigInt(nonce), work, hotkey, coldkey }) + return call('SubtensorModule', 'register', { netuid, block_number: toBigInt(blockNumber, 'blockNumber'), nonce: toBigInt(nonce, 'nonce'), work, hotkey, coldkey }) }, registerNetwork(hotkey: string) { return call('SubtensorModule', 'register_network', { hotkey }) @@ -3091,7 +3131,7 @@ export const calls = Object.freeze({ return call('SubtensorModule', 'remove_stake', { hotkey, netuid, amount_unstaked: alphaTransactionAmountForNetuid(amount, netuid, 'unstake amount') }) }, revealWeights(netuid: number, uids: number[], values: number[], salt: number[], versionKey: bigint | number | string) { - return call('SubtensorModule', 'reveal_weights', { netuid, uids, values, salt, version_key: BigInt(versionKey) }) + return call('SubtensorModule', 'reveal_weights', { netuid, uids, values, salt, version_key: toBigInt(versionKey, 'versionKey') }) }, rootRegister(hotkey: string) { return call('SubtensorModule', 'root_register', { hotkey }) @@ -3108,7 +3148,7 @@ export const calls = Object.freeze({ return call('SubtensorModule', 'set_children', { hotkey, netuid, children }) }, setWeights(netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string) { - return call('SubtensorModule', 'set_weights', { netuid, dests, weights, version_key: BigInt(versionKey) }) + return call('SubtensorModule', 'set_weights', { netuid, dests, weights, version_key: toBigInt(versionKey, 'versionKey') }) }, startCall(netuid: number) { return call('SubtensorModule', 'start_call', { netuid }) @@ -3154,7 +3194,7 @@ export const calls = Object.freeze({ }) }, register(netuid: number, blockNumber: bigint | number | string, nonce: bigint | number | string, work: ByteLike, hotkey: string, coldkey: string) { - return call('SubtensorModule', 'register', { netuid, block_number: BigInt(blockNumber), nonce: BigInt(nonce), work, hotkey, coldkey }) + return call('SubtensorModule', 'register', { netuid, block_number: toBigInt(blockNumber, 'blockNumber'), nonce: toBigInt(nonce, 'nonce'), work, hotkey, coldkey }) }, register_network(hotkey: string) { return call('SubtensorModule', 'register_network', { hotkey }) @@ -3163,7 +3203,7 @@ export const calls = Object.freeze({ return call('SubtensorModule', 'remove_stake', { hotkey, netuid, amount_unstaked: alphaTransactionAmountForNetuid(amountUnstaked, netuid, 'unstake amount') }) }, reveal_weights(netuid: number, uids: number[], values: number[], salt: number[], versionKey: bigint | number | string) { - return call('SubtensorModule', 'reveal_weights', { netuid, uids, values, salt, version_key: BigInt(versionKey) }) + return call('SubtensorModule', 'reveal_weights', { netuid, uids, values, salt, version_key: toBigInt(versionKey, 'versionKey') }) }, root_register(hotkey: string) { return call('SubtensorModule', 'root_register', { hotkey }) @@ -3180,7 +3220,7 @@ export const calls = Object.freeze({ return call('SubtensorModule', 'set_children', { hotkey, netuid, children }) }, set_weights(netuid: number, dests: number[], weights: number[], versionKey: bigint | number | string) { - return call('SubtensorModule', 'set_weights', { netuid, dests, weights, version_key: BigInt(versionKey) }) + return call('SubtensorModule', 'set_weights', { netuid, dests, weights, version_key: toBigInt(versionKey, 'versionKey') }) }, start_call(netuid: number) { return call('SubtensorModule', 'start_call', { netuid }) @@ -3339,6 +3379,14 @@ export function validateDescriptorSchema(runtime: Runtime): DescriptorSchemaIssu return issues } +function descriptorSchemaDriftError(issues: DescriptorSchemaIssue[]): ChainError { + const sample = issues.slice(0, 8).map((issue) => `${issue.path}: ${issue.message}`) + return new ChainError( + `descriptor schema drift detected (${issues.length} issue${issues.length === 1 ? '' : 's'}): ${sample.join('; ')}`, + issues, + ) +} + function callArgumentTypeIds(callInfo: { argTypeIds?: unknown; argTypes?: unknown }): number[] | null { if (Array.isArray(callInfo.argTypeIds)) { const ids = callInfo.argTypeIds.map((value) => Number(value)) @@ -3510,9 +3558,15 @@ function alphaTransactionAmountForNetuid(value: TransactionAmount, netuid: numbe return transactionAmountRao(value, { name }) } +const IPV4_MAX = 0xffff_ffffn +const IPV6_MAX_EXCLUSIVE = 1n << 128n + function normalizeServeIp(value: ServeIp, ipType?: number): { value: bigint | number; type: number } { + if (ipType != null && ipType !== 4 && ipType !== 6) { + throw new RangeError('ipType must be 4 or 6') + } let parsed: bigint - let inferredType = ipType ?? 4 + let syntaxType: number | undefined if (typeof value === 'bigint') { parsed = value } else if (typeof value === 'number') { @@ -3524,15 +3578,22 @@ function normalizeServeIp(value: ServeIp, ipType?: number): { value: bigint | nu else if (/^0x[0-9a-fA-F]+$/.test(text)) parsed = BigInt(text) else if (text.includes(':')) { parsed = parseIpv6(text) - inferredType = ipType ?? 6 + syntaxType = 6 } else if (text.includes('.')) { parsed = parseIpv4(text) - inferredType = ipType ?? 4 + syntaxType = 4 } else { throw new RangeError('ip string must be decimal, hex, IPv4, or IPv6') } } - if (parsed < 0n || parsed >= (1n << 128n)) throw new RangeError('ip must fit in u128') + if (parsed < 0n || parsed >= IPV6_MAX_EXCLUSIVE) throw new RangeError('ip must fit in u128') + if (syntaxType != null && ipType != null && syntaxType !== ipType) { + throw new RangeError('ipType does not match IP address family') + } + const inferredType = syntaxType ?? ipType ?? (parsed <= IPV4_MAX ? 4 : 6) + if (inferredType === 4 && parsed > IPV4_MAX) { + throw new RangeError('IPv4 address must fit in u32') + } return { value: parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : parsed, type: inferredType, @@ -3569,6 +3630,7 @@ function parseIpv6(value: string): bigint { const left = parseSide(doubleColon >= 0 ? zoneFree.slice(0, doubleColon) : zoneFree) const right = doubleColon >= 0 ? parseSide(zoneFree.slice(doubleColon + 2)) : [] const missing = doubleColon >= 0 ? 8 - left.length - right.length : 0 + if (doubleColon >= 0 && missing <= 0) throw new RangeError('invalid IPv6 address') const groups = doubleColon >= 0 ? [...left, ...Array(missing).fill(0), ...right] : left if (missing < 0 || groups.length !== 8) throw new RangeError('invalid IPv6 address') return groups.reduce((out, group) => (out << 16n) + BigInt(group), 0n) @@ -3789,15 +3851,56 @@ function jsonRpcResponseResult(payload: unknown, expectedId: number): unknown { throw new JsonRpcError('invalid JSON-RPC error response') } const rpcError = error as { message?: unknown; code?: unknown; data?: unknown } + if (typeof rpcError.message !== 'string' || typeof rpcError.code !== 'number') { + throw new JsonRpcError('invalid JSON-RPC error response') + } throw new JsonRpcError( - String(rpcError.message ?? 'JSON-RPC error'), - typeof rpcError.code === 'number' ? rpcError.code : undefined, + rpcError.message, + rpcError.code, rpcError.data, ) } return envelope.result } +function jsonRpcSubscriptionNotification( + payload: unknown, +): { subscription: string; result: unknown } | undefined { + if (payload == null || typeof payload !== 'object' || Array.isArray(payload)) { + throw new JsonRpcError('invalid JSON-RPC notification envelope') + } + const envelope = payload as { + jsonrpc?: unknown + id?: unknown + method?: unknown + result?: unknown + error?: unknown + params?: unknown + } + if (envelope.jsonrpc !== '2.0') { + throw new JsonRpcError('invalid JSON-RPC notification version') + } + if (hasOwn(envelope, 'id')) { + throw new JsonRpcError('JSON-RPC subscription notification must not contain id') + } + if (hasOwn(envelope, 'result') || hasOwn(envelope, 'error')) { + throw new JsonRpcError('JSON-RPC subscription notification result belongs in params.result') + } + if (typeof envelope.method !== 'string') return undefined + const params = envelope.params + if (params == null || typeof params !== 'object' || Array.isArray(params)) return undefined + const paramsObject = params as { subscription?: unknown; result?: unknown } + if (!hasOwn(paramsObject, 'subscription')) return undefined + const subscription = paramsObject.subscription + if (subscription == null || (typeof subscription !== 'string' && typeof subscription !== 'number')) { + throw new JsonRpcError('invalid JSON-RPC subscription id') + } + if (!hasOwn(paramsObject, 'result')) { + throw new JsonRpcError('JSON-RPC subscription notification is missing result') + } + return { subscription: String(subscription), result: paramsObject.result } +} + function extensionSignPayload(context: SignerPayloadContext): ExtensionSignPayloadRequest { return context.runtime.signerPayload(context.address, context.callData, context.txParams) } diff --git a/sdk/bittensor-ts/src/types.ts b/sdk/bittensor-ts/src/types.ts index c63afa0a6a..d6f40a5d58 100644 --- a/sdk/bittensor-ts/src/types.ts +++ b/sdk/bittensor-ts/src/types.ts @@ -1,5 +1,5 @@ export type ByteLike = Buffer | Uint8Array -export type IntegerLike = number | bigint +export type IntegerLike = number | bigint | string export type ScaleValue = | null diff --git a/sdk/bittensor-ts/src/wire.ts b/sdk/bittensor-ts/src/wire.ts index a65fa191c0..ea088e0c00 100644 --- a/sdk/bittensor-ts/src/wire.ts +++ b/sdk/bittensor-ts/src/wire.ts @@ -34,8 +34,17 @@ export function toBuffer(value: ByteLike, name = 'value'): Buffer { export function toBigInt(value: IntegerLike, name = 'value'): bigint { if (typeof value === 'bigint') return value - if (!Number.isSafeInteger(value)) { - throw new RangeError(`${name} must be a safe integer or bigint`) + if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) { + throw new RangeError(`${name} must be a safe integer, bigint, or integer string`) + } + return BigInt(value) + } + if (typeof value !== 'string') { + throw new TypeError(`${name} must be a safe integer, bigint, or integer string`) + } + if (!/^-?\d+$/.test(value)) { + throw new RangeError(`${name} must be an integer string`) } return BigInt(value) } diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 07ab34b949..96bcdb6e7f 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -1194,6 +1194,71 @@ test('transaction amounts require explicit units', () => { placeholder2: 0, }, ]) + assert.deepEqual(core.calls.subtensor.serveAxon(1, 0x1_0000_0000n, 30333), [ + 'SubtensorModule', + 'serve_axon', + { + netuid: 1, + version: 0, + ip: 0x1_0000_0000, + port: 30333, + ip_type: 6, + protocol: 4, + placeholder1: 0, + placeholder2: 0, + }, + ]) + assert.deepEqual(core.calls.subtensor.register( + 1, + '9007199254740993', + '9007199254740995', + Buffer.from([1]), + '5F', + '5G', + ), [ + 'SubtensorModule', + 'register', + { + netuid: 1, + block_number: 9007199254740993n, + nonce: 9007199254740995n, + work: Buffer.from([1]), + hotkey: '5F', + coldkey: '5G', + }, + ]) + assert.throws( + () => core.calls.subtensor.setWeights(1, [], [], Number.MAX_SAFE_INTEGER + 1), + /versionKey must be a safe integer/, + ) + assert.throws( + () => core.calls.SubtensorModule.register(1, Number.MAX_SAFE_INTEGER + 1, 1, Buffer.alloc(0), '5F', '5G'), + /blockNumber must be a safe integer/, + ) + assert.throws( + () => core.calls.subtensor.register(1, 1, Number.MAX_SAFE_INTEGER + 1, Buffer.alloc(0), '5F', '5G'), + /nonce must be a safe integer/, + ) + assert.throws( + () => core.calls.subtensor.revealWeights(1, [], [], [], '0x10'), + /versionKey must be an integer string/, + ) + assert.throws( + () => core.calls.subtensor.serveAxon(1, '2001:db8::1', 30333, 0, 4), + /ipType does not match/, + ) + assert.throws( + () => core.calls.subtensor.serveAxon(1, '192.0.2.1', 30333, 0, 6), + /ipType does not match/, + ) + assert.throws( + () => core.calls.subtensor.serveAxon(1, 0x1_0000_0000n, 30333, 0, 4), + /IPv4 address must fit in u32/, + ) + assert.throws( + () => core.calls.subtensor.serveAxon(1, '1:2:3:4:5:6:7::8', 30333), + /invalid IPv6 address/, + ) }) test('descriptor schema validation reports metadata drift', () => { @@ -1456,6 +1521,32 @@ test('JsonRpcTransport rejects pending requests on malformed websocket JSON', as await assert.rejects(pending, /invalid JSON-RPC message/) }) +test('JsonRpcTransport validates WebSocket JSON-RPC response envelopes', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + const transport = new core.JsonRpcTransport('ws://node-a', [], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + }) + + const missingResult = transport.request('state_getMetadata') + await waitFor(() => FakeWebSocket.sockets[0]?.sent.length === 1, 'websocket request send') + FakeWebSocket.sockets[0].serverMessage({ + jsonrpc: '2.0', + id: FakeWebSocket.sockets[0].sent[0].id, + }) + await assert.rejects(missingResult, /exactly one of result or error/) + + const malformedError = transport.request('state_getMetadata') + await waitFor(() => FakeWebSocket.sockets[0]?.sent.length === 2, 'second websocket request send') + FakeWebSocket.sockets[0].serverMessage({ + jsonrpc: '2.0', + id: FakeWebSocket.sockets[0].sent[1].id, + error: { message: 'missing code' }, + }) + await assert.rejects(malformedError, /invalid JSON-RPC error response/) +}) + test('JsonRpcTransport validates HTTP JSON-RPC envelopes and response size', async (t) => { const originalFetch = globalThis.fetch t.after(() => { @@ -1546,6 +1637,33 @@ test('JsonRpcTransport caps subscription notification queues', async (t) => { await assert.rejects(() => iterator.next(), /subscription notification queue exceeded limit/) }) +test('JsonRpcTransport validates subscription notification envelopes', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + FakeWebSocket.onSend = (socket, message) => { + if (message.method === 'chain_subscribeNewHeads') { + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: 'sub-1' })) + } + } + const transport = new core.JsonRpcTransport('ws://node-a', [], false, { + requestTimeoutMs: 100, + maxRequestRetries: 0, + }) + const subscription = await transport.subscribe( + 'chain_subscribeNewHeads', + [], + 'chain_unsubscribeNewHeads', + ) + const iterator = subscription[Symbol.asyncIterator]() + FakeWebSocket.sockets[0].serverMessage({ + jsonrpc: '2.0', + method: 'chain_subscription', + params: { subscription: 'sub-1' }, + }) + + await assert.rejects(() => iterator.next(), /missing result/) +}) + test('JsonRpcTransport does not resubmit submit-and-watch subscriptions after reconnect', async (t) => { const { FakeWebSocket, restore } = installFakeWebSocket() t.after(restore) @@ -1706,6 +1824,47 @@ test('Client accepts an injected WebSocket factory when no global WebSocket exis assert.deepEqual(urls, ['ws://node-a']) }) +test('Client passes maxSubscriptionQueue into its transport', async (t) => { + const { FakeWebSocket, restore } = installFakeWebSocket() + t.after(restore) + const genesis = `0x${'12'.repeat(32)}` + FakeWebSocket.onSend = (socket, message) => { + if (message.method === 'chain_getBlockHash') { + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: genesis })) + return + } + if (message.method === 'chain_subscribeNewHeads') { + queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: 'sub-1' })) + } + } + const client = new core.Client('local', { + endpoint: 'ws://node-a', + expectedGenesisHash: genesis, + requestTimeoutMs: 100, + maxRequestRetries: 0, + maxSubscriptionQueue: 1, + }) + const subscription = await client.transport.subscribe( + 'chain_subscribeNewHeads', + [], + 'chain_unsubscribeNewHeads', + ) + const iterator = subscription[Symbol.asyncIterator]() + FakeWebSocket.sockets[0].serverMessage({ + jsonrpc: '2.0', + method: 'chain_subscription', + params: { subscription: 'sub-1', result: { number: 1 } }, + }) + FakeWebSocket.sockets[0].serverMessage({ + jsonrpc: '2.0', + method: 'chain_subscription', + params: { subscription: 'sub-1', result: { number: 2 } }, + }) + + assert.deepEqual(await iterator.next(), { done: false, value: { number: 1 } }) + await assert.rejects(() => iterator.next(), /subscription notification queue exceeded limit/) +}) + test('Client autoConnect exposes a handled readiness promise', async (t) => { const originalRuntimeAt = core.Client.prototype.runtimeAt t.after(() => { diff --git a/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts b/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts index a21b61c777..d2b6df95ef 100644 --- a/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts +++ b/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts @@ -5,6 +5,12 @@ import { ethers } from "ethers"; import type { TypedApi } from "polkadot-api"; import { Binary } from "polkadot-api"; import { + GWEI, + IBALANCETRANSFER_ADDRESS, + IBalanceTransferABI, + MAX_TX_FEE, + WITHDRAW_CONTRACT_ABI, + WITHDRAW_CONTRACT_BYTECODE, bigintToRao, convertH160ToSS58, convertPublicKeyToSs58, @@ -15,10 +21,6 @@ import { generateKeyringPair, getBalance, getEthBalance, - GWEI, - IBALANCETRANSFER_ADDRESS, - IBalanceTransferABI, - MAX_TX_FEE, raoToEth, sendTransaction, ss58ToEthAddress, @@ -26,8 +28,6 @@ import { tao, waitForFinalizedBlocks, waitForTransactionWithRetry, - WITHDRAW_CONTRACT_ABI, - WITHDRAW_CONTRACT_BYTECODE, } from "../../utils"; async function estimateTransactionCost(provider: ethers.Provider, tx: ethers.TransactionRequest): Promise { @@ -113,7 +113,7 @@ describeSuite({ const txResponse = await ethWallet.sendTransaction(tx); const receipt = await txResponse.wait(); expect(receipt).toBeDefined(); - expect(receipt!.status).toEqual(1); + expect(receipt?.status).toEqual(1); const senderBalanceAfter = await getEthBalance(provider, ethWallet.address); const receiverBalanceAfter = await getEthBalance(provider, ethWallet2.address); @@ -264,6 +264,7 @@ describeSuite({ const withdrawTx = await contractForCall.withdraw(raoToEth(tao(1)).toString()); const withdrawReceipt = await withdrawTx.wait(); expect(withdrawReceipt?.status).toEqual(1); + await waitForFinalizedBlocks(api, 2); const contractBalanceAfterWithdraw = await getEthBalance(provider, contractAddress); const callerBalanceAfterWithdraw = await getEthBalance(provider, ethWallet.address); From 7b210701281826084e456f91f54990c4dc20e52c Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 16:14:34 -0700 Subject: [PATCH 58/72] fmt --- runtime/tests/evm_contract_value_transfer.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/runtime/tests/evm_contract_value_transfer.rs b/runtime/tests/evm_contract_value_transfer.rs index ff53ff4e41..3e2620f6c5 100644 --- a/runtime/tests/evm_contract_value_transfer.rs +++ b/runtime/tests/evm_contract_value_transfer.rs @@ -7,8 +7,7 @@ use pallet_evm::{AddressMapping, BalanceConverter, EvmBalance, Runner}; use sp_core::{H160, U256}; use subtensor_runtime_common::TaoBalance; -const WITHDRAW_CONTRACT_BYTECODE: &str = - "6080604052348015600e575f80fd5b506101148061001c5f395ff3fe608060405260043610601e575f3560e01c80632e1a7d4d146028576024565b36602457005b5f80fd5b603e6004803603810190603a919060b8565b6040565b005b3373ffffffffffffffffffffffffffffffffffffffff166108fc8290811502906040515f60405180830381858888f193505050501580156082573d5f803e3d5ffd5b5050565b5f80fd5b5f819050919050565b609a81608a565b811460a3575f80fd5b50565b5f8135905060b2816093565b92915050565b5f6020828403121560ca5760c96086565b5b5f60d58482850160a6565b9150509291505056fea2646970667358221220f43400858bfe4fcc0bf3c1e2e06d3a9e6ced86454a00bd7e4866b3d4d64e46bb64736f6c634300081a0033"; +const WITHDRAW_CONTRACT_BYTECODE: &str = "6080604052348015600e575f80fd5b506101148061001c5f395ff3fe608060405260043610601e575f3560e01c80632e1a7d4d146028576024565b36602457005b5f80fd5b603e6004803603810190603a919060b8565b6040565b005b3373ffffffffffffffffffffffffffffffffffffffff166108fc8290811502906040515f60405180830381858888f193505050501580156082573d5f803e3d5ffd5b5050565b5f80fd5b5f819050919050565b609a81608a565b811460a3575f80fd5b50565b5f8135905060b2816093565b92915050565b5f6020828403121560ca5760c96086565b5b5f60d58482850160a6565b9150509291505056fea2646970667358221220f43400858bfe4fcc0bf3c1e2e06d3a9e6ced86454a00bd7e4866b3d4d64e46bb64736f6c634300081a0033"; fn new_test_ext() -> sp_io::TestExternalities { let mut ext: sp_io::TestExternalities = RuntimeGenesisConfig::default() From e05308f18e359c6f8924cfb7ab00cc950db1455d Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 20:06:03 -0700 Subject: [PATCH 59/72] fixes --- .github/workflows/bittensor-ts-e2e.yml | 40 +- pallets/subtensor/src/tests/migration.rs | 1 + runtime/tests/evm_contract_value_transfer.rs | 5 +- sdk/bittensor-core-wasm/src/runtime.rs | 52 +- sdk/bittensor-core/src/client.rs | 360 +++++- sdk/bittensor-core/src/codec/extrinsic.rs | 2 + sdk/bittensor-core/src/transaction.rs | 27 +- sdk/bittensor-ts/README.md | 8 +- sdk/bittensor-ts/native/src/transaction.rs | 982 +++++++++++++--- .../scripts/check-native-parity.cjs | 380 +++++- sdk/bittensor-ts/src/browser.ts | 161 +-- sdk/bittensor-ts/src/client.ts | 1043 +++++++---------- sdk/bittensor-ts/src/index.ts | 1 + sdk/bittensor-ts/src/keys.ts | 28 +- sdk/bittensor-ts/src/native.ts | 191 +-- sdk/bittensor-ts/src/runtime.ts | 3 +- sdk/bittensor-ts/src/rust-bindings.ts | 191 +++ sdk/bittensor-ts/src/transaction.ts | 192 ++- sdk/bittensor-ts/test/basic.test.cjs | 888 ++++---------- ts-tests/suites/dev/sdk/test-bittensor-ts.ts | 3 +- 20 files changed, 2803 insertions(+), 1755 deletions(-) create mode 100644 sdk/bittensor-ts/src/rust-bindings.ts diff --git a/.github/workflows/bittensor-ts-e2e.yml b/.github/workflows/bittensor-ts-e2e.yml index ddfa803c7c..b186b4de9e 100644 --- a/.github/workflows/bittensor-ts-e2e.yml +++ b/.github/workflows/bittensor-ts-e2e.yml @@ -204,15 +204,29 @@ jobs: npm --prefix sdk/bittensor-ts ci npm --prefix sdk/bittensor-ts run check + - name: Package bittensor-ts build + run: | + set -euo pipefail + mapfile -t native_bins < <(find sdk/bittensor-ts -maxdepth 1 -type f -name '*.node' -printf '%f\n' | sort) + if [ "${#native_bins[@]}" -eq 0 ]; then + echo "::error::No bittensor-ts native binary was produced" + exit 1 + fi + tar -czf "$RUNNER_TEMP/bittensor-ts-build.tar.gz" \ + -C sdk/bittensor-ts \ + dist \ + native.cjs \ + native.generated.d.ts \ + "${native_bins[@]}" + tar -tzf "$RUNNER_TEMP/bittensor-ts-build.tar.gz" > "$RUNNER_TEMP/bittensor-ts-build.files" + grep -qx 'dist/index.js' "$RUNNER_TEMP/bittensor-ts-build.files" + grep -qx 'native.cjs' "$RUNNER_TEMP/bittensor-ts-build.files" + - name: Upload bittensor-ts build uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: bittensor-ts - path: | - sdk/bittensor-ts/dist - sdk/bittensor-ts/native.cjs - sdk/bittensor-ts/native.generated.d.ts - sdk/bittensor-ts/*.node + path: ${{ runner.temp }}/bittensor-ts-build.tar.gz if-no-files-found: error test-bittensor-ts-node-min: @@ -237,7 +251,13 @@ jobs: uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: bittensor-ts - path: sdk/bittensor-ts + path: ${{ runner.temp }}/bittensor-ts + + - name: Extract bittensor-ts build + run: | + tar -xzf "$RUNNER_TEMP/bittensor-ts/bittensor-ts-build.tar.gz" -C sdk/bittensor-ts + test -s sdk/bittensor-ts/dist/index.js + test -s sdk/bittensor-ts/native.cjs - name: Test package on minimum supported Node run: | @@ -343,7 +363,13 @@ jobs: uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: bittensor-ts - path: sdk/bittensor-ts + path: ${{ runner.temp }}/bittensor-ts + + - name: Extract bittensor-ts build + run: | + tar -xzf "$RUNNER_TEMP/bittensor-ts/bittensor-ts-build.tar.gz" -C sdk/bittensor-ts + test -s sdk/bittensor-ts/dist/index.js + test -s sdk/bittensor-ts/native.cjs - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/pallets/subtensor/src/tests/migration.rs b/pallets/subtensor/src/tests/migration.rs index 8e81447fbc..9b94c22178 100644 --- a/pallets/subtensor/src/tests/migration.rs +++ b/pallets/subtensor/src/tests/migration.rs @@ -1254,6 +1254,7 @@ fn test_per_u16_encodes_identically_to_u16() { } #[test] +#[allow(deprecated)] fn test_migrate_last_tx_block_delegate_take() { new_test_ext(1).execute_with(|| { // ------------------------------ diff --git a/runtime/tests/evm_contract_value_transfer.rs b/runtime/tests/evm_contract_value_transfer.rs index 3e2620f6c5..43a2b750c6 100644 --- a/runtime/tests/evm_contract_value_transfer.rs +++ b/runtime/tests/evm_contract_value_transfer.rs @@ -45,8 +45,9 @@ fn decode_hex(hex: &str) -> Vec { hex.as_bytes() .chunks_exact(2) .map(|chunk| { - let hi = (chunk[0] as char).to_digit(16).unwrap(); - let lo = (chunk[1] as char).to_digit(16).unwrap(); + let [hi, lo]: [u8; 2] = chunk.try_into().expect("chunks_exact yields pairs"); + let hi = char::from(hi).to_digit(16).unwrap(); + let lo = char::from(lo).to_digit(16).unwrap(); ((hi << 4) | lo) as u8 }) .collect() diff --git a/sdk/bittensor-core-wasm/src/runtime.rs b/sdk/bittensor-core-wasm/src/runtime.rs index e8dbda09b6..40ff8a9167 100644 --- a/sdk/bittensor-core-wasm/src/runtime.rs +++ b/sdk/bittensor-core-wasm/src/runtime.rs @@ -59,11 +59,17 @@ fn storage_entry_js(pallet: &str, info: &StorageInfo) -> Result, } +/// How a Rust-owned signing plan should treat RFC-0078 metadata hashes. +#[derive(Debug, Clone)] +pub enum MetadataHashMode { + Auto, + Disabled, + Explicit([u8; 32]), +} + +/// Public signer identity supplied by a caller that will produce the +/// signature outside Rust. Rust still owns nonce/era/payload construction and +/// validates the returned signature before assembling bytes. +#[derive(Debug, Clone)] +pub struct ExternalSigner { + pub ss58_address: String, + pub public_key: [u8; 32], + pub crypto_type: u8, + pub requires_metadata_proof: bool, +} + +/// Options that affect the exact signed payload. Defaults match the native +/// transaction executor so local and external signers share the same plan. +#[derive(Debug, Clone)] +pub struct ExternalSigningOptions { + pub nonce: Option, + pub period: Option, + pub tip: u128, + pub tip_asset_id: Option, + pub metadata_hash: MetadataHashMode, +} + +impl Default for ExternalSigningOptions { + fn default() -> Self { + Self { + nonce: None, + period: Some(DEFAULT_ERA_PERIOD), + tip: 0, + tip_asset_id: None, + metadata_hash: MetadataHashMode::Auto, + } + } +} + +/// Fully specified transaction signing plan for external signers. +#[derive(Debug, Clone)] +pub struct ExternalSigningPlan { + pub call_data: Vec, + pub signer_address: String, + pub public_key: [u8; 32], + pub crypto_type: u8, + pub params: TxParams, + pub payload: Vec, + pub included_in_extrinsic: Vec, + pub included_in_signed_data: Vec, + pub signer_payload: SignerPayload, + pub metadata_proof: Option>, + pub chain_info: Option, + pub fee_rao: Option, + pub warnings: Vec, +} + impl TxOutcome { fn pool_rejection(hash: String, message: String) -> Self { Self { @@ -503,6 +564,255 @@ impl Client { json_u64(&value) } + /// Chain constants used for RFC-0078 metadata hashes and Ledger proofs. + pub fn chain_info(&self) -> Result { + let runtime = self.runtime()?; + self.chain_info_for_runtime(&runtime) + } + + fn chain_info_for_runtime(&self, runtime: &Runtime) -> Result { + let spec_name = self + .runtime_version() + .map(|version| version.spec_name) + .unwrap_or_else(|_| "node-subtensor".into()); + let properties = match self.rpc_value("system_properties", json!([])) { + Ok(properties) => properties, + Err(CoreError::Rpc(_)) => JsonValue::Object(Default::default()), + Err(error) => return Err(error), + }; + let base58_prefix = property_u64( + &properties, + &["ss58Format", "ss58Prefix"], + u64::from(runtime.ss58_format), + )?; + let decimals = property_u64(&properties, &["tokenDecimals", "decimals"], 9)?; + Ok(ChainInfo { + spec_version: runtime.spec_version, + spec_name, + base58_prefix: u16::try_from(base58_prefix).map_err(|_| { + CoreError::Rpc(format!("ss58 prefix {base58_prefix} does not fit u16")) + })?, + decimals: u8::try_from(decimals).map_err(|_| { + CoreError::Rpc(format!("token decimals {decimals} does not fit u8")) + })?, + token_symbol: property_string(&properties, &["tokenSymbol", "symbol"], "TAO"), + }) + } + + fn tx_params_for_external( + &self, + runtime: &Runtime, + signer: &ExternalSigner, + options: ExternalSigningOptions, + ) -> Result<(TxParams, Option), CoreError> { + let nonce = match options.nonce { + Some(nonce) => nonce, + None => self.account_next_index(&signer.ss58_address)?, + }; + let current = self.block_number()?; + let (era, era_block_hash) = match options.period { + Some(period) if period > 0 => { + let birth = era_birth(period, current); + let hash = parse_h256(&self.block_hash(Some(birth))?)?; + ( + Value::record(vec![ + ("period".into(), Value::Uint(u128::from(period))), + ("current".into(), Value::Uint(u128::from(current))), + ]), + hash, + ) + } + _ => (Value::str("00"), self.genesis_hash), + }; + let supports_metadata_hash = runtime + .extrinsic + .signed_extensions + .iter() + .any(|extension| extension.identifier == "CheckMetadataHash"); + let default_metadata_hash = supports_metadata_hash || signer.requires_metadata_proof; + let mut chain_info = None; + let metadata_hash = match options.metadata_hash { + MetadataHashMode::Explicit(hash) => Some(hash), + MetadataHashMode::Disabled if supports_metadata_hash => { + return Err(CoreError::Policy( + "metadataHash cannot be disabled when the runtime declares CheckMetadataHash" + .into(), + )); + } + MetadataHashMode::Disabled if signer.requires_metadata_proof => { + return Err(CoreError::Policy( + "metadataHash cannot be disabled for a signer that requires metadata proof" + .into(), + )); + } + MetadataHashMode::Disabled => None, + MetadataHashMode::Auto if default_metadata_hash => { + let info = self.chain_info_for_runtime(runtime)?; + let digest = metadata_digest(&runtime.metadata_bytes, &info)?; + chain_info = Some(info); + Some(digest) + } + MetadataHashMode::Auto => None, + }; + if signer.requires_metadata_proof && chain_info.is_none() { + chain_info = Some(self.chain_info_for_runtime(runtime)?); + } + Ok(( + TxParams { + era, + nonce, + tip: options.tip, + tip_asset_id: options.tip_asset_id, + genesis_hash: self.genesis_hash, + era_block_hash, + metadata_hash, + }, + chain_info, + )) + } + + /// Create the exact payload an external signer must sign. + pub fn external_signing_plan( + &self, + call_data: &[u8], + signer: ExternalSigner, + options: ExternalSigningOptions, + ) -> Result { + let address_key = public_key_from_ss58(&signer.ss58_address)?; + if address_key != signer.public_key { + return Err(CoreError::Crypto( + "signer public key does not match signer address".into(), + )); + } + let runtime = self.runtime()?; + let (params, chain_info) = self.tx_params_for_external(&runtime, &signer, options)?; + let (included_in_extrinsic, included_in_signed_data) = + runtime.signature_payload_parts(¶ms)?; + let metadata_proof = if signer.requires_metadata_proof { + let info = chain_info.as_ref().ok_or_else(|| { + CoreError::Codec("chain info is required for metadata proof".into()) + })?; + Some(generate_extrinsic_proof( + call_data, + &included_in_extrinsic, + &included_in_signed_data, + &runtime.metadata_bytes, + info, + )?) + } else { + None + }; + let payload = runtime.signature_payload(call_data, ¶ms)?; + let signer_payload = runtime.signer_payload(&signer.ss58_address, call_data, ¶ms)?; + let mut warnings = Vec::new(); + let fee_rao = match self.estimate_fee_for_plan( + &runtime, + call_data, + signer.public_key, + signer.crypto_type, + ¶ms, + ) { + Ok(fee) => Some(fee), + Err(error) => { + warnings.push(format!("could not estimate fee: {error}")); + None + } + }; + Ok(ExternalSigningPlan { + call_data: call_data.to_vec(), + signer_address: signer.ss58_address, + public_key: signer.public_key, + crypto_type: signer.crypto_type, + params, + payload, + included_in_extrinsic, + included_in_signed_data, + signer_payload, + metadata_proof, + chain_info, + fee_rao, + warnings, + }) + } + + pub fn estimate_fee_external_plan( + &self, + plan: &ExternalSigningPlan, + ) -> Result { + let runtime = self.runtime()?; + self.estimate_fee_for_plan( + &runtime, + &plan.call_data, + plan.public_key, + plan.crypto_type, + &plan.params, + ) + } + + fn estimate_fee_for_plan( + &self, + runtime: &Runtime, + call_data: &[u8], + public_key: [u8; 32], + crypto_type: u8, + params: &TxParams, + ) -> Result { + let signature = vec![0u8; 64]; + let (extrinsic, _) = runtime.encode_signed_extrinsic( + call_data, + public_key, + &signature, + crypto_type, + params, + )?; + self.payment_query_fee(&extrinsic) + } + + pub fn assemble_external_extrinsic( + &self, + plan: &ExternalSigningPlan, + signature: &[u8], + crypto_type: Option, + ) -> Result<(Vec, String), CoreError> { + let signature_crypto_type = crypto_type.unwrap_or(plan.crypto_type); + if signature_crypto_type != plan.crypto_type { + return Err(CoreError::Crypto( + "signature crypto type does not match the Rust signing plan".into(), + )); + } + let verifier = Keypair::new( + Some(&plan.signer_address), + Some(&plan.public_key), + plan.crypto_type, + self.ss58_format, + )?; + if !verifier.verify(&plan.payload, signature)? { + return Err(CoreError::Crypto( + "external signature does not verify against the Rust signing plan".into(), + )); + } + let runtime = self.runtime()?; + let (extrinsic, hash) = runtime.encode_signed_extrinsic( + &plan.call_data, + plan.public_key, + signature, + signature_crypto_type, + &plan.params, + )?; + Ok((extrinsic, hex_prefixed(&hash))) + } + + pub fn submit_external( + &self, + plan: &ExternalSigningPlan, + signature: &[u8], + crypto_type: Option, + wait_for_finalization: bool, + ) -> Result { + let (extrinsic, hash) = self.assemble_external_extrinsic(plan, signature, crypto_type)?; + self.submit_encoded(&extrinsic, hash, wait_for_finalization) + } + /// Sign without submitting. Returns `(encoded extrinsic, 0x hash)`. pub fn sign_extrinsic( &self, @@ -552,7 +862,11 @@ impl Client { let nonce = self.account_next_index(&signer.ss58_address())?; let (extrinsic, _) = self.sign_extrinsic(call_data, signer, nonce, Some(DEFAULT_ERA_PERIOD))?; - let value = self.rpc_value("payment_queryInfo", json!([hex_prefixed(&extrinsic)]))?; + self.payment_query_fee(&extrinsic) + } + + fn payment_query_fee(&self, extrinsic: &[u8]) -> Result { + let value = self.rpc_value("payment_queryInfo", json!([hex_prefixed(extrinsic)]))?; let object = value .as_object() .ok_or_else(|| CoreError::Rpc("payment_queryInfo returned a non-object".into()))?; @@ -1200,10 +1514,11 @@ impl RpcBootstrap { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] struct RuntimeVersion { spec_version: u32, transaction_version: u32, + spec_name: String, } fn parse_runtime_version(value: JsonValue) -> Result { @@ -1221,6 +1536,11 @@ fn parse_runtime_version(value: JsonValue) -> Result .map_err(|_| CoreError::Rpc("specVersion does not fit u32".into()))?, transaction_version: u32::try_from(json_u64(transaction)?) .map_err(|_| CoreError::Rpc("transactionVersion does not fit u32".into()))?, + spec_name: object + .get("specName") + .and_then(JsonValue::as_str) + .unwrap_or("node-subtensor") + .to_owned(), }) } @@ -1564,6 +1884,36 @@ fn json_string<'a>(value: &'a JsonValue, context: &str) -> Result<&'a str, CoreE } } +fn property_value<'a>(properties: &'a JsonValue, keys: &[&str]) -> Option<&'a JsonValue> { + let object = properties.as_object()?; + keys.iter().find_map(|key| object.get(*key)) +} + +fn first_property(value: &JsonValue) -> &JsonValue { + value + .as_array() + .and_then(|items| items.first()) + .unwrap_or(value) +} + +fn property_u64(properties: &JsonValue, keys: &[&str], fallback: u64) -> Result { + let Some(value) = property_value(properties, keys).map(first_property) else { + return Ok(fallback); + }; + if value.is_null() { + return Ok(fallback); + } + json_u64(value) +} + +fn property_string(properties: &JsonValue, keys: &[&str], fallback: &str) -> String { + property_value(properties, keys) + .map(first_property) + .and_then(JsonValue::as_str) + .unwrap_or(fallback) + .to_owned() +} + fn json_number_u64(value: &JsonValue) -> Option { match value { JsonValue::Number(number) => number.to_string().parse().ok(), diff --git a/sdk/bittensor-core/src/codec/extrinsic.rs b/sdk/bittensor-core/src/codec/extrinsic.rs index 23b52a6848..3fe5fa1f7c 100644 --- a/sdk/bittensor-core/src/codec/extrinsic.rs +++ b/sdk/bittensor-core/src/codec/extrinsic.rs @@ -16,6 +16,7 @@ use crate::keys::ss58_from_public; use crate::runtime::{Runtime, SignedExtensionInfo}; /// Everything one signature payload / signed extrinsic needs beyond the call. +#[derive(Debug, Clone)] pub struct TxParams { /// `"00"` (immortal) or `{"period": N, "phase": P}` / `{"period": N, /// "current": M}` — the shapes the SDK has always fed the codec. @@ -33,6 +34,7 @@ pub struct TxParams { /// signed-extension field is encoded through the runtime metadata type that /// also drives [`Runtime::signature_payload`], so the display payload and the /// bytes Rust assembles for the final extrinsic cannot drift. +#[derive(Debug, Clone)] pub struct SignerPayload { pub address: String, pub block_hash: String, diff --git a/sdk/bittensor-core/src/transaction.rs b/sdk/bittensor-core/src/transaction.rs index 7b129aaf5d..c95b9cd855 100644 --- a/sdk/bittensor-core/src/transaction.rs +++ b/sdk/bittensor-core/src/transaction.rs @@ -8,7 +8,10 @@ use std::collections::BTreeSet; -use crate::client::{as_u128, Client, TxOutcome}; +use crate::client::{ + as_u128, Client, ExternalSigner, ExternalSigningOptions, ExternalSigningPlan, TxOutcome, + DEFAULT_ERA_PERIOD, +}; use crate::codec::Value; use crate::error::CoreError; use crate::keys::{Keypair, CRYPTO_SR25519}; @@ -21,6 +24,7 @@ pub enum SignerRole { } /// The key material used by semantic intents. +#[derive(Clone)] pub struct Wallet { pub coldkey: Keypair, pub hotkey: Keypair, @@ -1075,6 +1079,25 @@ impl<'a> Executor<'a> { }) } + pub fn external_signing_plan( + &self, + intent: &IntentCall, + signer: ExternalSigner, + options: ExternalSigningOptions, + policy: Option<&Policy>, + ) -> Result { + let call_data = intent.encode(self.client)?; + let plan = self + .client + .external_signing_plan(&call_data, signer, options)?; + let active = policy.or(self.policy.as_ref()); + let violations = active.map_or_else(Vec::new, |policy| policy.check(intent, plan.fee_rao)); + if !violations.is_empty() { + return Err(CoreError::Policy(violations.join("; "))); + } + Ok(plan) + } + pub fn execute(&self, intent: &IntentCall, wallet: &Wallet) -> Result { self.execute_with(intent, wallet, None, None, None, true) } @@ -1096,7 +1119,7 @@ impl<'a> Executor<'a> { &plan.call_data, wallet.signer(intent.signer), None, - Some(64), + Some(DEFAULT_ERA_PERIOD), wait_for_finalization, ) } diff --git a/sdk/bittensor-ts/README.md b/sdk/bittensor-ts/README.md index 24a4802614..f75e1332ec 100644 --- a/sdk/bittensor-ts/README.md +++ b/sdk/bittensor-ts/README.md @@ -140,10 +140,10 @@ or `taoAmount("1.25")` for TAO-denominated values, and pass `123n` or transaction builders to avoid confusing `"1"` rao with `"1.0"` TAO. Decimal TAO/alpha amounts with more than nine fractional digits are rejected. -`client.submit()` manages nonce reservation for in-process submissions. Detached -flows using `signExtrinsic()` followed by `submitSigned()` or `watchSigned()` -record the signed nonce after submission, but callers producing multiple -detached transactions concurrently should pass explicit `nonce` values. +`client.submit()` delegates automatic nonce selection to the Rust client when +submitting with a native `Keypair`. Low-level manual signing APIs such as +`signExtrinsic()` require an explicit `nonce`, and detached flows using +`submitSigned()` or `watchSigned()` do not inspect or coordinate nonce state. High-level transaction helpers such as `transfer()`, `staking.addStake()`, `setWeights()`, registration, and serving route through Rust trusted diff --git a/sdk/bittensor-ts/native/src/transaction.rs b/sdk/bittensor-ts/native/src/transaction.rs index 100ad60d89..f8e5d39cf1 100644 --- a/sdk/bittensor-ts/native/src/transaction.rs +++ b/sdk/bittensor-ts/native/src/transaction.rs @@ -1,15 +1,21 @@ use std::sync::Arc; -use bittensor_core::client::{BlockHeader, DispatchError, SubnetInfo, SwapQuote, TxOutcome}; +use bittensor_core::client::{ + BlockHeader, DispatchError, ExternalSigner, ExternalSigningOptions, ExternalSigningPlan, + MetadataHashMode, SubnetInfo, SwapQuote, TxOutcome, +}; +use bittensor_core::codec::Value; +use bittensor_core::digest::ChainInfo; use bittensor_core::transaction::{Executor, IntentCall, Plan, Policy, SignerRole, Spend, Wallet}; -use bittensor_core::Client; -use napi::bindgen_prelude::{BigInt, Buffer}; +use bittensor_core::{Client, CoreError}; +use napi::bindgen_prelude::{AsyncTask, BigInt, Buffer, Unknown}; +use napi::{Env, ScopedTask, Task}; use napi_derive::napi; use serde_json::Value as JsonValue; use crate::errors::{invalid_arg, CoreResultExt, NapiResult}; use crate::keys::NativeKeypair; -use crate::runtime::NativeMapPair; +use crate::runtime::{NativeSignerPayload, NativeTxParams}; use crate::values::{from_wire, to_wire}; #[napi(object)] @@ -86,6 +92,247 @@ pub struct NativeSignedExtrinsic { pub hash: String, } +#[napi(object)] +pub struct NativeExternalSigningOptions { + pub nonce: Option, + pub period: Option, + pub immortal: Option, + pub tip: Option, + pub tip_asset_id: Option, + pub metadata_hash_mode: Option, + pub metadata_hash: Option, +} + +#[napi(object)] +pub struct NativeExternalSigner { + pub signer_address: String, + pub public_key: Buffer, + pub crypto_type: u32, + pub requires_metadata_proof: bool, +} + +#[napi(object)] +pub struct NativeChainInfo { + pub spec_version: u32, + pub spec_name: String, + pub base58_prefix: u16, + pub decimals: u8, + pub token_symbol: String, +} + +#[napi] +pub struct NativeExternalSigningPlan { + pub(crate) inner: Arc, +} + +type ClientJob = Box Result + Send + 'static>; + +fn task_already_completed() -> napi::Error { + napi::Error::from_reason("native async task was already completed") +} + +macro_rules! client_task { + ($name:ident, $output:ty, $js:ty, $resolve:expr) => { + pub struct $name { + client: Arc, + job: Option>, + } + + impl $name { + fn new(client: Arc, job: F) -> Self + where + F: FnOnce(&Client) -> Result<$output, CoreError> + Send + 'static, + { + Self { + client, + job: Some(Box::new(job)), + } + } + } + + impl Task for $name { + type Output = $output; + type JsValue = $js; + + fn compute(&mut self) -> napi::Result { + let job = self.job.take().ok_or_else(task_already_completed)?; + job(&self.client).napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + ($resolve)(output) + } + } + }; +} + +pub struct ClientConnectTask { + endpoint: String, +} + +impl Task for ClientConnectTask { + type Output = Client; + type JsValue = NativeClient; + + fn compute(&mut self) -> napi::Result { + Client::connect(self.endpoint.clone()).napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + Ok(NativeClient { + inner: Arc::new(output), + }) + } +} + +client_task!(ClientStringTask, String, String, Ok); +client_task!(ClientBoolTask, bool, bool, Ok); +client_task!(ClientU64Task, u64, BigInt, |value| Ok(BigInt::from(value))); +client_task!(ClientHeaderTask, BlockHeader, NativeBlockHeader, |value| { + Ok(header_to_native(value)) +}); +client_task!(ClientBytesTask, Vec, Buffer, |value: Vec| { + Ok(value.into()) +}); +client_task!( + ClientTxOutcomeTask, + TxOutcome, + NativeTxOutcome, + outcome_to_native +); +client_task!( + ClientSignedExtrinsicTask, + (Vec, String), + NativeSignedExtrinsic, + |(bytes, hash): (Vec, String)| Ok(NativeSignedExtrinsic { + bytes: bytes.into(), + hash, + }) +); +client_task!( + ClientExternalSigningPlanTask, + ExternalSigningPlan, + NativeExternalSigningPlan, + |plan: ExternalSigningPlan| Ok(NativeExternalSigningPlan { + inner: Arc::new(plan), + }) +); +client_task!( + ClientSubnetsTask, + Vec, + Vec, + |items: Vec| Ok(items.into_iter().map(subnet_to_native).collect()) +); +client_task!(ClientSwapQuoteTask, SwapQuote, NativeSwapQuote, |value| { + Ok(quote_to_native(value)) +}); + +pub struct ClientWireValueTask { + client: Arc, + job: Option>, +} + +impl ClientWireValueTask { + fn new(client: Arc, job: F) -> Self + where + F: FnOnce(&Client) -> Result + Send + 'static, + { + Self { + client, + job: Some(Box::new(job)), + } + } +} + +impl<'task> ScopedTask<'task> for ClientWireValueTask { + type Output = Value; + type JsValue = Unknown<'task>; + + fn compute(&mut self) -> napi::Result { + let job = self.job.take().ok_or_else(task_already_completed)?; + job(&self.client).napi() + } + + fn resolve(&mut self, env: &'task Env, output: Self::Output) -> napi::Result { + env.to_js_value(&to_wire(&output)?) + } +} + +pub struct ClientWireValuesTask { + client: Arc, + job: Option>>, +} + +impl ClientWireValuesTask { + fn new(client: Arc, job: F) -> Self + where + F: FnOnce(&Client) -> Result, CoreError> + Send + 'static, + { + Self { + client, + job: Some(Box::new(job)), + } + } +} + +impl<'task> ScopedTask<'task> for ClientWireValuesTask { + type Output = Vec; + type JsValue = Unknown<'task>; + + fn compute(&mut self) -> napi::Result { + let job = self.job.take().ok_or_else(task_already_completed)?; + job(&self.client).napi() + } + + fn resolve(&mut self, env: &'task Env, output: Self::Output) -> napi::Result { + let wire = output + .iter() + .map(to_wire) + .collect::>>()?; + env.to_js_value(&wire) + } +} + +pub struct ClientMapTask { + client: Arc, + job: Option>>, +} + +impl ClientMapTask { + fn new(client: Arc, job: F) -> Self + where + F: FnOnce(&Client) -> Result, CoreError> + Send + 'static, + { + Self { + client, + job: Some(Box::new(job)), + } + } +} + +impl<'task> ScopedTask<'task> for ClientMapTask { + type Output = Vec<(Value, Value)>; + type JsValue = Unknown<'task>; + + fn compute(&mut self) -> napi::Result { + let job = self.job.take().ok_or_else(task_already_completed)?; + job(&self.client).napi() + } + + fn resolve(&mut self, env: &'task Env, output: Self::Output) -> napi::Result { + let wire = output + .iter() + .map(|(key, value)| { + let mut item = serde_json::Map::new(); + item.insert("key".to_owned(), to_wire(key)?); + item.insert("value".to_owned(), to_wire(value)?); + Ok(JsonValue::Object(item)) + }) + .collect::>>()?; + env.to_js_value(&wire) + } +} + #[napi] pub enum NativeSignerRole { Coldkey, @@ -472,6 +719,87 @@ impl NativeIntentCall { } } +#[napi] +impl NativeExternalSigningPlan { + #[napi(getter, js_name = "callData")] + pub fn call_data(&self) -> Buffer { + self.inner.call_data.clone().into() + } + + #[napi(getter, js_name = "signerAddress")] + pub fn signer_address(&self) -> String { + self.inner.signer_address.clone() + } + + #[napi(getter, js_name = "publicKey")] + pub fn public_key(&self) -> Buffer { + self.inner.public_key.to_vec().into() + } + + #[napi(getter, js_name = "cryptoType")] + pub fn crypto_type(&self) -> u32 { + u32::from(self.inner.crypto_type) + } + + #[napi(getter)] + pub fn nonce(&self) -> BigInt { + BigInt::from(self.inner.params.nonce) + } + + #[napi(getter)] + pub fn payload(&self) -> Buffer { + self.inner.payload.clone().into() + } + + #[napi(getter, js_name = "includedInExtrinsic")] + pub fn included_in_extrinsic(&self) -> Buffer { + self.inner.included_in_extrinsic.clone().into() + } + + #[napi(getter, js_name = "includedInSignedData")] + pub fn included_in_signed_data(&self) -> Buffer { + self.inner.included_in_signed_data.clone().into() + } + + #[napi(getter, js_name = "metadataHash")] + pub fn metadata_hash(&self) -> Option { + self.inner + .params + .metadata_hash + .map(|hash| hash.to_vec().into()) + } + + #[napi(getter, js_name = "metadataProof")] + pub fn metadata_proof(&self) -> Option { + self.inner.metadata_proof.clone().map(Into::into) + } + + #[napi(getter, js_name = "txParams")] + pub fn tx_params(&self) -> NapiResult { + tx_params_to_native(&self.inner.params) + } + + #[napi(getter, js_name = "signerPayload")] + pub fn signer_payload(&self) -> NativeSignerPayload { + self.inner.signer_payload.clone().into() + } + + #[napi(getter, js_name = "chainInfo")] + pub fn chain_info(&self) -> Option { + self.inner.chain_info.clone().map(chain_info_to_native) + } + + #[napi(getter, js_name = "feeRao")] + pub fn fee_rao(&self) -> Option { + self.inner.fee_rao.map(|value| value.to_string()) + } + + #[napi(getter)] + pub fn warnings(&self) -> Vec { + self.inner.warnings.clone() + } +} + #[napi] pub struct NativeClient { pub(crate) inner: Arc, @@ -479,11 +807,9 @@ pub struct NativeClient { #[napi] impl NativeClient { - #[napi(factory)] - pub fn connect(endpoint: String) -> napi::Result { - Client::connect(endpoint).napi().map(|inner| Self { - inner: Arc::new(inner), - }) + #[napi] + pub fn connect(endpoint: String) -> AsyncTask { + AsyncTask::new(ClientConnectTask { endpoint }) } #[napi(getter)] @@ -502,34 +828,41 @@ impl NativeClient { } #[napi(js_name = "blockHash")] - pub fn block_hash(&self, block: Option) -> NapiResult { + pub fn block_hash(&self, block: Option) -> NapiResult> { let block = block .as_ref() .map(|value| bigint_u64("block", value)) .transpose()?; - self.inner.block_hash(block).napi() + Ok(AsyncTask::new(ClientStringTask::new( + Arc::clone(&self.inner), + move |client| client.block_hash(block), + ))) } #[napi(js_name = "finalizedHead")] - pub fn finalized_head(&self) -> NapiResult { - self.inner.finalized_head().napi() + pub fn finalized_head(&self) -> AsyncTask { + AsyncTask::new(ClientStringTask::new(Arc::clone(&self.inner), |client| { + client.finalized_head() + })) } #[napi(js_name = "blockNumber")] - pub fn block_number(&self, block_hash: Option) -> NapiResult { - let number = match block_hash { - Some(hash) => self.inner.header(Some(&hash)).napi()?.number, - None => self.inner.block_number().napi()?, - }; - Ok(BigInt::from(number)) + pub fn block_number(&self, block_hash: Option) -> AsyncTask { + AsyncTask::new(ClientU64Task::new( + Arc::clone(&self.inner), + move |client| match block_hash { + Some(hash) => client.header(Some(&hash)).map(|header| header.number), + None => client.block_number(), + }, + )) } #[napi(js_name = "header")] - pub fn header(&self, block_hash: Option) -> NapiResult { - self.inner - .header(block_hash.as_deref()) - .napi() - .map(header_to_native) + pub fn header(&self, block_hash: Option) -> AsyncTask { + AsyncTask::new(ClientHeaderTask::new( + Arc::clone(&self.inner), + move |client| client.header(block_hash.as_deref()), + )) } #[napi(js_name = "readCatalog")] @@ -542,8 +875,10 @@ impl NativeClient { } #[napi(js_name = "refreshRuntime")] - pub fn refresh_runtime(&self) -> NapiResult { - self.inner.refresh_runtime().napi() + pub fn refresh_runtime(&self) -> AsyncTask { + AsyncTask::new(ClientBoolTask::new(Arc::clone(&self.inner), |client| { + client.refresh_runtime() + })) } #[napi(js_name = "composeCall")] @@ -552,11 +887,12 @@ impl NativeClient { pallet: String, call_function: String, params: JsonValue, - ) -> NapiResult { - self.inner - .compose_call(&pallet, &call_function, &from_wire(params)?) - .napi() - .map(Buffer::from) + ) -> NapiResult> { + let params = from_wire(params)?; + Ok(AsyncTask::new(ClientBytesTask::new( + Arc::clone(&self.inner), + move |client| client.compose_call(&pallet, &call_function, ¶ms), + ))) } #[napi(js_name = "decodeScale")] @@ -582,12 +918,12 @@ impl NativeClient { storage: String, params: JsonValue, block_hash: Option, - ) -> NapiResult { + ) -> NapiResult> { let params = wire_value_list("params", params)?; - self.inner - .query(&pallet, &storage, ¶ms, block_hash.as_deref()) - .napi() - .and_then(|value| to_wire(&value)) + Ok(AsyncTask::new(ClientWireValueTask::new( + Arc::clone(&self.inner), + move |client| client.query(&pallet, &storage, ¶ms, block_hash.as_deref()), + ))) } #[napi(js_name = "queryBatch")] @@ -597,14 +933,12 @@ impl NativeClient { storage: String, param_sets: JsonValue, block_hash: Option, - ) -> NapiResult> { + ) -> NapiResult> { let param_sets = wire_value_list_list("paramSets", param_sets)?; - self.inner - .query_batch(&pallet, &storage, ¶m_sets, block_hash.as_deref()) - .napi()? - .iter() - .map(to_wire) - .collect() + Ok(AsyncTask::new(ClientWireValuesTask::new( + Arc::clone(&self.inner), + move |client| client.query_batch(&pallet, &storage, ¶m_sets, block_hash.as_deref()), + ))) } #[napi(js_name = "queryMap")] @@ -614,19 +948,12 @@ impl NativeClient { storage: String, fixed_params: JsonValue, block_hash: Option, - ) -> NapiResult> { + ) -> NapiResult> { let fixed_params = wire_value_list("fixedParams", fixed_params)?; - self.inner - .query_map(&pallet, &storage, &fixed_params, block_hash.as_deref()) - .napi()? - .iter() - .map(|(key, value)| { - Ok(NativeMapPair { - key: to_wire(key)?, - value: to_wire(value)?, - }) - }) - .collect() + Ok(AsyncTask::new(ClientMapTask::new( + Arc::clone(&self.inner), + move |client| client.query_map(&pallet, &storage, &fixed_params, block_hash.as_deref()), + ))) } #[napi(js_name = "runtimeCall")] @@ -636,20 +963,19 @@ impl NativeClient { method: String, params: JsonValue, block_hash: Option, - ) -> NapiResult { + ) -> NapiResult> { let params = wire_value_list("params", params)?; - self.inner - .runtime_call(&api, &method, ¶ms, block_hash.as_deref()) - .napi() - .and_then(|value| to_wire(&value)) + Ok(AsyncTask::new(ClientWireValueTask::new( + Arc::clone(&self.inner), + move |client| client.runtime_call(&api, &method, ¶ms, block_hash.as_deref()), + ))) } #[napi(js_name = "accountNextIndex")] - pub fn account_next_index(&self, address: String) -> NapiResult { - self.inner - .account_next_index(&address) - .napi() - .map(BigInt::from) + pub fn account_next_index(&self, address: String) -> AsyncTask { + AsyncTask::new(ClientU64Task::new(Arc::clone(&self.inner), move |client| { + client.account_next_index(&address) + })) } #[napi(js_name = "signExtrinsic")] @@ -659,27 +985,36 @@ impl NativeClient { signer: &NativeKeypair, nonce: BigInt, period: Option, - ) -> NapiResult { + ) -> NapiResult> { let nonce = bigint_u64("nonce", &nonce)?; let period = period .as_ref() .map(|value| bigint_u64("period", value)) .transpose()?; - self.inner - .sign_extrinsic(&call_data, &signer.inner, nonce, period) - .napi() - .map(|(bytes, hash)| NativeSignedExtrinsic { - bytes: bytes.into(), - hash, - }) + let call_data = call_data.to_vec(); + let signer = signer.inner.clone(); + Ok(AsyncTask::new(ClientSignedExtrinsicTask::new( + Arc::clone(&self.inner), + move |client| client.sign_extrinsic(&call_data, &signer, nonce, period), + ))) } #[napi(js_name = "estimateFee")] - pub fn estimate_fee(&self, call_data: Buffer, signer: &NativeKeypair) -> NapiResult { - self.inner - .estimate_fee(&call_data, &signer.inner) - .napi() - .map(|value| value.to_string()) + pub fn estimate_fee( + &self, + call_data: Buffer, + signer: &NativeKeypair, + ) -> AsyncTask { + let call_data = call_data.to_vec(); + let signer = signer.inner.clone(); + AsyncTask::new(ClientStringTask::new( + Arc::clone(&self.inner), + move |client| { + client + .estimate_fee(&call_data, &signer) + .map(|value| value.to_string()) + }, + )) } #[napi(js_name = "submit")] @@ -690,7 +1025,7 @@ impl NativeClient { nonce: Option, period: Option, wait_for_finalization: Option, - ) -> NapiResult { + ) -> NapiResult> { let nonce = nonce .as_ref() .map(|value| bigint_u64("nonce", value)) @@ -699,16 +1034,13 @@ impl NativeClient { .as_ref() .map(|value| bigint_u64("period", value)) .transpose()?; - self.inner - .submit( - &call_data, - &signer.inner, - nonce, - period, - wait_for_finalization.unwrap_or(false), - ) - .napi() - .and_then(outcome_to_native) + let call_data = call_data.to_vec(); + let signer = signer.inner.clone(); + let wait_for_finalization = wait_for_finalization.unwrap_or(false); + Ok(AsyncTask::new(ClientTxOutcomeTask::new( + Arc::clone(&self.inner), + move |client| client.submit(&call_data, &signer, nonce, period, wait_for_finalization), + ))) } #[napi(js_name = "submitEncoded")] @@ -717,57 +1049,154 @@ impl NativeClient { extrinsic: Buffer, expected_hash: String, wait_for_finalization: Option, - ) -> NapiResult { - self.inner - .submit_encoded( - &extrinsic, - expected_hash, - wait_for_finalization.unwrap_or(false), - ) - .napi() - .and_then(outcome_to_native) + ) -> AsyncTask { + let extrinsic = extrinsic.to_vec(); + let wait_for_finalization = wait_for_finalization.unwrap_or(false); + AsyncTask::new(ClientTxOutcomeTask::new( + Arc::clone(&self.inner), + move |client| client.submit_encoded(&extrinsic, expected_hash, wait_for_finalization), + )) + } + + #[napi(js_name = "externalSigningPlan")] + pub fn external_signing_plan( + &self, + call_data: Buffer, + signer: NativeExternalSigner, + options: Option, + ) -> NapiResult> { + let call_data = call_data.to_vec(); + let signer = external_signer(signer)?; + let options = external_signing_options(options)?; + Ok(AsyncTask::new(ClientExternalSigningPlanTask::new( + Arc::clone(&self.inner), + move |client| client.external_signing_plan(&call_data, signer, options), + ))) + } + + #[napi(js_name = "externalSigningPlanForIntent")] + pub fn external_signing_plan_for_intent( + &self, + intent: &NativeIntentCall, + signer: NativeExternalSigner, + policy: &NativePolicy, + options: Option, + ) -> NapiResult> { + let intent = intent.inner.clone(); + let signer = external_signer(signer)?; + let policy = policy.inner.clone(); + let options = external_signing_options(options)?; + Ok(AsyncTask::new(ClientExternalSigningPlanTask::new( + Arc::clone(&self.inner), + move |client| { + Executor::new(client).external_signing_plan(&intent, signer, options, Some(&policy)) + }, + ))) + } + + #[napi(js_name = "estimateFeeExternal")] + pub fn estimate_fee_external( + &self, + plan: &NativeExternalSigningPlan, + ) -> AsyncTask { + let plan = Arc::clone(&plan.inner); + AsyncTask::new(ClientStringTask::new( + Arc::clone(&self.inner), + move |client| { + client + .estimate_fee_external_plan(&plan) + .map(|value| value.to_string()) + }, + )) + } + + #[napi(js_name = "assembleExternal")] + pub fn assemble_external( + &self, + plan: &NativeExternalSigningPlan, + signature: Buffer, + crypto_type: Option, + ) -> NapiResult> { + let plan = Arc::clone(&plan.inner); + let signature = signature.to_vec(); + let crypto_type = crypto_type + .map(|value| u8::try_from(value).map_err(|_| invalid_arg("cryptoType must fit u8"))) + .transpose()?; + Ok(AsyncTask::new(ClientSignedExtrinsicTask::new( + Arc::clone(&self.inner), + move |client| client.assemble_external_extrinsic(&plan, &signature, crypto_type), + ))) + } + + #[napi(js_name = "submitExternal")] + pub fn submit_external( + &self, + plan: &NativeExternalSigningPlan, + signature: Buffer, + wait_for_finalization: Option, + crypto_type: Option, + ) -> NapiResult> { + let plan = Arc::clone(&plan.inner); + let signature = signature.to_vec(); + let crypto_type = crypto_type + .map(|value| u8::try_from(value).map_err(|_| invalid_arg("cryptoType must fit u8"))) + .transpose()?; + let wait_for_finalization = wait_for_finalization.unwrap_or(false); + Ok(AsyncTask::new(ClientTxOutcomeTask::new( + Arc::clone(&self.inner), + move |client| { + client.submit_external(&plan, &signature, crypto_type, wait_for_finalization) + }, + ))) } #[napi(js_name = "balanceRao")] - pub fn balance_rao(&self, address: String) -> NapiResult { - self.inner - .balance_rao(&address) - .napi() - .map(|value| value.to_string()) + pub fn balance_rao(&self, address: String) -> AsyncTask { + AsyncTask::new(ClientStringTask::new( + Arc::clone(&self.inner), + move |client| client.balance_rao(&address).map(|value| value.to_string()), + )) } #[napi(js_name = "existentialDepositRao")] - pub fn existential_deposit_rao(&self) -> NapiResult { - self.inner - .existential_deposit_rao() - .napi() - .map(|value| value.to_string()) + pub fn existential_deposit_rao(&self) -> AsyncTask { + AsyncTask::new(ClientStringTask::new(Arc::clone(&self.inner), |client| { + client + .existential_deposit_rao() + .map(|value| value.to_string()) + })) } #[napi(js_name = "subnets")] - pub fn subnets(&self, block_hash: Option) -> NapiResult> { - self.inner - .subnets(block_hash.as_deref()) - .napi() - .map(|items| items.into_iter().map(subnet_to_native).collect()) + pub fn subnets(&self, block_hash: Option) -> AsyncTask { + AsyncTask::new(ClientSubnetsTask::new( + Arc::clone(&self.inner), + move |client| client.subnets(block_hash.as_deref()), + )) } #[napi(js_name = "metagraph")] - pub fn metagraph(&self, netuid: u16, block_hash: Option) -> NapiResult { - self.inner - .metagraph(netuid, block_hash.as_deref()) - .napi() - .and_then(|value| to_wire(&value)) + pub fn metagraph( + &self, + netuid: u16, + block_hash: Option, + ) -> AsyncTask { + AsyncTask::new(ClientWireValueTask::new( + Arc::clone(&self.inner), + move |client| client.metagraph(netuid, block_hash.as_deref()), + )) } #[napi(js_name = "neurons")] - pub fn neurons(&self, netuid: u16, block_hash: Option) -> NapiResult> { - self.inner - .neurons(netuid, block_hash.as_deref()) - .napi()? - .iter() - .map(to_wire) - .collect() + pub fn neurons( + &self, + netuid: u16, + block_hash: Option, + ) -> AsyncTask { + AsyncTask::new(ClientWireValuesTask::new( + Arc::clone(&self.inner), + move |client| client.neurons(netuid, block_hash.as_deref()), + )) } #[napi(js_name = "subnetHyperparameters")] @@ -775,11 +1204,11 @@ impl NativeClient { &self, netuid: u16, block_hash: Option, - ) -> NapiResult { - self.inner - .subnet_hyperparameters(netuid, block_hash.as_deref()) - .napi() - .and_then(|value| to_wire(&value)) + ) -> AsyncTask { + AsyncTask::new(ClientWireValueTask::new( + Arc::clone(&self.inner), + move |client| client.subnet_hyperparameters(netuid, block_hash.as_deref()), + )) } #[napi(js_name = "stakeRao")] @@ -789,11 +1218,15 @@ impl NativeClient { hotkey: String, netuid: u16, block_hash: Option, - ) -> NapiResult { - self.inner - .stake_rao(&coldkey, &hotkey, netuid, block_hash.as_deref()) - .napi() - .map(|value| value.to_string()) + ) -> AsyncTask { + AsyncTask::new(ClientStringTask::new( + Arc::clone(&self.inner), + move |client| { + client + .stake_rao(&coldkey, &hotkey, netuid, block_hash.as_deref()) + .map(|value| value.to_string()) + }, + )) } #[napi(js_name = "quoteStake")] @@ -802,20 +1235,21 @@ impl NativeClient { netuid: u16, amount_rao: BigInt, block_hash: Option, - ) -> NapiResult { - self.inner - .quote_stake( - netuid, - bigint_u128("amountRao", &amount_rao)?, - block_hash.as_deref(), - ) - .napi() - .map(quote_to_native) + ) -> NapiResult> { + let amount_rao = bigint_u128("amountRao", &amount_rao)?; + Ok(AsyncTask::new(ClientSwapQuoteTask::new( + Arc::clone(&self.inner), + move |client| client.quote_stake(netuid, amount_rao, block_hash.as_deref()), + ))) } #[napi(js_name = "composeIntent")] - pub fn compose_intent(&self, intent: &NativeIntentCall) -> NapiResult { - intent.inner.encode(&self.inner).napi().map(Buffer::from) + pub fn compose_intent(&self, intent: &NativeIntentCall) -> AsyncTask { + let intent = intent.inner.clone(); + AsyncTask::new(ClientBytesTask::new( + Arc::clone(&self.inner), + move |client| intent.encode(client), + )) } } @@ -850,6 +1284,87 @@ pub struct NativeExecutor { policy: Option, } +pub struct ExecutorPlanTask { + client: Arc, + policy: Option, + intent: IntentCall, + wallet: Wallet, + check_policy: Option, +} + +impl Task for ExecutorPlanTask { + type Output = Plan; + type JsValue = NativePlan; + + fn compute(&mut self) -> napi::Result { + let executor = executor_from_parts(&self.client, &self.policy); + match &self.check_policy { + Some(policy) => executor + .plan_with_policy(&self.intent, &self.wallet, policy) + .napi(), + None => executor.plan(&self.intent, &self.wallet).napi(), + } + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + plan_to_native(output) + } +} + +pub struct ExecutorExecuteTask { + client: Arc, + policy: Option, + intent: IntentCall, + wallet: Wallet, + wait_for_finalization: bool, +} + +impl Task for ExecutorExecuteTask { + type Output = TxOutcome; + type JsValue = NativeTxOutcome; + + fn compute(&mut self) -> napi::Result { + let executor = executor_from_parts(&self.client, &self.policy); + executor + .execute_with( + &self.intent, + &self.wallet, + None, + None, + None, + self.wait_for_finalization, + ) + .napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + outcome_to_native(output) + } +} + +pub struct ExecutorSubmitShieldedTask { + client: Arc, + policy: Option, + intent: IntentCall, + wallet: Wallet, +} + +impl Task for ExecutorSubmitShieldedTask { + type Output = TxOutcome; + type JsValue = NativeTxOutcome; + + fn compute(&mut self) -> napi::Result { + let executor = executor_from_parts(&self.client, &self.policy); + executor + .submit_shielded(&self.intent, &self.wallet, None) + .napi() + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { + outcome_to_native(output) + } +} + #[napi] impl NativeExecutor { #[napi(factory, js_name = "fromClient")] @@ -869,12 +1384,18 @@ impl NativeExecutor { } #[napi(js_name = "plan")] - pub fn plan(&self, intent: &NativeIntentCall, wallet: &NativeWallet) -> NapiResult { - let executor = self.executor(); - executor - .plan(&intent.inner, &wallet.inner) - .napi() - .and_then(plan_to_native) + pub fn plan( + &self, + intent: &NativeIntentCall, + wallet: &NativeWallet, + ) -> AsyncTask { + AsyncTask::new(ExecutorPlanTask { + client: Arc::clone(&self.client), + policy: self.policy.clone(), + intent: intent.inner.clone(), + wallet: wallet.inner.clone(), + check_policy: None, + }) } #[napi(js_name = "planWithPolicy")] @@ -883,12 +1404,14 @@ impl NativeExecutor { intent: &NativeIntentCall, wallet: &NativeWallet, policy: &NativePolicy, - ) -> NapiResult { - let executor = self.executor(); - executor - .plan_with_policy(&intent.inner, &wallet.inner, &policy.inner) - .napi() - .and_then(plan_to_native) + ) -> AsyncTask { + AsyncTask::new(ExecutorPlanTask { + client: Arc::clone(&self.client), + policy: self.policy.clone(), + intent: intent.inner.clone(), + wallet: wallet.inner.clone(), + check_policy: Some(policy.inner.clone()), + }) } #[napi(js_name = "execute")] @@ -897,19 +1420,14 @@ impl NativeExecutor { intent: &NativeIntentCall, wallet: &NativeWallet, wait_for_finalization: Option, - ) -> NapiResult { - let executor = self.executor(); - executor - .execute_with( - &intent.inner, - &wallet.inner, - None, - None, - None, - wait_for_finalization.unwrap_or(true), - ) - .napi() - .and_then(outcome_to_native) + ) -> AsyncTask { + AsyncTask::new(ExecutorExecuteTask { + client: Arc::clone(&self.client), + policy: self.policy.clone(), + intent: intent.inner.clone(), + wallet: wallet.inner.clone(), + wait_for_finalization: wait_for_finalization.unwrap_or(true), + }) } #[napi(js_name = "submitShielded")] @@ -917,21 +1435,20 @@ impl NativeExecutor { &self, intent: &NativeIntentCall, wallet: &NativeWallet, - ) -> NapiResult { - let executor = self.executor(); - executor - .submit_shielded(&intent.inner, &wallet.inner, None) - .napi() - .and_then(outcome_to_native) + ) -> AsyncTask { + AsyncTask::new(ExecutorSubmitShieldedTask { + client: Arc::clone(&self.client), + policy: self.policy.clone(), + intent: intent.inner.clone(), + wallet: wallet.inner.clone(), + }) } } -impl NativeExecutor { - fn executor(&self) -> Executor<'_> { - match &self.policy { - Some(policy) => Executor::with_policy(&self.client, policy.clone()), - None => Executor::new(&self.client), - } +fn executor_from_parts<'a>(client: &'a Arc, policy: &Option) -> Executor<'a> { + match policy { + Some(policy) => Executor::with_policy(client, policy.clone()), + None => Executor::new(client), } } @@ -1029,6 +1546,105 @@ fn quote_to_native(quote: SwapQuote) -> NativeSwapQuote { } } +fn external_signer(signer: NativeExternalSigner) -> NapiResult { + let public_key = buffer_32("publicKey", &signer.public_key)?; + let crypto_type = + u8::try_from(signer.crypto_type).map_err(|_| invalid_arg("cryptoType must fit u8"))?; + Ok(ExternalSigner { + ss58_address: signer.signer_address, + public_key, + crypto_type, + requires_metadata_proof: signer.requires_metadata_proof, + }) +} + +fn external_signing_options( + options: Option, +) -> NapiResult { + let Some(options) = options else { + return Ok(ExternalSigningOptions::default()); + }; + let nonce = options + .nonce + .as_ref() + .map(|value| bigint_u64("nonce", value)) + .transpose()?; + let period = if options.immortal.unwrap_or(false) { + None + } else if let Some(period) = &options.period { + Some(bigint_u64("period", period)?) + } else { + ExternalSigningOptions::default().period + }; + let tip = options + .tip + .as_ref() + .map(|value| bigint_u128("tip", value)) + .transpose()? + .unwrap_or(0); + let tip_asset_id = options + .tip_asset_id + .as_ref() + .map(|value| bigint_u128("tipAssetId", value)) + .transpose()?; + let mode = options + .metadata_hash_mode + .as_deref() + .unwrap_or("auto") + .to_ascii_lowercase(); + let metadata_hash = match mode.as_str() { + "auto" => MetadataHashMode::Auto, + "disabled" => MetadataHashMode::Disabled, + "explicit" => MetadataHashMode::Explicit(buffer_32( + "metadataHash", + options.metadata_hash.as_ref().ok_or_else(|| { + invalid_arg("metadataHash is required when metadataHashMode is explicit") + })?, + )?), + other => { + return Err(invalid_arg(format!( + "unsupported metadataHashMode {other:?}; expected auto, disabled, or explicit" + ))) + } + }; + Ok(ExternalSigningOptions { + nonce, + period, + tip, + tip_asset_id, + metadata_hash, + }) +} + +fn tx_params_to_native( + params: &bittensor_core::codec::extrinsic::TxParams, +) -> NapiResult { + Ok(NativeTxParams { + era: to_wire(¶ms.era)?, + nonce: BigInt::from(params.nonce), + tip: BigInt::from(params.tip), + tip_asset_id: params.tip_asset_id.map(BigInt::from), + genesis_hash: params.genesis_hash.to_vec().into(), + era_block_hash: params.era_block_hash.to_vec().into(), + metadata_hash: params.metadata_hash.map(|hash| hash.to_vec().into()), + }) +} + +fn chain_info_to_native(info: ChainInfo) -> NativeChainInfo { + NativeChainInfo { + spec_version: info.spec_version, + spec_name: info.spec_name, + base58_prefix: info.base58_prefix, + decimals: info.decimals, + token_symbol: info.token_symbol, + } +} + +fn buffer_32(name: &str, value: &Buffer) -> NapiResult<[u8; 32]> { + <[u8; 32]>::try_from(value.as_ref()) + .map_err(|_| invalid_arg(format!("{name} must be exactly 32 bytes"))) +} + fn wire_value_list(name: &str, value: JsonValue) -> NapiResult> { match from_wire(value)? { bittensor_core::codec::Value::List(values) diff --git a/sdk/bittensor-ts/scripts/check-native-parity.cjs b/sdk/bittensor-ts/scripts/check-native-parity.cjs index 588b170393..29dd925f5e 100755 --- a/sdk/bittensor-ts/scripts/check-native-parity.cjs +++ b/sdk/bittensor-ts/scripts/check-native-parity.cjs @@ -3,6 +3,7 @@ const fs = require('node:fs') const path = require('node:path') +const { pathToFileURL } = require('node:url') const ts = require('typescript') const root = path.resolve(__dirname, '..') @@ -507,6 +508,375 @@ function compareCoreCoverage(nativeExpected, wasmExpected, browserWrapperExpecte return failures } +function compactLength(buffer, offset) { + const first = buffer[offset] + const mode = first & 0b11 + if (mode === 0) return { length: first >> 2, offset: offset + 1 } + if (mode === 1) return { length: buffer.readUInt16LE(offset) >> 2, offset: offset + 2 } + if (mode === 2) return { length: buffer.readUInt32LE(offset) >>> 2, offset: offset + 4 } + const bytes = (first >> 2) + 4 + let length = 0 + for (let i = 0; i < bytes; i += 1) { + length += buffer[offset + 1 + i] * (256 ** i) + } + return { length, offset: offset + 1 + bytes } +} + +function goldenMetadataBytes() { + const golden = JSON.parse( + fs.readFileSync( + path.join(root, '..', 'python', 'tests', 'fixtures', 'golden.json'), + 'utf8', + ), + ) + const raw = Buffer.from(golden.metadata.v15_hex.slice(2), 'hex') + if (raw[0] !== 1) throw new Error('golden metadata fixture is not an opaque metadata wrapper') + const decoded = compactLength(raw, 1) + return raw.subarray(decoded.offset, decoded.offset + decoded.length) +} + +function bytesHex(value) { + return Buffer.from(value).toString('hex') +} + +function canonical(value) { + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + return { bytes: bytesHex(value) } + } + if (typeof value === 'bigint') { + return { bigint: value.toString() } + } + if (value instanceof Map) { + return { + map: [...value.entries()].map(([key, entryValue]) => [ + canonical(key), + canonical(entryValue), + ]), + } + } + if (Array.isArray(value)) return value.map(canonical) + if (value != null && typeof value === 'object') { + const out = {} + for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]) + return out + } + return value +} + +function stableJson(value) { + return JSON.stringify(canonical(value)) +} + +function assertSameBytes(label, nativeValue, browserValue) { + const nativeHex = bytesHex(nativeValue) + const browserHex = bytesHex(browserValue) + if (nativeHex !== browserHex) { + throw new Error(`${label} bytes differ: native ${nativeHex}, browser ${browserHex}`) + } +} + +function assertSameValue(label, nativeValue, browserValue) { + const nativeJson = stableJson(nativeValue) + const browserJson = stableJson(browserValue) + if (nativeJson !== browserJson) { + throw new Error( + `${label} values differ: native ${nativeJson.slice(0, 512)}, browser ${browserJson.slice(0, 512)}`, + ) + } +} + +function rustStorageEntryFields(entry) { + return { + pallet: entry.pallet, + name: entry.name, + prefix: entry.prefix, + modifier: entry.modifier, + valueType: entry.valueType, + valueTypeId: entry.valueTypeId, + paramTypes: entry.paramTypes, + paramTypeIds: entry.paramTypeIds, + paramHashers: entry.paramHashers, + defaultBytes: entry.defaultBytes, + } +} + +async function loadBrowserWasmModule() { + const wasmBindings = await import( + pathToFileURL(path.join(root, 'dist', 'wasm', 'bittensor_core_wasm_bg.js')).href + ) + const wasmBytes = fs.readFileSync( + path.join(root, 'dist', 'wasm', 'bittensor_core_wasm_bg.wasm'), + ) + const { instance } = await WebAssembly.instantiate(wasmBytes, { + './bittensor_core_wasm_bg.js': wasmBindings, + }) + wasmBindings.__wbg_set_wasm(instance.exports) + if (typeof instance.exports.__wbindgen_start === 'function') { + instance.exports.__wbindgen_start() + } + const module = { ...wasmBindings } + delete module.default + return module +} + +async function semanticParityFailures() { + const failures = [] + const check = (label, fn) => { + try { + fn() + } catch (error) { + failures.push( + `semantic parity ${label}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + let nativeSdk + let browserSdk + try { + nativeSdk = require(path.join(root, 'dist', 'index.js')) + browserSdk = require(path.join(root, 'dist', 'browser.js')) + browserSdk.configureBrowserWasm(loadBrowserWasmModule) + await browserSdk.initBrowser() + } catch (error) { + failures.push( + `semantic parity setup failed: ${error instanceof Error ? error.message : String(error)}`, + ) + return failures + } + + const metadata = goldenMetadataBytes() + const specVersion = 419 + const transactionVersion = 1 + const ss58Format = 42 + const specName = 'node-subtensor' + const nativeRuntime = new nativeSdk.Runtime( + metadata, + specVersion, + transactionVersion, + ss58Format, + ) + const browserRuntime = new browserSdk.Runtime( + new Uint8Array(metadata), + specVersion, + transactionVersion, + ss58Format, + ) + const mnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + const nativeKeypair = nativeSdk.Keypair.fromMnemonic(mnemonic) + const browserKeypair = browserSdk.Keypair.fromMnemonic(mnemonic) + + check('SS58', () => { + assertSameBytes('public key', nativeKeypair.publicKey, browserKeypair.publicKey) + if (nativeKeypair.ss58Address !== browserKeypair.ss58Address) { + throw new Error( + `address differs: native ${nativeKeypair.ss58Address}, browser ${browserKeypair.ss58Address}`, + ) + } + if ( + nativeSdk.ss58FromPublic(nativeKeypair.publicKey, ss58Format) !== + browserSdk.ss58FromPublic(browserKeypair.publicKey, ss58Format) + ) { + throw new Error('ss58FromPublic differs') + } + assertSameBytes( + 'publicKeyFromSs58', + nativeSdk.publicKeyFromSs58(nativeKeypair.ss58Address), + browserSdk.publicKeyFromSs58(browserKeypair.ss58Address), + ) + }) + + const remark = Uint8Array.of(1, 2, 3, 4) + const nativeCall = nativeRuntime.composeCall('System', 'remark', { + remark: Buffer.from(remark), + }) + const browserCall = browserRuntime.composeCall('System', 'remark', { remark }) + + check('call encoding', () => { + assertSameBytes('System.remark call', nativeCall, browserCall) + assertSameValue( + 'decoded System.remark call', + nativeRuntime.decodeCall(nativeCall), + browserRuntime.decodeCall(browserCall), + ) + }) + + check('storage keys and map conversion', () => { + const nativeEntry = nativeRuntime.storageEntry('System', 'Account') + const browserEntry = browserRuntime.storageEntry('System', 'Account') + assertSameValue( + 'storage entry', + rustStorageEntryFields(nativeEntry), + rustStorageEntryFields(browserEntry), + ) + const nativeStorageKey = nativeRuntime.storageKey('System', 'Account', [ + nativeKeypair.publicKey, + ]) + const browserStorageKey = browserRuntime.storageKey('System', 'Account', [ + browserKeypair.publicKey, + ]) + assertSameBytes('System.Account storage key', nativeStorageKey, browserStorageKey) + assertSameValue( + 'System.Account storage key params', + nativeRuntime.decodeStorageKeyParams('System', 'Account', nativeStorageKey), + browserRuntime.decodeStorageKeyParams('System', 'Account', browserStorageKey), + ) + assertSameValue( + 'System.Account map pair', + nativeRuntime.decodeMapPairs( + 'System', + 'Account', + [nativeStorageKey], + [nativeEntry.defaultBytes], + ), + browserRuntime.decodeMapPairs( + 'System', + 'Account', + [browserStorageKey], + [browserEntry.defaultBytes], + ), + ) + }) + + const genesisHash = Buffer.alloc(32, 1) + const eraBlockHash = Buffer.alloc(32, 2) + const nativeTxParams = { + era: '00', + nonce: 7n, + tip: 0n, + tipAssetId: null, + genesisHash, + eraBlockHash, + } + const browserTxParams = { + era: '00', + nonce: 7n, + tip: 0n, + tipAssetId: null, + genesisHash: new Uint8Array(genesisHash), + eraBlockHash: new Uint8Array(eraBlockHash), + } + const nativeParts = nativeRuntime.signaturePayloadParts(nativeTxParams) + const browserParts = browserRuntime.signaturePayloadParts(browserTxParams) + + check('signed payloads', () => { + assertSameValue('signature payload parts', nativeParts, browserParts) + assertSameBytes( + 'signature payload', + nativeRuntime.signaturePayload(nativeCall, nativeTxParams), + browserRuntime.signaturePayload(browserCall, browserTxParams), + ) + }) + + check('signed extrinsics', () => { + const signature = Buffer.from(Array.from({ length: 64 }, (_, index) => index)) + const nativeSigned = nativeRuntime.encodeSignedExtrinsic( + nativeCall, + nativeKeypair.publicKey, + signature, + nativeKeypair.cryptoType, + { era: '00', nonce: 7n, tip: 0n, tipAssetId: null, metadataHashEnabled: false }, + ) + const browserSigned = browserRuntime.encodeSignedExtrinsic( + browserCall, + browserKeypair.publicKey, + new Uint8Array(signature), + browserKeypair.cryptoType, + { era: '00', nonce: 7n, tip: 0n, tipAssetId: null, metadataHashEnabled: false }, + ) + assertSameValue('signed extrinsic', nativeSigned, browserSigned) + }) + + check('metadata digest and proof', () => { + const info = { + specVersion, + specName, + base58Prefix: ss58Format, + decimals: 9, + tokenSymbol: 'TAO', + } + assertSameBytes( + 'metadata digest', + nativeSdk.metadataDigest(metadata, info), + browserSdk.metadataDigest( + new Uint8Array(metadata), + specVersion, + specName, + ss58Format, + 9, + 'TAO', + ), + ) + assertSameBytes( + 'extrinsic proof', + nativeSdk.generateExtrinsicProof( + nativeCall, + nativeParts.includedInExtrinsic, + nativeParts.includedInSignedData, + metadata, + info, + ), + browserSdk.generateExtrinsicProof( + browserCall, + browserParts.includedInExtrinsic, + browserParts.includedInSignedData, + new Uint8Array(metadata), + specVersion, + specName, + ss58Format, + 9, + 'TAO', + ), + ) + }) + + check('SCALE integer boundaries', () => { + const vectors = [ + ['u8', 255], + ['u16', 65_535], + ['u32', 4_294_967_295], + ['u64', 18_446_744_073_709_551_615n], + ['u128', 340_282_366_920_938_463_463_374_607_431_768_211_455n], + ['i8', -128], + ['i16', -32_768], + ['i32', -2_147_483_648], + ['i64', -9_223_372_036_854_775_808n], + ['i128', -170_141_183_460_469_231_731_687_303_715_884_105_728n], + ] + for (const [typeName, value] of vectors) { + const nativeEncoded = nativeRuntime.encode(typeName, value) + const browserEncoded = browserRuntime.encode(typeName, value) + assertSameBytes(`${typeName} encoded`, nativeEncoded, browserEncoded) + assertSameValue( + `${typeName} decoded`, + nativeRuntime.decode(typeName, nativeEncoded), + browserRuntime.decode(typeName, browserEncoded), + ) + } + }) + + check('enum conversion', () => { + assertSameBytes( + 'immortal era', + nativeRuntime.encodeEra('00'), + browserRuntime.encodeEra('00'), + ) + assertSameBytes( + 'mortal era', + nativeRuntime.encodeEra({ period: 64, current: 12 }), + browserRuntime.encodeEra({ period: 64, current: 12 }), + ) + }) + + check('runtime API and metadata IR', () => { + assertSameValue('runtime API map', nativeRuntime.runtimeApiMap(), browserRuntime.runtimeApiMap()) + assertSameValue('metadata IR', nativeRuntime.metadataIr(), browserRuntime.metadataIr()) + }) + + return failures +} + const nativeClassInterfaces = { NativeKeypair: { instance: 'NativeKeypairHandle' }, NativeRuntime: { instance: 'NativeRuntimeHandle', statics: 'NativeRuntimeConstructor' }, @@ -514,6 +884,7 @@ const nativeClassInterfaces = { NativeLedgerDevice: { instance: 'NativeLedgerHandle', statics: 'NativeLedgerConstructor' }, NativePolicy: { instance: 'NativePolicyHandle', statics: 'NativePolicyConstructor' }, NativeIntentCall: { instance: 'NativeIntentCallHandle', statics: 'NativeIntentCallConstructor' }, + NativeExternalSigningPlan: { instance: 'NativeExternalSigningPlanHandle' }, NativeClient: { instance: 'NativeClientHandle', statics: 'NativeClientConstructor' }, NativeWallet: { instance: 'NativeWalletHandle', statics: 'NativeWalletConstructor' }, NativeExecutor: { instance: 'NativeExecutorHandle', statics: 'NativeExecutorConstructor' }, @@ -624,7 +995,7 @@ const browserWrapperExpected = allowlistedSurface( }, ) -try { +async function main() { const nativeExpected = rustBindingSurface(nativeRustRoot, 'napi') const wasmExpected = rustBindingSurface(wasmRustRoot, 'wasm_bindgen') const nativeGenerated = exportedSurface( @@ -697,6 +1068,7 @@ try { ), ...compareCoreCoverage(nativeExpected, wasmExpected, browserWrapperExpected), ] + failures.push(...(await semanticParityFailures())) if (failures.length > 0) { console.error('Bittensor core binding parity check failed:') @@ -707,7 +1079,9 @@ try { `Binding parity OK: native ${nativeExpected.values.size} Rust exports, ` + `WASM ${wasmExpected.values.size} Rust exports, browser portable wrapper ${browserWrapperExpected.values.size} exports`, ) -} catch (error) { +} + +main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)) process.exit(1) -} +}) diff --git a/sdk/bittensor-ts/src/browser.ts b/sdk/bittensor-ts/src/browser.ts index 5350dd2bdb..4e9cc30e98 100644 --- a/sdk/bittensor-ts/src/browser.ts +++ b/sdk/bittensor-ts/src/browser.ts @@ -2,6 +2,25 @@ import type { ModuleError, ScaleValue, } from './types' +import type { + RustEpochScheduleState, + RustKeypairKind, + RustKeypairMetadata, + RustKeypairPublic, + RustKeypairSignOptions, + RustMapPair, + RustMetadataIr, + RustMultisigAccount, + RustPayloadParts, + RustRuntimeApiMap, + RustRuntimePublic, + RustSignedExtrinsic, + RustSignedExtrinsicParams, + RustStorageChange, + RustStorageEntry, + RustSubstrateKeyType, + RustTransactionParams, +} from './rust-bindings' export const CRYPTO_ED25519 = 0 export const CRYPTO_SR25519 = 1 @@ -10,123 +29,24 @@ export const DEFAULT_SS58_FORMAT = 42 export type BrowserByteLike = Uint8Array | ArrayBuffer | ArrayBufferView export type BrowserMessage = string | BrowserByteLike export type BrowserIntegerLike = number | bigint -export type SubstrateKeyType = 'sr25519' | 'ed25519' -export type KeypairKind = 'Ed25519' | 'Sr25519' | 'PublicOnly' - -export interface KeypairMetadata { - address?: string - name?: string - type?: SubstrateKeyType - [key: string]: unknown -} - -export interface KeypairSignOptions { - /** Prefix the raw signature with its Substrate MultiSignature variant byte. */ - withType?: boolean -} - -export interface BrowserStorageEntry { - pallet: string - name: string - prefix: string - modifier: string - valueType: string - paramTypes: string[] - paramHashers: string[] - defaultBytes: Uint8Array -} - -export interface BrowserStorageChange { - key: string - value?: string | null -} - -export interface BrowserMapPair { - key: K - value: V -} - -export interface BrowserPayloadParts { - includedInExtrinsic: Uint8Array - includedInSignedData: Uint8Array -} - -export interface BrowserTransactionParams { - era: ScaleValue - nonce: BrowserIntegerLike - tip?: BrowserIntegerLike - tipAssetId?: BrowserIntegerLike | null - genesisHash: BrowserByteLike - eraBlockHash: BrowserByteLike - metadataHash?: BrowserByteLike | null -} - -export interface BrowserSignedExtrinsicParams { - era: ScaleValue - nonce: BrowserIntegerLike - tip?: BrowserIntegerLike - tipAssetId?: BrowserIntegerLike | null - metadataHashEnabled?: boolean -} - -export interface BrowserSignedExtrinsic { - bytes: Uint8Array - hash: Uint8Array -} - -export interface BrowserMultisigAccount { - accountId: Uint8Array - sortedSignatories: Uint8Array[] -} - -export interface BrowserEpochScheduleState { - lastEpochBlock: BrowserIntegerLike - pendingEpochAt: BrowserIntegerLike - subnetEpochIndex: BrowserIntegerLike - tempo: number - blocksSinceLastStep: BrowserIntegerLike - currentBlock: BrowserIntegerLike -} - -export type BrowserRuntimeApiMap = Record< - string, - Record< - string, - { - name: string - inputs: Array<[string, string]> - output: string - docs: string[] - } - > -> - -export interface BrowserMetadataIrCall { - name: string - args: string[] - docs: string -} - -export interface BrowserMetadataIrError { - index: number - name: string - docs: string -} - -export interface BrowserMetadataIrPallet { - name: string - index: number - calls: BrowserMetadataIrCall[] - errors: BrowserMetadataIrError[] - storage: string[] - constants: string[] -} - -export interface BrowserMetadataIr { - specVersion: number - pallets: BrowserMetadataIrPallet[] - runtimeApis: Array<{ name: string; methods: string[] }> -} +export type SubstrateKeyType = RustSubstrateKeyType +export type KeypairKind = RustKeypairKind +export interface KeypairMetadata extends RustKeypairMetadata {} +export interface KeypairSignOptions extends RustKeypairSignOptions {} +export interface BrowserStorageEntry extends RustStorageEntry {} +export interface BrowserStorageChange extends RustStorageChange {} +export interface BrowserMapPair extends RustMapPair {} +export interface BrowserPayloadParts extends RustPayloadParts {} +export interface BrowserTransactionParams + extends RustTransactionParams {} +export interface BrowserSignedExtrinsicParams + extends RustSignedExtrinsicParams {} +export interface BrowserSignedExtrinsic extends RustSignedExtrinsic {} +export interface BrowserMultisigAccount extends RustMultisigAccount {} +export interface BrowserEpochScheduleState + extends RustEpochScheduleState {} +export type BrowserRuntimeApiMap = RustRuntimeApiMap +export type BrowserMetadataIr = RustMetadataIr export interface BrowserWasmModule { default?: () => Promise | unknown @@ -411,6 +331,7 @@ function copyStorageEntry(entry: BrowserStorageEntry): BrowserStorageEntry { return { ...entry, paramTypes: entry.paramTypes.slice(), + paramTypeIds: entry.paramTypeIds?.slice() ?? [], paramHashers: entry.paramHashers.slice(), defaultBytes: copyBytes(entry.defaultBytes, 'defaultBytes'), } @@ -440,7 +361,8 @@ function metadataHashArg(value?: BrowserByteLike | null): Uint8Array | undefined return value == null ? undefined : toBytes(value, 'metadataHash') } -export class Runtime { +export class Runtime + implements RustRuntimePublic { private readonly handle: BrowserWasmRuntime constructor( @@ -677,7 +599,8 @@ export class Runtime { } } -export class Keypair { +export class Keypair + implements RustKeypairPublic { private handle!: BrowserWasmKeypair constructor( diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index f4dd7420cf..03bad0f22c 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -2,9 +2,9 @@ import { blake2_256, generateExtrinsicProof, hexToBytes, metadataDigest } from ' import { CRYPTO_ED25519, CRYPTO_SR25519, Keypair, publicKeyFromSs58, ss58FromPublic } from './keys' import { LedgerDevice } from './ledger' import { Runtime, decodeOptionalOpaqueMetadata, eraBirth, type RuntimeSignerPayload } from './runtime' -import { Executor, IntentCall, NativeChainClient, Policy, RustWallet, isIntentCall, rawCall, type PolicyOptions, type SignerRoleLike } from './transaction' -import { toBigInt, toBuffer } from './wire' -import type { NativeTxOutcome } from './native' +import { IntentCall, NativeChainClient, Policy, isIntentCall, rawCall, type PolicyOptions, type SignerRoleLike } from './transaction' +import { WIRE_TAG, fromWire, toBigInt, toBuffer } from './wire' +import type { NativeChainInfo, NativeExternalSigningOptions, NativeExternalSigningPlanHandle, NativeTxOutcome } from './native' import { Balance, UnitMismatchError, @@ -20,15 +20,13 @@ import { import type { ByteLike, ChainInfo, ScaleValue, SignedExtrinsic, StorageEntry, TransactionParams } from './types' export const SS58_FORMAT = 42 -export const DEFAULT_ERA_PERIOD = 128 +export const DEFAULT_ERA_PERIOD = 64 export const DEFAULT_HEAD_RUNTIME_TTL_MS = 12_000 export const DEFAULT_HISTORICAL_RUNTIME_CACHE_SIZE = 64 export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 export const DEFAULT_MAX_REQUEST_RETRIES = 2 export const DEFAULT_RETRY_BACKOFF_MS = 250 export const DEFAULT_MAX_RETRY_BACKOFF_MS = 5_000 -export const DEFAULT_NONCE_RECONCILE_BLOCKS = 8 -export const DEFAULT_MAX_NONCE_STATUS_HISTORY = 256 export const DEFAULT_ENDPOINT_VALIDATION_TTL_MS = 60_000 export const DEFAULT_MAX_SUBSCRIPTION_QUEUE = 1024 export const DEFAULT_MAX_WS_MESSAGE_BYTES = 16 * 1024 * 1024 @@ -59,7 +57,6 @@ const IDEMPOTENT_RPC_METHODS = new Set([ 'system_name', 'system_properties', 'system_version', - 'author_pendingExtrinsics', ]) export const NETWORKS = Object.freeze({ finney: 'wss://entrypoint-finney.opentensor.ai:443', @@ -177,6 +174,7 @@ export interface SignerPayloadContext { includedInExtrinsic?: Buffer includedInSignedData?: Buffer chainInfo?: ChainInfo + signerPayload?: ExtensionSignPayloadRequest } export interface ExtensionSignRawRequest { @@ -323,6 +321,13 @@ interface ResolvedSigner { requiresMetadataProof: boolean } +interface NativeSigningPlan { + native: NativeChainClient + runtime: Runtime + resolved: ResolvedSigner + plan: NativeExternalSigningPlanHandle +} + interface RuntimeVersionInfo { specVersion: number transactionVersion: number @@ -344,50 +349,16 @@ interface SigningSnapshot { genesisHash: string } -type NonceStatus = 'reserved' | 'submitted' | 'confirmed' | 'failed' | 'reusable' | 'ambiguous' - -interface AmbiguousNonce { - nonce: number - extrinsicHash?: string - sinceMs: number -} - -interface NonceAccountState { - next?: number - reusable: number[] - statuses: Map - ambiguous?: AmbiguousNonce - queue: Promise -} - -interface NonceReservation { - address: string - nonce: number -} - -interface NonceTrackingInfo { - reservation?: NonceReservation - invalidateAddress?: string -} - -type SubmittedExtrinsicLocation = 'pool' | 'block' | null - -const nativeClientAccess = new WeakMap NativeChainClient | undefined>() +const nativeClientAccess = new WeakMap Promise>() -function nativeRustClient(client: Client): NativeChainClient | undefined { - return nativeClientAccess.get(client)?.() +function nativeRustClient(client: Client): Promise { + return nativeClientAccess.get(client)?.() ?? Promise.resolve(undefined) } interface SubscriptionOptions extends RpcRequestOptions { resubscribe?: boolean } -const MANAGED_NONCE = Symbol('managedNonce') - -type ManagedSignedExtrinsicResult = SignedExtrinsicResult & { - [MANAGED_NONCE]?: NonceReservation -} - interface NormalizedSignature { signature: Buffer cryptoType: number @@ -1057,10 +1028,10 @@ export class Client { private runtimesBySpecVersion = new Map() private historicalRuntimeCache = new Map() private genesis?: string - private nonceAccounts = new Map() private readonly nativeEligible: boolean private readonly validateDescriptorsOnLoad: boolean private nativeClient?: NativeChainClient + private nativeClientPromise?: Promise private nativeUnavailable = false constructor(network: string = 'finney', options: ClientOptions = {}) { @@ -1113,7 +1084,7 @@ export class Client { } async connect(): Promise { - if (this.tryNativeClient() == null) await this.runtimeAt() + if (await this.tryNativeClient() == null) await this.runtimeAt() return this } @@ -1121,19 +1092,25 @@ export class Client { this.transport.close() } - private tryNativeClient(): NativeChainClient | undefined { + private async tryNativeClient(): Promise { if (!this.nativeEligible) return undefined if (this.nativeUnavailable) return undefined if (this.nativeClient != null) return this.nativeClient - try { - const client = NativeChainClient.connect(this.endpoint) - this.nativeClient = client - this.genesis = hex(client.genesisHash) - return client - } catch { - this.nativeUnavailable = true - return undefined + if (this.nativeClientPromise == null) { + this.nativeClientPromise = NativeChainClient.connect(this.endpoint).then( + (client) => { + this.nativeClient = client + this.genesis = hex(client.genesisHash) + return client + }, + () => { + this.nativeUnavailable = true + this.nativeClientPromise = undefined + return undefined + }, + ) } + return this.nativeClientPromise } async block(): Promise { @@ -1149,15 +1126,15 @@ export class Client { } async blockNumber(blockHash?: string | null): Promise { - const native = this.tryNativeClient() - if (native != null) return native.blockNumber(blockHash) + const native = await this.tryNativeClient() + if (native != null) return await native.blockNumber(blockHash) const header = await this.rpc('chain_getHeader', blockHash == null ? [] : [blockHash]) return headerNumber(header) } async blockHash(block?: number | null): Promise { - const native = this.tryNativeClient() - if (native != null) return native.blockHash(block == null ? undefined : block) + const native = await this.tryNativeClient() + if (native != null) return await native.blockHash(block == null ? undefined : block) return String(await this.rpc('chain_getBlockHash', block == null ? [] : [block])) } @@ -1170,13 +1147,13 @@ export class Client { } async finalizedHead(): Promise { - const native = this.tryNativeClient() - if (native != null) return native.finalizedHead() + const native = await this.tryNativeClient() + if (native != null) return await native.finalizedHead() return String(await this.rpc('chain_getFinalizedHead')) } async genesisHash(): Promise { - const native = this.tryNativeClient() + const native = await this.tryNativeClient() if (native != null) { this.genesis = hex(native.genesisHash) return this.genesis @@ -1363,8 +1340,8 @@ export class Client { const [moduleName, itemName, itemParams, blockRef] = normalizeStorageArgs(pallet, storageFunction, paramsOrBlock, block) const blockHash = await this.resolveReadBlockHash(blockRef) - const native = this.tryNativeClient() - if (native != null) return native.query(moduleName, itemName, itemParams, blockHash) as T + const native = await this.tryNativeClient() + if (native != null) return await native.query(moduleName, itemName, itemParams, blockHash) as T const runtime = await this.runtimeAt(blockHash) const key = runtime.storageKey(moduleName, itemName, itemParams) const raw = await this.rpc('state_getStorage', [hex(key), blockHash]) @@ -1382,8 +1359,8 @@ export class Client { normalizeBatchArgs(pallet, storageFunction, paramSetsOrBlock, block) if (sets.length === 0) return [] const blockHash = await this.resolveReadBlockHash(blockRef) - const native = this.tryNativeClient() - if (native != null) return native.queryBatch(moduleName, itemName, sets, blockHash) as Array + const native = await this.tryNativeClient() + if (native != null) return await native.queryBatch(moduleName, itemName, sets, blockHash) as Array const runtime = await this.runtimeAt(blockHash) const keys = runtime.storageKeyBatch(moduleName, itemName, sets) const raw = await this.rpc('state_queryStorageAt', [keys.map(hex), blockHash]) @@ -1407,8 +1384,8 @@ export class Client { normalizeStorageArgs(pallet, storageFunction, paramsOrBlock, block) const blockHash = await this.resolveReadBlockHash(blockRef) if (pageSizeOrOptions === 512) { - const native = this.tryNativeClient() - if (native != null) return native.queryMap(moduleName, itemName, itemParams, blockHash) as Array<[K, V]> + const native = await this.tryNativeClient() + if (native != null) return await native.queryMap(moduleName, itemName, itemParams, blockHash) as Array<[K, V]> } const runtime = await this.runtimeAt(blockHash) const prefix = runtime.storageKey(moduleName, itemName, itemParams) @@ -1496,8 +1473,8 @@ export class Client { ): Promise { const [apiName, methodName, callParams, blockRef] = normalizeRuntimeArgs(api, method, paramsOrBlock, block) const blockHash = await this.resolveReadBlockHash(blockRef) - const native = this.tryNativeClient() - if (native != null) return native.runtimeCall(apiName, methodName, callParams, blockHash) as T + const native = await this.tryNativeClient() + if (native != null) return await native.runtimeCall(apiName, methodName, callParams, blockHash) as T const runtime = await this.runtimeAt(blockHash) const info = runtime.runtimeApis()[apiName]?.[methodName] if (info == null) throw new ChainError(`runtime API ${apiName}.${methodName} not found`) @@ -1548,24 +1525,24 @@ export class Client { ): Promise { const [moduleName, constantName] = typeof pallet === 'string' ? [pallet, name as string] : pallet if (block == null) { - const native = this.tryNativeClient() + const native = await this.tryNativeClient() if (native != null) return native.constant(moduleName, constantName) as T } return (await this.runtimeAt(block)).constant(moduleName, constantName) } - decodeScale( + async decodeScale( typeString: string, data: ByteLike | string, block?: number | string | null, ): Promise { if (block == null) { - const native = this.tryNativeClient() + const native = await this.tryNativeClient() if (native != null) { - return Promise.resolve(native.decodeScale( + return native.decodeScale( typeString, typeof data === 'string' ? hexToBuffer(data) : toBuffer(data, 'data'), - ) as T) + ) as T } } return this.runtimeAt(block).then((runtime) => @@ -1581,10 +1558,10 @@ export class Client { return this.decodeScale(typeString, data, block) } - composeCall(pallet: string, fn: string, params: ScaleValue = {}, block?: number | string | null): Promise { + async composeCall(pallet: string, fn: string, params: ScaleValue = {}, block?: number | string | null): Promise { if (block == null) { - const native = this.tryNativeClient() - if (native != null) return Promise.resolve(native.composeCall(pallet, fn, params)) + const native = await this.tryNativeClient() + if (native != null) return await native.composeCall(pallet, fn, params) } return this.runtimeAt(block).then((runtime) => runtime.composeCall(pallet, fn, params)) } @@ -1681,45 +1658,28 @@ export class Client { } async signExtrinsic(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { - return this.signExtrinsicWithNonce(call, signer, options, false) - } - - private async signExtrinsicWithNonce( - call: CallLike, - signer: SignerLike, - options: SubmitOptions, - manageNonce: boolean, - ): Promise { - if (!manageNonce) { - const nativeSigned = this.signExtrinsicWithNative(call, signer, options) - if (nativeSigned != null) return nativeSigned - } - return this.signExtrinsicWithSnapshot(call, signer, options, manageNonce, await this.signingSnapshot()) + this.assertExplicitNonce(options, 'signExtrinsic') + const nativeSigned = await this.signExtrinsicWithNativePlan(call, signer, options) + if (nativeSigned != null) return nativeSigned + return this.signExtrinsicWithSnapshot(call, signer, options, await this.signingSnapshot()) } - private signExtrinsicWithNative( + private async signExtrinsicWithNativePlan( call: CallLike, signer: SignerLike, options: SubmitOptions, - ): ManagedSignedExtrinsicResult | undefined { - if (!(signer instanceof Keypair) || !nativeSigningOptionsSupported(options)) return undefined - const native = this.tryNativeClient() + ): Promise { + const native = await this.tryNativeClient() if (native == null) return undefined - const callData = this.composeNativeCallData(call, options, native) - const nonce = options.nonce ?? native.accountNextIndex(signer.ss58Address) - const period = options.period === undefined ? DEFAULT_ERA_PERIOD : options.period - const signed = native.signExtrinsic( - callData, - signer, - toBigInt(nonce, 'nonce'), - period == null ? null : toBigInt(period, 'period'), - ) + const planned = await this.nativeSigningPlan(call, signer, options, native) + const signature = await this.signRustPlan(planned) + const signed = await native.assembleExternal(planned.plan, signature.signature, signature.cryptoType) return { bytes: Buffer.from(signed.bytes), hash: hexToBuffer(signed.hash), hex: hex(signed.bytes), - signerAddress: signer.ss58Address, - nonce, + signerAddress: planned.resolved.ss58Address, + nonce: parseNonce(planned.plan.nonce, 'native signing plan nonce'), } } @@ -1727,142 +1687,119 @@ export class Client { call: CallLike, signer: SignerLike, options: SubmitOptions, - manageNonce: boolean, snapshot: SigningSnapshot, - ): Promise { - let resolved: ResolvedSigner | undefined - let reservation: NonceReservation | undefined - try { - const { runtime } = snapshot - const prepared = this.prepareCallForSigning(call, runtime, options) - const callData = prepared.callData - resolved = await this.resolveSigner(signer, runtime) - reservation = manageNonce && options.nonce == null - ? await this.reserveNonce(resolved.ss58Address) - : undefined - const nonce = options.nonce ?? reservation?.nonce ?? await this.peekNextIndex(resolved.ss58Address) - const period = options.period === undefined ? DEFAULT_ERA_PERIOD : options.period - const { era, eraBlockHash } = await this.normalizeEra(period, snapshot) - const tip = taoTransactionAmountRao(options.tip ?? 0n, 'tip') - const tipAssetId = options.tipAssetId == null ? null : assetIdValue(options.tipAssetId, 'tipAssetId') - const metadataHashOptionProvided = hasOwn(options, 'metadataHash') - const supportsMetadataHash = runtimeSupportsMetadataHash(runtime) - const defaultMetadataHash = supportsMetadataHash || resolved.requiresMetadataProof - if (metadataHashOptionProvided && options.metadataHash == null && defaultMetadataHash) { - throw new ChainError( - supportsMetadataHash - ? 'metadataHash cannot be disabled when the runtime declares CheckMetadataHash' - : 'metadataHash cannot be disabled for a signer that requires metadata proof', + ): Promise { + const { runtime } = snapshot + const prepared = this.prepareCallForSigning(call, runtime, options) + const callData = prepared.callData + const resolved = await this.resolveSigner(signer, runtime) + const nonce = this.explicitNonce(options, 'signExtrinsic') + const period = options.period === undefined ? DEFAULT_ERA_PERIOD : options.period + const { era, eraBlockHash } = await this.normalizeEra(period, snapshot) + const tip = taoTransactionAmountRao(options.tip ?? 0n, 'tip') + const tipAssetId = options.tipAssetId == null ? null : assetIdValue(options.tipAssetId, 'tipAssetId') + const metadataHashOptionProvided = hasOwn(options, 'metadataHash') + const supportsMetadataHash = runtimeSupportsMetadataHash(runtime) + const defaultMetadataHash = supportsMetadataHash || resolved.requiresMetadataProof + if (metadataHashOptionProvided && options.metadataHash == null && defaultMetadataHash) { + throw new ChainError( + supportsMetadataHash + ? 'metadataHash cannot be disabled when the runtime declares CheckMetadataHash' + : 'metadataHash cannot be disabled for a signer that requires metadata proof', + ) + } + const chainInfo = resolved.requiresMetadataProof || (!metadataHashOptionProvided && defaultMetadataHash) + ? await this.chainInfo(runtime, snapshot.blockHash) + : undefined + const metadataHash = + metadataHashOptionProvided + ? options.metadataHash == null + ? null + : toBuffer(options.metadataHash, 'metadataHash') + : defaultMetadataHash + ? metadataDigest(runtime.metadataBytes, chainInfo!) + : null + const txParams = { + era, + nonce, + tip, + tipAssetId, + genesisHash: hexToBuffer(snapshot.genesisHash), + eraBlockHash: hexToBuffer(eraBlockHash), + metadataHash, + } + const proofParts = resolved.requiresMetadataProof + ? runtime.signaturePayloadParts(txParams) + : undefined + const metadataProof = proofParts == null + ? undefined + : generateExtrinsicProof( + callData, + proofParts.includedInExtrinsic, + proofParts.includedInSignedData, + runtime.metadataBytes, + chainInfo!, ) - } - const chainInfo = resolved.requiresMetadataProof || (!metadataHashOptionProvided && defaultMetadataHash) - ? await this.chainInfo(runtime, snapshot.blockHash) - : undefined - const metadataHash = - metadataHashOptionProvided - ? options.metadataHash == null - ? null - : toBuffer(options.metadataHash, 'metadataHash') - : defaultMetadataHash - ? metadataDigest(runtime.metadataBytes, chainInfo!) - : null - const txParams = { - era, - nonce, - tip, - tipAssetId, - genesisHash: hexToBuffer(snapshot.genesisHash), - eraBlockHash: hexToBuffer(eraBlockHash), - metadataHash, - } - const proofParts = resolved.requiresMetadataProof - ? runtime.signaturePayloadParts(txParams) - : undefined - const metadataProof = proofParts == null - ? undefined - : generateExtrinsicProof( - callData, - proofParts.includedInExtrinsic, - proofParts.includedInSignedData, - runtime.metadataBytes, - chainInfo!, - ) - const payload = runtime.signaturePayload(callData, txParams) - const context: SignerPayloadContext = { - client: this, - runtime, - address: resolved.ss58Address, - publicKey: resolved.publicKey, - cryptoType: resolved.cryptoType, - callData, - payload, - txParams, - metadataHash, - metadataProof, - proof: metadataProof, - includedInExtrinsic: proofParts?.includedInExtrinsic, - includedInSignedData: proofParts?.includedInSignedData, - chainInfo, - } - const signedBySigner = await this.signWithSigner(resolved.signer, payload, context) - const signed = runtime.encodeSignedExtrinsic(callData, resolved.publicKey, signedBySigner.signature, signedBySigner.cryptoType, { - era, - nonce, - tip, - tipAssetId, - metadataHashEnabled: metadataHash != null, - }) - const result: ManagedSignedExtrinsicResult = { - ...signed, - hex: hex(signed.bytes), - signerAddress: resolved.ss58Address, - nonce, - } - if (reservation != null) result[MANAGED_NONCE] = reservation - return result - } catch (error) { - if (reservation != null) await this.failNonce(reservation, true) - throw error + const payload = runtime.signaturePayload(callData, txParams) + const context: SignerPayloadContext = { + client: this, + runtime, + address: resolved.ss58Address, + publicKey: resolved.publicKey, + cryptoType: resolved.cryptoType, + callData, + payload, + txParams, + metadataHash, + metadataProof, + proof: metadataProof, + includedInExtrinsic: proofParts?.includedInExtrinsic, + includedInSignedData: proofParts?.includedInSignedData, + chainInfo, + } + const signedBySigner = await this.signWithSigner(resolved.signer, payload, context) + const signed = runtime.encodeSignedExtrinsic(callData, resolved.publicKey, signedBySigner.signature, signedBySigner.cryptoType, { + era, + nonce, + tip, + tipAssetId, + metadataHashEnabled: metadataHash != null, + }) + return { + ...signed, + hex: hex(signed.bytes), + signerAddress: resolved.ss58Address, + nonce, } } async submit(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { - const nativeResult = this.submitWithNative(call, signer, options) + const nativeResult = await this.submitWithNativePlan(call, signer, options) if (nativeResult != null) return nativeResult - const signed = await this.signExtrinsicWithNonce(call, signer, options, true) + this.assertExplicitNonce(options, 'submit') + const signed = await this.signExtrinsic(call, signer, options) return this.submitSigned(signed, signed.signerAddress, options) } - private submitWithNative( + private async submitWithNativePlan( call: CallLike, signer: SignerLike, options: SubmitOptions, - ): Promise | undefined { - if (!(signer instanceof Keypair) || !nativeSubmissionOptionsSupported(options)) return undefined - if (!options.waitForInclusion && !options.waitForFinalization) return undefined - const native = this.tryNativeClient() + ): Promise { + const native = await this.tryNativeClient() if (native == null) return undefined - return Promise.resolve().then(() => { - if (isIntentCall(call) && options.nonce == null && options.period === undefined) { - const policy = policyForSubmitOptions(options) - const executor = new Executor(native, policy) - return nativeOutcomeToExtrinsicResult( - executor.execute(call, RustWallet.fromKeypair(signer), options.waitForFinalization === true), - options.waitForFinalization === true, - ) - } - const callData = this.composeNativeCallData(call, options, native) - return nativeOutcomeToExtrinsicResult( - native.submit( - callData, - signer, - options.nonce == null ? null : toBigInt(options.nonce, 'nonce'), - options.period === undefined || options.period == null ? null : toBigInt(options.period, 'period'), - options.waitForFinalization === true, - ), + assertNativeSubmitOptions(options) + const planned = await this.nativeSigningPlan(call, signer, options, native) + const signature = await this.signRustPlan(planned) + return nativeOutcomeToExtrinsicResult( + await native.submitExternal( + planned.plan, + signature.signature, options.waitForFinalization === true, - ) - }) + signature.cryptoType, + ), + options.waitForFinalization === true, + ) } async submitSigned( @@ -1870,12 +1807,11 @@ export class Client { signerAddress?: string, options: SubmitOptions = {}, ): Promise { + void signerAddress const bytes = Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array ? toBuffer(extrinsic, 'extrinsic') : toBuffer(extrinsic.bytes, 'extrinsic.bytes') const extrinsicHash = hex(blake2_256(bytes)) - const tracking = await this.signedExtrinsicNonceTracking(bytes, extrinsic, signerAddress) - const reservation = tracking.reservation if (options.waitForInclusion || options.waitForFinalization) { const watcher = await this.watchSigned(extrinsic, { signerAddress, @@ -1894,27 +1830,19 @@ export class Client { retryForever: false, })) } catch (error) { - if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) - else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) throw error } let returnedHash: string try { returnedHash = normalizeHash32(hash, 'author_submitExtrinsic hash') } catch (error) { - if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) - else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) throw error } if (!sameHex(returnedHash, extrinsicHash)) { - if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) - else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) throw new ChainError( `author_submitExtrinsic returned hash ${returnedHash}, expected ${extrinsicHash}`, ) } - if (reservation != null) await this.submitNonce(reservation) - else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) return { status: 'submitted', message: 'Submitted', extrinsicHash, events: [] } } @@ -1922,12 +1850,11 @@ export class Client { extrinsic: SignedExtrinsicResult | SignedExtrinsic | ByteLike, options: Pick & { signerAddress?: string } = {}, ): Promise { + void options.signerAddress const bytes = Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array ? toBuffer(extrinsic, 'extrinsic') : toBuffer(extrinsic.bytes, 'extrinsic.bytes') const extrinsicHash = hex(blake2_256(bytes)) - const tracking = await this.signedExtrinsicNonceTracking(bytes, extrinsic, options.signerAddress) - const reservation = tracking.reservation let subscription: AsyncIterable & { unsubscribe(): Promise } try { subscription = await this.transport.subscribe( @@ -1942,11 +1869,7 @@ export class Client { retryForever: false, }, ) - if (reservation != null) await this.submitNonce(reservation) - else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) } catch (error) { - if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) - else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) throw error } const result = this.resolveWatchedExtrinsic( @@ -1957,17 +1880,6 @@ export class Client { timeoutMs: options.timeoutMs, signal: options.signal, }, - ).then( - async (value) => { - if (reservation != null) await this.confirmNonce(reservation) - else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) - return value - }, - async (error) => { - if (reservation != null) await this.reconcileNonceReservation(reservation, extrinsicHash) - else if (tracking.invalidateAddress != null) this.clearNonce(tracking.invalidateAddress) - throw error - }, ) return { extrinsicHash, @@ -1984,217 +1896,17 @@ export class Client { return this.peekNextIndex(address) } - clearNonce(address: string): void { - this.nonceAccounts.delete(address) + private assertExplicitNonce(options: SubmitOptions, operation: string): void { + this.explicitNonce(options, operation) } - private async signedExtrinsicNonceTracking( - bytes: Buffer, - extrinsic: unknown, - signerAddress?: string, - ): Promise { - const decoded = await this.decodeExtrinsicNonceReservation(bytes).catch(() => undefined) - if (decoded != null) return { reservation: decoded } - - const managed = managedNonceReservation(extrinsic) - if (managed != null) return { reservation: managed } - - const address = stringValue(signerAddress) - return address == null ? {} : { invalidateAddress: address } - } - - private async decodeExtrinsicNonceReservation(bytes: Buffer): Promise { - const runtime = await this.runtimeAt() - const decoded = runtime.decodeExtrinsic(bytes, false) as unknown - const fields = decoded == null || typeof decoded !== 'object' - ? {} - : decoded as Record - const address = extrinsicSignerAddress(fields.address, runtime.ss58Format) - const nonce = safeNonceNumber(fields.nonce) - return address == null || nonce == null ? undefined : { address, nonce } - } - - private async reserveNonce(address: string): Promise { - return this.withNonceAccount(address, async (state) => { - if (state.next == null) { - const chainNext = await this.peekNextIndex(address) - await this.resolveAmbiguousNonce(address, state, chainNext) - advanceNonceStateToChainNext(state, chainNext) - if (state.next == null) state.next = chainNext - } - state.reusable.sort((left, right) => left - right) - let nonce = state.next - if (state.reusable.length > 0 && state.reusable[0] <= state.next) { - nonce = state.reusable.shift() as number - } - while (state.statuses.has(nonce) && state.statuses.get(nonce) !== 'reusable') nonce += 1 - if (nonce >= state.next) state.next = nonce + 1 - state.statuses.set(nonce, 'reserved') - return { address, nonce } - }) - } - - private async submitNonce(reservation: NonceReservation): Promise { - await this.withNonceAccount(reservation.address, (state) => { - state.statuses.set(reservation.nonce, 'submitted') - pruneNonceStatuses(state) - }) - } - - private async confirmNonce(reservation: NonceReservation): Promise { - await this.withNonceAccount(reservation.address, (state) => { - state.statuses.set(reservation.nonce, 'confirmed') - pruneNonceStatuses(state) - }) - } - - private async failNonce(reservation: NonceReservation, reusable: boolean): Promise { - await this.withNonceAccount(reservation.address, (state) => { - const current = state.statuses.get(reservation.nonce) - if (current === 'confirmed') return - state.statuses.set(reservation.nonce, reusable ? 'reusable' : 'failed') - if (reusable && !state.reusable.includes(reservation.nonce)) { - state.reusable.push(reservation.nonce) - state.reusable.sort((left, right) => left - right) - } else if (!reusable) { - state.reusable = state.reusable.filter((nonce) => nonce !== reservation.nonce) - } - pruneNonceStatuses(state) - }) - } - - private async quarantineNonce(reservation: NonceReservation, extrinsicHash?: string): Promise { - await this.withNonceAccount(reservation.address, (state) => { - quarantineNonceState(state, reservation.nonce, extrinsicHash) - }) - } - - private async reconcileNonceReservation( - reservation: NonceReservation, - extrinsicHash?: string, - ): Promise { - const [locationResult, chainNextResult] = await Promise.all([ - extrinsicHash == null - ? Promise.resolve<{ ok: true; location: SubmittedExtrinsicLocation }>({ - ok: true, - location: null, - }) - : this.submittedExtrinsicLocation(extrinsicHash).then( - (location) => ({ ok: true as const, location }), - (error) => ({ ok: false as const, error }), - ), - this.peekNextIndex(reservation.address).then( - (nonce) => ({ ok: true as const, nonce }), - (error) => ({ ok: false as const, error }), - ), - ]) - await this.withNonceAccount(reservation.address, (state) => { - const location = locationResult.ok ? locationResult.location : undefined - const chainNext = chainNextResult.ok ? chainNextResult.nonce : undefined - const submitted = location != null || (chainNext != null && chainNext > reservation.nonce) - if (!submitted) { - quarantineNonceState(state, reservation.nonce, extrinsicHash) - return - } - - if (chainNext != null) advanceNonceStateToChainNext(state, chainNext) - else if (state.next == null || state.next <= reservation.nonce) state.next = reservation.nonce + 1 - if (state.ambiguous != null && reservation.nonce >= state.ambiguous.nonce) { - state.ambiguous = undefined - } - if (state.next == null || state.next <= reservation.nonce) state.next = reservation.nonce + 1 - - const minimumReusableNonce = Math.max(state.next, reservation.nonce + 1) - state.reusable = state.reusable.filter( - (nonce) => nonce >= minimumReusableNonce && nonce !== reservation.nonce, + private explicitNonce(options: SubmitOptions, operation: string): number { + if (options.nonce == null) { + throw new ChainError( + `${operation} requires options.nonce; use submit() with a native Keypair for Rust-managed nonce submission or pass an explicit nonce for low-level signing`, ) - for (const [nonce, status] of state.statuses) { - if (status === 'reusable' || nonce <= reservation.nonce) { - state.statuses.delete(nonce) - } - } - - state.statuses.set(reservation.nonce, location === 'block' ? 'confirmed' : 'submitted') - while (state.statuses.has(state.next) && state.statuses.get(state.next) !== 'reusable') { - state.next += 1 - } - pruneNonceStatuses(state) - }) - } - - private async resolveAmbiguousNonce( - address: string, - state: NonceAccountState, - chainNext: number, - ): Promise { - const ambiguous = state.ambiguous - if (ambiguous == null) return - - if (chainNext > ambiguous.nonce) { - clearAmbiguousNonceState(state, chainNext) - return } - - if (ambiguous.extrinsicHash != null) { - const location = await this.submittedExtrinsicLocation(ambiguous.extrinsicHash).catch(() => undefined) - if (location != null) { - clearAmbiguousNonceState(state, Math.max(chainNext, ambiguous.nonce + 1)) - state.statuses.set(ambiguous.nonce, location === 'block' ? 'confirmed' : 'submitted') - pruneNonceStatuses(state) - return - } - } - - throw new ChainError( - `nonce ${ambiguous.nonce} for ${address} is ambiguous after a failed submission; automatic submissions are paused until the chain nonce advances, the original extrinsic is located, or clearNonce(address) is called`, - ) - } - - private async submittedExtrinsicLocation(extrinsicHash: string): Promise { - const normalized = extrinsicHash.toLowerCase() - if (await this.pendingExtrinsicsContain(normalized)) return 'pool' - return await this.recentBlocksContainExtrinsic(normalized) ? 'block' : null - } - - private async pendingExtrinsicsContain(extrinsicHash: string): Promise { - const pending = await this.rpc('author_pendingExtrinsics') - if (!Array.isArray(pending)) return false - return pending.some((extrinsic) => hashExtrinsicHex(extrinsic) === extrinsicHash) - } - - private async recentBlocksContainExtrinsic(extrinsicHash: string): Promise { - const current = await this.blockNumber() - const earliest = Math.max(0, current - DEFAULT_NONCE_RECONCILE_BLOCKS + 1) - for (let number = current; number >= earliest; number -= 1) { - const blockHash = await this.blockHash(number) - const raw = await this.rpc('chain_getBlock', [blockHash]) - const extrinsics = (raw as { block?: { extrinsics?: unknown[] } })?.block?.extrinsics ?? [] - if (extrinsics.some((extrinsic) => hashExtrinsicHex(extrinsic) === extrinsicHash)) return true - } - return false - } - - private withNonceAccount( - address: string, - operation: (state: NonceAccountState) => T | Promise, - ): Promise { - const state = this.nonceAccount(address) - const run = state.queue.then(() => operation(state), () => operation(state)) - state.queue = run.then(() => undefined, () => undefined) - return run - } - - private nonceAccount(address: string): NonceAccountState { - let state = this.nonceAccounts.get(address) - if (state == null) { - state = { - reusable: [], - statuses: new Map(), - queue: Promise.resolve(), - } - this.nonceAccounts.set(address, state) - } - return state + return parseNonce(options.nonce, 'nonce') } async estimateFee( @@ -2202,12 +1914,13 @@ export class Client { signer: SignerLike, options: Pick = {}, ): Promise { - if (signer instanceof Keypair) { - const native = this.tryNativeClient() - if (native != null) { - const callData = this.composeNativeCallData(call, options, native) - return Balance.fromRao(native.estimateFee(callData, signer)) - } + const native = await this.tryNativeClient() + if (native != null) { + const planned = await this.nativeSigningPlan(call, signer, options as SubmitOptions, native) + const feeRao = planned.plan.feeRao == null + ? await native.estimateFeeExternal(planned.plan) + : BigInt(planned.plan.feeRao) + return Balance.fromRao(feeRao) } const snapshot = await this.signingSnapshot() const { runtime } = snapshot @@ -2216,7 +1929,7 @@ export class Client { ...options, nonce: await this.peekNextIndex(account.ss58Address), period: null, - }, false, snapshot) + }, snapshot) const queryInfo = runtime.runtimeApis().TransactionPaymentApi?.query_info if (queryInfo == null) { throw new ChainError('TransactionPaymentApi.query_info metadata is unavailable') @@ -2410,6 +2123,65 @@ export class Client { return runtime.composeCall(pallet, fn, params) } + private async nativeSigningPlan( + call: CallLike, + signer: SignerLike, + options: SubmitOptions, + native: NativeChainClient, + ): Promise { + const runtime = await this.headRuntime() + const resolved = await this.resolveSigner(signer, runtime) + const planOptions = nativeExternalSigningOptions(options) + let plan: NativeExternalSigningPlanHandle + if (isIntentCall(call)) { + plan = await native.externalSigningPlanForIntent( + call, + resolved.ss58Address, + resolved.publicKey, + resolved.cryptoType, + resolved.requiresMetadataProof, + policyForSubmitOptions(options), + planOptions, + ) + } else if (Buffer.isBuffer(call) || call instanceof Uint8Array) { + assertRawBytesAllowed(options) + plan = await native.externalSigningPlan( + toBuffer(call, 'call'), + resolved.ss58Address, + resolved.publicKey, + resolved.cryptoType, + resolved.requiresMetadataProof, + planOptions, + ) + } else { + if (!rawCallsAllowed(options)) { + throw new ChainError( + 'raw metadata calls require explicit raw-call permission; use an IntentCall trusted constructor or pass allowRawCall: true', + ) + } + const [pallet, fn, params] = normalizeCall(call) + const intent = rawCall(pallet, fn, params, { + op: `${pallet}.${fn}`, + signerRole: options.rawSignerRole ?? 'coldkey', + }) + plan = await native.externalSigningPlanForIntent( + intent, + resolved.ss58Address, + resolved.publicKey, + resolved.cryptoType, + resolved.requiresMetadataProof, + policyForSubmitOptions(options), + planOptions, + ) + } + return { native, runtime, resolved, plan } + } + + private async signRustPlan(planned: NativeSigningPlan): Promise { + const context = nativePlanSignerContext(this, planned.runtime, planned.resolved, planned.plan) + return this.signWithSigner(planned.resolved.signer, context.payload, context) + } + private prepareCallForSigning( call: CallLike, runtime: Runtime, @@ -2437,33 +2209,6 @@ export class Client { return { callData: this.callDataWithRuntime(intent, runtime), intent } } - private composeNativeCallData( - call: CallLike, - options: Pick, - native: NativeChainClient, - ): Buffer { - if (isIntentCall(call)) { - this.enforceIntentPolicy(call, options as SubmitOptions) - return native.composeIntent(call) - } - if (Buffer.isBuffer(call) || call instanceof Uint8Array) { - assertRawBytesAllowed(options as SubmitOptions) - return toBuffer(call, 'call') - } - if (!rawCallsAllowed(options as SubmitOptions)) { - throw new ChainError( - 'raw metadata calls require explicit raw-call permission; use an IntentCall trusted constructor or pass allowRawCall: true', - ) - } - const [pallet, fn, params] = normalizeCall(call) - const intent = rawCall(pallet, fn, params, { - op: `${pallet}.${fn}`, - signerRole: options.rawSignerRole ?? 'coldkey', - }) - this.enforceIntentPolicy(intent, options as SubmitOptions) - return native.composeIntent(intent) - } - private enforceIntentPolicy(intent: IntentCall, options: SubmitOptions): void { const policy = policyForSubmitOptions(options) const violations = policy.check(intent) @@ -2763,9 +2508,9 @@ export class SubnetsNamespace { async subnet(netuid: number, block?: number | string | null): Promise { const blockHash = await this.client.resolveReadBlockHash(block) - const native = nativeRustClient(this.client) + const native = await nativeRustClient(this.client) if (native != null) { - const subnet = native.subnets(blockHash).find((item) => item.netuid === netuid) + const subnet = (await native.subnets(blockHash)).find((item) => item.netuid === netuid) if (subnet != null) { return { netuid: subnet.netuid, @@ -2789,9 +2534,9 @@ export class SubnetsNamespace { async all(block?: number | string | null): Promise { const blockHash = await this.client.resolveReadBlockHash(block) - const native = nativeRustClient(this.client) + const native = await nativeRustClient(this.client) if (native != null) { - return native.subnets(blockHash).map((subnet) => ({ + return (await native.subnets(blockHash)).map((subnet) => ({ netuid: subnet.netuid, tempo: subnet.tempo, burn: Balance.fromRao(subnet.burnRao), @@ -2831,18 +2576,20 @@ export class SubnetsNamespace { return this.exists(netuid, block) } - metagraph(netuid: number, block?: number | string | null): Promise { - const native = nativeRustClient(this.client) + async metagraph(netuid: number, block?: number | string | null): Promise { + const native = await nativeRustClient(this.client) if (native != null) { - return this.client.resolveReadBlockHash(block).then((blockHash) => native.metagraph(netuid, blockHash) as T) + const blockHash = await this.client.resolveReadBlockHash(block) + return await native.metagraph(netuid, blockHash) as T } return this.client.runtime(runtimeApi.SubnetInfoRuntimeApi.get_metagraph, [netuid], block) } - hyperparameters(netuid: number, block?: number | string | null): Promise { - const native = nativeRustClient(this.client) + async hyperparameters(netuid: number, block?: number | string | null): Promise { + const native = await nativeRustClient(this.client) if (native != null) { - return this.client.resolveReadBlockHash(block).then((blockHash) => native.subnetHyperparameters(netuid, blockHash) as T) + const blockHash = await this.client.resolveReadBlockHash(block) + return await native.subnetHyperparameters(netuid, blockHash) as T } return this.client.runtime(runtimeApi.SubnetInfoRuntimeApi.get_subnet_hyperparams_v3, [netuid], block) } @@ -2871,10 +2618,11 @@ export class SubnetsNamespace { export class NeuronsNamespace { constructor(private readonly client: Client) {} - all(netuid: number, lite = true, block?: number | string | null): Promise { - const native = nativeRustClient(this.client) + async all(netuid: number, lite = true, block?: number | string | null): Promise { + const native = await nativeRustClient(this.client) if (native != null && lite) { - return this.client.resolveReadBlockHash(block).then((blockHash) => native.neurons(netuid, blockHash) as T) + const blockHash = await this.client.resolveReadBlockHash(block) + return await native.neurons(netuid, blockHash) as T } return this.client.runtime( lite ? runtimeApi.NeuronInfoRuntimeApi.get_neurons_lite : runtimeApi.NeuronInfoRuntimeApi.get_neurons, @@ -2905,10 +2653,10 @@ export class StakingNamespace { constructor(private readonly client: Client) {} async get(coldkey: string, hotkey: string, netuid: number, block?: number | string | null): Promise { - const native = nativeRustClient(this.client) + const native = await nativeRustClient(this.client) if (native != null) { const blockHash = await this.client.resolveReadBlockHash(block) - return Balance.fromRao(native.stakeRao(coldkey, hotkey, netuid, blockHash), netuid) + return Balance.fromRao(await native.stakeRao(coldkey, hotkey, netuid, blockHash), netuid) } const info = await this.client.runtime | null>( runtimeApi.StakeInfoRuntimeApi.get_stake_info_for_hotkey_coldkey_netuid, @@ -3248,23 +2996,23 @@ interface CallDescriptorEntry { } const CALL_DESCRIPTOR_ENTRIES: CallDescriptorEntry[] = [ - { path: 'calls.balances.transferKeepAlive', descriptor: descriptor('Balances', 'transfer_keep_alive'), args: ['dest', 'value'], argTypes: ['MultiAddress', 'Compact'] }, - { path: 'calls.balances.transferAllowDeath', descriptor: descriptor('Balances', 'transfer_allow_death'), args: ['dest', 'value'], argTypes: ['MultiAddress', 'Compact'] }, - { path: 'calls.subtensor.addStake', descriptor: descriptor('SubtensorModule', 'add_stake'), args: ['hotkey', 'netuid', 'amount_staked'], argTypes: ['AccountId32', 'u16', 'u64'] }, - { path: 'calls.subtensor.burnedRegister', descriptor: descriptor('SubtensorModule', 'burned_register'), args: ['netuid', 'hotkey'], argTypes: ['u16', 'AccountId32'] }, - { path: 'calls.subtensor.commitWeights', descriptor: descriptor('SubtensorModule', 'commit_weights'), args: ['netuid', 'commit_hash'], argTypes: ['u16', 'H256'] }, - { path: 'calls.subtensor.moveStake', descriptor: descriptor('SubtensorModule', 'move_stake'), args: ['origin_hotkey', 'destination_hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypes: ['AccountId32', 'AccountId32', 'u16', 'u16', 'u64'] }, - { path: 'calls.subtensor.register', descriptor: descriptor('SubtensorModule', 'register'), args: ['netuid', 'block_number', 'nonce', 'work', 'hotkey', 'coldkey'], argTypes: ['u16', 'u64', 'u64', 'Vec', 'AccountId32', 'AccountId32'] }, + { path: 'calls.balances.transferKeepAlive', descriptor: descriptor('Balances', 'transfer_keep_alive'), args: ['dest', 'value'], argTypes: ['MultiAddress', 'Compact'] }, + { path: 'calls.balances.transferAllowDeath', descriptor: descriptor('Balances', 'transfer_allow_death'), args: ['dest', 'value'], argTypes: ['MultiAddress', 'Compact'] }, + { path: 'calls.subtensor.addStake', descriptor: descriptor('SubtensorModule', 'add_stake'), args: ['hotkey', 'netuid', 'amount_staked'], argTypes: ['AccountId32', 'NetUid', 'TaoBalance'] }, + { path: 'calls.subtensor.burnedRegister', descriptor: descriptor('SubtensorModule', 'burned_register'), args: ['netuid', 'hotkey'], argTypes: ['NetUid', 'AccountId32'] }, + { path: 'calls.subtensor.commitWeights', descriptor: descriptor('SubtensorModule', 'commit_weights'), args: ['netuid', 'commit_hash'], argTypes: ['NetUid', 'H256'] }, + { path: 'calls.subtensor.moveStake', descriptor: descriptor('SubtensorModule', 'move_stake'), args: ['origin_hotkey', 'destination_hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypes: ['AccountId32', 'AccountId32', 'NetUid', 'NetUid', 'AlphaBalance'] }, + { path: 'calls.subtensor.register', descriptor: descriptor('SubtensorModule', 'register'), args: ['netuid', 'block_number', 'nonce', 'work', 'hotkey', 'coldkey'], argTypes: ['NetUid', 'u64', 'u64', 'Vec', 'AccountId32', 'AccountId32'] }, { path: 'calls.subtensor.registerNetwork', descriptor: descriptor('SubtensorModule', 'register_network'), args: ['hotkey'], argTypes: ['AccountId32'] }, - { path: 'calls.subtensor.removeStake', descriptor: descriptor('SubtensorModule', 'remove_stake'), args: ['hotkey', 'netuid', 'amount_unstaked'], argTypes: ['AccountId32', 'u16', 'u64'] }, - { path: 'calls.subtensor.revealWeights', descriptor: descriptor('SubtensorModule', 'reveal_weights'), args: ['netuid', 'uids', 'values', 'salt', 'version_key'], argTypes: ['u16', 'Vec', 'Vec', 'Vec', 'u64'] }, + { path: 'calls.subtensor.removeStake', descriptor: descriptor('SubtensorModule', 'remove_stake'), args: ['hotkey', 'netuid', 'amount_unstaked'], argTypes: ['AccountId32', 'NetUid', 'AlphaBalance'] }, + { path: 'calls.subtensor.revealWeights', descriptor: descriptor('SubtensorModule', 'reveal_weights'), args: ['netuid', 'uids', 'values', 'salt', 'version_key'], argTypes: ['NetUid', 'Vec', 'Vec', 'Vec', 'u64'] }, { path: 'calls.subtensor.rootRegister', descriptor: descriptor('SubtensorModule', 'root_register'), args: ['hotkey'], argTypes: ['AccountId32'] }, - { path: 'calls.subtensor.serveAxon', descriptor: descriptor('SubtensorModule', 'serve_axon'), args: ['netuid', 'version', 'ip', 'port', 'ip_type', 'protocol', 'placeholder1', 'placeholder2'], argTypes: ['u16', 'u32', 'u128', 'u16', 'u8', 'u8', 'u8', 'u8'] }, - { path: 'calls.subtensor.servePrometheus', descriptor: descriptor('SubtensorModule', 'serve_prometheus'), args: ['netuid', 'version', 'ip', 'port', 'ip_type'], argTypes: ['u16', 'u32', 'u128', 'u16', 'u8'] }, - { path: 'calls.subtensor.setChildren', descriptor: descriptor('SubtensorModule', 'set_children'), args: ['hotkey', 'netuid', 'children'], argTypes: ['AccountId32', 'u16', 'Vec<(u64, AccountId32)>'] }, - { path: 'calls.subtensor.setWeights', descriptor: descriptor('SubtensorModule', 'set_weights'), args: ['netuid', 'dests', 'weights', 'version_key'], argTypes: ['u16', 'Vec', 'Vec', 'u64'] }, - { path: 'calls.subtensor.startCall', descriptor: descriptor('SubtensorModule', 'start_call'), args: ['netuid'], argTypes: ['u16'] }, - { path: 'calls.subtensor.transferStake', descriptor: descriptor('SubtensorModule', 'transfer_stake'), args: ['destination_coldkey', 'hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypes: ['AccountId32', 'AccountId32', 'u16', 'u16', 'u64'] }, + { path: 'calls.subtensor.serveAxon', descriptor: descriptor('SubtensorModule', 'serve_axon'), args: ['netuid', 'version', 'ip', 'port', 'ip_type', 'protocol', 'placeholder1', 'placeholder2'], argTypes: ['NetUid', 'u32', 'u128', 'u16', 'u8', 'u8', 'u8', 'u8'] }, + { path: 'calls.subtensor.servePrometheus', descriptor: descriptor('SubtensorModule', 'serve_prometheus'), args: ['netuid', 'version', 'ip', 'port', 'ip_type'], argTypes: ['NetUid', 'u32', 'u128', 'u16', 'u8'] }, + { path: 'calls.subtensor.setChildren', descriptor: descriptor('SubtensorModule', 'set_children'), args: ['hotkey', 'netuid', 'children'], argTypes: ['AccountId32', 'NetUid', 'Vec<(u64, AccountId32)>'] }, + { path: 'calls.subtensor.setWeights', descriptor: descriptor('SubtensorModule', 'set_weights'), args: ['netuid', 'dests', 'weights', 'version_key'], argTypes: ['NetUid', 'Vec', 'Vec', 'u64'] }, + { path: 'calls.subtensor.startCall', descriptor: descriptor('SubtensorModule', 'start_call'), args: ['netuid'], argTypes: ['NetUid'] }, + { path: 'calls.subtensor.transferStake', descriptor: descriptor('SubtensorModule', 'transfer_stake'), args: ['destination_coldkey', 'hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypes: ['AccountId32', 'AccountId32', 'NetUid', 'NetUid', 'AlphaBalance'] }, { path: 'calls.subtensor.unstakeAll', descriptor: descriptor('SubtensorModule', 'unstake_all'), args: ['hotkey'], argTypes: ['AccountId32'] }, ] @@ -3645,94 +3393,6 @@ function staticSignerAddress(signer: unknown): string | undefined { return stringValue(value.ss58Address) ?? stringValue(value.address) } -function managedNonceReservation(extrinsic: unknown): NonceReservation | undefined { - if (extrinsic == null || typeof extrinsic !== 'object') return undefined - if (Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array) return undefined - return (extrinsic as ManagedSignedExtrinsicResult)[MANAGED_NONCE] -} - -function extrinsicSignerAddress(value: unknown, ss58Format: number): string | undefined { - if (typeof value === 'string') return value - if (Buffer.isBuffer(value) || value instanceof Uint8Array) { - return value.length === 32 ? ss58FromPublic(value, ss58Format) : undefined - } - if (value == null || typeof value !== 'object') return undefined - for (const candidate of Object.values(value as Record)) { - const address = extrinsicSignerAddress(candidate, ss58Format) - if (address != null) return address - } - return undefined -} - -function safeNonceNumber(value: unknown): number | undefined { - const nonce = typeof value === 'bigint' ? Number(value) : Number(value) - return Number.isSafeInteger(nonce) && nonce >= 0 ? nonce : undefined -} - -function hashExtrinsicHex(extrinsic: unknown): string | undefined { - if (typeof extrinsic !== 'string') return undefined - try { - return hex(blake2_256(hexToBuffer(extrinsic))).toLowerCase() - } catch { - return undefined - } -} - -function advanceNonceStateToChainNext(state: NonceAccountState, chainNext: number): void { - if (state.next == null || state.next < chainNext) state.next = chainNext - state.reusable = state.reusable.filter((nonce) => nonce >= chainNext) - for (const [nonce, status] of state.statuses) { - if (nonce >= chainNext) continue - if (status === 'ambiguous' && state.ambiguous?.nonce === nonce) { - state.ambiguous = undefined - } - state.statuses.delete(nonce) - } - pruneNonceStatuses(state) -} - -function pruneNonceStatuses(state: NonceAccountState): void { - if (state.next != null) { - for (const [nonce, status] of state.statuses) { - if (nonce >= state.next) continue - if (status === 'submitted' || status === 'confirmed' || status === 'failed') { - state.statuses.delete(nonce) - } - } - } - if (state.statuses.size <= DEFAULT_MAX_NONCE_STATUS_HISTORY * 2) return - for (const [nonce, status] of state.statuses) { - if (state.statuses.size <= DEFAULT_MAX_NONCE_STATUS_HISTORY) break - if (status !== 'reserved' && status !== 'ambiguous') state.statuses.delete(nonce) - } -} - -function quarantineNonceState(state: NonceAccountState, nonce: number, extrinsicHash?: string): void { - state.next = undefined - state.reusable = [] - for (const [existingNonce, status] of state.statuses) { - if (status === 'reusable') state.statuses.delete(existingNonce) - } - state.statuses.set(nonce, 'ambiguous') - state.ambiguous = { - nonce, - extrinsicHash, - sinceMs: Date.now(), - } - pruneNonceStatuses(state) -} - -function clearAmbiguousNonceState(state: NonceAccountState, next: number): void { - const ambiguous = state.ambiguous - if (ambiguous != null && state.statuses.get(ambiguous.nonce) === 'ambiguous') { - state.statuses.delete(ambiguous.nonce) - } - state.ambiguous = undefined - state.next = next - state.reusable = state.reusable.filter((nonce) => nonce >= next) - pruneNonceStatuses(state) -} - function sameHex(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase() } @@ -3901,8 +3561,92 @@ function jsonRpcSubscriptionNotification( return { subscription: String(subscription), result: paramsObject.result } } +function nativeExternalSigningOptions(options: SubmitOptions): NativeExternalSigningOptions { + const out: NativeExternalSigningOptions = {} + if (options.nonce != null) out.nonce = toBigInt(options.nonce, 'nonce') + if (options.period === null) { + out.immortal = true + } else if (options.period !== undefined) { + out.period = toBigInt(options.period, 'period') + } + if (options.tip != null) { + out.tip = taoTransactionAmountRao(options.tip, 'tip') + } + if (options.tipAssetId != null) { + out.tipAssetId = assetIdValue(options.tipAssetId, 'tipAssetId') + } + if (hasOwn(options, 'metadataHash')) { + if (options.metadataHash == null) { + out.metadataHashMode = 'disabled' + } else { + out.metadataHashMode = 'explicit' + out.metadataHash = toBuffer(options.metadataHash, 'metadataHash') + } + } + return out +} + +function nativePlanSignerContext( + client: Client, + runtime: Runtime, + resolved: ResolvedSigner, + plan: NativeExternalSigningPlanHandle, +): SignerPayloadContext { + const metadataHash = plan.metadataHash == null ? null : Buffer.from(plan.metadataHash) + const metadataProof = plan.metadataProof == null ? undefined : Buffer.from(plan.metadataProof) + return { + client, + runtime, + address: resolved.ss58Address, + publicKey: Buffer.from(plan.publicKey), + cryptoType: plan.cryptoType, + callData: Buffer.from(plan.callData), + payload: Buffer.from(plan.payload), + txParams: nativeTxParamsToTransactionParams(plan.txParams), + metadataHash, + metadataProof, + proof: metadataProof, + includedInExtrinsic: Buffer.from(plan.includedInExtrinsic), + includedInSignedData: Buffer.from(plan.includedInSignedData), + chainInfo: plan.chainInfo == null ? undefined : nativeChainInfoToChainInfo(plan.chainInfo), + signerPayload: plan.signerPayload, + } +} + +function nativeTxParamsToTransactionParams( + params: NativeExternalSigningPlanHandle['txParams'], +): TransactionParams { + return { + era: fromWire(params.era), + nonce: params.nonce, + tip: params.tip, + tipAssetId: params.tipAssetId ?? null, + genesisHash: Buffer.from(params.genesisHash), + eraBlockHash: Buffer.from(params.eraBlockHash), + metadataHash: params.metadataHash == null ? null : Buffer.from(params.metadataHash), + } +} + +function nativeChainInfoToChainInfo(info: NativeChainInfo): ChainInfo { + return { + specVersion: info.specVersion, + specName: info.specName, + base58Prefix: info.base58Prefix, + decimals: info.decimals, + tokenSymbol: info.tokenSymbol, + } +} + +function assertNativeSubmitOptions(options: SubmitOptions): void { + if (options.signal != null || options.timeoutMs != null) { + throw new ChainError( + 'native Rust transaction execution does not support signal or timeoutMs; use submitSigned()/watchSigned() for custom transport cancellation', + ) + } +} + function extensionSignPayload(context: SignerPayloadContext): ExtensionSignPayloadRequest { - return context.runtime.signerPayload(context.address, context.callData, context.txParams) + return context.signerPayload ?? context.runtime.signerPayload(context.address, context.callData, context.txParams) } function normalizeSignature( @@ -4020,18 +3764,6 @@ function policyForSubmitOptions(options: SubmitOptions): Policy { }) } -function nativeSigningOptionsSupported(options: SubmitOptions): boolean { - return options.tip == null && - options.tipAssetId == null && - !hasOwn(options, 'metadataHash') -} - -function nativeSubmissionOptionsSupported(options: SubmitOptions): boolean { - return nativeSigningOptionsSupported(options) && - options.signal == null && - options.timeoutMs == null -} - function nativeOutcomeToExtrinsicResult(outcome: NativeTxOutcome, finalized: boolean): ExtrinsicResult { const blockNumber = outcome.blockNumber == null ? undefined : Number(outcome.blockNumber) const extrinsicIndex = outcome.extrinsicIndex ?? undefined @@ -4119,17 +3851,68 @@ function eventName(event: unknown): string { function eventExtrinsicIndex(event: unknown): number | null { const value = event as { extrinsic_idx?: unknown; phase?: unknown } - if (value.extrinsic_idx != null) return Number(value.extrinsic_idx) + const index = eventIndexNumber(value.extrinsic_idx) + if (index != null) return index const phase = value.phase as Record | undefined const apply = phase?.ApplyExtrinsic ?? phase?.applyExtrinsic - return apply == null ? null : Number(apply) + return eventIndexNumber(apply) +} + +function eventIndexNumber(value: unknown): number | null { + if (value == null) return null + if (typeof value === 'number') return Number.isSafeInteger(value) && value >= 0 ? value : null + if (typeof value === 'bigint') { + const number = Number(value) + return Number.isSafeInteger(number) && number >= 0 ? number : null + } + if (typeof value === 'string') { + if (!/^(?:0x[0-9a-fA-F]+|\d+)$/.test(value)) return null + const number = Number(value) + return Number.isSafeInteger(number) && number >= 0 ? number : null + } + if (typeof value !== 'object' || Buffer.isBuffer(value) || value instanceof Uint8Array) return null + + const object = value as Record + if (object[WIRE_TAG] === 'bigint' && typeof object.value === 'string') { + return eventIndexNumber(object.value) + } + if (Array.isArray(value) && value.length === 1) return eventIndexNumber(value[0]) + + const entries = Object.entries(object).filter(([key]) => key !== WIRE_TAG) + if (entries.length !== 1) return null + return eventIndexNumber(entries[0][1]) } function feeFromEvent(event: unknown): Balance | undefined { const attrs = (event as { attributes?: unknown }).attributes if (attrs == null || typeof attrs !== 'object') return undefined const amount = (attrs as Record).actual_fee ?? (attrs as Record).actualFee ?? (attrs as Record).fee - return amount == null ? undefined : Balance.fromRao(String(amount)) + const rao = decimalAmount(amount) + return rao == null ? undefined : Balance.fromRao(rao) +} + +function decimalAmount(value: unknown): string | undefined { + if (value == null) return undefined + if (typeof value === 'bigint') return value >= 0n ? value.toString(10) : undefined + if (typeof value === 'number') { + return Number.isSafeInteger(value) && value >= 0 ? value.toString(10) : undefined + } + if (typeof value === 'string') { + if (/^\d+$/.test(value)) return value + if (/^0x[0-9a-fA-F]+$/.test(value)) return BigInt(value).toString(10) + return undefined + } + if (typeof value !== 'object' || Buffer.isBuffer(value) || value instanceof Uint8Array) return undefined + + const object = value as Record + if (object[WIRE_TAG] === 'bigint' && typeof object.value === 'string') { + return decimalAmount(object.value) + } + if (Array.isArray(value) && value.length === 1) return decimalAmount(value[0]) + + const entries = Object.entries(object).filter(([key]) => key !== WIRE_TAG) + if (entries.length !== 1) return undefined + return decimalAmount(entries[0][1]) } function dispatchFailureMessage(runtime: Runtime, event: unknown): string { diff --git a/sdk/bittensor-ts/src/index.ts b/sdk/bittensor-ts/src/index.ts index d060c2a36a..2d2f3d6303 100644 --- a/sdk/bittensor-ts/src/index.ts +++ b/sdk/bittensor-ts/src/index.ts @@ -25,6 +25,7 @@ export type { SignerRoleName, } from './transaction' export * from './types' +export * from './rust-bindings' export * from './value' export * from './wallet' export { fromWire, toWire, WIRE_TAG } diff --git a/sdk/bittensor-ts/src/keys.ts b/sdk/bittensor-ts/src/keys.ts index 310caf5170..7d764dc769 100644 --- a/sdk/bittensor-ts/src/keys.ts +++ b/sdk/bittensor-ts/src/keys.ts @@ -2,25 +2,22 @@ import native, { type NativeKeypairHandle } from './native' import { nativeAsync, nativeCall } from './errors' import { coerceMessage, toBuffer } from './wire' import type { ByteLike } from './types' +import type { + RustKeypairKind, + RustKeypairMetadata, + RustKeypairPublic, + RustKeypairSignOptions, + RustSubstrateKeyType, +} from './rust-bindings' export const CRYPTO_ED25519 = nativeCall(() => native.cryptoEd25519()) export const CRYPTO_SR25519 = nativeCall(() => native.cryptoSr25519()) export const DEFAULT_SS58_FORMAT = nativeCall(() => native.defaultSs58Format()) -export type SubstrateKeyType = 'sr25519' | 'ed25519' -export type KeypairKind = 'Ed25519' | 'Sr25519' | 'PublicOnly' - -export interface KeypairMetadata { - address?: string - name?: string - type?: SubstrateKeyType - [key: string]: unknown -} - -export interface KeypairSignOptions { - /** Prefix the raw signature with its Substrate MultiSignature variant byte. */ - withType?: boolean -} +export type SubstrateKeyType = RustSubstrateKeyType +export type KeypairKind = RustKeypairKind +export interface KeypairMetadata extends RustKeypairMetadata {} +export interface KeypairSignOptions extends RustKeypairSignOptions {} export interface GeneratedKeypair { keypair: Keypair @@ -121,7 +118,8 @@ export function keyTypeForCryptoType(cryptoType: number): SubstrateKeyType { throw new RangeError(`unsupported crypto type ${cryptoType}`) } -export class Keypair implements PolkadotCompatibleKeypair { +export class Keypair + implements PolkadotCompatibleKeypair, RustKeypairPublic { private handle!: NativeKeypairHandle constructor( diff --git a/sdk/bittensor-ts/src/native.ts b/sdk/bittensor-ts/src/native.ts index 6f2852a084..9ab73df8a0 100644 --- a/sdk/bittensor-ts/src/native.ts +++ b/sdk/bittensor-ts/src/native.ts @@ -1,8 +1,20 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + RustEpochScheduleState, + RustKeypairKind, + RustMapPair, + RustPayloadParts, + RustSignedExtrinsic, + RustSignedExtrinsicParams, + RustStorageChange, + RustStorageEntry, + RustTransactionParams, +} from './rust-bindings' + export interface NativeKeypairHandle { readonly cryptoType: number - readonly kind: 'Ed25519' | 'Sr25519' | 'PublicOnly' + readonly kind: RustKeypairKind readonly publicKey: Buffer readonly ss58Address: string readonly ss58Format: number @@ -13,28 +25,11 @@ export interface NativeKeypairHandle { decrypt(ciphertext: Buffer): Buffer } -export interface NativeStorageEntry { - pallet: string - name: string - prefix: string - modifier: string - valueType: string - valueTypeId: number - paramTypes: string[] - paramTypeIds: number[] - paramHashers: string[] - defaultBytes: Buffer -} +export interface NativeStorageEntry extends RustStorageEntry {} -export interface NativeStorageChange { - key: string - value?: string | null -} +export interface NativeStorageChange extends RustStorageChange {} -export interface NativeMapPair { - key: unknown - value: unknown -} +export interface NativeMapPair extends RustMapPair {} export interface NativeBlockHeader { hash: string @@ -63,14 +58,8 @@ export interface NativeSignedExtrinsic { hash: string } -export interface NativeTxParams { - era: unknown - nonce: bigint +export interface NativeTxParams extends RustTransactionParams { tip: bigint - tipAssetId?: bigint | null - genesisHash: Buffer - eraBlockHash: Buffer - metadataHash?: Buffer | null } export interface NativeSignerPayload { @@ -91,11 +80,8 @@ export interface NativeSignerPayload { mode?: number | null } -export interface NativeExtrinsicParams { - era: unknown - nonce: bigint +export interface NativeExtrinsicParams extends RustSignedExtrinsicParams { tip: bigint - tipAssetId?: bigint | null metadataHashEnabled: boolean } @@ -235,10 +221,7 @@ export interface NativeRuntimeHandle { moduleError(moduleIndex: number, errorIndex: number): { name: string; docs: string[] } signedExtensionIdentifiers(): string[] encodeEra(era: unknown): Buffer - signaturePayloadParts(params: NativeTxParams): { - includedInExtrinsic: Buffer - includedInSignedData: Buffer - } + signaturePayloadParts(params: NativeTxParams): RustPayloadParts signaturePayload(callData: Buffer, params: NativeTxParams): Buffer signerPayload( address: string, @@ -251,7 +234,7 @@ export interface NativeRuntimeHandle { signature: Buffer, signatureVersion: number, params: NativeExtrinsicParams, - ): { bytes: Buffer; hash: Buffer } + ): RustSignedExtrinsic decodeExtrinsic(data: Buffer, strict: boolean): unknown runtimeApiMap(): unknown metadataIr(): unknown @@ -266,14 +249,7 @@ export interface NativeRuntimeConstructor { ): NativeRuntimeHandle } -export interface NativeEpochScheduleState { - lastEpochBlock: bigint - pendingEpochAt: bigint - subnetEpochIndex: bigint - tempo: number - blocksSinceLastStep: bigint - currentBlock: bigint -} +export interface NativeEpochScheduleState extends RustEpochScheduleState {} export interface NativeLedgerHandle { appVersion(): Promise<{ major: number; minor: number; patch: number }> @@ -309,6 +285,49 @@ export interface NativePlan { callData: Buffer } +export interface NativeExternalSigningOptions { + nonce?: bigint | null + period?: bigint | null + immortal?: boolean | null + tip?: bigint | null + tipAssetId?: bigint | null + metadataHashMode?: 'auto' | 'disabled' | 'explicit' | string | null + metadataHash?: Buffer | null +} + +export interface NativeExternalSigner { + signerAddress: string + publicKey: Buffer + cryptoType: number + requiresMetadataProof: boolean +} + +export interface NativeChainInfo { + specVersion: number + specName: string + base58Prefix: number + decimals: number + tokenSymbol: string +} + +export interface NativeExternalSigningPlanHandle { + readonly callData: Buffer + readonly signerAddress: string + readonly publicKey: Buffer + readonly cryptoType: number + readonly nonce: bigint + readonly payload: Buffer + readonly includedInExtrinsic: Buffer + readonly includedInSignedData: Buffer + readonly metadataHash?: Buffer | null + readonly metadataProof?: Buffer | null + readonly txParams: NativeTxParams + readonly signerPayload: NativeSignerPayload + readonly chainInfo?: NativeChainInfo | null + readonly feeRao?: string | null + readonly warnings: string[] +} + export interface NativeDispatchError { pallet?: string | null name: string @@ -426,52 +445,75 @@ export interface NativeClientHandle { readonly endpoint: string readonly ss58Format: number readonly genesisHash: Buffer - blockHash(block?: bigint | null): string - finalizedHead(): string - blockNumber(blockHash?: string | null): bigint - header(blockHash?: string | null): NativeBlockHeader + blockHash(block?: bigint | null): Promise + finalizedHead(): Promise + blockNumber(blockHash?: string | null): Promise + header(blockHash?: string | null): Promise readCatalog(): string[] - refreshRuntime(): boolean - composeCall(pallet: string, callFunction: string, params: unknown): Buffer + refreshRuntime(): Promise + composeCall(pallet: string, callFunction: string, params: unknown): Promise decodeScale(typeName: string, data: Buffer): unknown constant(pallet: string, name: string): unknown - query(pallet: string, storage: string, params: unknown, blockHash?: string | null): unknown - queryBatch(pallet: string, storage: string, paramSets: unknown, blockHash?: string | null): unknown[] - queryMap(pallet: string, storage: string, fixedParams: unknown, blockHash?: string | null): NativeMapPair[] - runtimeCall(api: string, method: string, params: unknown, blockHash?: string | null): unknown - accountNextIndex(address: string): bigint + query(pallet: string, storage: string, params: unknown, blockHash?: string | null): Promise + queryBatch(pallet: string, storage: string, paramSets: unknown, blockHash?: string | null): Promise + queryMap(pallet: string, storage: string, fixedParams: unknown, blockHash?: string | null): Promise + runtimeCall(api: string, method: string, params: unknown, blockHash?: string | null): Promise + accountNextIndex(address: string): Promise signExtrinsic( callData: Buffer, signer: NativeKeypairHandle, nonce: bigint, period?: bigint | null, - ): NativeSignedExtrinsic - estimateFee(callData: Buffer, signer: NativeKeypairHandle): string + ): Promise + estimateFee(callData: Buffer, signer: NativeKeypairHandle): Promise submit( callData: Buffer, signer: NativeKeypairHandle, nonce?: bigint | null, period?: bigint | null, waitForFinalization?: boolean | null, - ): NativeTxOutcome + ): Promise submitEncoded( extrinsic: Buffer, expectedHash: string, waitForFinalization?: boolean | null, - ): NativeTxOutcome - balanceRao(address: string): string - existentialDepositRao(): string - subnets(blockHash?: string | null): NativeSubnetInfo[] - metagraph(netuid: number, blockHash?: string | null): unknown - neurons(netuid: number, blockHash?: string | null): unknown[] - subnetHyperparameters(netuid: number, blockHash?: string | null): unknown - stakeRao(coldkey: string, hotkey: string, netuid: number, blockHash?: string | null): string - quoteStake(netuid: number, amountRao: bigint, blockHash?: string | null): NativeSwapQuote - composeIntent(intent: NativeIntentCallHandle): Buffer + ): Promise + externalSigningPlan( + callData: Buffer, + signer: NativeExternalSigner, + options?: NativeExternalSigningOptions | null, + ): Promise + externalSigningPlanForIntent( + intent: NativeIntentCallHandle, + signer: NativeExternalSigner, + policy: NativePolicyHandle, + options?: NativeExternalSigningOptions | null, + ): Promise + estimateFeeExternal(plan: NativeExternalSigningPlanHandle): Promise + assembleExternal( + plan: NativeExternalSigningPlanHandle, + signature: Buffer, + cryptoType?: number | null, + ): Promise + submitExternal( + plan: NativeExternalSigningPlanHandle, + signature: Buffer, + waitForFinalization?: boolean | null, + cryptoType?: number | null, + ): Promise + balanceRao(address: string): Promise + existentialDepositRao(): Promise + subnets(blockHash?: string | null): Promise + metagraph(netuid: number, blockHash?: string | null): Promise + neurons(netuid: number, blockHash?: string | null): Promise + subnetHyperparameters(netuid: number, blockHash?: string | null): Promise + stakeRao(coldkey: string, hotkey: string, netuid: number, blockHash?: string | null): Promise + quoteStake(netuid: number, amountRao: bigint, blockHash?: string | null): Promise + composeIntent(intent: NativeIntentCallHandle): Promise } export interface NativeClientConstructor { - connect(endpoint: string): NativeClientHandle + connect(endpoint: string): Promise } export interface NativeWalletHandle {} @@ -482,18 +524,18 @@ export interface NativeWalletConstructor { } export interface NativeExecutorHandle { - plan(intent: NativeIntentCallHandle, wallet: NativeWalletHandle): NativePlan + plan(intent: NativeIntentCallHandle, wallet: NativeWalletHandle): Promise planWithPolicy( intent: NativeIntentCallHandle, wallet: NativeWalletHandle, policy: NativePolicyHandle, - ): NativePlan + ): Promise execute( intent: NativeIntentCallHandle, wallet: NativeWalletHandle, waitForFinalization?: boolean | null, - ): NativeTxOutcome - submitShielded(intent: NativeIntentCallHandle, wallet: NativeWalletHandle): NativeTxOutcome + ): Promise + submitShielded(intent: NativeIntentCallHandle, wallet: NativeWalletHandle): Promise } export interface NativeExecutorConstructor { @@ -510,6 +552,7 @@ export interface NativeBinding { NativeSpendKind: { readonly None: number; readonly Bounded: number; readonly Unbounded: number } NativePolicy: NativePolicyConstructor NativeIntentCall: NativeIntentCallConstructor + NativeExternalSigningPlan: { readonly prototype: NativeExternalSigningPlanHandle } NativeClient: NativeClientConstructor NativeWallet: NativeWalletConstructor NativeExecutor: NativeExecutorConstructor diff --git a/sdk/bittensor-ts/src/runtime.ts b/sdk/bittensor-ts/src/runtime.ts index 2fb3ec8bd6..eedfff6680 100644 --- a/sdk/bittensor-ts/src/runtime.ts +++ b/sdk/bittensor-ts/src/runtime.ts @@ -35,6 +35,7 @@ import type { TransactionParams, TypeSpec, } from './types' +import type { RustRuntimePublic } from './rust-bindings' export type PayloadPartsTuple = [Buffer, Buffer] export type SignedExtrinsicTuple = [Buffer, Buffer] @@ -196,7 +197,7 @@ export class ScaleCursor { } } -export class Runtime { +export class Runtime implements RustRuntimePublic { private readonly handle: NativeRuntimeHandle constructor( diff --git a/sdk/bittensor-ts/src/rust-bindings.ts b/sdk/bittensor-ts/src/rust-bindings.ts new file mode 100644 index 0000000000..41cc06b9f7 --- /dev/null +++ b/sdk/bittensor-ts/src/rust-bindings.ts @@ -0,0 +1,191 @@ +import type { + IntegerLike, + MetadataIr, + ModuleError, + RuntimeApiMap, + ScaleValue, +} from './types' + +export type RustSubstrateKeyType = 'sr25519' | 'ed25519' +export type RustKeypairKind = 'Ed25519' | 'Sr25519' | 'PublicOnly' + +export interface RustKeypairMetadata { + address?: string + name?: string + type?: RustSubstrateKeyType + [key: string]: unknown +} + +export interface RustKeypairSignOptions { + /** Prefix the raw signature with its Substrate MultiSignature variant byte. */ + withType?: boolean +} + +export interface RustKeypairPublic< + Bytes extends Uint8Array, + ByteInput, + Message = string | ByteInput, +> { + readonly cryptoType: number + readonly kind: RustKeypairKind + readonly publicKey: Bytes + readonly addressRaw: Bytes + readonly ss58Address: string + readonly address: string + readonly type: RustSubstrateKeyType + readonly scheme: 'Ed25519' | 'Sr25519' + readonly ss58Format: number + readonly meta: RustKeypairMetadata + readonly isLocked: boolean + sign(message: Message, options?: RustKeypairSignOptions): Bytes + verify(message: Message, signature: ByteInput, signerPublic?: string | ByteInput): boolean + derive(suri: string, meta?: RustKeypairMetadata): RustKeypairPublic + setMeta(meta: RustKeypairMetadata): void +} + +export interface RustStorageEntry { + pallet: string + name: string + prefix: string + modifier: string + valueType: string + valueTypeId: number + paramTypes: string[] + paramTypeIds: number[] + paramHashers: string[] + defaultBytes: Bytes +} + +export interface RustStorageChange { + key: string + value?: string | null +} + +export interface RustMapPair { + key: K + value: V +} + +export interface RustPayloadParts { + includedInExtrinsic: Bytes + includedInSignedData: Bytes +} + +export interface RustTransactionParams { + era: ScaleValue + nonce: Integer + tip?: Integer + tipAssetId?: Integer | null + genesisHash: Bytes + eraBlockHash: Bytes + metadataHash?: Bytes | null +} + +export interface RustSignedExtrinsicParams { + era: ScaleValue + nonce: Integer + tip?: Integer + tipAssetId?: Integer | null + metadataHashEnabled?: boolean +} + +export interface RustSignedExtrinsic { + bytes: Bytes + hash: Bytes +} + +export interface RustMultisigAccount { + accountId: Bytes + sortedSignatories: Bytes[] +} + +export interface RustEpochScheduleState { + lastEpochBlock: Integer + pendingEpochAt: Integer + subnetEpochIndex: Integer + tempo: number + blocksSinceLastStep: Integer + currentBlock: Integer +} + +export type RustRuntimeApiMap = RuntimeApiMap +export type RustMetadataIr = MetadataIr + +export interface RustRuntimePublic< + Bytes extends Uint8Array, + ByteInput, + Integer = IntegerLike, +> { + readonly specVersion: number + readonly transactionVersion: number + readonly ss58Format: number + readonly isV15: boolean + readonly extrinsicVersion: number + decode( + typeString: string, + data: ByteInput, + strict?: boolean, + ): T + decodeBatch( + typeStrings: string[], + data: ByteInput[], + ): T[] + encode(typeString: string, value: ScaleValue): Bytes + typeIdOf(name: string): number | null + typeNameOf(id: number): string | null + registryJson(): string + composeCall(pallet: string, fn: string, params: ScaleValue): Bytes + decodeCall(data: ByteInput): T + storageEntry(pallet: string, storageFunction: string): RustStorageEntry + storagePrefix(pallet: string, storageFunction: string): Bytes + storageKey(pallet: string, storageFunction: string, params?: ScaleValue[]): Bytes + storageKeyBatch( + pallet: string, + storageFunction: string, + paramsList: ScaleValue[][], + ): Bytes[] + decodeStorageKeyParams( + pallet: string, + storageFunction: string, + key: ByteInput, + fixed?: number, + ): T[] + decodeMapPairs( + pallet: string, + storageFunction: string, + rawKeys: ByteInput[], + rawValues: ByteInput[], + fixed?: number, + ): Array> + decodeMapChanges( + pallet: string, + storageFunction: string, + changes: RustStorageChange[], + fixed?: number, + ): Array> + constant(pallet: string, name: string): T | undefined + moduleError(moduleIndex: number, errorIndex: number): ModuleError + signedExtensionIdentifiers(): string[] + encodeEra(era: ScaleValue): Bytes + signaturePayloadParts( + params: RustTransactionParams, + ): RustPayloadParts + signaturePayload( + callData: ByteInput, + params: RustTransactionParams, + ): Bytes + encodeSignedExtrinsic( + callData: ByteInput, + publicKey: ByteInput, + signature: ByteInput, + signatureVersion: number, + params: RustSignedExtrinsicParams, + ): RustSignedExtrinsic + decodeExtrinsic( + data: ByteInput, + strict?: boolean, + ): T + runtimeApiMap(): RustRuntimeApiMap + runtimeApis(): RustRuntimeApiMap + metadataIr(): RustMetadataIr +} diff --git a/sdk/bittensor-ts/src/transaction.ts b/sdk/bittensor-ts/src/transaction.ts index 9ed6c9394d..99da016a06 100644 --- a/sdk/bittensor-ts/src/transaction.ts +++ b/sdk/bittensor-ts/src/transaction.ts @@ -2,6 +2,9 @@ import native, { type NativeBlockHeader, type NativeClientHandle, type NativeExecutorHandle, + type NativeExternalSigner, + type NativeExternalSigningOptions, + type NativeExternalSigningPlanHandle, type NativeIntentCallHandle, type NativeMapPair, type NativePlan, @@ -13,7 +16,7 @@ import native, { type NativeTxOutcome, type NativeWalletHandle, } from './native' -import { nativeCall } from './errors' +import { nativeAsync, nativeCall } from './errors' import { fromWire, toWire } from './wire' import type { ScaleValue } from './types' import { Keypair, nativeKeypairHandle } from './keys' @@ -329,8 +332,8 @@ export class NativeChainClient { this.native = nativeClient } - static connect(endpoint: string): NativeChainClient { - return new NativeChainClient(nativeCall(() => native.NativeClient.connect(endpoint))) + static async connect(endpoint: string): Promise { + return new NativeChainClient(await nativeAsync(() => native.NativeClient.connect(endpoint))) } get endpoint(): string { @@ -349,28 +352,28 @@ export class NativeChainClient { return nativeCall(() => this.native.readCatalog()) } - refreshRuntime(): boolean { - return nativeCall(() => this.native.refreshRuntime()) + refreshRuntime(): Promise { + return nativeAsync(() => this.native.refreshRuntime()) } - blockHash(block?: bigint | number | null): string { - return nativeCall(() => this.native.blockHash(block == null ? undefined : bigintValue(block, 'block'))) + blockHash(block?: bigint | number | null): Promise { + return nativeAsync(() => this.native.blockHash(block == null ? undefined : bigintValue(block, 'block'))) } - finalizedHead(): string { - return nativeCall(() => this.native.finalizedHead()) + finalizedHead(): Promise { + return nativeAsync(() => this.native.finalizedHead()) } - blockNumber(blockHash?: string | null): number { - return Number(nativeCall(() => this.native.blockNumber(blockHash ?? undefined))) + async blockNumber(blockHash?: string | null): Promise { + return Number(await nativeAsync(() => this.native.blockNumber(blockHash ?? undefined))) } - header(blockHash?: string | null): NativeBlockHeader { - return nativeCall(() => this.native.header(blockHash ?? undefined)) + header(blockHash?: string | null): Promise { + return nativeAsync(() => this.native.header(blockHash ?? undefined)) } - composeCall(pallet: string, fn: string, params: ScaleValue = {}): Buffer { - return nativeCall(() => this.native.composeCall(pallet, fn, toWire(params))) + composeCall(pallet: string, fn: string, params: ScaleValue = {}): Promise { + return nativeAsync(() => this.native.composeCall(pallet, fn, toWire(params))) } decodeScale(typeName: string, data: Buffer): ScaleValue { @@ -381,42 +384,42 @@ export class NativeChainClient { return fromWire(nativeCall(() => this.native.constant(pallet, name))) } - query(pallet: string, storage: string, params: ScaleValue[] = [], blockHash?: string | null): ScaleValue { - return fromWire(nativeCall(() => + async query(pallet: string, storage: string, params: ScaleValue[] = [], blockHash?: string | null): Promise { + return fromWire(await nativeAsync(() => this.native.query(pallet, storage, toWire(params), blockHash ?? undefined), )) } - queryBatch( + async queryBatch( pallet: string, storage: string, paramSets: ScaleValue[][] = [], blockHash?: string | null, - ): ScaleValue[] { - return nativeCall(() => + ): Promise { + return (await nativeAsync(() => this.native.queryBatch(pallet, storage, toWire(paramSets), blockHash ?? undefined), - ).map(fromWire) + )).map(fromWire) } - queryMap( + async queryMap( pallet: string, storage: string, fixedParams: ScaleValue[] = [], blockHash?: string | null, - ): Array<[ScaleValue, ScaleValue]> { - return nativeCall(() => + ): Promise> { + return (await nativeAsync(() => this.native.queryMap(pallet, storage, toWire(fixedParams), blockHash ?? undefined), - ).map((pair: NativeMapPair) => [fromWire(pair.key), fromWire(pair.value)]) + )).map((pair: NativeMapPair) => [fromWire(pair.key), fromWire(pair.value)]) } - runtimeCall(api: string, method: string, params: ScaleValue[] = [], blockHash?: string | null): ScaleValue { - return fromWire(nativeCall(() => + async runtimeCall(api: string, method: string, params: ScaleValue[] = [], blockHash?: string | null): Promise { + return fromWire(await nativeAsync(() => this.native.runtimeCall(api, method, toWire(params), blockHash ?? undefined), )) } - accountNextIndex(address: string): number { - return Number(nativeCall(() => this.native.accountNextIndex(address))) + async accountNextIndex(address: string): Promise { + return Number(await nativeAsync(() => this.native.accountNextIndex(address))) } signExtrinsic( @@ -424,8 +427,8 @@ export class NativeChainClient { signer: Keypair, nonce: bigint | number, period?: bigint | number | null, - ): NativeSignedExtrinsic { - return nativeCall(() => + ): Promise { + return nativeAsync(() => this.native.signExtrinsic( callData, nativeKeypairHandle(signer), @@ -435,8 +438,8 @@ export class NativeChainClient { ) } - estimateFee(callData: Buffer, signer: Keypair): bigint { - return BigInt(nativeCall(() => this.native.estimateFee(callData, nativeKeypairHandle(signer)))) + async estimateFee(callData: Buffer, signer: Keypair): Promise { + return BigInt(await nativeAsync(() => this.native.estimateFee(callData, nativeKeypairHandle(signer)))) } submit( @@ -445,8 +448,8 @@ export class NativeChainClient { nonce?: bigint | number | null, period?: bigint | number | null, waitForFinalization = false, - ): NativeTxOutcome { - return nativeCall(() => + ): Promise { + return nativeAsync(() => this.native.submit( callData, nativeKeypairHandle(signer), @@ -457,46 +460,117 @@ export class NativeChainClient { ) } - submitEncoded(extrinsic: Buffer, expectedHash: string, waitForFinalization = false): NativeTxOutcome { - return nativeCall(() => this.native.submitEncoded(extrinsic, expectedHash, waitForFinalization)) + submitEncoded(extrinsic: Buffer, expectedHash: string, waitForFinalization = false): Promise { + return nativeAsync(() => this.native.submitEncoded(extrinsic, expectedHash, waitForFinalization)) } - balanceRao(address: string): bigint { - return BigInt(nativeCall(() => this.native.balanceRao(address))) + externalSigningPlan( + callData: Buffer, + signerAddress: string, + publicKey: Buffer, + cryptoType: number, + requiresMetadataProof: boolean, + options?: NativeExternalSigningOptions | null, + ): Promise { + const signer: NativeExternalSigner = { + signerAddress, + publicKey, + cryptoType, + requiresMetadataProof, + } + return nativeAsync(() => + this.native.externalSigningPlan( + callData, + signer, + options ?? undefined, + ), + ) } - existentialDepositRao(): bigint { - return BigInt(nativeCall(() => this.native.existentialDepositRao())) + externalSigningPlanForIntent( + intent: IntentCall, + signerAddress: string, + publicKey: Buffer, + cryptoType: number, + requiresMetadataProof: boolean, + policy: Policy, + options?: NativeExternalSigningOptions | null, + ): Promise { + const signer: NativeExternalSigner = { + signerAddress, + publicKey, + cryptoType, + requiresMetadataProof, + } + return nativeAsync(() => + this.native.externalSigningPlanForIntent( + intent.native, + signer, + policy.native, + options ?? undefined, + ), + ) } - subnets(blockHash?: string | null): NativeSubnetInfo[] { - return nativeCall(() => this.native.subnets(blockHash ?? undefined)) + async estimateFeeExternal(plan: NativeExternalSigningPlanHandle): Promise { + return BigInt(await nativeAsync(() => this.native.estimateFeeExternal(plan))) } - metagraph(netuid: number, blockHash?: string | null): ScaleValue { - return fromWire(nativeCall(() => this.native.metagraph(netuid, blockHash ?? undefined))) + assembleExternal( + plan: NativeExternalSigningPlanHandle, + signature: Buffer, + cryptoType?: number | null, + ): Promise { + return nativeAsync(() => this.native.assembleExternal(plan, signature, cryptoType ?? undefined)) } - neurons(netuid: number, blockHash?: string | null): ScaleValue[] { - return nativeCall(() => this.native.neurons(netuid, blockHash ?? undefined)).map(fromWire) + submitExternal( + plan: NativeExternalSigningPlanHandle, + signature: Buffer, + waitForFinalization = false, + cryptoType?: number | null, + ): Promise { + return nativeAsync(() => + this.native.submitExternal(plan, signature, waitForFinalization, cryptoType ?? undefined), + ) } - subnetHyperparameters(netuid: number, blockHash?: string | null): ScaleValue { - return fromWire(nativeCall(() => this.native.subnetHyperparameters(netuid, blockHash ?? undefined))) + async balanceRao(address: string): Promise { + return BigInt(await nativeAsync(() => this.native.balanceRao(address))) } - stakeRao(coldkey: string, hotkey: string, netuid: number, blockHash?: string | null): bigint { - return BigInt(nativeCall(() => this.native.stakeRao(coldkey, hotkey, netuid, blockHash ?? undefined))) + async existentialDepositRao(): Promise { + return BigInt(await nativeAsync(() => this.native.existentialDepositRao())) } - quoteStake(netuid: number, amountRao: bigint | number | string, blockHash?: string | null): NativeSwapQuote { - return nativeCall(() => + subnets(blockHash?: string | null): Promise { + return nativeAsync(() => this.native.subnets(blockHash ?? undefined)) + } + + async metagraph(netuid: number, blockHash?: string | null): Promise { + return fromWire(await nativeAsync(() => this.native.metagraph(netuid, blockHash ?? undefined))) + } + + async neurons(netuid: number, blockHash?: string | null): Promise { + return (await nativeAsync(() => this.native.neurons(netuid, blockHash ?? undefined))).map(fromWire) + } + + async subnetHyperparameters(netuid: number, blockHash?: string | null): Promise { + return fromWire(await nativeAsync(() => this.native.subnetHyperparameters(netuid, blockHash ?? undefined))) + } + + async stakeRao(coldkey: string, hotkey: string, netuid: number, blockHash?: string | null): Promise { + return BigInt(await nativeAsync(() => this.native.stakeRao(coldkey, hotkey, netuid, blockHash ?? undefined))) + } + + quoteStake(netuid: number, amountRao: bigint | number | string, blockHash?: string | null): Promise { + return nativeAsync(() => this.native.quoteStake(netuid, bigintValue(amountRao, 'amountRao'), blockHash ?? undefined), ) } - composeIntent(intent: IntentCall): Buffer { - return nativeCall(() => this.native.composeIntent(intent.native)) + composeIntent(intent: IntentCall): Promise { + return nativeAsync(() => this.native.composeIntent(intent.native)) } } @@ -533,12 +607,12 @@ export class Executor { ) } - plan(intent: IntentCall, wallet: RustWallet): NativePlan { - return nativeCall(() => this.native.plan(intent.native, wallet.native)) + plan(intent: IntentCall, wallet: RustWallet): Promise { + return nativeAsync(() => this.native.plan(intent.native, wallet.native)) } - execute(intent: IntentCall, wallet: RustWallet, waitForFinalization = true): NativeTxOutcome { - return nativeCall(() => this.native.execute(intent.native, wallet.native, waitForFinalization)) + execute(intent: IntentCall, wallet: RustWallet, waitForFinalization = true): Promise { + return nativeAsync(() => this.native.execute(intent.native, wallet.native, waitForFinalization)) } } diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 96bcdb6e7f..3108c1221d 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -123,9 +123,8 @@ function fakeSigningClient(runtime, callData) { client.finalizedHead = async () => finalizedHash client.blockNumber = async () => 64 client.genesisHash = async () => `0x${'41'.repeat(32)}` - client.rpc = async (method, params = []) => { + client.rpc = async (method) => { if (method === 'system_accountNextIndex') { - client.lastNonceAddress = params[0] return 12 } if (method === 'state_getRuntimeVersion') { @@ -1327,21 +1326,21 @@ test('descriptor schema validation accepts metadata-local type ID shifts', () => { name: 'SubtensorModule', calls: [ - { name: 'add_stake', args: ['hotkey', 'netuid', 'amount_staked'], argTypeIds: [0, 40, 6] }, - { name: 'burned_register', args: ['netuid', 'hotkey'], argTypeIds: [40, 0] }, - { name: 'commit_weights', args: ['netuid', 'commit_hash'], argTypeIds: [40, 13] }, - { name: 'move_stake', args: ['origin_hotkey', 'destination_hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypeIds: [0, 0, 40, 40, 6] }, - { name: 'register', args: ['netuid', 'block_number', 'nonce', 'work', 'hotkey', 'coldkey'], argTypeIds: [40, 6, 6, 14, 0, 0] }, + { name: 'add_stake', args: ['hotkey', 'netuid', 'amount_staked'], argTypeIds: [0, 41, 6] }, + { name: 'burned_register', args: ['netuid', 'hotkey'], argTypeIds: [41, 0] }, + { name: 'commit_weights', args: ['netuid', 'commit_hash'], argTypeIds: [41, 13] }, + { name: 'move_stake', args: ['origin_hotkey', 'destination_hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypeIds: [0, 0, 41, 41, 7] }, + { name: 'register', args: ['netuid', 'block_number', 'nonce', 'work', 'hotkey', 'coldkey'], argTypeIds: [41, 9, 9, 14, 0, 0] }, { name: 'register_network', args: ['hotkey'], argTypeIds: [0] }, - { name: 'remove_stake', args: ['hotkey', 'netuid', 'amount_unstaked'], argTypeIds: [0, 40, 6] }, - { name: 'reveal_weights', args: ['netuid', 'uids', 'values', 'salt', 'version_key'], argTypeIds: [40, 209, 209, 209, 6] }, + { name: 'remove_stake', args: ['hotkey', 'netuid', 'amount_unstaked'], argTypeIds: [0, 41, 7] }, + { name: 'reveal_weights', args: ['netuid', 'uids', 'values', 'salt', 'version_key'], argTypeIds: [41, 209, 209, 209, 9] }, { name: 'root_register', args: ['hotkey'], argTypeIds: [0] }, - { name: 'serve_axon', args: ['netuid', 'version', 'ip', 'port', 'ip_type', 'protocol', 'placeholder1', 'placeholder2'], argTypeIds: [40, 4, 8, 40, 2, 2, 2, 2] }, - { name: 'serve_prometheus', args: ['netuid', 'version', 'ip', 'port', 'ip_type'], argTypeIds: [40, 4, 8, 40, 2] }, - { name: 'set_children', args: ['hotkey', 'netuid', 'children'], argTypeIds: [0, 40, 44] }, - { name: 'set_weights', args: ['netuid', 'dests', 'weights', 'version_key'], argTypeIds: [40, 209, 209, 6] }, - { name: 'start_call', args: ['netuid'], argTypeIds: [40] }, - { name: 'transfer_stake', args: ['destination_coldkey', 'hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypeIds: [0, 0, 40, 40, 6] }, + { name: 'serve_axon', args: ['netuid', 'version', 'ip', 'port', 'ip_type', 'protocol', 'placeholder1', 'placeholder2'], argTypeIds: [41, 4, 8, 5, 2, 2, 2, 2] }, + { name: 'serve_prometheus', args: ['netuid', 'version', 'ip', 'port', 'ip_type'], argTypeIds: [41, 4, 8, 5, 2] }, + { name: 'set_children', args: ['hotkey', 'netuid', 'children'], argTypeIds: [0, 41, 44] }, + { name: 'set_weights', args: ['netuid', 'dests', 'weights', 'version_key'], argTypeIds: [41, 209, 209, 9] }, + { name: 'start_call', args: ['netuid'], argTypeIds: [41] }, + { name: 'transfer_stake', args: ['destination_coldkey', 'hotkey', 'origin_netuid', 'destination_netuid', 'alpha_amount'], argTypeIds: [0, 0, 41, 41, 7] }, { name: 'unstake_all', args: ['hotkey'], argTypeIds: [0] }, ], }, @@ -1353,14 +1352,17 @@ test('descriptor schema validation accepts metadata-local type ID shifts', () => 0: 'AccountId32', 2: 'u8', 4: 'u32', - 6: 'u64', + 5: 'u16', + 6: 'TaoBalance', + 7: 'AlphaBalance', 8: 'u128', + 9: 'u64', 13: 'H256', 14: 'Vec', - 40: 'u16', + 41: 'NetUid', 44: 'Vec<(u64, AccountId32)>', 176: 'MultiAddress', - 178: 'Compact', + 178: 'Compact', 209: 'Vec', }[typeId] ?? null }, @@ -2247,11 +2249,10 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => }, } - const signed = await client.signExtrinsic(callData, signer, { period: null, allowRawCall: true }) + const signed = await client.signExtrinsic(callData, signer, { period: null, nonce: 12, allowRawCall: true }) assert.equal(signed.signerAddress, address) assert.equal(signed.nonce, 12) - assert.equal(client.lastNonceAddress, address) assert.equal(request.address, address) assert.equal(request.type, 'bytes') assert.equal(request.data, '0x090909') @@ -2261,26 +2262,35 @@ test('Client signs extrinsics with extension-style signRaw signers', async () => assert.deepEqual(captures.encoded.signature, Buffer.alloc(64, 9)) assert.equal(captures.encoded.signatureVersion, core.CRYPTO_SR25519) assert.equal(captures.encoded.params.metadataHashEnabled, false) - assert.equal(await client.accountNextIndex(address), 12) - assert.equal(await client.accountNextIndex(address), 12) await assert.rejects( - () => client.signExtrinsic(callData, signer, { period: null, allowRawCall: true, tip: core.Balance.fromAlpha('1', 7) }), + () => client.signExtrinsic(callData, signer, { period: null, nonce: 12, allowRawCall: true, tip: core.Balance.fromAlpha('1', 7) }), /tip must be a TAO balance/, ) await assert.rejects( - () => client.signExtrinsic(callData, signer, { period: null, allowRawCall: true, tipAssetId: core.taoAmount('1') }), + () => client.signExtrinsic(callData, signer, { period: null, nonce: 12, allowRawCall: true, tipAssetId: core.taoAmount('1') }), /tipAssetId must be a bigint/, ) await assert.rejects( - () => client.signExtrinsic(callData, signer, { period: null, allowRawCall: true, tipAssetId: -1 }), + () => client.signExtrinsic(callData, signer, { period: null, nonce: 12, allowRawCall: true, tipAssetId: -1 }), /tipAssetId must be non-negative/, ) await assert.rejects( - () => client.signExtrinsic(callData, { ...signer, publicKey: Buffer.alloc(32, 7) }, { period: null, allowRawCall: true }), + () => client.signExtrinsic(callData, { ...signer, publicKey: Buffer.alloc(32, 7) }, { period: null, nonce: 12, allowRawCall: true }), /publicKey does not match/, ) }) +test('Client manual signing requires an explicit nonce', async () => { + const { runtime } = fakeSigningRuntime() + const client = fakeSigningClient(runtime, Buffer.from([5, 6, 7])) + const signer = core.Keypair.fromUri('//Alice') + + await assert.rejects( + () => client.signExtrinsic(core.IntentCall.transfer(signer.ss58Address, 1n), signer), + /signExtrinsic requires options\.nonce/, + ) +}) + test('Client rejects raw call shapes unless callers opt in', async () => { const { runtime } = fakeSigningRuntime() const callData = Buffer.from([5, 6, 7]) @@ -2288,16 +2298,17 @@ test('Client rejects raw call shapes unless callers opt in', async () => { const signer = core.Keypair.fromUri('//Alice') await assert.rejects( - () => client.signExtrinsic(callData, signer, { period: null }), + () => client.signExtrinsic(callData, signer, { period: null, nonce: 12 }), /opaque call bytes require explicit raw-call permission/, ) await assert.rejects( - () => client.signExtrinsic(['System', 'remark', { remark: Buffer.from('hello') }], signer, { period: null }), + () => client.signExtrinsic(['System', 'remark', { remark: Buffer.from('hello') }], signer, { period: null, nonce: 12 }), /raw metadata calls require explicit raw-call permission/, ) await assert.rejects( () => client.signExtrinsic(callData, signer, { period: null, + nonce: 12, policy: new core.Policy({ allowRawCalls: true, maxSpendRao: 1n }), }), /opaque call bytes cannot prove fee, spend, or subnet policy/, @@ -2312,7 +2323,7 @@ test('Client accepts raw metadata calls only with explicit raw permission', asyn const signed = await client.signExtrinsic( ['System', 'remark', { remark: Buffer.from('hello') }], signer, - { period: null, allowRawCall: true }, + { period: null, nonce: 12, allowRawCall: true }, ) assert.ok(signed.bytes.length > 0) @@ -2364,7 +2375,7 @@ test('Client enables metadata hash by default for software signers when supporte }, } - await client.signExtrinsic(callData, signer, { period: null, allowRawCall: true }) + await client.signExtrinsic(callData, signer, { period: null, nonce: 12, allowRawCall: true }) const expectedMetadataHash = core.metadataDigest(metadataBytes, { specVersion: runtime.specVersion, @@ -2377,12 +2388,12 @@ test('Client enables metadata hash by default for software signers when supporte assert.deepEqual(captures.payloadParams.metadataHash, expectedMetadataHash) assert.equal(request.metadataHash, `0x${expectedMetadataHash.toString('hex')}`) await assert.rejects( - () => client.signExtrinsic(callData, signer, { period: null, allowRawCall: true, metadataHash: null }), + () => client.signExtrinsic(callData, signer, { period: null, nonce: 12, allowRawCall: true, metadataHash: null }), /metadataHash cannot be disabled/, ) const explicitMetadataHash = Buffer.alloc(32, 3) - await client.signExtrinsic(callData, signer, { period: null, allowRawCall: true, metadataHash: explicitMetadataHash }) + await client.signExtrinsic(callData, signer, { period: null, nonce: 12, allowRawCall: true, metadataHash: explicitMetadataHash }) assert.equal(captures.encoded.params.metadataHashEnabled, true) assert.deepEqual(captures.payloadParams.metadataHash, explicitMetadataHash) assert.equal(request.metadataHash, `0x${explicitMetadataHash.toString('hex')}`) @@ -2437,6 +2448,7 @@ test('Client passes structured payloads to extension signPayload signers', async await client.signExtrinsic(callData, signer, { allowRawCall: true, period: 64, + nonce: 12, tip: core.raoAmount(1), tipAssetId: 0n, metadataHash: Buffer.alloc(32, 3), @@ -2464,7 +2476,6 @@ test('Client rejects invalid chain nonce values', async () => { test('Client rejects mismatched submit hashes and keeps local hash authoritative', async () => { const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) - client.signedExtrinsicNonceTracking = async () => ({}) client.transport.request = async (method) => { assert.equal(method, 'author_submitExtrinsic') return `0x${'00'.repeat(32)}` @@ -2472,7 +2483,26 @@ test('Client rejects mismatched submit hashes and keeps local hash authoritative await assert.rejects(() => client.submitSigned(Buffer.from([1, 2])), /returned hash/) }) -test('Client reconciles managed nonce before rejecting mismatched submit hash', async () => { +test('Client submit with a JavaScript signer requires an explicit nonce', async () => { + const callData = Buffer.from([7, 5, 3]) + const { runtime } = fakeSigningRuntime() + const client = fakeSigningClient(runtime, callData) + const publicKey = Buffer.alloc(32, 22) + const signer = { + address: core.ss58FromPublic(publicKey, 42), + publicKey, + signRaw() { + throw new Error('signRaw should not be reached without a nonce') + }, + } + + await assert.rejects( + () => client.submit(callData, signer, { period: null, allowRawCall: true }), + /submit requires options\.nonce/, + ) +}) + +test('Client submit with a JavaScript signer uses the explicit nonce without account coordination', async () => { const callData = Buffer.from([7, 5, 3]) const capturedNonces = [] const { runtime } = fakeSigningRuntime({ @@ -2488,7 +2518,7 @@ test('Client reconciles managed nonce before rejecting mismatched submit hash', }, }) const client = fakeSigningClient(runtime, callData) - const publicKey = Buffer.alloc(32, 22) + const publicKey = Buffer.alloc(32, 23) const address = core.ss58FromPublic(publicKey, 42) const typedSignature = Buffer.concat([ Buffer.from([core.CRYPTO_SR25519]), @@ -2498,7 +2528,7 @@ test('Client reconciles managed nonce before rejecting mismatched submit hash', client.rpc = async (method, params = []) => { if (method === 'system_accountNextIndex') { nonceReads.push(params[0]) - return 30 + return 99 } if (method === 'state_getRuntimeVersion') { return { @@ -2510,11 +2540,7 @@ test('Client reconciles managed nonce before rejecting mismatched submit hash', if (method === 'system_properties') { return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } } - if (method === 'author_submitExtrinsic') return `0x${'ff'.repeat(32)}` - if (method === 'author_pendingExtrinsics') return [] - if (method === 'chain_getHeader') return { number: '0x0' } - if (method === 'chain_getBlockHash') return `0x${'02'.repeat(32)}` - if (method === 'chain_getBlock') return { block: { extrinsics: [] } } + if (method === 'author_submitExtrinsic') return submittedExtrinsicHash(params[0]) throw new Error(`unexpected RPC ${method}`) } const signer = { @@ -2525,16 +2551,179 @@ test('Client reconciles managed nonce before rejecting mismatched submit hash', }, } - await assert.rejects( - () => client.submit(callData, signer, { period: null, allowRawCall: true }), - /returned hash/, - ) - await assert.rejects( - () => client.submit(callData, signer, { period: null, allowRawCall: true }), - /nonce 30 .* ambiguous/, - ) + const result = await client.submit(callData, signer, { period: null, nonce: 30, allowRawCall: true }) + + assert.equal(result.status, 'submitted') assert.deepEqual(capturedNonces, [30]) - assert.deepEqual(nonceReads, [address, address, address]) + assert.deepEqual(nonceReads, []) +}) + +test('Client submit delegates automatic Keypair nonces to the Rust client', async () => { + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const signer = core.Keypair.fromUri('//Alice') + const callData = Buffer.from([1, 2, 3]) + const { runtime } = fakeSigningRuntime() + client.headRuntime = async () => runtime + const calls = [] + const plan = { + callData, + signerAddress: signer.ss58Address, + publicKey: signer.publicKey, + cryptoType: signer.cryptoType, + nonce: 77n, + payload: Buffer.from([9, 8, 7]), + includedInExtrinsic: Buffer.alloc(0), + includedInSignedData: Buffer.alloc(0), + metadataHash: null, + metadataProof: null, + txParams: { + era: '00', + nonce: 77n, + tip: 0n, + tipAssetId: null, + genesisHash: Buffer.alloc(32, 1), + eraBlockHash: Buffer.alloc(32, 1), + metadataHash: null, + }, + signerPayload: {}, + chainInfo: null, + feeRao: null, + warnings: [], + } + client.tryNativeClient = () => ({ + externalSigningPlan(submittedCallData, signerAddress, publicKey, cryptoType, requiresMetadataProof, options) { + calls.push({ stage: 'plan', submittedCallData, signerAddress, publicKey, cryptoType, requiresMetadataProof, options }) + return plan + }, + submitExternal(submittedPlan, signature, waitForFinalization, cryptoType) { + calls.push({ stage: 'submit', submittedPlan, signature, waitForFinalization, cryptoType }) + return { + success: true, + extrinsicHash: `0x${'11'.repeat(32)}`, + blockHash: `0x${'22'.repeat(32)}`, + blockNumber: 7n, + extrinsicIndex: 2, + feeRao: null, + events: [], + error: null, + message: 'Success', + } + }, + }) + + const result = await client.submit(callData, signer, { allowRawCall: true }) + + assert.equal(result.status, 'inBlock') + assert.equal(result.extrinsicId, '7-0002') + assert.equal(calls.length, 2) + assert.equal(calls[0].stage, 'plan') + assert.deepEqual(calls[0].submittedCallData, callData) + assert.equal(calls[0].signerAddress, signer.ss58Address) + assert.deepEqual(calls[0].publicKey, signer.publicKey) + assert.equal(calls[0].cryptoType, signer.cryptoType) + assert.equal(calls[0].requiresMetadataProof, false) + assert.deepEqual(calls[0].options, {}) + assert.equal(calls[1].stage, 'submit') + assert.equal(calls[1].submittedPlan, plan) + assert.equal(calls[1].signature.length, 64) + assert.equal(calls[1].waitForFinalization, false) + assert.equal(calls[1].cryptoType, signer.cryptoType) +}) + +test('Client submit uses the Rust signing plan for external signers', async () => { + const callData = Buffer.from([3, 1, 4]) + const { runtime } = fakeSigningRuntime() + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + client.headRuntime = async () => runtime + const publicKey = Buffer.alloc(32, 24) + const address = core.ss58FromPublic(publicKey, 42) + const signerPayload = { + address, + blockHash: `0x${'10'.repeat(32)}`, + blockNumber: '0x00000000', + era: '0x00', + genesisHash: `0x${'10'.repeat(32)}`, + method: `0x${callData.toString('hex')}`, + nonce: '0x2c', + signedExtensions: ['CheckNonce'], + specVersion: '0x01000000', + tip: '0x00', + transactionVersion: '0x01000000', + version: 4, + assetId: null, + metadataHash: null, + mode: null, + } + const plan = { + callData, + signerAddress: address, + publicKey, + cryptoType: core.CRYPTO_SR25519, + nonce: 44n, + payload: Buffer.from([4, 4, 4]), + includedInExtrinsic: Buffer.from([1]), + includedInSignedData: Buffer.from([2]), + metadataHash: null, + metadataProof: null, + txParams: { + era: '00', + nonce: 44n, + tip: 0n, + tipAssetId: null, + genesisHash: Buffer.alloc(32, 1), + eraBlockHash: Buffer.alloc(32, 1), + metadataHash: null, + }, + signerPayload, + chainInfo: null, + feeRao: '125', + warnings: [], + } + const calls = [] + client.tryNativeClient = () => ({ + externalSigningPlan(submittedCallData, signerAddress, submittedPublicKey, cryptoType, requiresMetadataProof, options) { + calls.push({ stage: 'plan', submittedCallData, signerAddress, submittedPublicKey, cryptoType, requiresMetadataProof, options }) + return plan + }, + submitExternal(submittedPlan, signature, waitForFinalization, cryptoType) { + calls.push({ stage: 'submit', submittedPlan, signature, waitForFinalization, cryptoType }) + return { + success: true, + extrinsicHash: `0x${'33'.repeat(32)}`, + blockHash: `0x${'44'.repeat(32)}`, + blockNumber: 8n, + extrinsicIndex: 1, + feeRao: '125', + events: [], + error: null, + message: 'Success', + } + }, + }) + const signer = { + address, + publicKey, + signPayload(payload, context) { + calls.push({ stage: 'sign', payload, context }) + return { signature: Buffer.concat([Buffer.from([core.CRYPTO_SR25519]), Buffer.alloc(64, 6)]) } + }, + } + + const result = await client.submit(callData, signer, { allowRawCall: true }) + + assert.equal(result.status, 'inBlock') + assert.equal(result.fee.rao, 125n) + assert.equal(calls.length, 3) + assert.equal(calls[0].stage, 'plan') + assert.deepEqual(calls[0].submittedCallData, callData) + assert.equal(calls[0].options.nonce, undefined) + assert.equal(calls[1].stage, 'sign') + assert.deepEqual(calls[1].payload, signerPayload) + assert.deepEqual(calls[1].context.payload, plan.payload) + assert.equal(calls[2].stage, 'submit') + assert.equal(calls[2].submittedPlan, plan) + assert.deepEqual(calls[2].signature, Buffer.alloc(64, 6)) + assert.equal(calls[2].cryptoType, core.CRYPTO_SR25519) }) test('Client estimateFee peeks the chain nonce without reserving it', async () => { @@ -2617,340 +2806,6 @@ test('Client estimateFee peeks the chain nonce without reserving it', async () = assert.equal(stateCallParams[2], `0x${'42'.repeat(32)}`) }) -test('Client serializes concurrent initial nonce reservations during submit', async () => { - const callData = Buffer.from([8, 8, 8]) - const capturedNonces = [] - const { runtime } = fakeSigningRuntime({ - signaturePayload(_callData, params) { - capturedNonces.push(params.nonce) - return Buffer.from([Number(params.nonce)]) - }, - encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { - return { - bytes: Buffer.from([signatureVersion, Number(params.nonce)]), - hash: Buffer.alloc(32, Number(params.nonce)), - } - }, - }) - const client = fakeSigningClient(runtime, callData) - const publicKey = Buffer.alloc(32, 11) - const address = core.ss58FromPublic(publicKey, 42) - const typedSignature = Buffer.concat([ - Buffer.from([core.CRYPTO_SR25519]), - Buffer.alloc(64, 5), - ]) - let reads = 0 - let releaseRead - const readGate = new Promise((resolve) => { - releaseRead = resolve - }) - client.rpc = async (method, params = []) => { - if (method === 'system_accountNextIndex') { - assert.equal(params[0], address) - reads += 1 - await readGate - return 14 - } - if (method === 'state_getRuntimeVersion') { - return { - specName: 'node-subtensor', - specVersion: runtime.specVersion, - transactionVersion: runtime.transactionVersion, - } - } - if (method === 'system_properties') { - return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } - } - if (method === 'author_submitExtrinsic') return submittedExtrinsicHash(params[0]) - throw new Error(`unexpected RPC ${method}`) - } - const signer = { - address, - publicKey, - signRaw() { - return { signature: `0x${typedSignature.toString('hex')}` } - }, - } - - const first = client.submit(callData, signer, { period: null, allowRawCall: true }) - const second = client.submit(callData, signer, { period: null, allowRawCall: true }) - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(reads, 1) - releaseRead() - - await Promise.all([first, second]) - assert.equal(reads, 1) - assert.deepEqual(capturedNonces.sort((a, b) => a - b), [14, 15]) -}) - -test('Client releases only the failed reserved nonce', async () => { - const callData = Buffer.from([3, 2, 1]) - const capturedNonces = [] - const { runtime } = fakeSigningRuntime({ - signaturePayload(_callData, params) { - capturedNonces.push(params.nonce) - return Buffer.from([Number(params.nonce)]) - }, - encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { - return { - bytes: Buffer.from([signatureVersion, Number(params.nonce)]), - hash: Buffer.alloc(32, Number(params.nonce)), - } - }, - }) - const client = fakeSigningClient(runtime, callData) - const publicKey = Buffer.alloc(32, 12) - const address = core.ss58FromPublic(publicKey, 42) - const typedSignature = Buffer.concat([ - Buffer.from([core.CRYPTO_SR25519]), - Buffer.alloc(64, 2), - ]) - let releaseSign - let signStarted - const signGate = new Promise((resolve) => { - releaseSign = resolve - }) - const started = new Promise((resolve) => { - signStarted = resolve - }) - client.rpc = async (method, params = []) => { - if (method === 'system_accountNextIndex') return 12 - if (method === 'state_getRuntimeVersion') { - return { - specName: 'node-subtensor', - specVersion: runtime.specVersion, - transactionVersion: runtime.transactionVersion, - } - } - if (method === 'system_properties') { - return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } - } - if (method === 'author_submitExtrinsic') return submittedExtrinsicHash(params[0]) - throw new Error(`unexpected RPC ${method}`) - } - const failingSigner = { - address, - publicKey, - async signRaw() { - signStarted() - await signGate - throw new Error('signer declined') - }, - } - const succeedingSigner = { - address, - publicKey, - signRaw() { - return { signature: `0x${typedSignature.toString('hex')}` } - }, - } - - const signing = client.submit(callData, failingSigner, { period: null, allowRawCall: true }) - await started - const independentlySubmitted = await client.submit(callData, succeedingSigner, { period: null, allowRawCall: true }) - releaseSign() - await assert.rejects(signing, /signer declined/) - await independentlySubmitted - await client.submit(callData, succeedingSigner, { period: null, allowRawCall: true }) - - assert.deepEqual(capturedNonces, [12, 13, 12]) -}) - -test('Client quarantines an ambiguous submit nonce even when a fallback node reports it absent', async () => { - const callData = Buffer.from([4, 5, 6]) - const capturedNonces = [] - const { runtime } = fakeSigningRuntime({ - signaturePayload(_callData, params) { - capturedNonces.push(params.nonce) - return Buffer.from([Number(params.nonce)]) - }, - encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { - return { - bytes: Buffer.from([signatureVersion, Number(params.nonce)]), - hash: Buffer.alloc(32, Number(params.nonce)), - } - }, - }) - const client = fakeSigningClient(runtime, callData) - const publicKey = Buffer.alloc(32, 13) - const address = core.ss58FromPublic(publicKey, 42) - const typedSignature = Buffer.concat([ - Buffer.from([core.CRYPTO_SR25519]), - Buffer.alloc(64, 8), - ]) - const nonceReads = [] - let submitAttempts = 0 - client.rpc = async (method, params = []) => { - if (method === 'system_accountNextIndex') { - nonceReads.push(params[0]) - return 20 - } - if (method === 'state_getRuntimeVersion') { - return { - specName: 'node-subtensor', - specVersion: runtime.specVersion, - transactionVersion: runtime.transactionVersion, - } - } - if (method === 'system_properties') { - return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } - } - if (method === 'author_submitExtrinsic') { - submitAttempts += 1 - if (submitAttempts === 1) throw new core.JsonRpcError('lost response') - return submittedExtrinsicHash(params[0]) - } - if (method === 'author_pendingExtrinsics') return [] - if (method === 'chain_getHeader') return { number: '0x0' } - if (method === 'chain_getBlockHash') return `0x${'01'.repeat(32)}` - if (method === 'chain_getBlock') return { block: { extrinsics: [] } } - throw new Error(`unexpected RPC ${method}`) - } - const signer = { - address, - publicKey, - signRaw() { - return { signature: `0x${typedSignature.toString('hex')}` } - }, - } - - await assert.rejects(() => client.submit(callData, signer, { period: null, allowRawCall: true }), /lost response/) - await assert.rejects( - () => client.submit(callData, signer, { period: null, allowRawCall: true }), - /nonce 20 .* ambiguous/, - ) - assert.deepEqual(capturedNonces, [20]) - assert.deepEqual(nonceReads, [address, address, address]) -}) - -test('Client invalidates nonce state after unknown ambiguous submission reconciliation', async () => { - const callData = Buffer.from([4, 5, 8]) - const capturedNonces = [] - const { runtime } = fakeSigningRuntime({ - signaturePayload(_callData, params) { - capturedNonces.push(params.nonce) - return Buffer.from([Number(params.nonce)]) - }, - encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { - return { - bytes: Buffer.from([signatureVersion, Number(params.nonce)]), - hash: Buffer.alloc(32, Number(params.nonce)), - } - }, - }) - const client = fakeSigningClient(runtime, callData) - const publicKey = Buffer.alloc(32, 16) - const address = core.ss58FromPublic(publicKey, 42) - const typedSignature = Buffer.concat([ - Buffer.from([core.CRYPTO_SR25519]), - Buffer.alloc(64, 9), - ]) - let submitAttempts = 0 - let networkRestored = false - client.rpc = async (method, params = []) => { - if (method === 'system_accountNextIndex') { - if (submitAttempts === 0) return 50 - if (!networkRestored) throw new Error('network still unavailable') - return 55 - } - if (method === 'state_getRuntimeVersion') { - return { - specName: 'node-subtensor', - specVersion: runtime.specVersion, - transactionVersion: runtime.transactionVersion, - } - } - if (method === 'system_properties') { - return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } - } - if (method === 'author_submitExtrinsic') { - submitAttempts += 1 - if (submitAttempts === 1) { - throw new core.JsonRpcError('lost response') - } - return submittedExtrinsicHash(params[0]) - } - if (method === 'author_pendingExtrinsics') throw new Error('network still unavailable') - throw new Error(`unexpected RPC ${method}`) - } - const signer = { - address, - publicKey, - signRaw() { - return { signature: `0x${typedSignature.toString('hex')}` } - }, - } - - await assert.rejects(() => client.submit(callData, signer, { period: null, allowRawCall: true }), /lost response/) - networkRestored = true - await client.submit(callData, signer, { period: null, allowRawCall: true }) - - assert.deepEqual(capturedNonces, [50, 55]) -}) - -test('Client protects an ambiguous submit nonce when the extrinsic is still pending', async () => { - const callData = Buffer.from([4, 5, 7]) - const capturedNonces = [] - const { runtime } = fakeSigningRuntime({ - signaturePayload(_callData, params) { - capturedNonces.push(params.nonce) - return Buffer.from([Number(params.nonce)]) - }, - encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { - return { - bytes: Buffer.from([signatureVersion, Number(params.nonce)]), - hash: Buffer.alloc(32, Number(params.nonce)), - } - }, - }) - const client = fakeSigningClient(runtime, callData) - const publicKey = Buffer.alloc(32, 15) - const address = core.ss58FromPublic(publicKey, 42) - const typedSignature = Buffer.concat([ - Buffer.from([core.CRYPTO_SR25519]), - Buffer.alloc(64, 3), - ]) - const nonceReads = [] - let submitAttempts = 0 - let submittedHex - client.rpc = async (method, params = []) => { - if (method === 'system_accountNextIndex') { - nonceReads.push(params[0]) - return 40 - } - if (method === 'state_getRuntimeVersion') { - return { - specName: 'node-subtensor', - specVersion: runtime.specVersion, - transactionVersion: runtime.transactionVersion, - } - } - if (method === 'system_properties') { - return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } - } - if (method === 'author_submitExtrinsic') { - submitAttempts += 1 - submittedHex = params[0] - if (submitAttempts === 1) throw new core.JsonRpcError('lost response') - return submittedExtrinsicHash(params[0]) - } - if (method === 'author_pendingExtrinsics') return [submittedHex] - throw new Error(`unexpected RPC ${method}`) - } - const signer = { - address, - publicKey, - signRaw() { - return { signature: `0x${typedSignature.toString('hex')}` } - }, - } - - await assert.rejects(() => client.submit(callData, signer, { period: null, allowRawCall: true }), /lost response/) - await client.submit(callData, signer, { period: null, allowRawCall: true }) - assert.deepEqual(capturedNonces, [40, 41]) - assert.deepEqual(nonceReads, [address, address]) -}) - test('Client submit without inclusion reports pool submission, not execution success', async () => { const callData = Buffer.from([7, 7, 7]) const { runtime } = fakeSigningRuntime() @@ -2962,7 +2817,7 @@ test('Client submit without inclusion reports pool submission, not execution suc Buffer.alloc(64, 6), ]) client.rpc = async (method, params = []) => { - if (method === 'system_accountNextIndex') return 30 + if (method === 'system_accountNextIndex') throw new Error('submit should not read a nonce') if (method === 'state_getRuntimeVersion') { return { specName: 'node-subtensor', @@ -2984,12 +2839,11 @@ test('Client submit without inclusion reports pool submission, not execution suc }, } - const result = await client.submit(callData, signer, { period: null, allowRawCall: true }) + const result = await client.submit(callData, signer, { period: null, nonce: 30, allowRawCall: true }) assert.equal(result.status, 'submitted') assert.equal(result.success, undefined) assert.equal(result.message, 'Submitted') - assert.equal(client.nonceAccounts.get(address).statuses.size, 0) }) test('Client treats string and object fatal watch statuses as failures', async () => { @@ -3061,50 +2915,11 @@ test('Client reports included extrinsics with missing dispatch outcome as unknow assert.equal(result.extrinsicId, '42-0000') }) -test('Client records detached submitSigned nonces before the next managed submit', async () => { - const callData = Buffer.from([2, 4, 6]) - const capturedNonces = [] +test('Client submitSigned submits detached bytes without nonce coordination', async () => { const submitMaxRetries = [] const submitRetryForever = [] - const publicKey = Buffer.alloc(32, 18) - const address = core.ss58FromPublic(publicKey, 42) - const { runtime } = fakeSigningRuntime({ - signaturePayload(_callData, params) { - capturedNonces.push(params.nonce) - return Buffer.from([Number(params.nonce)]) - }, - encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { - return { - bytes: Buffer.from([signatureVersion, Number(params.nonce)]), - hash: Buffer.alloc(32, Number(params.nonce)), - } - }, - decodeExtrinsic(data) { - return { address, nonce: data[1] } - }, - }) - const client = fakeSigningClient(runtime, callData) - const typedSignature = Buffer.concat([ - Buffer.from([core.CRYPTO_SR25519]), - Buffer.alloc(64, 4), - ]) - let nonceReads = 0 - client.rpc = async (method, params = [], options = {}) => { - if (method === 'system_accountNextIndex') { - assert.equal(params[0], address) - nonceReads += 1 - return nonceReads === 1 ? 70 : 71 - } - if (method === 'state_getRuntimeVersion') { - return { - specName: 'node-subtensor', - specVersion: runtime.specVersion, - transactionVersion: runtime.transactionVersion, - } - } - if (method === 'system_properties') { - return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } - } + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + client.transport.request = async (method, params = [], options = {}) => { if (method === 'author_submitExtrinsic') { submitMaxRetries.push(options.maxRetries) submitRetryForever.push(options.retryForever) @@ -3112,145 +2927,21 @@ test('Client records detached submitSigned nonces before the next managed submit } throw new Error(`unexpected RPC ${method}`) } - const signer = { - address, - publicKey, - signRaw() { - return { signature: `0x${typedSignature.toString('hex')}` } - }, + const detached = { + bytes: Buffer.from([9, 9, 9]), + hash: Buffer.alloc(32, 9), + hex: '0x090909', + signerAddress: 'mutating this does not affect any nonce cache', + nonce: 999, } - await client.submit(callData, signer, { period: null, allowRawCall: true }) - const detached = await client.signExtrinsic(callData, signer, { period: null, allowRawCall: true }) - assert.equal(detached.nonce, 71) await client.submitSigned(detached) - await client.submit(callData, signer, { period: null, allowRawCall: true }) - assert.deepEqual(capturedNonces, [70, 71, 72]) - assert.deepEqual(submitMaxRetries, [0, 0, 0]) - assert.deepEqual(submitRetryForever, [false, false, false]) - assert.equal(nonceReads, 2) + assert.deepEqual(submitMaxRetries, [0]) + assert.deepEqual(submitRetryForever, [false]) }) -test('Client decodes detached signed nonce instead of trusting mutable public fields', async () => { - const callData = Buffer.from([2, 4, 5]) - const capturedNonces = [] - const publicKey = Buffer.alloc(32, 20) - const address = core.ss58FromPublic(publicKey, 42) - const { runtime } = fakeSigningRuntime({ - signaturePayload(_callData, params) { - capturedNonces.push(params.nonce) - return Buffer.from([Number(params.nonce)]) - }, - encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { - return { - bytes: Buffer.from([signatureVersion, Number(params.nonce)]), - hash: Buffer.alloc(32, Number(params.nonce)), - } - }, - decodeExtrinsic(data) { - return { address, nonce: data[1] } - }, - }) - const client = fakeSigningClient(runtime, callData) - const typedSignature = Buffer.concat([ - Buffer.from([core.CRYPTO_SR25519]), - Buffer.alloc(64, 6), - ]) - client.rpc = async (method, params = []) => { - if (method === 'system_accountNextIndex') { - assert.equal(params[0], address) - return 70 - } - if (method === 'state_getRuntimeVersion') { - return { - specName: 'node-subtensor', - specVersion: runtime.specVersion, - transactionVersion: runtime.transactionVersion, - } - } - if (method === 'system_properties') { - return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } - } - if (method === 'author_submitExtrinsic') return submittedExtrinsicHash(params[0]) - throw new Error(`unexpected RPC ${method}`) - } - const signer = { - address, - publicKey, - signRaw() { - return { signature: `0x${typedSignature.toString('hex')}` } - }, - } - - const detached = await client.signExtrinsic(callData, signer, { period: null, allowRawCall: true }) - detached.signerAddress = core.ss58FromPublic(Buffer.alloc(32, 99), 42) - detached.nonce = 999 - await client.submitSigned(detached) - await client.submit(callData, signer, { period: null, allowRawCall: true }) - - assert.deepEqual(capturedNonces, [70, 71]) -}) - -test('Client invalidates nonce state for opaque externally signed submissions', async () => { - const callData = Buffer.from([2, 4, 7]) - const capturedNonces = [] - const { runtime } = fakeSigningRuntime({ - signaturePayload(_callData, params) { - capturedNonces.push(params.nonce) - return Buffer.from([Number(params.nonce)]) - }, - encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { - return { - bytes: Buffer.from([signatureVersion, Number(params.nonce)]), - hash: Buffer.alloc(32, Number(params.nonce)), - } - }, - }) - const client = fakeSigningClient(runtime, callData) - const publicKey = Buffer.alloc(32, 21) - const address = core.ss58FromPublic(publicKey, 42) - const typedSignature = Buffer.concat([ - Buffer.from([core.CRYPTO_SR25519]), - Buffer.alloc(64, 2), - ]) - let nonceReads = 0 - client.rpc = async (method, params = []) => { - if (method === 'system_accountNextIndex') { - assert.equal(params[0], address) - nonceReads += 1 - return nonceReads === 1 ? 90 : 100 - } - if (method === 'state_getRuntimeVersion') { - return { - specName: 'node-subtensor', - specVersion: runtime.specVersion, - transactionVersion: runtime.transactionVersion, - } - } - if (method === 'system_properties') { - return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } - } - if (method === 'author_submitExtrinsic') return submittedExtrinsicHash(params[0]) - throw new Error(`unexpected RPC ${method}`) - } - const signer = { - address, - publicKey, - signRaw() { - return { signature: `0x${typedSignature.toString('hex')}` } - }, - } - - await client.submit(callData, signer, { period: null, allowRawCall: true }) - await client.submitSigned(Buffer.from([9, 9, 9]), address) - await client.submit(callData, signer, { period: null, allowRawCall: true }) - - assert.deepEqual(capturedNonces, [90, 100]) - assert.equal(nonceReads, 2) -}) - -test('Client records detached watchSigned nonces before the next managed submit', async (t) => { +test('Client watchSigned submits detached bytes without nonce coordination', async (t) => { const { FakeWebSocket, restore } = installFakeWebSocket() t.after(restore) FakeWebSocket.onSend = (socket, message) => { @@ -3272,26 +2963,7 @@ test('Client records detached watchSigned nonces before the next managed submit' } } - const callData = Buffer.from([2, 4, 8]) - const capturedNonces = [] - const publicKey = Buffer.alloc(32, 19) - const address = core.ss58FromPublic(publicKey, 42) - const { runtime } = fakeSigningRuntime({ - signaturePayload(_callData, params) { - capturedNonces.push(params.nonce) - return Buffer.from([Number(params.nonce)]) - }, - encodeSignedExtrinsic(_callData, _publicKey, _signature, signatureVersion, params) { - return { - bytes: Buffer.from([signatureVersion, Number(params.nonce)]), - hash: Buffer.alloc(32, Number(params.nonce)), - } - }, - decodeExtrinsic(data) { - return { address, nonce: data[1] } - }, - }) - const client = fakeSigningClient(runtime, callData) + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) client.transport = new core.JsonRpcTransport('ws://node-a', [], false, { requestTimeoutMs: 100, maxRequestRetries: 0, @@ -3304,39 +2976,8 @@ test('Client records detached watchSigned nonces before the next managed submit' blockHash, events: [], }) - const typedSignature = Buffer.concat([ - Buffer.from([core.CRYPTO_SR25519]), - Buffer.alloc(64, 5), - ]) - let nonceReads = 0 - client.rpc = async (method, params = []) => { - if (method === 'system_accountNextIndex') { - assert.equal(params[0], address) - nonceReads += 1 - return 80 - } - if (method === 'state_getRuntimeVersion') { - return { - specName: 'node-subtensor', - specVersion: runtime.specVersion, - transactionVersion: runtime.transactionVersion, - } - } - if (method === 'system_properties') { - return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } - } - throw new Error(`unexpected RPC ${method}`) - } - const signer = { - address, - publicKey, - signRaw() { - return { signature: `0x${typedSignature.toString('hex')}` } - }, - } - const detached = await client.signExtrinsic(callData, signer, { period: null, allowRawCall: true }) - const watcher = await client.watchSigned(detached, { timeoutMs: 100 }) + const watcher = await client.watchSigned(Buffer.from([2, 4, 8]), { timeoutMs: 100 }) await waitFor( () => FakeWebSocket.sockets[0].sent.some((message) => message.method === 'author_submitAndWatchExtrinsic'), 'detached submit-and-watch request', @@ -3347,13 +2988,9 @@ test('Client records detached watchSigned nonces before the next managed submit' params: { subscription: 'watch-detached', result: { inBlock: `0x${'44'.repeat(32)}` } }, }) await watcher.result - await client.submit(callData, signer, { period: null, allowRawCall: true }) - - assert.deepEqual(capturedNonces, [80, 81]) - assert.equal(nonceReads, 2) }) -test('Client submission watches support timeout and reconcile managed nonces', async (t) => { +test('Client submission watches support timeout for explicit-nonce JavaScript submissions', async (t) => { const { FakeWebSocket, restore } = installFakeWebSocket() t.after(restore) FakeWebSocket.onSend = (socket, message) => { @@ -3396,7 +3033,6 @@ test('Client submission watches support timeout and reconcile managed nonces', a Buffer.alloc(64, 1), ]) client.rpc = async (method, params = []) => { - if (method === 'system_accountNextIndex') return 60 if (method === 'state_getRuntimeVersion') { return { specName: 'node-subtensor', @@ -3407,10 +3043,6 @@ test('Client submission watches support timeout and reconcile managed nonces', a if (method === 'system_properties') { return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } } - if (method === 'author_pendingExtrinsics') return [] - if (method === 'chain_getHeader') return { number: '0x0' } - if (method === 'chain_getBlockHash') return `0x${'02'.repeat(32)}` - if (method === 'chain_getBlock') return { block: { extrinsics: [] } } if (method === 'author_submitExtrinsic') return submittedExtrinsicHash(params[0]) throw new Error(`unexpected RPC ${method}`) } @@ -3423,13 +3055,9 @@ test('Client submission watches support timeout and reconcile managed nonces', a } await assert.rejects( - () => client.submit(callData, signer, { period: null, allowRawCall: true, waitForInclusion: true, timeoutMs: 5 }), + () => client.submit(callData, signer, { period: null, nonce: 60, allowRawCall: true, waitForInclusion: true, timeoutMs: 5 }), (error) => error.name === 'RequestTimeoutError', ) - await assert.rejects( - () => client.submit(callData, signer, { period: null, allowRawCall: true }), - /nonce 60 .* ambiguous/, - ) assert.deepEqual(capturedNonces, [60]) assert.equal( FakeWebSocket.sockets[0].sent.some((message) => message.method === 'author_unwatchExtrinsic'), @@ -3495,7 +3123,7 @@ test('Client generates RFC-0078 proof for Ledger metadata-verifying signers', as tokenSymbol: 'TAO', } - const signed = await client.signExtrinsic(vector.callData, signer, { period: null, allowRawCall: true }) + const signed = await client.signExtrinsic(vector.callData, signer, { period: null, nonce: 12, allowRawCall: true }) const expectedMetadataHash = core.metadataDigest(metadataBytes, chainInfo) const expectedProof = core.generateExtrinsicProof( vector.callData, diff --git a/ts-tests/suites/dev/sdk/test-bittensor-ts.ts b/ts-tests/suites/dev/sdk/test-bittensor-ts.ts index 1bf78b6f5a..e1a2f2e2e7 100644 --- a/ts-tests/suites/dev/sdk/test-bittensor-ts.ts +++ b/ts-tests/suites/dev/sdk/test-bittensor-ts.ts @@ -35,7 +35,8 @@ describeSuite({ try { await client.assertDescriptorSchema(); - const signed = await client.signExtrinsic(call, alice); + const nonce = await client.accountNextIndex(alice.ss58Address); + const signed = await client.signExtrinsic(call, alice, { allowRawCall: true, nonce }); const watcher = await client.watchSigned(signed); await context.createBlock(); From cb479d5381ce4d382b2bc292277fd084b5053b36 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Sun, 12 Jul 2026 21:34:59 -0700 Subject: [PATCH 60/72] fis TS checks --- sdk/bittensor-ts/README.md | 43 +- sdk/bittensor-ts/native/src/runtime.rs | 4 + sdk/bittensor-ts/native/src/transaction.rs | 18 +- sdk/bittensor-ts/src/browser.ts | 4 - sdk/bittensor-ts/src/client.ts | 587 ++++++++++++++------- sdk/bittensor-ts/src/native.ts | 2 + sdk/bittensor-ts/src/runtime.ts | 6 + sdk/bittensor-ts/src/transaction.ts | 10 + sdk/bittensor-ts/test/basic.test.cjs | 103 +++- 9 files changed, 531 insertions(+), 246 deletions(-) diff --git a/sdk/bittensor-ts/README.md b/sdk/bittensor-ts/README.md index f75e1332ec..0b2d76b3ef 100644 --- a/sdk/bittensor-ts/README.md +++ b/sdk/bittensor-ts/README.md @@ -19,13 +19,14 @@ The Node TypeScript layer is limited to JavaScript-friendly names and defaults, lossless `Buffer`/`bigint`/`Map` boundary conversion, error classes, signing-compatibility adapters for the signer objects expected by Polkadot.js, Polkadot API, and Moonwall, wallet filesystem management, generated-style -descriptors, and the client responsibilities that deliberately remain outside -the current blocking Rust client: async WebSocket transport, subscriptions, -endpoint fallback, browser support, and extension-signer interop. On Node, -chain reads, call composition, fee estimation, Rust-keypair signing, -inclusion/finalization receipts, dispatch-error interpretation, and high-level -transaction semantics prefer the native Rust client and transaction executor when -available. High-level TypeScript transaction helpers construct Rust +descriptors, and extension-signer interop. On Node, the default `Client` uses +the native Rust client as the authoritative backend for connection, runtime +refresh and caching, storage reads, runtime calls, call composition, nonce +reads, fee estimation, signing plans, submission, inclusion/finalization +receipts, dispatch-error interpretation, and high-level transaction semantics. +The independent TypeScript JSON-RPC transport is exposed separately as +`BrowserChainClient` for browser/custom-transport use cases where the native +client cannot run. High-level TypeScript transaction helpers construct Rust `IntentCall` values, and arbitrary pallet/function calls are classified as raw by Rust policy before signing. @@ -64,9 +65,11 @@ The native crate is isolated under `sdk/bittensor-ts/native`; it links `sdk/bittensor-core` directly and contains binding glue only. No chain algorithm is reimplemented in TypeScript. -Node.js 22 or newer is required for the default WSS client path because the -SDK uses the unflagged global `WebSocket`. Older Node runtimes can still use -HTTP endpoints or pass `webSocketFactory`/`webSocketConstructor` explicitly. +Node.js 22 or newer is required for `BrowserChainClient`'s default WSS path +because that transport uses the unflagged global `WebSocket`. Older Node +runtimes can still use HTTP endpoints with `BrowserChainClient` or pass +`webSocketFactory`/`webSocketConstructor` explicitly. The default Node `Client` +delegates transport to Rust. Browser builds also require `wasm-pack` so `npm run build` can emit the `dist/wasm/bittensor_core_wasm.js` bundle used by `@bittensor/sdk/browser`. @@ -121,19 +124,25 @@ await client.transfer(alice, '5F...', Balance.fromTao('0.01'), { await client.close() ``` -Fallback endpoints are validated against a trusted genesis hash before use. -Known mainnet aliases (`finney`, `archive`) use the checked-in mainnet genesis -hash. Custom endpoint sets, and named networks without a built-in trust anchor, -must pass `expectedGenesisHash` when `fallbackEndpoints` are configured: +`Client` is the Node-native high-level client. Raw JSON-RPC calls, +subscriptions, injected WebSockets, and endpoint fallback belong to the explicit +`BrowserChainClient` transport client: ```ts -const client = new Client('local', { +import { BrowserChainClient } from '@bittensor/sdk' + +const client = new BrowserChainClient('local', { endpoint: 'wss://primary.example', fallbackEndpoints: ['wss://fallback.example'], expectedGenesisHash: '0x...', }) ``` +Fallback endpoints are validated against a trusted genesis hash before use. +Known mainnet aliases (`finney`, `archive`) use the checked-in mainnet genesis +hash. Custom endpoint sets, and named networks without a built-in trust anchor, +must pass `expectedGenesisHash` when `fallbackEndpoints` are configured. + Transaction amount inputs are intentionally explicit. Pass `Balance.fromTao("1.25")` or `taoAmount("1.25")` for TAO-denominated values, and pass `123n` or `raoAmount("123")` for rao. Raw `number` and `string` amounts are rejected by @@ -143,7 +152,9 @@ TAO/alpha amounts with more than nine fractional digits are rejected. `client.submit()` delegates automatic nonce selection to the Rust client when submitting with a native `Keypair`. Low-level manual signing APIs such as `signExtrinsic()` require an explicit `nonce`, and detached flows using -`submitSigned()` or `watchSigned()` do not inspect or coordinate nonce state. +`submitSigned()` delegate encoded submission to Rust. `watchSigned()` is only +available on `BrowserChainClient`, where the TypeScript WebSocket transport owns +the subscription. High-level transaction helpers such as `transfer()`, `staking.addStake()`, `setWeights()`, registration, and serving route through Rust trusted diff --git a/sdk/bittensor-ts/native/src/runtime.rs b/sdk/bittensor-ts/native/src/runtime.rs index 918e9d2c5c..408a062571 100644 --- a/sdk/bittensor-ts/native/src/runtime.rs +++ b/sdk/bittensor-ts/native/src/runtime.rs @@ -280,6 +280,10 @@ pub struct NativeRuntime { } impl NativeRuntime { + pub(crate) fn from_arc(inner: Arc) -> Self { + Self { inner } + } + fn entry(&self, pallet: &str, name: &str) -> NapiResult<&StorageInfo> { self.inner.storage_entry(pallet, name).ok_or_else(|| { into_napi(CoreError::NotInRuntime(format!( diff --git a/sdk/bittensor-ts/native/src/transaction.rs b/sdk/bittensor-ts/native/src/transaction.rs index f8e5d39cf1..93f826085e 100644 --- a/sdk/bittensor-ts/native/src/transaction.rs +++ b/sdk/bittensor-ts/native/src/transaction.rs @@ -15,7 +15,7 @@ use serde_json::Value as JsonValue; use crate::errors::{invalid_arg, CoreResultExt, NapiResult}; use crate::keys::NativeKeypair; -use crate::runtime::{NativeSignerPayload, NativeTxParams}; +use crate::runtime::{NativeRuntime, NativeSignerPayload, NativeTxParams}; use crate::values::{from_wire, to_wire}; #[napi(object)] @@ -226,6 +226,9 @@ client_task!( client_task!(ClientSwapQuoteTask, SwapQuote, NativeSwapQuote, |value| { Ok(quote_to_native(value)) }); +client_task!(ClientChainInfoTask, ChainInfo, NativeChainInfo, |value| { + Ok(chain_info_to_native(value)) +}); pub struct ClientWireValueTask { client: Arc, @@ -881,6 +884,19 @@ impl NativeClient { })) } + #[napi(js_name = "runtime")] + pub fn runtime(&self) -> NapiResult { + self.inner.runtime().napi().map(NativeRuntime::from_arc) + } + + #[napi(js_name = "chainInfo")] + pub fn chain_info(&self) -> NapiResult> { + Ok(AsyncTask::new(ClientChainInfoTask::new( + Arc::clone(&self.inner), + |client| client.chain_info(), + ))) + } + #[napi(js_name = "composeCall")] pub fn compose_call( &self, diff --git a/sdk/bittensor-ts/src/browser.ts b/sdk/bittensor-ts/src/browser.ts index 4e9cc30e98..ee76cf0ed5 100644 --- a/sdk/bittensor-ts/src/browser.ts +++ b/sdk/bittensor-ts/src/browser.ts @@ -317,10 +317,6 @@ function toInteger(value: BrowserIntegerLike, name = 'value'): number { return value } -function cryptoTypeForKeyType(type: SubstrateKeyType): number { - return type === 'ed25519' ? CRYPTO_ED25519 : CRYPTO_SR25519 -} - function keyTypeForCryptoType(cryptoType: number): SubstrateKeyType { if (cryptoType === CRYPTO_ED25519) return 'ed25519' if (cryptoType === CRYPTO_SR25519) return 'sr25519' diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 03bad0f22c..9560328044 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -1,5 +1,5 @@ import { blake2_256, generateExtrinsicProof, hexToBytes, metadataDigest } from './crypto' -import { CRYPTO_ED25519, CRYPTO_SR25519, Keypair, publicKeyFromSs58, ss58FromPublic } from './keys' +import { CRYPTO_ED25519, CRYPTO_SR25519, Keypair, publicKeyFromSs58 } from './keys' import { LedgerDevice } from './ledger' import { Runtime, decodeOptionalOpaqueMetadata, eraBirth, type RuntimeSignerPayload } from './runtime' import { IntentCall, NativeChainClient, Policy, isIntentCall, rawCall, type PolicyOptions, type SignerRoleLike } from './transaction' @@ -145,7 +145,7 @@ export interface QueryMapOptions { } export interface SignerAccountContext { - client: Client + client: ChainClient runtime: Runtime ss58Format: number } @@ -160,7 +160,7 @@ export interface SignerAccount { } export interface SignerPayloadContext { - client: Client + client: ChainClient runtime: Runtime address: string publicKey: Buffer @@ -349,9 +349,11 @@ interface SigningSnapshot { genesisHash: string } -const nativeClientAccess = new WeakMap Promise>() +export type ChainClient = Client | BrowserChainClient -function nativeRustClient(client: Client): Promise { +const nativeClientAccess = new WeakMap Promise>() + +function nativeRustClient(client: object): Promise { return nativeClientAccess.get(client)?.() ?? Promise.resolve(undefined) } @@ -1011,7 +1013,7 @@ export class JsonRpcTransport { } } -export class Client { +export class BrowserChainClient { readonly network: string readonly endpoint: string readonly transport: JsonRpcTransport @@ -1019,7 +1021,7 @@ export class Client { readonly subnets: SubnetsNamespace readonly neurons: NeuronsNamespace readonly staking: StakingNamespace - readonly ready?: Promise + ready?: Promise private readonly headRuntimeTtlMs: number private readonly historicalRuntimeCacheSize: number @@ -1027,12 +1029,8 @@ export class Client { private headRuntimeCache?: HeadRuntimeCacheEntry private runtimesBySpecVersion = new Map() private historicalRuntimeCache = new Map() - private genesis?: string - private readonly nativeEligible: boolean + protected genesis?: string private readonly validateDescriptorsOnLoad: boolean - private nativeClient?: NativeChainClient - private nativeClientPromise?: Promise - private nativeUnavailable = false constructor(network: string = 'finney', options: ClientOptions = {}) { const [label, endpoint] = resolveEndpoint(options.endpoint ?? network) @@ -1050,10 +1048,6 @@ export class Client { this.expectedGenesisHash = expectedGenesisHash this.genesis = expectedGenesisHash this.validateDescriptorsOnLoad = options.validateDescriptorSchema ?? expectedGenesisHash != null - this.nativeEligible = fallbackEndpoints.length === 0 && - options.webSocket == null && - options.webSocketConstructor == null && - options.webSocketFactory == null this.transport = new JsonRpcTransport(endpoint, fallbackEndpoints, options.retryForever, { webSocket: options.webSocket, webSocketConstructor: options.webSocketConstructor, @@ -1071,7 +1065,6 @@ export class Client { this.subnets = new SubnetsNamespace(this) this.neurons = new NeuronsNamespace(this) this.staking = new StakingNamespace(this) - nativeClientAccess.set(this, () => this.tryNativeClient()) this.headRuntimeTtlMs = nonNegativeNumber(options.headRuntimeTtlMs, DEFAULT_HEAD_RUNTIME_TTL_MS) this.historicalRuntimeCacheSize = nonNegativeInteger( options.historicalRuntimeCacheSize, @@ -1084,7 +1077,7 @@ export class Client { } async connect(): Promise { - if (await this.tryNativeClient() == null) await this.runtimeAt() + await this.runtimeAt() return this } @@ -1092,27 +1085,6 @@ export class Client { this.transport.close() } - private async tryNativeClient(): Promise { - if (!this.nativeEligible) return undefined - if (this.nativeUnavailable) return undefined - if (this.nativeClient != null) return this.nativeClient - if (this.nativeClientPromise == null) { - this.nativeClientPromise = NativeChainClient.connect(this.endpoint).then( - (client) => { - this.nativeClient = client - this.genesis = hex(client.genesisHash) - return client - }, - () => { - this.nativeUnavailable = true - this.nativeClientPromise = undefined - return undefined - }, - ) - } - return this.nativeClientPromise - } - async block(): Promise { return this.blockNumber() } @@ -1126,15 +1098,11 @@ export class Client { } async blockNumber(blockHash?: string | null): Promise { - const native = await this.tryNativeClient() - if (native != null) return await native.blockNumber(blockHash) const header = await this.rpc('chain_getHeader', blockHash == null ? [] : [blockHash]) return headerNumber(header) } async blockHash(block?: number | null): Promise { - const native = await this.tryNativeClient() - if (native != null) return await native.blockHash(block == null ? undefined : block) return String(await this.rpc('chain_getBlockHash', block == null ? [] : [block])) } @@ -1147,17 +1115,10 @@ export class Client { } async finalizedHead(): Promise { - const native = await this.tryNativeClient() - if (native != null) return await native.finalizedHead() return String(await this.rpc('chain_getFinalizedHead')) } async genesisHash(): Promise { - const native = await this.tryNativeClient() - if (native != null) { - this.genesis = hex(native.genesisHash) - return this.genesis - } this.genesis ??= await this.blockHash(0) return this.genesis } @@ -1340,8 +1301,6 @@ export class Client { const [moduleName, itemName, itemParams, blockRef] = normalizeStorageArgs(pallet, storageFunction, paramsOrBlock, block) const blockHash = await this.resolveReadBlockHash(blockRef) - const native = await this.tryNativeClient() - if (native != null) return await native.query(moduleName, itemName, itemParams, blockHash) as T const runtime = await this.runtimeAt(blockHash) const key = runtime.storageKey(moduleName, itemName, itemParams) const raw = await this.rpc('state_getStorage', [hex(key), blockHash]) @@ -1359,8 +1318,6 @@ export class Client { normalizeBatchArgs(pallet, storageFunction, paramSetsOrBlock, block) if (sets.length === 0) return [] const blockHash = await this.resolveReadBlockHash(blockRef) - const native = await this.tryNativeClient() - if (native != null) return await native.queryBatch(moduleName, itemName, sets, blockHash) as Array const runtime = await this.runtimeAt(blockHash) const keys = runtime.storageKeyBatch(moduleName, itemName, sets) const raw = await this.rpc('state_queryStorageAt', [keys.map(hex), blockHash]) @@ -1383,10 +1340,6 @@ export class Client { const [moduleName, itemName, itemParams, blockRef] = normalizeStorageArgs(pallet, storageFunction, paramsOrBlock, block) const blockHash = await this.resolveReadBlockHash(blockRef) - if (pageSizeOrOptions === 512) { - const native = await this.tryNativeClient() - if (native != null) return await native.queryMap(moduleName, itemName, itemParams, blockHash) as Array<[K, V]> - } const runtime = await this.runtimeAt(blockHash) const prefix = runtime.storageKey(moduleName, itemName, itemParams) const entry = runtime.storageEntry(moduleName, itemName) @@ -1473,8 +1426,6 @@ export class Client { ): Promise { const [apiName, methodName, callParams, blockRef] = normalizeRuntimeArgs(api, method, paramsOrBlock, block) const blockHash = await this.resolveReadBlockHash(blockRef) - const native = await this.tryNativeClient() - if (native != null) return await native.runtimeCall(apiName, methodName, callParams, blockHash) as T const runtime = await this.runtimeAt(blockHash) const info = runtime.runtimeApis()[apiName]?.[methodName] if (info == null) throw new ChainError(`runtime API ${apiName}.${methodName} not found`) @@ -1524,10 +1475,6 @@ export class Client { block?: number | string | null, ): Promise { const [moduleName, constantName] = typeof pallet === 'string' ? [pallet, name as string] : pallet - if (block == null) { - const native = await this.tryNativeClient() - if (native != null) return native.constant(moduleName, constantName) as T - } return (await this.runtimeAt(block)).constant(moduleName, constantName) } @@ -1536,15 +1483,6 @@ export class Client { data: ByteLike | string, block?: number | string | null, ): Promise { - if (block == null) { - const native = await this.tryNativeClient() - if (native != null) { - return native.decodeScale( - typeString, - typeof data === 'string' ? hexToBuffer(data) : toBuffer(data, 'data'), - ) as T - } - } return this.runtimeAt(block).then((runtime) => runtime.decode(typeString, typeof data === 'string' ? hexToBuffer(data) : data, false), ) @@ -1559,10 +1497,6 @@ export class Client { } async composeCall(pallet: string, fn: string, params: ScaleValue = {}, block?: number | string | null): Promise { - if (block == null) { - const native = await this.tryNativeClient() - if (native != null) return await native.composeCall(pallet, fn, params) - } return this.runtimeAt(block).then((runtime) => runtime.composeCall(pallet, fn, params)) } @@ -1659,30 +1593,9 @@ export class Client { async signExtrinsic(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { this.assertExplicitNonce(options, 'signExtrinsic') - const nativeSigned = await this.signExtrinsicWithNativePlan(call, signer, options) - if (nativeSigned != null) return nativeSigned return this.signExtrinsicWithSnapshot(call, signer, options, await this.signingSnapshot()) } - private async signExtrinsicWithNativePlan( - call: CallLike, - signer: SignerLike, - options: SubmitOptions, - ): Promise { - const native = await this.tryNativeClient() - if (native == null) return undefined - const planned = await this.nativeSigningPlan(call, signer, options, native) - const signature = await this.signRustPlan(planned) - const signed = await native.assembleExternal(planned.plan, signature.signature, signature.cryptoType) - return { - bytes: Buffer.from(signed.bytes), - hash: hexToBuffer(signed.hash), - hex: hex(signed.bytes), - signerAddress: planned.resolved.ss58Address, - nonce: parseNonce(planned.plan.nonce, 'native signing plan nonce'), - } - } - private async signExtrinsicWithSnapshot( call: CallLike, signer: SignerLike, @@ -1774,34 +1687,11 @@ export class Client { } async submit(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { - const nativeResult = await this.submitWithNativePlan(call, signer, options) - if (nativeResult != null) return nativeResult this.assertExplicitNonce(options, 'submit') const signed = await this.signExtrinsic(call, signer, options) return this.submitSigned(signed, signed.signerAddress, options) } - private async submitWithNativePlan( - call: CallLike, - signer: SignerLike, - options: SubmitOptions, - ): Promise { - const native = await this.tryNativeClient() - if (native == null) return undefined - assertNativeSubmitOptions(options) - const planned = await this.nativeSigningPlan(call, signer, options, native) - const signature = await this.signRustPlan(planned) - return nativeOutcomeToExtrinsicResult( - await native.submitExternal( - planned.plan, - signature.signature, - options.waitForFinalization === true, - signature.cryptoType, - ), - options.waitForFinalization === true, - ) - } - async submitSigned( extrinsic: SignedExtrinsicResult | SignedExtrinsic | ByteLike, signerAddress?: string, @@ -1914,14 +1804,6 @@ export class Client { signer: SignerLike, options: Pick = {}, ): Promise { - const native = await this.tryNativeClient() - if (native != null) { - const planned = await this.nativeSigningPlan(call, signer, options as SubmitOptions, native) - const feeRao = planned.plan.feeRao == null - ? await native.estimateFeeExternal(planned.plan) - : BigInt(planned.plan.feeRao) - return Balance.fromRao(feeRao) - } const snapshot = await this.signingSnapshot() const { runtime } = snapshot const account = await this.resolveSigner(signer, runtime) @@ -2123,65 +2005,6 @@ export class Client { return runtime.composeCall(pallet, fn, params) } - private async nativeSigningPlan( - call: CallLike, - signer: SignerLike, - options: SubmitOptions, - native: NativeChainClient, - ): Promise { - const runtime = await this.headRuntime() - const resolved = await this.resolveSigner(signer, runtime) - const planOptions = nativeExternalSigningOptions(options) - let plan: NativeExternalSigningPlanHandle - if (isIntentCall(call)) { - plan = await native.externalSigningPlanForIntent( - call, - resolved.ss58Address, - resolved.publicKey, - resolved.cryptoType, - resolved.requiresMetadataProof, - policyForSubmitOptions(options), - planOptions, - ) - } else if (Buffer.isBuffer(call) || call instanceof Uint8Array) { - assertRawBytesAllowed(options) - plan = await native.externalSigningPlan( - toBuffer(call, 'call'), - resolved.ss58Address, - resolved.publicKey, - resolved.cryptoType, - resolved.requiresMetadataProof, - planOptions, - ) - } else { - if (!rawCallsAllowed(options)) { - throw new ChainError( - 'raw metadata calls require explicit raw-call permission; use an IntentCall trusted constructor or pass allowRawCall: true', - ) - } - const [pallet, fn, params] = normalizeCall(call) - const intent = rawCall(pallet, fn, params, { - op: `${pallet}.${fn}`, - signerRole: options.rawSignerRole ?? 'coldkey', - }) - plan = await native.externalSigningPlanForIntent( - intent, - resolved.ss58Address, - resolved.publicKey, - resolved.cryptoType, - resolved.requiresMetadataProof, - policyForSubmitOptions(options), - planOptions, - ) - } - return { native, runtime, resolved, plan } - } - - private async signRustPlan(planned: NativeSigningPlan): Promise { - const context = nativePlanSignerContext(this, planned.runtime, planned.resolved, planned.plan) - return this.signWithSigner(planned.resolved.signer, context.payload, context) - } - private prepareCallForSigning( call: CallLike, runtime: Runtime, @@ -2243,7 +2066,7 @@ export class Client { return blockHash ?? await this.finalizedHead() } - private async resolveSigner(signer: SignerLike, runtime: Runtime): Promise { + protected async resolveSigner(signer: SignerLike, runtime: Runtime): Promise { const normalizedSigner: Keypair | ChainSigner = signer instanceof LedgerDevice ? signer.signer() : signer const signerShape = normalizedSigner as ChainSigner @@ -2280,7 +2103,7 @@ export class Client { } } - private async signWithSigner( + protected async signWithSigner( signer: Keypair | ChainSigner, payload: Buffer, context: SignerPayloadContext, @@ -2420,13 +2243,351 @@ export class Client { } } +export class Client extends BrowserChainClient { + private readonly nodeExpectedGenesisHash?: string + private nodeNativeClient?: NativeChainClient + private nodeNativeClientPromise?: Promise + + constructor(network: string = 'finney', options: ClientOptions = {}) { + assertNodeClientOptions(options) + super(network, { ...options, autoConnect: false }) + this.nodeExpectedGenesisHash = normalizeGenesisHash( + options.expectedGenesisHash ?? trustedGenesisHash(this.network), + ) + nativeClientAccess.set(this, () => this.requireNativeClient()) + if (options.autoConnect) { + this.ready = this.connect() + this.ready.catch(() => undefined) + } + } + + async connect(): Promise { + await this.requireNativeClient() + return this + } + + async close(): Promise { + // NativeChainClient owns its connection and has no explicit close hook. + } + + private async requireNativeClient(): Promise { + if (this.nodeNativeClient != null) return this.nodeNativeClient + this.nodeNativeClientPromise ??= NativeChainClient.connect(this.endpoint) + .then((client) => { + const genesis = hex(client.genesisHash) + if (this.nodeExpectedGenesisHash != null && !sameHex(genesis, this.nodeExpectedGenesisHash)) { + throw new EndpointValidationError( + `endpoint ${this.endpoint} genesis ${genesis} does not match expected genesis ${this.nodeExpectedGenesisHash}`, + ) + } + this.nodeNativeClient = client + this.genesis = genesis + return client + }) + .catch((error) => { + this.nodeNativeClientPromise = undefined + throw error + }) + return this.nodeNativeClientPromise + } + + override async blockNumber(blockHash?: string | null): Promise { + return (await this.requireNativeClient()).blockNumber(blockHash) + } + + override async blockHash(block?: number | null): Promise { + return (await this.requireNativeClient()).blockHash(block == null ? undefined : block) + } + + override async finalizedHead(): Promise { + return (await this.requireNativeClient()).finalizedHead() + } + + override async genesisHash(): Promise { + const genesis = hex((await this.requireNativeClient()).genesisHash) + this.genesis = genesis + return genesis + } + + override async runtimeAt(block?: number | string | null): Promise { + if (block != null) { + throw new ChainError( + 'historical runtime snapshots are only available on BrowserChainClient; Node Client uses the Rust NativeChainClient runtime cache', + ) + } + return (await this.requireNativeClient()).runtime() + } + + override async query( + pallet: string | Descriptor, + storageFunction?: string | ScaleValue[], + paramsOrBlock?: ScaleValue[] | number | string | null, + block?: number | string | null, + ): Promise { + const [moduleName, itemName, itemParams, blockRef] = + normalizeStorageArgs(pallet, storageFunction, paramsOrBlock, block) + const blockHash = await this.resolveReadBlockHash(blockRef) + return await (await this.requireNativeClient()).query(moduleName, itemName, itemParams, blockHash) as T + } + + override async queryBatch( + pallet: string | Descriptor, + storageFunction: string | ScaleValue[][], + paramSetsOrBlock?: ScaleValue[][] | number | string | null, + block?: number | string | null, + ): Promise> { + const [moduleName, itemName, sets, blockRef] = + normalizeBatchArgs(pallet, storageFunction, paramSetsOrBlock, block) + if (sets.length === 0) return [] + const blockHash = await this.resolveReadBlockHash(blockRef) + return await (await this.requireNativeClient()).queryBatch(moduleName, itemName, sets, blockHash) as Array + } + + override invalidateRuntimeCache(): void { + void this.nodeNativeClient?.refreshRuntime().catch(() => undefined) + } + + override async chainInfo(_runtime?: Runtime, _blockHash?: string | null): Promise { + return nativeChainInfoToChainInfo(await (await this.requireNativeClient()).chainInfo()) + } + + override rpc(method: string, _params: unknown[] = []): Promise { + return Promise.reject(new ChainError( + `raw JSON-RPC method ${method} is not available on the Node Client; use BrowserChainClient for custom transport RPC`, + )) + } + + override async runtimeCall( + api: string | Descriptor, + method?: string | ScaleValue[], + paramsOrBlock: ScaleValue[] | number | string | null = [], + block?: number | string | null, + ): Promise { + const [apiName, methodName, callParams, blockRef] = normalizeRuntimeArgs(api, method, paramsOrBlock, block) + const blockHash = await this.resolveReadBlockHash(blockRef) + return await (await this.requireNativeClient()).runtimeCall(apiName, methodName, callParams, blockHash) as T + } + + override async queryMap( + pallet: string | Descriptor, + storageFunction?: string | ScaleValue[], + paramsOrBlock?: ScaleValue[] | number | string | null, + block?: number | string | null, + pageSizeOrOptions: number | QueryMapOptions = 512, + ): Promise> { + if (pageSizeOrOptions !== 512) { + throw new ChainError( + 'queryMap pagination controls are only available on BrowserChainClient; Node Client delegates map pagination to Rust', + ) + } + const [moduleName, itemName, itemParams, blockRef] = + normalizeStorageArgs(pallet, storageFunction, paramsOrBlock, block) + const blockHash = await this.resolveReadBlockHash(blockRef) + return await (await this.requireNativeClient()).queryMap(moduleName, itemName, itemParams, blockHash) as Array<[K, V]> + } + + override async stateCall(_method: string, _data: ByteLike | string, _block?: number | string | null): Promise { + throw new ChainError('raw state_call is only available on BrowserChainClient; use runtimeCall() for Rust-backed runtime APIs') + } + + override async constant( + pallet: string | Descriptor, + name?: string, + block?: number | string | null, + ): Promise { + if (block != null) { + throw new ChainError('historical constants are only available on BrowserChainClient') + } + const [moduleName, constantName] = typeof pallet === 'string' ? [pallet, name as string] : pallet + return (await this.requireNativeClient()).constant(moduleName, constantName) as T + } + + override async decodeScale( + typeString: string, + data: ByteLike | string, + block?: number | string | null, + ): Promise { + if (block != null) { + throw new ChainError('historical SCALE decoding is only available on BrowserChainClient') + } + return (await this.requireNativeClient()).decodeScale( + typeString, + typeof data === 'string' ? hexToBuffer(data) : toBuffer(data, 'data'), + ) as T + } + + override async composeCall(pallet: string, fn: string, params: ScaleValue = {}, block?: number | string | null): Promise { + if (block != null) { + throw new ChainError('historical call composition is only available on BrowserChainClient') + } + return (await this.requireNativeClient()).composeCall(pallet, fn, params) + } + + override async signExtrinsic(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { + const native = await this.requireNativeClient() + const planned = await this.nativeSigningPlan(call, signer, options, native) + const signature = await this.signRustPlan(planned) + const signed = await native.assembleExternal(planned.plan, signature.signature, signature.cryptoType) + return { + bytes: Buffer.from(signed.bytes), + hash: hexToBuffer(signed.hash), + hex: hex(signed.bytes), + signerAddress: planned.resolved.ss58Address, + nonce: parseNonce(planned.plan.nonce, 'native signing plan nonce'), + } + } + + override async submit(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { + assertNativeSubmitOptions(options) + const native = await this.requireNativeClient() + const planned = await this.nativeSigningPlan(call, signer, options, native) + const signature = await this.signRustPlan(planned) + return nativeOutcomeToExtrinsicResult( + await native.submitExternal( + planned.plan, + signature.signature, + options.waitForFinalization === true, + signature.cryptoType, + ), + options.waitForFinalization === true, + ) + } + + override async *blocks(_options: { finalized?: boolean } = {}): AsyncIterable { + throw new ChainError('block subscriptions are only available on BrowserChainClient') + } + + override waitForBlock(_block?: number | null, _options: { timeoutMs?: number } = {}): Promise { + return Promise.reject(new ChainError('block subscriptions are only available on BrowserChainClient')) + } + + override blockInfo(_block?: number | null): Promise { + return Promise.reject(new ChainError('block inspection is only available on BrowserChainClient')) + } + + override async submitSigned( + extrinsic: SignedExtrinsicResult | SignedExtrinsic | ByteLike, + signerAddress?: string, + options: SubmitOptions = {}, + ): Promise { + void signerAddress + assertNativeSubmitOptions(options) + const bytes = Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array + ? toBuffer(extrinsic, 'extrinsic') + : toBuffer(extrinsic.bytes, 'extrinsic.bytes') + const extrinsicHash = hex(blake2_256(bytes)) + return nativeOutcomeToExtrinsicResult( + await (await this.requireNativeClient()).submitEncoded( + bytes, + extrinsicHash, + options.waitForFinalization === true, + ), + options.waitForFinalization === true, + ) + } + + override watchSigned( + _extrinsic: SignedExtrinsicResult | SignedExtrinsic | ByteLike, + _options: Pick & { signerAddress?: string } = {}, + ): Promise { + return Promise.reject(new ChainError('submit-and-watch subscriptions are only available on BrowserChainClient')) + } + + override async estimateFee( + call: CallLike, + signer: SignerLike, + options: Pick = {}, + ): Promise { + const native = await this.requireNativeClient() + const planned = await this.nativeSigningPlan(call, signer, options as SubmitOptions, native) + const feeRao = planned.plan.feeRao == null + ? await native.estimateFeeExternal(planned.plan) + : BigInt(planned.plan.feeRao) + return Balance.fromRao(feeRao) + } + + override async peekNextIndex(address: string): Promise { + return (await this.requireNativeClient()).accountNextIndex(address) + } + + override async accountNextIndex(address: string, _useCache = false): Promise { + return this.peekNextIndex(address) + } + + override async validateDescriptorSchema(block?: number | string | null): Promise { + if (block != null) { + throw new ChainError('historical descriptor validation is only available on BrowserChainClient') + } + return validateDescriptorSchema((await this.requireNativeClient()).runtime()) + } + + private async nativeSigningPlan( + call: CallLike, + signer: SignerLike, + options: SubmitOptions, + native: NativeChainClient, + ): Promise { + const runtime = await this.runtimeAt() + const resolved = await this.resolveSigner(signer, runtime) + const planOptions = nativeExternalSigningOptions(options) + let plan: NativeExternalSigningPlanHandle + if (isIntentCall(call)) { + plan = await native.externalSigningPlanForIntent( + call, + resolved.ss58Address, + resolved.publicKey, + resolved.cryptoType, + resolved.requiresMetadataProof, + policyForSubmitOptions(options), + planOptions, + ) + } else if (Buffer.isBuffer(call) || call instanceof Uint8Array) { + assertRawBytesAllowed(options) + plan = await native.externalSigningPlan( + toBuffer(call, 'call'), + resolved.ss58Address, + resolved.publicKey, + resolved.cryptoType, + resolved.requiresMetadataProof, + planOptions, + ) + } else { + if (!rawCallsAllowed(options)) { + throw new ChainError( + 'raw metadata calls require explicit raw-call permission; use an IntentCall trusted constructor or pass allowRawCall: true', + ) + } + const [pallet, fn, params] = normalizeCall(call) + const intent = rawCall(pallet, fn, params, { + op: `${pallet}.${fn}`, + signerRole: options.rawSignerRole ?? 'coldkey', + }) + plan = await native.externalSigningPlanForIntent( + intent, + resolved.ss58Address, + resolved.publicKey, + resolved.cryptoType, + resolved.requiresMetadataProof, + policyForSubmitOptions(options), + planOptions, + ) + } + return { native, runtime, resolved, plan } + } + + private async signRustPlan(planned: NativeSigningPlan): Promise { + const context = nativePlanSignerContext(this, planned.runtime, planned.resolved, planned.plan) + return this.signWithSigner(planned.resolved.signer, context.payload, context) + } +} + export class Snapshot { readonly balances: SnapshotBalancesNamespace readonly subnets: SnapshotSubnetsNamespace readonly staking: SnapshotStakingNamespace readonly neurons: SnapshotNeuronsNamespace - constructor(readonly client: Client, readonly block: number, readonly blockHash: string) { + constructor(readonly client: ChainClient, readonly block: number, readonly blockHash: string) { this.balances = new SnapshotBalancesNamespace(this) this.subnets = new SnapshotSubnetsNamespace(this) this.staking = new SnapshotStakingNamespace(this) @@ -2461,7 +2622,7 @@ export class Snapshot { } export class BalancesNamespace { - constructor(private readonly client: Client) {} + constructor(private readonly client: ChainClient) {} async free(address: string, block?: number | string | null): Promise { const account = await this.client.query>(storage.System.Account, [address], block) @@ -2504,7 +2665,7 @@ export interface SubnetInfo { } export class SubnetsNamespace { - constructor(private readonly client: Client) {} + constructor(private readonly client: ChainClient) {} async subnet(netuid: number, block?: number | string | null): Promise { const blockHash = await this.client.resolveReadBlockHash(block) @@ -2616,7 +2777,7 @@ export class SubnetsNamespace { } export class NeuronsNamespace { - constructor(private readonly client: Client) {} + constructor(private readonly client: ChainClient) {} async all(netuid: number, lite = true, block?: number | string | null): Promise { const native = await nativeRustClient(this.client) @@ -2650,7 +2811,7 @@ export interface StakePosition { } export class StakingNamespace { - constructor(private readonly client: Client) {} + constructor(private readonly client: ChainClient) {} async get(coldkey: string, hotkey: string, netuid: number, block?: number | string | null): Promise { const native = await nativeRustClient(this.client) @@ -3188,7 +3349,7 @@ const READS = [ { name: 'stake_for_coldkey', category: 'Staking', params: ['coldkey_ss58'] }, ] -async function read(client: Client, name: string, params: Record, block?: number | string | null): Promise { +async function read(client: ChainClient, name: string, params: Record, block?: number | string | null): Promise { switch (name) { case 'balance': return client.balances.get(String(params.coldkey_ss58), block) @@ -3283,6 +3444,38 @@ function normalizeQueryMapOptions(value: number | QueryMapOptions): Required 0) unsupported.push('fallbackEndpoints') + for (const key of [ + 'retryForever', + 'webSocket', + 'webSocketConstructor', + 'webSocketFactory', + 'headRuntimeTtlMs', + 'historicalRuntimeCacheSize', + 'requestTimeoutMs', + 'maxRequestRetries', + 'retryBackoffMs', + 'maxRetryBackoffMs', + 'endpointValidationTtlMs', + 'maxSubscriptionQueue', + 'maxMessageBytes', + 'validateDescriptorSchema', + ] satisfies Array) { + if (hasOwn(options, key)) unsupported.push(key) + } + return unsupported +} + function positiveInteger(value: unknown, name: string): number { const parsed = Number(value) if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new RangeError(`${name} must be a positive safe integer`) @@ -3587,7 +3780,7 @@ function nativeExternalSigningOptions(options: SubmitOptions): NativeExternalSig } function nativePlanSignerContext( - client: Client, + client: ChainClient, runtime: Runtime, resolved: ResolvedSigner, plan: NativeExternalSigningPlanHandle, diff --git a/sdk/bittensor-ts/src/native.ts b/sdk/bittensor-ts/src/native.ts index 9ab73df8a0..f8761700f1 100644 --- a/sdk/bittensor-ts/src/native.ts +++ b/sdk/bittensor-ts/src/native.ts @@ -451,6 +451,8 @@ export interface NativeClientHandle { header(blockHash?: string | null): Promise readCatalog(): string[] refreshRuntime(): Promise + runtime(): NativeRuntimeHandle + chainInfo(): Promise composeCall(pallet: string, callFunction: string, params: unknown): Promise decodeScale(typeName: string, data: Buffer): unknown constant(pallet: string, name: string): unknown diff --git a/sdk/bittensor-ts/src/runtime.ts b/sdk/bittensor-ts/src/runtime.ts index eedfff6680..0afe5a10de 100644 --- a/sdk/bittensor-ts/src/runtime.ts +++ b/sdk/bittensor-ts/src/runtime.ts @@ -217,6 +217,12 @@ export class Runtime implements RustRuntimePublic ) } + static fromNativeHandle(handle: NativeRuntimeHandle): Runtime { + const runtime = Object.create(Runtime.prototype) as Runtime + ;(runtime as unknown as { handle: NativeRuntimeHandle }).handle = handle + return runtime + } + get specVersion(): number { return this.handle.specVersion } diff --git a/sdk/bittensor-ts/src/transaction.ts b/sdk/bittensor-ts/src/transaction.ts index 99da016a06..7c6c242477 100644 --- a/sdk/bittensor-ts/src/transaction.ts +++ b/sdk/bittensor-ts/src/transaction.ts @@ -1,5 +1,6 @@ import native, { type NativeBlockHeader, + type NativeChainInfo, type NativeClientHandle, type NativeExecutorHandle, type NativeExternalSigner, @@ -20,6 +21,7 @@ import { nativeAsync, nativeCall } from './errors' import { fromWire, toWire } from './wire' import type { ScaleValue } from './types' import { Keypair, nativeKeypairHandle } from './keys' +import { Runtime } from './runtime' export type SignerRoleName = 'coldkey' | 'hotkey' export type SignerRoleLike = SignerRoleName | number @@ -356,6 +358,14 @@ export class NativeChainClient { return nativeAsync(() => this.native.refreshRuntime()) } + runtime(): Runtime { + return Runtime.fromNativeHandle(nativeCall(() => this.native.runtime())) + } + + chainInfo(): Promise { + return nativeAsync(() => this.native.chainInfo()) + } + blockHash(block?: bigint | number | null): Promise { return nativeAsync(() => this.native.blockHash(block == null ? undefined : bigintValue(block, 'block'))) } diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 3108c1221d..770f6d299e 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -116,7 +116,7 @@ function fakeSigningRuntime(overrides = {}) { } function fakeSigningClient(runtime, callData) { - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) const finalizedHash = `0x${'42'.repeat(32)}` client.runtimeAt = async () => runtime client.callData = async () => Buffer.from(callData) @@ -157,7 +157,7 @@ function fakeRuntimeCacheClient(options = {}) { transactionVersion: 1, } const blockVersions = new Map() - const client = new core.Client('local', { + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944', headRuntimeTtlMs: options.headRuntimeTtlMs ?? 1_000, historicalRuntimeCacheSize: options.historicalRuntimeCacheSize ?? 2, @@ -1005,6 +1005,7 @@ test('module-shaped export mirrors the public Rust crate', () => { test('chain client surface is exported without Polkadot.js glue', () => { assert.equal(typeof core.Client, 'function') + assert.equal(typeof core.BrowserChainClient, 'function') assert.equal(typeof core.Client.prototype.watchSigned, 'function') assert.equal(Object.prototype.hasOwnProperty.call(core, 'NativeChainClient'), false) assert.equal(Object.prototype.hasOwnProperty.call(core, 'RustWallet'), false) @@ -1041,12 +1042,58 @@ test('chain client surface is exported without Polkadot.js glue', () => { ]) }) +test('Node Client keeps the Rust native backend authoritative', async () => { + assert.throws( + () => new core.Client('local', { + endpoint: 'http://primary', + fallbackEndpoints: ['http://fallback'], + }), + /BrowserChainClient/, + ) + assert.throws( + () => new core.Client('local', { + endpoint: 'ws://node-a', + webSocketFactory() { + throw new Error('not reached') + }, + }), + /BrowserChainClient/, + ) + + const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const calls = [] + const blockHash = `0x${'55'.repeat(32)}` + client.requireNativeClient = async () => ({ + finalizedHead() { + calls.push({ method: 'finalizedHead' }) + return blockHash + }, + query(pallet, storage, params, block) { + calls.push({ method: 'query', pallet, storage, params, block }) + return 42 + }, + }) + client.rpc = async () => { + throw new Error('raw RPC should not be used by Node Client query') + } + + assert.equal(await client.query('Example', 'Value'), 42) + assert.deepEqual(calls, [ + { method: 'finalizedHead' }, + { method: 'query', pallet: 'Example', storage: 'Value', params: [], block: blockHash }, + ]) + await assert.rejects( + () => client.queryMap('Example', 'Map', [], null, { pageSize: 1 }), + /delegates map pagination to Rust/, + ) +}) + test('declared Node runtime supports the default WebSocket client path', () => { assert.equal(typeof globalThis.WebSocket, 'function') }) test('Client subnet hyperparameters read uses the v3 runtime API', async () => { - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) const calls = [] client.runtime = async (method, params, block) => { calls.push({ method, params, block }) @@ -1812,7 +1859,7 @@ test('Client accepts an injected WebSocket factory when no global WebSocket exis })) } - const client = new core.Client('local', { + const client = new core.BrowserChainClient('local', { endpoint: 'ws://node-a', webSocketFactory(url) { urls.push(url) @@ -1839,7 +1886,7 @@ test('Client passes maxSubscriptionQueue into its transport', async (t) => { queueMicrotask(() => socket.serverMessage({ jsonrpc: '2.0', id: message.id, result: 'sub-1' })) } } - const client = new core.Client('local', { + const client = new core.BrowserChainClient('local', { endpoint: 'ws://node-a', expectedGenesisHash: genesis, requestTimeoutMs: 100, @@ -1868,15 +1915,15 @@ test('Client passes maxSubscriptionQueue into its transport', async (t) => { }) test('Client autoConnect exposes a handled readiness promise', async (t) => { - const originalRuntimeAt = core.Client.prototype.runtimeAt + const originalRuntimeAt = core.BrowserChainClient.prototype.runtimeAt t.after(() => { - core.Client.prototype.runtimeAt = originalRuntimeAt + core.BrowserChainClient.prototype.runtimeAt = originalRuntimeAt }) - core.Client.prototype.runtimeAt = async () => { + core.BrowserChainClient.prototype.runtimeAt = async () => { throw new Error('startup failed') } - const client = new core.Client('local', { + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944', autoConnect: true, }) @@ -1914,7 +1961,7 @@ test('Client validates fallback endpoint genesis before use', async (t) => { } } - const client = new core.Client('local', { + const client = new core.BrowserChainClient('local', { endpoint: 'http://primary', expectedGenesisHash: primaryGenesis, fallbackEndpoints: ['http://fallback'], @@ -1953,7 +2000,7 @@ test('Client fallback can validate from expected genesis when primary is unavail } } - const client = new core.Client('local', { + const client = new core.BrowserChainClient('local', { endpoint: 'http://primary', expectedGenesisHash: expectedGenesis, fallbackEndpoints: ['http://fallback'], @@ -1992,7 +2039,7 @@ test('Client rotates past a wrong-genesis primary to a valid fallback', async (t } } - const client = new core.Client('local', { + const client = new core.BrowserChainClient('local', { endpoint: 'http://primary', expectedGenesisHash: expectedGenesis, fallbackEndpoints: ['http://fallback'], @@ -2007,7 +2054,7 @@ test('Client rotates past a wrong-genesis primary to a valid fallback', async (t test('Client requires expected genesis for custom fallback endpoints', () => { assert.throws( - () => new core.Client('local', { + () => new core.BrowserChainClient('local', { endpoint: 'http://primary', fallbackEndpoints: ['http://fallback'], }), @@ -2092,7 +2139,7 @@ test('Client caches historical runtimes by block hash with LRU eviction', async }) test('Client queryBatch decodes metadata defaults for missing storage values', async () => { - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) const blockHash = `0x${'33'.repeat(32)}` const keys = [Buffer.from([1]), Buffer.from([2])] const runtime = { @@ -2132,7 +2179,7 @@ test('Client queryBatch decodes metadata defaults for missing storage values', a }) test('Client query and runtimeCall pin default reads to one finalized block', async () => { - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) const blockHash = `0x${'34'.repeat(32)}` const calls = [] const runtime = { @@ -2196,7 +2243,7 @@ test('Client query and runtimeCall pin default reads to one finalized block', as }) test('Client queryMap pins reads and rejects pagination without progress', async () => { - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) const blockHash = `0x${'44'.repeat(32)}` const calls = [] const runtime = { @@ -2333,7 +2380,7 @@ test('Client accepts raw metadata calls only with explicit raw permission', asyn }) test('Client callData composes trusted Rust intent calls', async () => { - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) const captures = {} client.composeCall = async (pallet, fn, params, block) => { captures.composeCall = { pallet, fn, params, block } @@ -2469,13 +2516,13 @@ test('Client passes structured payloads to extension signPayload signers', async }) test('Client rejects invalid chain nonce values', async () => { - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) client.rpc = async () => 'NaN' await assert.rejects(() => client.accountNextIndex('5F'), /invalid nonce/) }) test('Client rejects mismatched submit hashes and keeps local hash authoritative', async () => { - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) client.transport.request = async (method) => { assert.equal(method, 'author_submitExtrinsic') return `0x${'00'.repeat(32)}` @@ -2563,7 +2610,7 @@ test('Client submit delegates automatic Keypair nonces to the Rust client', asyn const signer = core.Keypair.fromUri('//Alice') const callData = Buffer.from([1, 2, 3]) const { runtime } = fakeSigningRuntime() - client.headRuntime = async () => runtime + client.runtimeAt = async () => runtime const calls = [] const plan = { callData, @@ -2590,7 +2637,7 @@ test('Client submit delegates automatic Keypair nonces to the Rust client', asyn feeRao: null, warnings: [], } - client.tryNativeClient = () => ({ + client.requireNativeClient = () => ({ externalSigningPlan(submittedCallData, signerAddress, publicKey, cryptoType, requiresMetadataProof, options) { calls.push({ stage: 'plan', submittedCallData, signerAddress, publicKey, cryptoType, requiresMetadataProof, options }) return plan @@ -2634,7 +2681,7 @@ test('Client submit uses the Rust signing plan for external signers', async () = const callData = Buffer.from([3, 1, 4]) const { runtime } = fakeSigningRuntime() const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) - client.headRuntime = async () => runtime + client.runtimeAt = async () => runtime const publicKey = Buffer.alloc(32, 24) const address = core.ss58FromPublic(publicKey, 42) const signerPayload = { @@ -2680,7 +2727,7 @@ test('Client submit uses the Rust signing plan for external signers', async () = warnings: [], } const calls = [] - client.tryNativeClient = () => ({ + client.requireNativeClient = () => ({ externalSigningPlan(submittedCallData, signerAddress, submittedPublicKey, cryptoType, requiresMetadataProof, options) { calls.push({ stage: 'plan', submittedCallData, signerAddress, submittedPublicKey, cryptoType, requiresMetadataProof, options }) return plan @@ -2847,7 +2894,7 @@ test('Client submit without inclusion reports pool submission, not execution suc }) test('Client treats string and object fatal watch statuses as failures', async () => { - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) const subscriptionFor = (status) => ({ async *[Symbol.asyncIterator]() { yield status @@ -2866,7 +2913,7 @@ test('Client treats string and object fatal watch statuses as failures', async ( }) test('Client continues watching after retracted extrinsic status', async () => { - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) const blockHash = `0x${'33'.repeat(32)}` const extrinsicHash = `0x${'44'.repeat(32)}` const subscription = { @@ -2895,7 +2942,7 @@ test('Client continues watching after retracted extrinsic status', async () => { }) test('Client reports included extrinsics with missing dispatch outcome as unknown', async () => { - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) const extrinsic = '0x0102' const extrinsicHash = `0x${core.blake2_256(Buffer.from([1, 2])).toString('hex')}` client.rpc = async (method) => { @@ -2918,7 +2965,7 @@ test('Client reports included extrinsics with missing dispatch outcome as unknow test('Client submitSigned submits detached bytes without nonce coordination', async () => { const submitMaxRetries = [] const submitRetryForever = [] - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) client.transport.request = async (method, params = [], options = {}) => { if (method === 'author_submitExtrinsic') { submitMaxRetries.push(options.maxRetries) @@ -2963,7 +3010,7 @@ test('Client watchSigned submits detached bytes without nonce coordination', asy } } - const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) + const client = new core.BrowserChainClient('local', { endpoint: 'http://127.0.0.1:9944' }) client.transport = new core.JsonRpcTransport('ws://node-a', [], false, { requestTimeoutMs: 100, maxRequestRetries: 0, From 252c684c763dfbf58a639742236e247a52158715 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Mon, 13 Jul 2026 07:28:58 -0700 Subject: [PATCH 61/72] try fix ci --- sdk/bittensor-ts/README.md | 6 ++--- sdk/bittensor-ts/src/client.ts | 36 +++++++++++++++++++++++++--- sdk/bittensor-ts/test/basic.test.cjs | 24 +++++++++++++++++++ ts-tests/biome.jsonc | 4 +++- 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/sdk/bittensor-ts/README.md b/sdk/bittensor-ts/README.md index 0b2d76b3ef..6b26251413 100644 --- a/sdk/bittensor-ts/README.md +++ b/sdk/bittensor-ts/README.md @@ -152,9 +152,9 @@ TAO/alpha amounts with more than nine fractional digits are rejected. `client.submit()` delegates automatic nonce selection to the Rust client when submitting with a native `Keypair`. Low-level manual signing APIs such as `signExtrinsic()` require an explicit `nonce`, and detached flows using -`submitSigned()` delegate encoded submission to Rust. `watchSigned()` is only -available on `BrowserChainClient`, where the TypeScript WebSocket transport owns -the subscription. +`submitSigned()` and `watchSigned()` delegate encoded submission and receipt +tracking to Rust. On `BrowserChainClient`, `watchSigned()` uses the TypeScript +WebSocket transport for custom/browser subscription handling. High-level transaction helpers such as `transfer()`, `staking.addStake()`, `setWeights()`, registration, and serving route through Rust trusted diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 9560328044..93fcb11b41 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -2487,10 +2487,32 @@ export class Client extends BrowserChainClient { } override watchSigned( - _extrinsic: SignedExtrinsicResult | SignedExtrinsic | ByteLike, - _options: Pick & { signerAddress?: string } = {}, + extrinsic: SignedExtrinsicResult | SignedExtrinsic | ByteLike, + options: Pick & { signerAddress?: string } = {}, ): Promise { - return Promise.reject(new ChainError('submit-and-watch subscriptions are only available on BrowserChainClient')) + assertNativeWatchOptions(options) + const bytes = Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array + ? toBuffer(extrinsic, 'extrinsic') + : toBuffer(extrinsic.bytes, 'extrinsic.bytes') + const extrinsicHash = hex(blake2_256(bytes)) + const result = this.requireNativeClient() + .then((native) => native.submitEncoded( + bytes, + extrinsicHash, + options.waitForFinalization === true, + )) + .then((outcome) => nativeOutcomeToExtrinsicResult( + outcome, + options.waitForFinalization === true, + )) + return Promise.resolve({ + extrinsicHash, + result, + unsubscribe: async () => { + // Native receipt tracking is a bounded blocking task rather than a + // JSON-RPC subscription, so there is no remote subscription to cancel. + }, + }) } override async estimateFee( @@ -3838,6 +3860,14 @@ function assertNativeSubmitOptions(options: SubmitOptions): void { } } +function assertNativeWatchOptions(options: Pick): void { + if (options.signal != null || options.timeoutMs != null) { + throw new ChainError( + 'native Rust submit-and-watch does not support signal or timeoutMs; use BrowserChainClient for custom transport cancellation', + ) + } +} + function extensionSignPayload(context: SignerPayloadContext): ExtensionSignPayloadRequest { return context.signerPayload ?? context.runtime.signerPayload(context.address, context.callData, context.txParams) } diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 770f6d299e..a07d022b5d 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -1072,6 +1072,20 @@ test('Node Client keeps the Rust native backend authoritative', async () => { calls.push({ method: 'query', pallet, storage, params, block }) return 42 }, + submitEncoded(bytes, expectedHash, waitForFinalization) { + calls.push({ method: 'submitEncoded', bytes, expectedHash, waitForFinalization }) + return { + success: true, + extrinsicHash: expectedHash, + blockHash: `0x${'66'.repeat(32)}`, + blockNumber: 12n, + extrinsicIndex: 3, + feeRao: '7', + events: [], + error: null, + message: 'Success', + } + }, }) client.rpc = async () => { throw new Error('raw RPC should not be used by Node Client query') @@ -1086,6 +1100,16 @@ test('Node Client keeps the Rust native backend authoritative', async () => { () => client.queryMap('Example', 'Map', [], null, { pageSize: 1 }), /delegates map pagination to Rust/, ) + const watcher = await client.watchSigned(Buffer.from([1, 2, 3]), { waitForFinalization: true }) + const watched = await watcher.result + assert.equal(watched.status, 'finalized') + assert.equal(watched.extrinsicIndex, 3) + assert.deepEqual(calls.at(-1), { + method: 'submitEncoded', + bytes: Buffer.from([1, 2, 3]), + expectedHash: watcher.extrinsicHash, + waitForFinalization: true, + }) }) test('declared Node runtime supports the default WebSocket client path', () => { diff --git a/ts-tests/biome.jsonc b/ts-tests/biome.jsonc index 033d799010..bb68c971a6 100644 --- a/ts-tests/biome.jsonc +++ b/ts-tests/biome.jsonc @@ -11,6 +11,8 @@ "**/html/**", "**/build/**", "**/target/**", + "**/.papi/**", + "**/specs/**", "**/scripts/tmp/**", "**/node_modules/**", "**/.yarn/**", @@ -57,4 +59,4 @@ "enabled": false } } -} \ No newline at end of file +} From 10c3b099f77d0485d470696d00a2e9545edf4b95 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Mon, 13 Jul 2026 07:46:42 -0700 Subject: [PATCH 62/72] fix CI --- .github/workflows/typescript-e2e.yml | 204 --------------------------- 1 file changed, 204 deletions(-) delete mode 100644 .github/workflows/typescript-e2e.yml diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml deleted file mode 100644 index b46dbcb541..0000000000 --- a/.github/workflows/typescript-e2e.yml +++ /dev/null @@ -1,204 +0,0 @@ -name: Typescript E2E Tests - -on: - pull_request: - -concurrency: - group: typescript-e2e-${{ github.ref }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - -permissions: - contents: read - -jobs: - trusted-pr: - name: Trusted PR source (non-fork) - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false - runs-on: ubuntu-latest - steps: - - run: echo "Non-fork PR; self-hosted runners may execute checkout." - - # typescript-formatting is a required merge check, so the workflow must - # always trigger and that job always runs (it is cheap). The expensive node - # builds and zombienet suites skip themselves when the PR touches nothing - # they exercise; `skipped` satisfies branch protection. - changes: - name: detect e2e-relevant changes - runs-on: ubuntu-latest - permissions: - pull-requests: read - outputs: - e2e: ${{ steps.filter.outputs.e2e }} - steps: - # Plain gh-api file listing instead of a marketplace action: the org's - # Actions allowlist rejects unlisted third-party actions (startup_failure). - - name: Filter changed paths - id: filter - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename') - # SDK-only lockfile movement is covered by SDK checks; do not run - # zombienet unless chain/runtime or ts-tests inputs changed. - pattern='^(ts-tests|common|node|pallets|precompiles|primitives|runtime|support|chain-extensions|src|vendor)/|^(Cargo\.toml|build\.rs|rust-toolchain\.toml)$|^\.github/workflows/typescript-e2e\.yml$' - if grep -qE "$pattern" <<< "$files"; then - echo "e2e=true" >> "$GITHUB_OUTPUT" - else - echo "no e2e-relevant paths changed" - echo "e2e=false" >> "$GITHUB_OUTPUT" - fi - - typescript-formatting: - runs-on: ubuntu-latest - steps: - - name: Check-out repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Setup pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version-file: ts-tests/.nvmrc - - - name: Install system dependencies - run: | - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ - -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ - build-essential clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config - - - name: Install e2e dependencies - working-directory: ts-tests - run: pnpm install --frozen-lockfile - - - name: Formatting check - run: | - cd ts-tests - pnpm run fmt - - # Build the node binary in both variants and share as artifacts. - build: - runs-on: [self-hosted, fireactions-turbo-8] - environment: - name: sccache-writer - deployment: false - needs: [trusted-pr, typescript-formatting, changes] - if: needs.changes.outputs.e2e == 'true' - timeout-minutes: 60 - strategy: - matrix: - include: - - variant: release - flags: "" - - variant: fast - flags: "--features fast-runtime" - env: - RUST_BACKTRACE: full - steps: - - name: Check-out repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install system dependencies - run: | - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ - -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ - build-essential clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config - - - name: Install Rust - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - - - name: Shared R2 compiler cache - uses: ./.github/actions/sccache-setup - with: - credential-mode: auto - writer-access-key-id: ${{ secrets.SCCACHE_R2_WRITE_ACCESS_KEY_ID }} - writer-secret-access-key: ${{ secrets.SCCACHE_R2_WRITE_SECRET_ACCESS_KEY }} - - - name: Cache Cargo registry and git data - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - key: e2e-${{ matrix.variant }} - cache-on-failure: true - cache-targets: false - - - name: Build node-subtensor (${{ matrix.variant }}) - run: cargo build --profile release ${{ matrix.flags }} -p node-subtensor - - - name: Upload binary - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: node-subtensor-${{ matrix.variant }} - path: target/release/node-subtensor - if-no-files-found: error - - run-e2e-tests: - needs: [trusted-pr, build] - runs-on: [self-hosted, fireactions-turbo-8] - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - include: - - test: dev - binary: release - - test: zombienet_shield - binary: release - - test: zombienet_staking - binary: fast - - test: zombienet_coldkey_swap - binary: fast - - test: zombienet_subnets - binary: fast - - test: zombienet_evm - binary: fast - - name: "typescript-e2e-${{ matrix.test }}" - - steps: - - name: Check-out repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Download binary - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: node-subtensor-${{ matrix.binary }} - path: target/release - - - name: Make binary executable - run: chmod +x target/release/node-subtensor - - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version-file: ts-tests/.nvmrc - - - name: Setup pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - with: - version: 10 - - - name: Install e2e dependencies - working-directory: ts-tests - run: pnpm install --frozen-lockfile - - - name: Install lsof (dev foundation RPC port discovery) - if: matrix.test == 'dev' - run: | - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends lsof - - - name: Run tests - run: | - cd ts-tests - pnpm moonwall test ${{ matrix.test }} From ce5f46cd2b9f954fbfb1342c29d13ed0f91a14f9 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Mon, 13 Jul 2026 07:57:34 -0700 Subject: [PATCH 63/72] cover all old ts e2e in new --- .github/workflows/bittensor-ts-e2e.yml | 38 +++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bittensor-ts-e2e.yml b/.github/workflows/bittensor-ts-e2e.yml index b186b4de9e..0f5615c6b1 100644 --- a/.github/workflows/bittensor-ts-e2e.yml +++ b/.github/workflows/bittensor-ts-e2e.yml @@ -56,7 +56,7 @@ jobs: fi typescript-formatting: - name: TypeScript formatting and static checks + name: typescript-formatting runs-on: ubuntu-latest steps: - name: Check-out repository @@ -265,7 +265,9 @@ jobs: node -e "if (typeof globalThis.WebSocket !== 'function') throw new Error('globalThis.WebSocket is required by the default WSS client path')" node --test sdk/bittensor-ts/test/*.test.cjs - # Build the node binary in both variants and share as artifacts for Moonwall. + # Build the node binary variants and share them as artifacts for Moonwall. + # The standard variants preserve the previous runtime feature coverage, while + # the metadata-hash variants exercise the SDK signing path. build: name: Build node-subtensor (${{ matrix.variant }}) runs-on: [self-hosted, fireactions-turbo-8] @@ -279,8 +281,12 @@ jobs: matrix: include: - variant: release - flags: "--features metadata-hash" + flags: "" - variant: fast + flags: "--features fast-runtime" + - variant: release-metadata-hash + flags: "--features metadata-hash" + - variant: fast-metadata-hash flags: "--features fast-runtime,metadata-hash" env: RUST_BACKTRACE: full @@ -333,18 +339,42 @@ jobs: include: - test: dev binary: release + name_suffix: "" - test: zombienet_shield binary: release + name_suffix: "" - test: zombienet_staking binary: fast + name_suffix: "" - test: zombienet_coldkey_swap binary: fast + name_suffix: "" - test: zombienet_subnets binary: fast + name_suffix: "" - test: zombienet_evm binary: fast + name_suffix: "" + - test: dev + binary: release-metadata-hash + name_suffix: "-metadata-hash" + - test: zombienet_shield + binary: release-metadata-hash + name_suffix: "-metadata-hash" + - test: zombienet_staking + binary: fast-metadata-hash + name_suffix: "-metadata-hash" + - test: zombienet_coldkey_swap + binary: fast-metadata-hash + name_suffix: "-metadata-hash" + - test: zombienet_subnets + binary: fast-metadata-hash + name_suffix: "-metadata-hash" + - test: zombienet_evm + binary: fast-metadata-hash + name_suffix: "-metadata-hash" - name: "bittensor-ts-e2e-${{ matrix.test }}" + name: "bittensor-ts-e2e-${{ matrix.test }}${{ matrix.name_suffix }}" steps: - name: Check-out repository From 46ac117563e2eba9a2af312315edffb35c89f719 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Mon, 13 Jul 2026 09:15:21 -0700 Subject: [PATCH 64/72] more fixes --- sdk/bittensor-ts/native/src/lib.rs | 10 +- sdk/bittensor-ts/native/src/runtime.rs | 62 ++++++ sdk/bittensor-ts/native/src/values.rs | 64 ++++++ sdk/bittensor-ts/package.json | 1 + sdk/bittensor-ts/src/client.ts | 98 ++++----- sdk/bittensor-ts/src/index.ts | 4 + sdk/bittensor-ts/src/wire.ts | 18 ++ sdk/bittensor-ts/test/basic.test.cjs | 94 +++++---- sdk/bittensor-ts/test/localnet.test.cjs | 253 ++++++++++++++++++++++++ 9 files changed, 493 insertions(+), 111 deletions(-) create mode 100644 sdk/bittensor-ts/test/localnet.test.cjs diff --git a/sdk/bittensor-ts/native/src/lib.rs b/sdk/bittensor-ts/native/src/lib.rs index 247d2f4119..4edf8d7bfd 100644 --- a/sdk/bittensor-ts/native/src/lib.rs +++ b/sdk/bittensor-ts/native/src/lib.rs @@ -11,14 +11,16 @@ mod timelock; mod transaction; mod values; -use bittensor_core::codec::value::{to_corpus_json, u256_decimal}; +use bittensor_core::codec::value::u256_decimal; use bittensor_core::codec::Value; use napi::bindgen_prelude::{BigInt, Buffer}; use napi_derive::napi; use serde_json::Value as JsonValue; use crate::errors::{invalid_arg, NapiResult}; -use crate::values::{from_descriptor, from_wire, to_descriptor, to_wire, WIRE_TAG}; +use crate::values::{ + from_descriptor, from_wire, to_descriptor, to_js_safe_corpus_json, to_wire, WIRE_TAG, +}; #[napi(object)] pub struct NativeCoreValueField { @@ -54,7 +56,7 @@ pub fn wire_roundtrip(value: JsonValue) -> NapiResult { #[napi(js_name = "valueToCorpusJson")] pub fn value_to_corpus_json(value: JsonValue) -> NapiResult { - Ok(to_corpus_json(&from_wire(value)?)) + to_js_safe_corpus_json(&from_wire(value)?) } #[napi(js_name = "u256LeToDecimal")] @@ -83,7 +85,7 @@ pub fn wire_to_core_value_descriptor(value: JsonValue) -> NapiResult #[napi(js_name = "coreValueDescriptorToCorpusJson")] pub fn core_value_descriptor_to_corpus_json(value: JsonValue) -> NapiResult { - Ok(to_corpus_json(&from_descriptor(value)?)) + to_js_safe_corpus_json(&from_descriptor(value)?) } #[napi(js_name = "coreValueDescriptorDisplay")] diff --git a/sdk/bittensor-ts/native/src/runtime.rs b/sdk/bittensor-ts/native/src/runtime.rs index 408a062571..621ab6d4e0 100644 --- a/sdk/bittensor-ts/native/src/runtime.rs +++ b/sdk/bittensor-ts/native/src/runtime.rs @@ -841,6 +841,16 @@ impl NativeRuntime { } let mut output = Vec::new(); for (param, value) in method.inputs.iter().zip(values.iter()) { + if runtime_api_param_is_extrinsic( + &self.inner, + &api_name, + &method_name, + ¶m.name, + param.ty, + ) { + output.extend_from_slice(&runtime_api_extrinsic_bytes(value)?); + continue; + } self.inner.encode_id(param.ty, value, &mut output).napi()?; } Ok(output.into()) @@ -1462,6 +1472,58 @@ fn runtime_api_infos_json(runtime: &Runtime) -> JsonValue { ) } +fn runtime_api_param_is_extrinsic( + runtime: &Runtime, + api_name: &str, + method_name: &str, + param_name: &str, + type_id: u32, +) -> bool { + if api_name == "TransactionPaymentApi" + && matches!(method_name, "query_info" | "query_fee_details") + && param_name == "uxt" + { + return true; + } + + if let Some(name) = runtime.type_name_of(type_id) { + if matches!( + name, + "Extrinsic" | "UncheckedExtrinsic" | "UncheckedExtrinsicV4" | "OpaqueExtrinsic" + ) { + return true; + } + } + + let Ok(ty) = runtime.resolve(type_id) else { + return false; + }; + let last = ty.path.segments.last().map(String::as_str); + matches!( + last, + Some("Extrinsic" | "UncheckedExtrinsic" | "UncheckedExtrinsicV4" | "OpaqueExtrinsic") + ) +} + +fn runtime_api_extrinsic_bytes(value: &Value) -> NapiResult> { + match value { + Value::Bytes(bytes) => Ok(bytes.clone()), + Value::Str(value) => { + let raw = value.trim_start_matches("0x"); + if raw.len() % 2 != 0 || !raw.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(invalid_arg( + "runtime API extrinsic parameter must be bytes or even-length hex", + )); + } + hex::decode(raw) + .map_err(|error| invalid_arg(format!("invalid runtime API extrinsic hex: {error}"))) + } + _ => Err(invalid_arg( + "runtime API extrinsic parameter must be bytes or even-length hex", + )), + } +} + fn metadata_ir_json(runtime: &Runtime) -> NapiResult { let join_docs = |docs: &[String]| -> String { docs.iter() diff --git a/sdk/bittensor-ts/native/src/values.rs b/sdk/bittensor-ts/native/src/values.rs index 528c1c9c66..944d4fca0e 100644 --- a/sdk/bittensor-ts/native/src/values.rs +++ b/sdk/bittensor-ts/native/src/values.rs @@ -443,6 +443,70 @@ pub fn values_to_wire(values: &[Value]) -> NapiResult { .map(JsonValue::Array) } +pub fn to_js_safe_corpus_json(value: &Value) -> NapiResult { + to_js_safe_corpus_json_at(value, 0) +} + +fn to_js_safe_corpus_json_at(value: &Value, depth: usize) -> NapiResult { + if depth > MAX_WIRE_DEPTH { + return Err(invalid_arg("corpus value nesting exceeds 256 levels")); + } + match value { + Value::Null => Ok(JsonValue::Null), + Value::Bool(value) => Ok(JsonValue::Bool(*value)), + Value::Int(value) if (MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(value) => { + let integer = + i64::try_from(*value).map_err(|_| invalid_arg("safe integer conversion failed"))?; + Ok(JsonValue::Number(Number::from(integer))) + } + Value::Int(value) => Ok(JsonValue::String(value.to_string())), + Value::Uint(value) if *value <= MAX_SAFE_INTEGER as u128 => { + let integer = u64::try_from(*value) + .map_err(|_| invalid_arg("safe unsigned integer conversion failed"))?; + Ok(JsonValue::Number(Number::from(integer))) + } + Value::Uint(value) => Ok(JsonValue::String(value.to_string())), + Value::U256(value) => { + let decimal = u256_decimal(value); + if let Ok(value) = decimal.parse::() { + if value <= MAX_SAFE_INTEGER as u128 { + let integer = u64::try_from(value) + .map_err(|_| invalid_arg("safe u256 conversion failed"))?; + return Ok(JsonValue::Number(Number::from(integer))); + } + } + Ok(JsonValue::String(decimal)) + } + Value::Str(value) => Ok(JsonValue::String(value.clone())), + Value::Bytes(value) => Ok(JsonValue::String(format!("0x{}", hex::encode(value)))), + Value::List(values) | Value::Tuple(values) => values + .iter() + .map(|value| to_js_safe_corpus_json_at(value, depth + 1)) + .collect::>>() + .map(JsonValue::Array), + Value::Dict(entries) => { + let mut map = Map::new(); + for (key, value) in entries { + map.insert( + corpus_key(key), + to_js_safe_corpus_json_at(value, depth + 1)?, + ); + } + Ok(JsonValue::Object(map)) + } + } +} + +fn corpus_key(key: &Value) -> String { + match key { + Value::Str(value) => value.clone(), + Value::Int(value) => value.to_string(), + Value::Uint(value) => value.to_string(), + Value::Bool(value) => (if *value { "True" } else { "False" }).to_owned(), + other => format!("{other:?}"), + } +} + #[cfg(test)] mod tests { #![allow(clippy::expect_used)] diff --git a/sdk/bittensor-ts/package.json b/sdk/bittensor-ts/package.json index 634a4667e0..8c8d37af64 100644 --- a/sdk/bittensor-ts/package.json +++ b/sdk/bittensor-ts/package.json @@ -53,6 +53,7 @@ "build": "npm run build:native && npm run build:ts && npm run build:wasm && npm run check:binding-parity", "check": "npm run build && npm test", "test": "node --test test/*.test.cjs && node scripts/browser-smoke.cjs", + "test:localnet": "BT_TS_LOCALNET=1 node --test test/localnet.test.cjs", "clean": "node scripts/clean.cjs", "prepack": "npm run build", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 93fcb11b41..6d0c889c4e 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -1812,22 +1812,13 @@ export class BrowserChainClient { nonce: await this.peekNextIndex(account.ss58Address), period: null, }, snapshot) - const queryInfo = runtime.runtimeApis().TransactionPaymentApi?.query_info - if (queryInfo == null) { - throw new ChainError('TransactionPaymentApi.query_info metadata is unavailable') + const info = await this.rpc('payment_queryInfo', [hex(signed.bytes)]) + const fields = recordValue(info) + const partialFee = decimalAmount(fields?.partialFee ?? fields?.partial_fee) + if (partialFee == null) { + throw new ChainError('payment_queryInfo response is missing partialFee', info) } - const encodedInput = runtime.encodeRuntimeApiInput('TransactionPaymentApi', 'query_info', [signed.bytes, signed.bytes.length]) - const raw = await this.rpc('state_call', [ - 'TransactionPaymentApi_query_info', - hex(encodedInput), - snapshot.blockHash, - ]) - const info = runtime.decodeTypeId>( - queryInfo.outputTypeId, - hexToBuffer(String(raw)), - false, - ) - return Balance.fromRao(String(info.partial_fee ?? info.partialFee ?? 0)) + return Balance.fromRao(partialFee) } submitCall(pallet: string, fn: string, params: ScaleValue, signer: SignerLike, options: SubmitOptions = {}): Promise { @@ -2267,7 +2258,7 @@ export class Client extends BrowserChainClient { } async close(): Promise { - // NativeChainClient owns its connection and has no explicit close hook. + this.transport.close() } private async requireNativeClient(): Promise { @@ -2311,9 +2302,7 @@ export class Client extends BrowserChainClient { override async runtimeAt(block?: number | string | null): Promise { if (block != null) { - throw new ChainError( - 'historical runtime snapshots are only available on BrowserChainClient; Node Client uses the Rust NativeChainClient runtime cache', - ) + return super.runtimeAt(block) } return (await this.requireNativeClient()).runtime() } @@ -2351,10 +2340,8 @@ export class Client extends BrowserChainClient { return nativeChainInfoToChainInfo(await (await this.requireNativeClient()).chainInfo()) } - override rpc(method: string, _params: unknown[] = []): Promise { - return Promise.reject(new ChainError( - `raw JSON-RPC method ${method} is not available on the Node Client; use BrowserChainClient for custom transport RPC`, - )) + override rpc(method: string, params: unknown[] = []): Promise { + return super.rpc(method, params) } override async runtimeCall( @@ -2376,8 +2363,12 @@ export class Client extends BrowserChainClient { pageSizeOrOptions: number | QueryMapOptions = 512, ): Promise> { if (pageSizeOrOptions !== 512) { - throw new ChainError( - 'queryMap pagination controls are only available on BrowserChainClient; Node Client delegates map pagination to Rust', + return super.queryMap( + pallet, + storageFunction, + paramsOrBlock, + block, + pageSizeOrOptions, ) } const [moduleName, itemName, itemParams, blockRef] = @@ -2386,8 +2377,8 @@ export class Client extends BrowserChainClient { return await (await this.requireNativeClient()).queryMap(moduleName, itemName, itemParams, blockHash) as Array<[K, V]> } - override async stateCall(_method: string, _data: ByteLike | string, _block?: number | string | null): Promise { - throw new ChainError('raw state_call is only available on BrowserChainClient; use runtimeCall() for Rust-backed runtime APIs') + override async stateCall(method: string, data: ByteLike | string, block?: number | string | null): Promise { + return super.stateCall(method, data, block) } override async constant( @@ -2396,7 +2387,7 @@ export class Client extends BrowserChainClient { block?: number | string | null, ): Promise { if (block != null) { - throw new ChainError('historical constants are only available on BrowserChainClient') + return super.constant(pallet, name, block) } const [moduleName, constantName] = typeof pallet === 'string' ? [pallet, name as string] : pallet return (await this.requireNativeClient()).constant(moduleName, constantName) as T @@ -2408,7 +2399,7 @@ export class Client extends BrowserChainClient { block?: number | string | null, ): Promise { if (block != null) { - throw new ChainError('historical SCALE decoding is only available on BrowserChainClient') + return super.decodeScale(typeString, data, block) } return (await this.requireNativeClient()).decodeScale( typeString, @@ -2418,7 +2409,7 @@ export class Client extends BrowserChainClient { override async composeCall(pallet: string, fn: string, params: ScaleValue = {}, block?: number | string | null): Promise { if (block != null) { - throw new ChainError('historical call composition is only available on BrowserChainClient') + return super.composeCall(pallet, fn, params, block) } return (await this.requireNativeClient()).composeCall(pallet, fn, params) } @@ -2453,16 +2444,16 @@ export class Client extends BrowserChainClient { ) } - override async *blocks(_options: { finalized?: boolean } = {}): AsyncIterable { - throw new ChainError('block subscriptions are only available on BrowserChainClient') + override async *blocks(options: { finalized?: boolean } = {}): AsyncIterable { + yield* super.blocks(options) } - override waitForBlock(_block?: number | null, _options: { timeoutMs?: number } = {}): Promise { - return Promise.reject(new ChainError('block subscriptions are only available on BrowserChainClient')) + override waitForBlock(block?: number | null, options: { timeoutMs?: number } = {}): Promise { + return super.waitForBlock(block, options) } - override blockInfo(_block?: number | null): Promise { - return Promise.reject(new ChainError('block inspection is only available on BrowserChainClient')) + override blockInfo(block?: number | null): Promise { + return super.blockInfo(block) } override async submitSigned( @@ -2538,7 +2529,7 @@ export class Client extends BrowserChainClient { override async validateDescriptorSchema(block?: number | string | null): Promise { if (block != null) { - throw new ChainError('historical descriptor validation is only available on BrowserChainClient') + return super.validateDescriptorSchema(block) } return validateDescriptorSchema((await this.requireNativeClient()).runtime()) } @@ -3296,7 +3287,7 @@ export function validateDescriptorSchema(runtime: Runtime): DescriptorSchemaIssu } else { const actualArgTypes = actualArgTypeIds.map((typeId) => runtimeTypeName(runtime, typeId)) for (let index = 0; index < entry.argTypes.length; index += 1) { - if (actualArgTypes[index] !== entry.argTypes[index]) { + if (!runtimeTypeMatches(entry.argTypes[index], actualArgTypes[index])) { issues.push({ ...entry, kind: 'call', @@ -3333,6 +3324,17 @@ function callArgumentTypeIds(callInfo: { argTypeIds?: unknown; argTypes?: unknow return null } +const RUNTIME_TYPE_ALIASES = new Map([ + ['AlphaBalance', 'u64'], + ['Compact', 'Compact'], + ['NetUid', 'u16'], + ['TaoBalance', 'u64'], +]) + +function runtimeTypeMatches(expected: string, actual: string): boolean { + return actual === expected || actual === RUNTIME_TYPE_ALIASES.get(expected) +} + function runtimeTypeName(runtime: Runtime, typeId: number): string { try { return runtime.typeNameOf(typeId) ?? `scale_info::${typeId}` @@ -3470,31 +3472,13 @@ function assertNodeClientOptions(options: ClientOptions): void { const unsupported = unsupportedNodeClientOptions(options) if (unsupported.length === 0) return throw new ChainError( - `Node Client is backed exclusively by Rust NativeChainClient; use BrowserChainClient for TypeScript transport options: ${unsupported.join(', ')}`, + `Node Client is backed by one Rust NativeChainClient endpoint; unsupported options: ${unsupported.join(', ')}`, ) } function unsupportedNodeClientOptions(options: ClientOptions): string[] { const unsupported: string[] = [] if ((options.fallbackEndpoints?.length ?? 0) > 0) unsupported.push('fallbackEndpoints') - for (const key of [ - 'retryForever', - 'webSocket', - 'webSocketConstructor', - 'webSocketFactory', - 'headRuntimeTtlMs', - 'historicalRuntimeCacheSize', - 'requestTimeoutMs', - 'maxRequestRetries', - 'retryBackoffMs', - 'maxRetryBackoffMs', - 'endpointValidationTtlMs', - 'maxSubscriptionQueue', - 'maxMessageBytes', - 'validateDescriptorSchema', - ] satisfies Array) { - if (hasOwn(options, key)) unsupported.push(key) - } return unsupported } diff --git a/sdk/bittensor-ts/src/index.ts b/sdk/bittensor-ts/src/index.ts index 2d2f3d6303..5b327812ac 100644 --- a/sdk/bittensor-ts/src/index.ts +++ b/sdk/bittensor-ts/src/index.ts @@ -12,11 +12,15 @@ export * from './modules' export * from './runtime' export * from './timelock' export { + Executor, IntentCall, + NativeChainClient, Policy, + RustWallet, SignerRole, isIntentCall, rawCall, + signerRoleValue, } from './transaction' export type { PolicyOptions, diff --git a/sdk/bittensor-ts/src/wire.ts b/sdk/bittensor-ts/src/wire.ts index ea088e0c00..02e06067a1 100644 --- a/sdk/bittensor-ts/src/wire.ts +++ b/sdk/bittensor-ts/src/wire.ts @@ -6,7 +6,10 @@ export const WIRE_TAG = '__bittensor_core_wire__' as const const BIGINT_TAG = 'bigint' const BYTES_TAG = 'bytes' const DICT_TAG = 'dict' +const SERDE_JSON_NUMBER = '$serde_json::private::Number' const MAX_DEPTH = 256 +const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER) +const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER) type WireValue = | null @@ -141,6 +144,9 @@ function fromWireAt(value: unknown, depth: number): ScaleValue { } const object = value as Record + if (typeof object[SERDE_JSON_NUMBER] === 'string') { + return fromSerdeJsonNumber(object[SERDE_JSON_NUMBER]) + } const tag = object[WIRE_TAG] if (tag === BIGINT_TAG) { if (typeof object.value !== 'string') throw new TypeError('invalid native bigint wire value') @@ -170,3 +176,15 @@ function fromWireAt(value: unknown, depth: number): ScaleValue { } return output } + +function fromSerdeJsonNumber(value: string): number | bigint { + if (!/^-?\d+$/.test(value)) { + const number = Number(value) + if (Number.isFinite(number)) return number + throw new TypeError('invalid native serde_json number wire value') + } + const bigint = BigInt(value) + return bigint >= MIN_SAFE_BIGINT && bigint <= MAX_SAFE_BIGINT + ? Number(bigint) + : bigint +} diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index a07d022b5d..bdd0a73184 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -627,6 +627,11 @@ test('Python-compatible bittensor_core names are exported', () => { assert.equal(typeof core.get_encrypted_commitment, 'function') assert.equal(typeof core.get_signature_for_round, 'function') assert.equal(core.era_birth(64n, 70n), core.eraBirth(64n, 70n)) + assert.equal(typeof core.NativeChainClient, 'function') + assert.equal(typeof core.RustWallet, 'function') + assert.equal(typeof core.Executor, 'function') + assert.equal(core.signerRoleValue('coldkey'), core.SignerRole.Coldkey) + assert.equal(core.signerRoleValue('hotkey'), core.SignerRole.Hotkey) const camelMultisig = core.multisigAccountId([alice.public_key, bob.public_key], 2) const [snakeAccountId, snakeSorted] = core.multisig_account_id( @@ -809,6 +814,12 @@ test('dynamic boundary preserves large bigint, bytes, and non-string map keys', assert.equal(output.get('nested').wideSafeNumber, Number.MAX_SAFE_INTEGER) }) +test('dynamic boundary accepts native serde_json arbitrary-precision number markers', () => { + const privateNumber = '$serde_json::private::Number' + assert.equal(core.fromWire({ [privateNumber]: '1783957824001' }), 1783957824001) + assert.equal(core.fromWire({ [privateNumber]: '10000000000000000000' }), 10000000000000000000n) +}) + test('public constants and low-level hashes are exposed', () => { assert.equal(core.CRYPTO_ED25519, 0) assert.equal(core.CRYPTO_SR25519, 1) @@ -954,6 +965,10 @@ test('exact codec::Value descriptors preserve every Rust enum variant', () => { core.u256LeToDecimal(Buffer.alloc(32, 0xff)), '115792089237316195423570985008687907853269984665640564039457584007913129639935', ) + assert.equal( + core.coreValueDescriptorToCorpusJson(core.coreValueU256Le(Buffer.alloc(32, 0xff))), + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + ) }) test('public Cursor and TypeSpec APIs are forwarded to Rust', () => { @@ -1007,9 +1022,9 @@ test('chain client surface is exported without Polkadot.js glue', () => { assert.equal(typeof core.Client, 'function') assert.equal(typeof core.BrowserChainClient, 'function') assert.equal(typeof core.Client.prototype.watchSigned, 'function') - assert.equal(Object.prototype.hasOwnProperty.call(core, 'NativeChainClient'), false) - assert.equal(Object.prototype.hasOwnProperty.call(core, 'RustWallet'), false) - assert.equal(Object.prototype.hasOwnProperty.call(core, 'Executor'), false) + assert.equal(typeof core.NativeChainClient, 'function') + assert.equal(typeof core.RustWallet, 'function') + assert.equal(typeof core.Executor, 'function') assert.equal(core.Subtensor, core.SubtensorClient) assert.equal(typeof core.subtensor, 'function') assert.equal(typeof core.Wallet, 'function') @@ -1048,17 +1063,15 @@ test('Node Client keeps the Rust native backend authoritative', async () => { endpoint: 'http://primary', fallbackEndpoints: ['http://fallback'], }), - /BrowserChainClient/, - ) - assert.throws( - () => new core.Client('local', { - endpoint: 'ws://node-a', - webSocketFactory() { - throw new Error('not reached') - }, - }), - /BrowserChainClient/, + /fallbackEndpoints/, ) + assert.doesNotThrow(() => new core.Client('local', { + endpoint: 'ws://node-a', + requestTimeoutMs: 10, + webSocketFactory() { + throw new Error('not reached') + }, + })) const client = new core.Client('local', { endpoint: 'http://127.0.0.1:9944' }) const calls = [] @@ -1072,6 +1085,10 @@ test('Node Client keeps the Rust native backend authoritative', async () => { calls.push({ method: 'query', pallet, storage, params, block }) return 42 }, + queryMap(pallet, storage, params, block) { + calls.push({ method: 'queryMap', pallet, storage, params, block }) + return [[1, true]] + }, submitEncoded(bytes, expectedHash, waitForFinalization) { calls.push({ method: 'submitEncoded', bytes, expectedHash, waitForFinalization }) return { @@ -1096,10 +1113,14 @@ test('Node Client keeps the Rust native backend authoritative', async () => { { method: 'finalizedHead' }, { method: 'query', pallet: 'Example', storage: 'Value', params: [], block: blockHash }, ]) - await assert.rejects( - () => client.queryMap('Example', 'Map', [], null, { pageSize: 1 }), - /delegates map pagination to Rust/, - ) + assert.deepEqual(await client.queryMap('Example', 'Map'), [[1, true]]) + assert.deepEqual(calls.at(-1), { + method: 'queryMap', + pallet: 'Example', + storage: 'Map', + params: [], + block: blockHash, + }) const watcher = await client.watchSigned(Buffer.from([1, 2, 3]), { waitForFinalization: true }) const watched = await watcher.result assert.equal(watched.status, 'finalized') @@ -2799,33 +2820,7 @@ test('Client submit uses the Rust signing plan for external signers', async () = test('Client estimateFee peeks the chain nonce without reserving it', async () => { const callData = Buffer.from([9, 8, 7]) - const { runtime, captures } = fakeSigningRuntime({ - runtimeApis() { - return { - TransactionPaymentApi: { - query_info: { - inputDetails: [ - { name: 'uxt', typeId: 10, type: 'Extrinsic' }, - { name: 'len', typeId: 11, type: 'u32' }, - ], - outputTypeId: 1, - }, - }, - } - }, - encodeRuntimeApiInput(api, method, params) { - assert.equal(api, 'TransactionPaymentApi') - assert.equal(method, 'query_info') - assert.equal(params.length, 2) - const [uxt, len] = params - const encodedLen = Buffer.alloc(4) - encodedLen.writeUInt32LE(len, 0) - return Buffer.concat([Buffer.from(uxt), encodedLen]) - }, - decodeTypeId() { - return { partial_fee: 123n } - }, - }) + const { runtime, captures } = fakeSigningRuntime() const client = fakeSigningClient(runtime, callData) const publicKey = Buffer.alloc(32, 8) const address = core.ss58FromPublic(publicKey, 42) @@ -2834,7 +2829,7 @@ test('Client estimateFee peeks the chain nonce without reserving it', async () = Buffer.alloc(64, 4), ]) const nonceReads = [] - let stateCallParams + let paymentQueryInfoParams client.rpc = async (method, params = []) => { if (method === 'system_accountNextIndex') { nonceReads.push(params[0]) @@ -2850,9 +2845,9 @@ test('Client estimateFee peeks the chain nonce without reserving it', async () = if (method === 'system_properties') { return { ss58Format: 42, tokenDecimals: [9], tokenSymbol: ['TAO'] } } - if (method === 'state_call') { - stateCallParams = params - return '0x00' + if (method === 'payment_queryInfo') { + paymentQueryInfoParams = params + return { partialFee: '123' } } throw new Error(`unexpected RPC ${method}`) } @@ -2873,8 +2868,7 @@ test('Client estimateFee peeks the chain nonce without reserving it', async () = assert.equal(firstRealNonce, 7) assert.equal(secondRealNonce, 7) assert.deepEqual(nonceReads, [address, address, address]) - assert.equal(stateCallParams[0], 'TransactionPaymentApi_query_info') - assert.equal(stateCallParams[2], `0x${'42'.repeat(32)}`) + assert.deepEqual(paymentQueryInfoParams, ['0x010404']) }) test('Client submit without inclusion reports pool submission, not execution success', async () => { diff --git a/sdk/bittensor-ts/test/localnet.test.cjs b/sdk/bittensor-ts/test/localnet.test.cjs new file mode 100644 index 0000000000..4c80437168 --- /dev/null +++ b/sdk/bittensor-ts/test/localnet.test.cjs @@ -0,0 +1,253 @@ +'use strict' + +const assert = require('node:assert/strict') +const test = require('node:test') + +const sdk = require('../dist/index.js') + +const endpoint = process.env.BT_CHAIN_ENDPOINT ?? 'ws://127.0.0.1:9944' +const enabled = process.env.BT_TS_LOCALNET === '1' || process.env.BT_CHAIN_ENDPOINT != null +const timeout = Number(process.env.BT_TS_LOCALNET_TIMEOUT_MS ?? 120_000) +const localnet = { + skip: enabled ? false : 'set BT_TS_LOCALNET=1 or BT_CHAIN_ENDPOINT to run localnet integration tests', + timeout, +} + +function assertHash(value, name = 'hash') { + assert.match(String(value), /^0x[0-9a-fA-F]{64}$/, `${name} is a 32-byte hash`) +} + +function assertBalance(value, name = 'balance') { + assert.ok(value instanceof sdk.Balance, `${name} is a Balance`) + assert.equal(typeof value.rao, 'bigint', `${name}.rao is bigint`) +} + +async function closeAll(...clients) { + await Promise.all(clients.map(async (client) => { + if (client?.close == null) return + await client.close().catch(() => undefined) + })) +} + +test('localnet covers live reads, metadata, namespaces, snapshots, and fallback RPC helpers', localnet, async () => { + const alice = sdk.Keypair.fromUri('//Alice') + const bob = sdk.Keypair.fromUri('//Bob') + const node = new sdk.Client(endpoint, { autoConnect: false, requestTimeoutMs: 20_000 }) + const browser = new sdk.BrowserChainClient(endpoint, { autoConnect: false, requestTimeoutMs: 20_000 }) + const native = await sdk.NativeChainClient.connect(endpoint) + + try { + await node.connect() + await browser.connect() + + const head = await node.finalizedHead() + assertHash(head, 'finalized head') + const blockNumber = await node.blockNumber(head) + assert.ok(blockNumber >= 0) + assertHash(await node.blockHash(blockNumber), 'block hash') + assertHash(await node.genesisHash(), 'genesis hash') + + const runtime = await node.runtimeAt() + const historicalRuntime = await node.runtimeAt(head) + assert.equal(historicalRuntime.specVersion, runtime.specVersion) + assert.equal(runtime.typeNameOf(runtime.storageEntry('System', 'Account').valueTypeId) != null, true) + assert.equal(runtime.runtimeApis().TransactionPaymentApi.query_info.name, 'query_info') + + const metadata = await node.stateCall('Metadata_metadata_at_version', '0x0f000000', head) + assert.match(String(metadata), /^0x/) + assert.equal(await node.decodeScale('u32', Buffer.from([42, 0, 0, 0]), head), 42) + assert.ok((await node.composeCall('System', 'remark', { remark: Buffer.from('historical') }, head)).length > 0) + assert.deepEqual(await node.validateDescriptorSchema(head), []) + await node.assertDescriptorSchema(head) + + const now = await node.query(sdk.storage.Timestamp.Now) + assert.ok(typeof now === 'number' || typeof now === 'bigint') + assert.ok(await node.timestamp() instanceof Date) + const blockInfo = await node.blockInfo(blockNumber) + assert.equal(blockInfo?.number, blockNumber) + assertHash(blockInfo?.hash, 'blockInfo hash') + + const account = await node.query(sdk.storage.System.Account, [alice.ss58Address]) + assert.ok(account?.data) + const accountBatch = await node.queryBatch(sdk.storage.System.Account, [ + [alice.ss58Address], + [bob.ss58Address], + ]) + assert.equal(accountBatch.length, 2) + const networkPage = await node.queryMap( + sdk.storage.SubtensorModule.NetworksAdded, + [], + head, + undefined, + { pageSize: 1, maxResults: 3 }, + ) + assert.ok(networkPage.length >= 1) + + assertBalance(await node.balances.get(alice.ss58Address), 'alice balance') + assertBalance((await node.balances.getMany([alice.ss58Address, bob.ss58Address]))[alice.ss58Address], 'alice batch balance') + assertBalance(await node.balances.existentialDeposit(head), 'existential deposit') + assertBalance(await node.getBalance(alice), 'getBalance alias') + + const subnets = await node.subnets.all() + assert.ok(subnets.length >= 1) + const netuid = subnets[0].netuid + const subnet = await node.subnets.info(netuid) + assert.equal(subnet.netuid, netuid) + assert.equal(await node.subnets.exists(netuid), true) + assertBalance(await node.subnets.burn(netuid), 'subnet burn') + assert.equal(typeof await node.subnets.commitRevealEnabled(netuid), 'boolean') + assert.ok(await node.subnets.hyperparameters(netuid)) + assert.ok(await node.subnets.metagraph(netuid)) + assert.ok(Array.isArray(await node.neurons.all(netuid))) + assertBalance(await node.staking.get(alice.ss58Address, bob.ss58Address, netuid), 'stake balance') + assert.ok(Array.isArray(await node.staking.positions(alice.ss58Address))) + + assertBalance(await node.read('balance', { coldkey_ss58: alice.ss58Address }), 'read balance') + assert.ok(Array.isArray(await node.read('subnets'))) + assert.equal((await node.read('subnet', { netuid })).netuid, netuid) + assertBalance(await node.read('burn', { netuid }), 'read burn') + assert.equal(typeof await node.read('commit_reveal_enabled', { netuid }), 'boolean') + assert.ok(await node.read('subnet_hyperparameters', { netuid })) + assert.ok(await node.read('metagraph', { netuid })) + assertBalance( + await node.read('stake', { + coldkey_ss58: alice.ss58Address, + hotkey_ss58: bob.ss58Address, + netuid, + }), + 'read stake', + ) + + const snapshot = await node.at(blockNumber) + assert.equal(snapshot.block, blockNumber) + assertBalance(await snapshot.balances.get(alice.ss58Address), 'snapshot balance') + assert.equal((await snapshot.subnets.info(netuid)).netuid, netuid) + assert.ok(Array.isArray(await snapshot.neurons.all(netuid))) + assert.ok(Array.isArray(await snapshot.staking.positions(alice.ss58Address))) + + assert.equal(native.ss58Format, 42) + assertHash(`0x${native.genesisHash.toString('hex')}`, 'native genesis') + assert.ok(native.readCatalog().length > 0) + assert.equal(await native.blockNumber(head), blockNumber) + assert.equal(await native.query('Timestamp', 'Now', [], head), now) + assert.equal((await native.queryBatch('System', 'Account', [[alice.ss58Address]])).length, 1) + assert.ok((await native.queryMap('SubtensorModule', 'NetworksAdded')).length >= 1) + assert.ok((await native.runtimeCall('SubnetInfoRuntimeApi', 'get_subnet_hyperparams_v3', [netuid]))) + + const browserAccount = await browser.query(sdk.storage.System.Account, [alice.ss58Address]) + assert.deepEqual(browserAccount.data.free, account.data.free) + const waited = await browser.waitForBlock(blockNumber, { timeoutMs: 30_000 }) + assert.ok(waited.number >= blockNumber) + } finally { + await closeAll(node, browser) + } +}) + +test('localnet covers signing, fee estimation, runtime payment APIs, and bounded submissions', localnet, async () => { + const alice = sdk.Keypair.fromUri('//Alice') + const bob = sdk.Keypair.fromUri('//Bob') + const node = new sdk.Client(endpoint, { autoConnect: false, requestTimeoutMs: 20_000 }) + const browser = new sdk.BrowserChainClient(endpoint, { autoConnect: false, requestTimeoutMs: 20_000 }) + const native = await sdk.NativeChainClient.connect(endpoint) + + try { + await node.connect() + await browser.connect() + + const transfer = sdk.IntentCall.transfer(bob.ss58Address, 1n) + const tuple = transfer.asCallTuple() + assert.deepEqual(tuple.slice(0, 2), ['Balances', 'transfer_keep_alive']) + assert.equal(sdk.isIntentCall(transfer), true) + assert.equal(transfer.withSummary('one rao transfer').summary, 'one rao transfer') + assert.equal(sdk.rawCall('System', 'remark', { remark: Buffer.from('raw') }).pallet, 'System') + + const allowPolicy = new sdk.Policy({ maxFeeRao: 10_000_000n, maxSpendRao: 1n }) + assert.deepEqual(allowPolicy.check(transfer, 1n), []) + assert.equal(allowPolicy.hasOpaqueByteRestrictions(), true) + assert.equal(allowPolicy.withRawCalls().allowRawCalls, true) + assert.ok(new sdk.Policy({ maxSpendRao: 0n }).check(transfer, 1n).length > 0) + + for (const intent of [ + sdk.IntentCall.transferAllowDeath(bob.ss58Address, 1n), + sdk.IntentCall.transferAll(bob.ss58Address, false), + sdk.IntentCall.setWeights(0, [], [], 0n), + sdk.IntentCall.addStake(bob.ss58Address, 0, 1n), + sdk.IntentCall.addStakeLimit(bob.ss58Address, 0, 1n, 1n, false), + sdk.IntentCall.removeStake(bob.ss58Address, 0, 1n), + sdk.IntentCall.removeStakeLimit(bob.ss58Address, 0, 1n, 1n, false), + sdk.IntentCall.burnedRegister(0, bob.ss58Address), + sdk.IntentCall.rootRegister(bob.ss58Address), + sdk.IntentCall.registerSubnet(bob.ss58Address), + sdk.IntentCall.startCall(0), + sdk.IntentCall.serveAxon(0, 2130706433, 30333), + sdk.IntentCall.moveStake(bob.ss58Address, 0, alice.ss58Address, 0, 1n), + sdk.IntentCall.swapStake(bob.ss58Address, 0, 0, 1n), + sdk.IntentCall.transferStake(alice.ss58Address, bob.ss58Address, 0, 0, 1n), + sdk.IntentCall.unstakeAll(bob.ss58Address), + sdk.IntentCall.unstakeAllAlpha(bob.ss58Address), + sdk.IntentCall.setHyperparameter(0, 'immunity_period', 99), + sdk.IntentCall.setRootClaimType('Keep'), + sdk.calls.balances.transferKeepAlive(bob.ss58Address, sdk.rao(1n)), + sdk.calls.subtensor.servePrometheus(0, '127.0.0.1', 9090), + ]) { + assert.ok((await node.callData(intent, await node.finalizedHead())).length > 0) + } + + const nativeFee = await node.estimateFee(transfer, alice) + const browserFee = await browser.estimateFee(transfer, alice) + assertBalance(nativeFee, 'native fee') + assertBalance(browserFee, 'browser fee') + assert.ok(nativeFee.rao > 0n) + assert.ok(browserFee.rao > 0n) + + const nonce = await browser.peekNextIndex(alice.ss58Address) + const signed = await browser.signExtrinsic(transfer, alice, { nonce, period: null }) + assert.equal(signed.signerAddress, alice.ss58Address) + assert.equal(signed.nonce, nonce) + assert.match(signed.hex, /^0x[0-9a-fA-F]+$/) + assert.equal(signed.hash.length, 32) + const paymentInfo = await browser.runtimeCall( + sdk.runtimeApi.TransactionPaymentApi.query_info, + [signed.bytes, signed.bytes.length], + ) + assert.ok(BigInt(paymentInfo.partial_fee ?? paymentInfo.partialFee) > 0n) + + const nativeCallData = await native.composeIntent(transfer) + assert.ok(nativeCallData.length > 0) + assert.ok(await native.estimateFee(nativeCallData, alice) > 0n) + const plan = await native.externalSigningPlanForIntent( + transfer, + alice.ss58Address, + alice.publicKey, + alice.cryptoType, + false, + allowPolicy, + ) + assert.equal(plan.signerAddress, alice.ss58Address) + assert.ok(plan.payload.length > 0) + assert.ok(await native.estimateFeeExternal(plan) > 0n) + const assembled = await native.assembleExternal(plan, alice.sign(plan.payload), alice.cryptoType) + assert.ok(assembled.bytes.length > 0) + assertHash(assembled.hash, 'assembled hash') + + const submitResult = await node.submit(transfer, alice, { + waitForInclusion: true, + policy: { maxFeeRao: 10_000_000n, maxSpendRao: 1n }, + }) + assert.match(submitResult.status, /^(inBlock|finalized)$/) + assertHash(submitResult.extrinsicHash, 'node submit hash') + + const nextNonce = await browser.peekNextIndex(alice.ss58Address) + const detached = await browser.signExtrinsic(transfer, alice, { nonce: nextNonce, period: null }) + const watched = await browser.watchSigned(detached, { + signerAddress: alice.ss58Address, + timeoutMs: 60_000, + }) + assertHash(watched.extrinsicHash, 'watched hash') + const watchedResult = await watched.result + assert.match(watchedResult.status, /^(inBlock|finalized)$/) + await watched.unsubscribe() + } finally { + await closeAll(node, browser) + } +}) From 05b51b28a1ae3e3a2616723ee1a0640748ac6fbc Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Mon, 13 Jul 2026 09:42:34 -0700 Subject: [PATCH 65/72] Use bittensor SDK helpers in ts-tests --- ts-tests/package.json | 7 +- ts-tests/pnpm-lock.yaml | 226 ++++++++---------- .../test-mevshield-execute-orders.ts | 11 +- .../dev/subtensor/staking/test-add-staking.ts | 5 +- .../00-evm-substrate-transfer.test.ts | 7 +- .../01-contract-deploy-call.test.ts | 19 +- .../zombienet_evm/03-wasm-contract.test.ts | 64 ++--- .../zombienet_evm/04-edge-cases.test.ts | 6 +- .../05-direct-call-precompile.test.ts | 10 +- .../zombienet_shield/00.01-basic.test.ts | 29 ++- .../zombienet_shield/01-scaling.test.ts | 13 +- .../zombienet_shield/02-edge-cases.test.ts | 15 +- .../suites/zombienet_shield/03-timing.test.ts | 17 +- .../zombienet_shield/04-mortality.test.ts | 19 +- .../00-register-network.test.ts | 7 +- ts-tests/utils/staking.ts | 40 ++-- ts-tests/utils/wasm-contract.ts | 6 +- 17 files changed, 229 insertions(+), 272 deletions(-) diff --git a/ts-tests/package.json b/ts-tests/package.json index 19a7a91a58..882f6edd35 100644 --- a/ts-tests/package.json +++ b/ts-tests/package.json @@ -36,18 +36,13 @@ } }, "dependencies": { - "@inquirer/prompts": "7.3.1", "@bittensor/sdk": "link:../sdk/bittensor-ts", + "@inquirer/prompts": "7.3.1", "@polkadot-api/ink-contracts": "^0.4.1", "@polkadot-api/merkleize-metadata": "^1.1.15", "@polkadot-api/sdk-ink": "^0.5.1", "@polkadot-api/substrate-bindings": "^0.17.0", "@polkadot/api": "*", - "@polkadot/keyring": "*", - "@polkadot/types": "*", - "@polkadot/types-codec": "*", - "@polkadot/util": "*", - "@polkadot/util-crypto": "*", "@zombienet/orchestrator": "0.0.105", "ethereum-cryptography": "3.1.0", "polkadot-api": "^1.22.0", diff --git a/ts-tests/pnpm-lock.yaml b/ts-tests/pnpm-lock.yaml index 72ce256e39..c5c813f557 100644 --- a/ts-tests/pnpm-lock.yaml +++ b/ts-tests/pnpm-lock.yaml @@ -20,12 +20,12 @@ importers: .: dependencies: - '@inquirer/prompts': - specifier: 7.3.1 - version: 7.3.1(@types/node@25.9.2) '@bittensor/sdk': specifier: link:../sdk/bittensor-ts version: link:../sdk/bittensor-ts + '@inquirer/prompts': + specifier: 7.3.1 + version: 7.3.1(@types/node@25.9.2) '@polkadot-api/ink-contracts': specifier: ^0.4.1 version: 0.4.6 @@ -41,24 +41,9 @@ importers: '@polkadot/api': specifier: '*' version: 16.5.6 - '@polkadot/keyring': - specifier: '*' - version: 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3) - '@polkadot/types': - specifier: '*' - version: 16.5.6 - '@polkadot/types-codec': - specifier: '*' - version: 16.5.6 - '@polkadot/util': - specifier: '*' - version: 14.0.3 - '@polkadot/util-crypto': - specifier: '*' - version: 14.0.3(@polkadot/util@14.0.3) '@zombienet/orchestrator': specifier: 0.0.105 - version: 0.0.105(@polkadot/util@14.0.3)(@types/node@25.9.2)(chokidar@3.6.0) + version: 0.0.105(@polkadot/util@13.5.9)(@types/node@25.9.2)(chokidar@3.6.0) ethereum-cryptography: specifier: 3.1.0 version: 3.1.0 @@ -77,13 +62,13 @@ importers: version: 1.9.4 '@moonwall/cli': specifier: 5.18.3 - version: 5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api-derive@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3))(@polkadot/rpc-provider@16.5.6)(@polkadot/types-codec@16.5.6)(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3)(@types/debug@4.1.12)(@types/node@25.9.2)(chokidar@3.6.0)(debug@4.3.7)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(ts-node@10.9.2(@types/node@25.9.2)(typescript@5.8.3))(tsx@4.22.4)(typescript@5.8.3)(zod@3.25.76) + version: 5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api-derive@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9))(@polkadot/rpc-provider@16.5.6)(@polkadot/types-codec@16.5.6)(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9)(@types/debug@4.1.12)(@types/node@25.9.2)(chokidar@3.6.0)(debug@4.3.7)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(ts-node@10.9.2(@types/node@25.9.2)(typescript@5.8.3))(tsx@4.22.4)(typescript@5.8.3)(zod@3.25.76) '@moonwall/util': specifier: 5.18.3 - version: 5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api-derive@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3))(@polkadot/rpc-provider@16.5.6)(@polkadot/types-codec@16.5.6)(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3)(@types/debug@4.1.12)(@types/node@25.9.2)(chokidar@3.6.0)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(typescript@5.8.3)(yaml@2.8.3)(zod@3.25.76) + version: 5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api-derive@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9))(@polkadot/rpc-provider@16.5.6)(@polkadot/types-codec@16.5.6)(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9)(@types/debug@4.1.12)(@types/node@25.9.2)(chokidar@3.6.0)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(typescript@5.8.3)(yaml@2.8.3)(zod@3.25.76) '@polkadot/wasm-crypto': specifier: ^7.4.1 - version: 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) + version: 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) '@types/debug': specifier: 4.1.12 version: 4.1.12 @@ -1163,10 +1148,6 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} - '@noble/ciphers@2.2.0': - resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} - engines: {node: '>= 20.19.0'} - '@noble/curves@1.2.0': resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} @@ -3592,10 +3573,6 @@ packages: engines: {node: '>=10'} hasBin: true - mlkem@2.7.0: - resolution: {integrity: sha512-I2bcB5d6jtkdan6MLGOxObpUbidqv0ej+PhbCGnXUqmcGYZ6X8F0qBpU6HE4mvYc81NSznBrVDp+Uc808Ba2RA==} - engines: {node: '>=16.0.0'} - mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -4468,7 +4445,7 @@ packages: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} toml@https://codeload.github.com/pepoviola/toml-node/tar.gz/5e17114f1af5b5b70e4f2ec10cd007623c928988: - resolution: {tarball: https://codeload.github.com/pepoviola/toml-node/tar.gz/5e17114f1af5b5b70e4f2ec10cd007623c928988} + resolution: {gitHosted: true, tarball: https://codeload.github.com/pepoviola/toml-node/tar.gz/5e17114f1af5b5b70e4f2ec10cd007623c928988} version: 3.0.0 totalist@3.0.1: @@ -5961,7 +5938,7 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} - '@moonwall/cli@5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api-derive@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3))(@polkadot/rpc-provider@16.5.6)(@polkadot/types-codec@16.5.6)(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3)(@types/debug@4.1.12)(@types/node@25.9.2)(chokidar@3.6.0)(debug@4.3.7)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(ts-node@10.9.2(@types/node@25.9.2)(typescript@5.8.3))(tsx@4.22.4)(typescript@5.8.3)(zod@3.25.76)': + '@moonwall/cli@5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api-derive@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9))(@polkadot/rpc-provider@16.5.6)(@polkadot/types-codec@16.5.6)(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9)(@types/debug@4.1.12)(@types/node@25.9.2)(chokidar@3.6.0)(debug@4.3.7)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(ts-node@10.9.2(@types/node@25.9.2)(typescript@5.8.3))(tsx@4.22.4)(typescript@5.8.3)(zod@3.25.76)': dependencies: '@acala-network/chopsticks': 1.4.2(debug@4.3.7)(ts-node@10.9.2(@types/node@25.9.2)(typescript@5.8.3)) '@ast-grep/napi': 0.40.5 @@ -5973,21 +5950,21 @@ snapshots: '@effect/sql': 0.48.6(@effect/experimental@0.57.11(@effect/platform@0.93.8(effect@3.21.3))(effect@3.21.3))(@effect/platform@0.93.8(effect@3.21.3))(effect@3.21.3) '@effect/workflow': 0.15.2(@effect/experimental@0.57.11(@effect/platform@0.93.8(effect@3.21.3))(effect@3.21.3))(@effect/platform@0.93.8(effect@3.21.3))(@effect/rpc@0.72.2(@effect/platform@0.93.8(effect@3.21.3))(effect@3.21.3))(effect@3.21.3) '@inquirer/prompts': 8.5.2(@types/node@25.9.2) - '@moonwall/types': 5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3))(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3)(@types/debug@4.1.12)(@vitest/ui@3.2.6)(chokidar@3.6.0)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(typescript@5.8.3)(yaml@2.8.3)(zod@3.25.76) - '@moonwall/util': 5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api-derive@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3))(@polkadot/rpc-provider@16.5.6)(@polkadot/types-codec@16.5.6)(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3)(@types/debug@4.1.12)(@types/node@25.9.2)(chokidar@3.6.0)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(typescript@5.8.3)(yaml@2.8.3)(zod@3.25.76) + '@moonwall/types': 5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9))(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9)(@types/debug@4.1.12)(@vitest/ui@3.2.6)(chokidar@3.6.0)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(typescript@5.8.3)(yaml@2.8.3)(zod@3.25.76) + '@moonwall/util': 5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api-derive@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9))(@polkadot/rpc-provider@16.5.6)(@polkadot/types-codec@16.5.6)(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9)(@types/debug@4.1.12)(@types/node@25.9.2)(chokidar@3.6.0)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(typescript@5.8.3)(yaml@2.8.3)(zod@3.25.76) '@octokit/rest': 22.0.1 '@polkadot/api': 16.5.6 '@polkadot/api-derive': 16.5.6 - '@polkadot/keyring': 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3) + '@polkadot/keyring': 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9) '@polkadot/rpc-provider': 16.5.6 '@polkadot/types': 16.5.6 '@polkadot/types-codec': 16.5.6 - '@polkadot/util': 14.0.3 - '@polkadot/util-crypto': 14.0.3(@polkadot/util@14.0.3) + '@polkadot/util': 13.5.9 + '@polkadot/util-crypto': 14.0.3(@polkadot/util@13.5.9) '@types/react': 19.2.7 '@types/tmp': 0.2.6 '@vitest/ui': 3.2.6(vitest@3.2.6) - '@zombienet/orchestrator': 0.0.113(@polkadot/util@14.0.3)(@types/node@25.9.2)(chokidar@3.6.0) + '@zombienet/orchestrator': 0.0.113(@polkadot/util@13.5.9)(@types/node@25.9.2)(chokidar@3.6.0) '@zombienet/utils': 0.0.30(@types/node@25.9.2)(chokidar@3.6.0)(typescript@5.8.3) arkregex: 0.0.4 bottleneck: 2.19.5 @@ -6068,14 +6045,14 @@ snapshots: - utf-8-validate - zod - '@moonwall/types@5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3))(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3)(@types/debug@4.1.12)(@vitest/ui@3.2.6)(chokidar@3.6.0)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(typescript@5.8.3)(yaml@2.8.3)(zod@3.25.76)': + '@moonwall/types@5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9))(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9)(@types/debug@4.1.12)(@vitest/ui@3.2.6)(chokidar@3.6.0)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(typescript@5.8.3)(yaml@2.8.3)(zod@3.25.76)': dependencies: '@polkadot/api': 16.5.6 '@polkadot/api-base': 16.5.6 - '@polkadot/keyring': 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3) + '@polkadot/keyring': 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9) '@polkadot/types': 16.5.6 - '@polkadot/util': 14.0.3 - '@polkadot/util-crypto': 14.0.3(@polkadot/util@14.0.3) + '@polkadot/util': 13.5.9 + '@polkadot/util-crypto': 14.0.3(@polkadot/util@13.5.9) '@types/node': 24.13.1 '@zombienet/utils': 0.0.30(@types/node@24.13.1)(chokidar@3.6.0)(typescript@5.8.3) bottleneck: 2.19.5 @@ -6115,18 +6092,18 @@ snapshots: - yaml - zod - '@moonwall/util@5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api-derive@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3))(@polkadot/rpc-provider@16.5.6)(@polkadot/types-codec@16.5.6)(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3)(@types/debug@4.1.12)(@types/node@25.9.2)(chokidar@3.6.0)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(typescript@5.8.3)(yaml@2.8.3)(zod@3.25.76)': + '@moonwall/util@5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api-derive@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9))(@polkadot/rpc-provider@16.5.6)(@polkadot/types-codec@16.5.6)(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9)(@types/debug@4.1.12)(@types/node@25.9.2)(chokidar@3.6.0)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(typescript@5.8.3)(yaml@2.8.3)(zod@3.25.76)': dependencies: '@inquirer/prompts': 8.5.2(@types/node@25.9.2) - '@moonwall/types': 5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3))(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3)(@types/debug@4.1.12)(@vitest/ui@3.2.6)(chokidar@3.6.0)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(typescript@5.8.3)(yaml@2.8.3)(zod@3.25.76) + '@moonwall/types': 5.18.3(@polkadot/api-base@16.5.6)(@polkadot/api@16.5.6)(@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9))(@polkadot/types@16.5.6)(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9)(@types/debug@4.1.12)(@vitest/ui@3.2.6)(chokidar@3.6.0)(encoding@0.1.13)(jsdom@23.2.0)(postcss@8.5.15)(rxjs@7.8.2)(tsx@4.22.4)(typescript@5.8.3)(yaml@2.8.3)(zod@3.25.76) '@polkadot/api': 16.5.6 '@polkadot/api-derive': 16.5.6 - '@polkadot/keyring': 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3) + '@polkadot/keyring': 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9) '@polkadot/rpc-provider': 16.5.6 '@polkadot/types': 16.5.6 '@polkadot/types-codec': 16.5.6 - '@polkadot/util': 14.0.3 - '@polkadot/util-crypto': 14.0.3(@polkadot/util@14.0.3) + '@polkadot/util': 13.5.9 + '@polkadot/util-crypto': 14.0.3(@polkadot/util@13.5.9) '@vitest/ui': 3.2.6(vitest@3.2.6) arkregex: 0.0.4 bottleneck: 2.19.5 @@ -6199,8 +6176,6 @@ snapshots: '@noble/ciphers@1.3.0': {} - '@noble/ciphers@2.2.0': {} - '@noble/curves@1.2.0': dependencies: '@noble/hashes': 1.3.2 @@ -6897,7 +6872,7 @@ snapshots: '@polkadot/api-augment': 14.3.1 '@polkadot/api-base': 14.3.1 '@polkadot/api-derive': 14.3.1 - '@polkadot/keyring': 13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@14.0.3))(@polkadot/util@13.5.9) + '@polkadot/keyring': 13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@13.5.9))(@polkadot/util@13.5.9) '@polkadot/rpc-augment': 14.3.1 '@polkadot/rpc-core': 14.3.1 '@polkadot/rpc-provider': 14.3.1 @@ -6921,7 +6896,7 @@ snapshots: '@polkadot/api-augment': 16.5.6 '@polkadot/api-base': 16.5.6 '@polkadot/api-derive': 16.5.6 - '@polkadot/keyring': 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3) + '@polkadot/keyring': 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@14.0.3) '@polkadot/rpc-augment': 16.5.6 '@polkadot/rpc-core': 16.5.6 '@polkadot/rpc-provider': 16.5.6 @@ -6940,22 +6915,22 @@ snapshots: - supports-color - utf-8-validate - '@polkadot/keyring@13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@14.0.3))(@polkadot/util@13.5.9)': + '@polkadot/keyring@13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@13.5.9))(@polkadot/util@13.5.9)': dependencies: '@polkadot/util': 13.5.9 - '@polkadot/util-crypto': 13.5.9(@polkadot/util@14.0.3) + '@polkadot/util-crypto': 13.5.9(@polkadot/util@13.5.9) tslib: 2.8.1 - '@polkadot/keyring@13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@14.0.3))(@polkadot/util@14.0.3)': + '@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@13.5.9)': dependencies: - '@polkadot/util': 14.0.3 - '@polkadot/util-crypto': 13.5.9(@polkadot/util@14.0.3) + '@polkadot/util': 13.5.9 + '@polkadot/util-crypto': 14.0.3(@polkadot/util@13.5.9) tslib: 2.8.1 - '@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3)': + '@polkadot/keyring@14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@14.0.3)': dependencies: '@polkadot/util': 14.0.3 - '@polkadot/util-crypto': 14.0.3(@polkadot/util@14.0.3) + '@polkadot/util-crypto': 14.0.3(@polkadot/util@13.5.9) tslib: 2.8.1 '@polkadot/networks@13.5.9': @@ -7022,7 +6997,7 @@ snapshots: '@polkadot/rpc-provider@14.3.1': dependencies: - '@polkadot/keyring': 13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@14.0.3))(@polkadot/util@13.5.9) + '@polkadot/keyring': 13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@13.5.9))(@polkadot/util@13.5.9) '@polkadot/types': 14.3.1 '@polkadot/types-support': 14.3.1 '@polkadot/util': 13.5.9 @@ -7043,7 +7018,7 @@ snapshots: '@polkadot/rpc-provider@16.5.6': dependencies: - '@polkadot/keyring': 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3) + '@polkadot/keyring': 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@14.0.3) '@polkadot/types': 16.5.6 '@polkadot/types-support': 16.5.6 '@polkadot/util': 14.0.3 @@ -7130,7 +7105,7 @@ snapshots: '@polkadot/types@14.3.1': dependencies: - '@polkadot/keyring': 13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@14.0.3))(@polkadot/util@13.5.9) + '@polkadot/keyring': 13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@13.5.9))(@polkadot/util@13.5.9) '@polkadot/types-augment': 14.3.1 '@polkadot/types-codec': 14.3.1 '@polkadot/types-create': 14.3.1 @@ -7141,7 +7116,7 @@ snapshots: '@polkadot/types@16.5.6': dependencies: - '@polkadot/keyring': 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3))(@polkadot/util@14.0.3) + '@polkadot/keyring': 14.0.3(@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9))(@polkadot/util@14.0.3) '@polkadot/types-augment': 16.5.6 '@polkadot/types-codec': 16.5.6 '@polkadot/types-create': 16.5.6 @@ -7156,24 +7131,25 @@ snapshots: '@noble/hashes': 1.8.0 '@polkadot/networks': 13.5.9 '@polkadot/util': 13.5.9 - '@polkadot/wasm-crypto': 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) + '@polkadot/wasm-crypto': 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) '@polkadot/wasm-util': 7.5.4(@polkadot/util@13.5.9) '@polkadot/x-bigint': 13.5.9 - '@polkadot/x-randomvalues': 13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)) + '@polkadot/x-randomvalues': 13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)) '@scure/base': 1.2.6 tslib: 2.8.1 - '@polkadot/util-crypto@13.5.9(@polkadot/util@14.0.3)': + '@polkadot/util-crypto@14.0.3(@polkadot/util@13.5.9)': dependencies: '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 - '@polkadot/networks': 13.5.9 - '@polkadot/util': 14.0.3 - '@polkadot/wasm-crypto': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) - '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/x-bigint': 13.5.9 - '@polkadot/x-randomvalues': 13.5.9(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)) + '@polkadot/networks': 14.0.3 + '@polkadot/util': 13.5.9 + '@polkadot/wasm-crypto': 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) + '@polkadot/wasm-util': 7.5.4(@polkadot/util@13.5.9) + '@polkadot/x-bigint': 14.0.3 + '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)) '@scure/base': 1.2.6 + '@scure/sr25519': 0.2.0 tslib: 2.8.1 '@polkadot/util-crypto@14.0.3(@polkadot/util@14.0.3)': @@ -7182,10 +7158,10 @@ snapshots: '@noble/hashes': 1.8.0 '@polkadot/networks': 14.0.3 '@polkadot/util': 14.0.3 - '@polkadot/wasm-crypto': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) + '@polkadot/wasm-crypto': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) '@polkadot/x-bigint': 14.0.3 - '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)) + '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)) '@scure/base': 1.2.6 '@scure/sr25519': 0.2.0 tslib: 2.8.1 @@ -7210,25 +7186,25 @@ snapshots: bn.js: 5.2.3 tslib: 2.8.1 - '@polkadot/wasm-bridge@7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)))': + '@polkadot/wasm-bridge@7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)))': dependencies: '@polkadot/util': 13.5.9 '@polkadot/wasm-util': 7.5.4(@polkadot/util@13.5.9) - '@polkadot/x-randomvalues': 13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)) + '@polkadot/x-randomvalues': 13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)) tslib: 2.8.1 - '@polkadot/wasm-bridge@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)))': + '@polkadot/wasm-bridge@7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)))': dependencies: - '@polkadot/util': 14.0.3 - '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/x-randomvalues': 13.5.9(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)) + '@polkadot/util': 13.5.9 + '@polkadot/wasm-util': 7.5.4(@polkadot/util@13.5.9) + '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)) tslib: 2.8.1 - '@polkadot/wasm-bridge@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)))': + '@polkadot/wasm-bridge@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)))': dependencies: '@polkadot/util': 14.0.3 '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)) + '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)) tslib: 2.8.1 '@polkadot/wasm-crypto-asmjs@7.5.4(@polkadot/util@13.5.9)': @@ -7241,34 +7217,34 @@ snapshots: '@polkadot/util': 14.0.3 tslib: 2.8.1 - '@polkadot/wasm-crypto-init@7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)))': + '@polkadot/wasm-crypto-init@7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)))': dependencies: '@polkadot/util': 13.5.9 - '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) + '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) '@polkadot/wasm-crypto-asmjs': 7.5.4(@polkadot/util@13.5.9) '@polkadot/wasm-crypto-wasm': 7.5.4(@polkadot/util@13.5.9) '@polkadot/wasm-util': 7.5.4(@polkadot/util@13.5.9) - '@polkadot/x-randomvalues': 13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)) + '@polkadot/x-randomvalues': 13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)) tslib: 2.8.1 - '@polkadot/wasm-crypto-init@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)))': + '@polkadot/wasm-crypto-init@7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)))': dependencies: - '@polkadot/util': 14.0.3 - '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) - '@polkadot/wasm-crypto-asmjs': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/wasm-crypto-wasm': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/x-randomvalues': 13.5.9(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)) + '@polkadot/util': 13.5.9 + '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) + '@polkadot/wasm-crypto-asmjs': 7.5.4(@polkadot/util@13.5.9) + '@polkadot/wasm-crypto-wasm': 7.5.4(@polkadot/util@13.5.9) + '@polkadot/wasm-util': 7.5.4(@polkadot/util@13.5.9) + '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)) tslib: 2.8.1 - '@polkadot/wasm-crypto-init@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)))': + '@polkadot/wasm-crypto-init@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)))': dependencies: '@polkadot/util': 14.0.3 - '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) + '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) '@polkadot/wasm-crypto-asmjs': 7.5.4(@polkadot/util@14.0.3) '@polkadot/wasm-crypto-wasm': 7.5.4(@polkadot/util@14.0.3) '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)) + '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)) tslib: 2.8.1 '@polkadot/wasm-crypto-wasm@7.5.4(@polkadot/util@13.5.9)': @@ -7283,37 +7259,37 @@ snapshots: '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) tslib: 2.8.1 - '@polkadot/wasm-crypto@7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)))': + '@polkadot/wasm-crypto@7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)))': dependencies: '@polkadot/util': 13.5.9 - '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) + '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) '@polkadot/wasm-crypto-asmjs': 7.5.4(@polkadot/util@13.5.9) - '@polkadot/wasm-crypto-init': 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) + '@polkadot/wasm-crypto-init': 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) '@polkadot/wasm-crypto-wasm': 7.5.4(@polkadot/util@13.5.9) '@polkadot/wasm-util': 7.5.4(@polkadot/util@13.5.9) - '@polkadot/x-randomvalues': 13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)) + '@polkadot/x-randomvalues': 13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)) tslib: 2.8.1 - '@polkadot/wasm-crypto@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)))': + '@polkadot/wasm-crypto@7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)))': dependencies: - '@polkadot/util': 14.0.3 - '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) - '@polkadot/wasm-crypto-asmjs': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/wasm-crypto-init': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@13.5.9(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) - '@polkadot/wasm-crypto-wasm': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/x-randomvalues': 13.5.9(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)) + '@polkadot/util': 13.5.9 + '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) + '@polkadot/wasm-crypto-asmjs': 7.5.4(@polkadot/util@13.5.9) + '@polkadot/wasm-crypto-init': 7.5.4(@polkadot/util@13.5.9)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) + '@polkadot/wasm-crypto-wasm': 7.5.4(@polkadot/util@13.5.9) + '@polkadot/wasm-util': 7.5.4(@polkadot/util@13.5.9) + '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)) tslib: 2.8.1 - '@polkadot/wasm-crypto@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)))': + '@polkadot/wasm-crypto@7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)))': dependencies: '@polkadot/util': 14.0.3 - '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) + '@polkadot/wasm-bridge': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) '@polkadot/wasm-crypto-asmjs': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/wasm-crypto-init': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))) + '@polkadot/wasm-crypto-init': 7.5.4(@polkadot/util@14.0.3)(@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))) '@polkadot/wasm-crypto-wasm': 7.5.4(@polkadot/util@14.0.3) '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3)) + '@polkadot/x-randomvalues': 14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)) tslib: 2.8.1 '@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9)': @@ -7356,24 +7332,24 @@ snapshots: dependencies: tslib: 2.8.1 - '@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))': + '@polkadot/x-randomvalues@13.5.9(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))': dependencies: '@polkadot/util': 13.5.9 - '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) + '@polkadot/wasm-util': 7.5.4(@polkadot/util@13.5.9) '@polkadot/x-global': 13.5.9 tslib: 2.8.1 - '@polkadot/x-randomvalues@13.5.9(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))': + '@polkadot/x-randomvalues@14.0.3(@polkadot/util@13.5.9)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))': dependencies: - '@polkadot/util': 14.0.3 - '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) - '@polkadot/x-global': 13.5.9 + '@polkadot/util': 13.5.9 + '@polkadot/wasm-util': 7.5.4(@polkadot/util@13.5.9) + '@polkadot/x-global': 14.0.3 tslib: 2.8.1 - '@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@14.0.3))': + '@polkadot/x-randomvalues@14.0.3(@polkadot/util@14.0.3)(@polkadot/wasm-util@7.5.4(@polkadot/util@13.5.9))': dependencies: '@polkadot/util': 14.0.3 - '@polkadot/wasm-util': 7.5.4(@polkadot/util@14.0.3) + '@polkadot/wasm-util': 7.5.4(@polkadot/util@13.5.9) '@polkadot/x-global': 14.0.3 tslib: 2.8.1 @@ -7718,11 +7694,11 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@zombienet/orchestrator@0.0.105(@polkadot/util@14.0.3)(@types/node@25.9.2)(chokidar@3.6.0)': + '@zombienet/orchestrator@0.0.105(@polkadot/util@13.5.9)(@types/node@25.9.2)(chokidar@3.6.0)': dependencies: '@polkadot/api': 14.3.1 - '@polkadot/keyring': 13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@14.0.3))(@polkadot/util@14.0.3) - '@polkadot/util-crypto': 13.5.9(@polkadot/util@14.0.3) + '@polkadot/keyring': 13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@13.5.9))(@polkadot/util@13.5.9) + '@polkadot/util-crypto': 13.5.9(@polkadot/util@13.5.9) '@zombienet/utils': 0.0.28(@types/node@25.9.2)(chokidar@3.6.0)(typescript@5.8.3) JSONStream: 1.3.5 chai: 4.5.0 @@ -7750,11 +7726,11 @@ snapshots: - supports-color - utf-8-validate - '@zombienet/orchestrator@0.0.113(@polkadot/util@14.0.3)(@types/node@25.9.2)(chokidar@3.6.0)': + '@zombienet/orchestrator@0.0.113(@polkadot/util@13.5.9)(@types/node@25.9.2)(chokidar@3.6.0)': dependencies: '@polkadot/api': 14.3.1 - '@polkadot/keyring': 13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@14.0.3))(@polkadot/util@14.0.3) - '@polkadot/util-crypto': 13.5.9(@polkadot/util@14.0.3) + '@polkadot/keyring': 13.5.9(@polkadot/util-crypto@13.5.9(@polkadot/util@13.5.9))(@polkadot/util@13.5.9) + '@polkadot/util-crypto': 13.5.9(@polkadot/util@13.5.9) '@zombienet/utils': 0.0.30(@types/node@25.9.2)(chokidar@3.6.0)(typescript@5.8.3) JSONStream: 1.3.5 chai: 4.5.0 @@ -9298,8 +9274,6 @@ snapshots: mkdirp@1.0.4: optional: true - mlkem@2.7.0: {} - mlly@1.8.2: dependencies: acorn: 8.16.0 diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts index daa06882f5..1b22efa5bb 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-mevshield-execute-orders.ts @@ -1,25 +1,24 @@ +import { bytesToHex as u8aToHex } from "@bittensor/sdk"; import { beforeAll, describeSuite, expect } from "@moonwall/cli"; -import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; -import { tao, generateKeyringPair } from "../../../../utils"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair, tao } from "../../../../utils"; import { - devForceSetBalance, - devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, + devForceSetBalance, devRegisterSubnet, devSudoSetLockReductionInterval, } from "../../../../utils/dev-helpers.js"; import { - buildSignedOrder, FAR_FUTURE, + buildSignedOrder, fetchChainId, getOrderStatus, orderId, registerLimitOrderTypes, } from "../../../../utils/limit-orders.js"; import { encryptTransaction } from "../../../../utils/shield_helpers.js"; -import { u8aToHex } from "@polkadot/util"; describeSuite({ id: "DEV_SUB_LIMIT_ORDERS_MEVSHIELD", diff --git a/ts-tests/suites/dev/subtensor/staking/test-add-staking.ts b/ts-tests/suites/dev/subtensor/staking/test-add-staking.ts index 8d8e13cfb6..2a32e8eede 100644 --- a/ts-tests/suites/dev/subtensor/staking/test-add-staking.ts +++ b/ts-tests/suites/dev/subtensor/staking/test-add-staking.ts @@ -1,7 +1,6 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; -import type { ApiPromise } from "@polkadot/api"; import type { KeyringPair } from "@moonwall/util"; -import { BN } from "@polkadot/util"; +import type { ApiPromise } from "@polkadot/api"; describeSuite({ id: "DEV_SUB_STAKING_ADD_STAKING_01", @@ -27,8 +26,6 @@ describeSuite({ test: async () => { const alice = context.keyring.alice; const bob = context.keyring.bob; - const appFees = new BN(100_000); - // Register network let tx = polkadotJs.tx.subtensorModule.registerNetwork(bob.address); await context.createBlock([await tx.signAsync(alice)]); diff --git a/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts b/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts index a9a696ea23..bab5a37366 100644 --- a/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts +++ b/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; import { MultiAddress, subtensor } from "@polkadot-api/descriptors"; -import type { KeyringPair } from "@polkadot/keyring/types"; import { ethers } from "ethers"; import type { TypedApi } from "polkadot-api"; import { Binary } from "polkadot-api"; @@ -222,8 +222,7 @@ describeSuite({ gas_limit: BigInt(1000000), max_fee_per_gas: [BigInt(10e9), BigInt(0), BigInt(0), BigInt(0)], max_priority_fee_per_gas: undefined, - // PAPI encodes this field with the Binary codec despite the Uint8Array annotation. - input: Binary.fromText("") as unknown as Uint8Array, + input: Binary.fromText(""), nonce: undefined, access_list: [], authorization_list: [], @@ -426,7 +425,7 @@ describeSuite({ gas_limit: BigInt(1000000), max_fee_per_gas: [BigInt(10e9), BigInt(0), BigInt(0), BigInt(0)], max_priority_fee_per_gas: undefined, - input: Binary.fromText("") as unknown as Uint8Array, + input: Binary.fromText(""), nonce: undefined, access_list: [], authorization_list: [], diff --git a/ts-tests/suites/zombienet_evm/01-contract-deploy-call.test.ts b/ts-tests/suites/zombienet_evm/01-contract-deploy-call.test.ts index ad2af9bc32..79aa45fa65 100644 --- a/ts-tests/suites/zombienet_evm/01-contract-deploy-call.test.ts +++ b/ts-tests/suites/zombienet_evm/01-contract-deploy-call.test.ts @@ -1,16 +1,21 @@ +import { publicKeyFromSs58 as decodeAddress, bytesToHex as u8aToHex } from "@bittensor/sdk"; import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; import { MultiAddress, subtensor } from "@polkadot-api/descriptors"; -import type { KeyringPair } from "@polkadot/keyring/types"; -import { u8aToHex } from "@polkadot/util"; -import { decodeAddress } from "@polkadot/util-crypto"; import { ethers } from "ethers"; import type { TypedApi } from "polkadot-api"; import { - addNewSubnetwork, ALPHA_POOL_CONTRACT_ABI, ALPHA_POOL_CONTRACT_BYTECODE, BRIDGE_TOKEN_CONTRACT_ABI, BRIDGE_TOKEN_CONTRACT_BYTECODE, + IPROXY_ADDRESS, + IProxyABI, + ISTAKING_V2_ADDRESS, + IStakingV2ABI, + STAKE_WRAP_ABI, + STAKE_WRAP_BYTECODE, + addNewSubnetwork, burnedRegister, convertH160ToPublicKey, convertH160ToSS58, @@ -22,13 +27,7 @@ import { getBalance, getProxies, getStake, - IPROXY_ADDRESS, - IProxyABI, - ISTAKING_V2_ADDRESS, - IStakingV2ABI, raoToEth, - STAKE_WRAP_ABI, - STAKE_WRAP_BYTECODE, startCall, sudoSetLockReductionInterval, tao, diff --git a/ts-tests/suites/zombienet_evm/03-wasm-contract.test.ts b/ts-tests/suites/zombienet_evm/03-wasm-contract.test.ts index c76c637ba0..98bba61668 100644 --- a/ts-tests/suites/zombienet_evm/03-wasm-contract.test.ts +++ b/ts-tests/suites/zombienet_evm/03-wasm-contract.test.ts @@ -1,12 +1,12 @@ -import { beforeAll, beforeEach, describeSuite, expect } from "@moonwall/cli"; -import { contracts, MultiAddress, subtensor } from "@polkadot-api/descriptors"; -import { getInkClient, InkClient } from "@polkadot-api/ink-contracts"; -import type { KeyringPair } from "@polkadot/keyring/types"; import fs from "node:fs"; +import { beforeAll, beforeEach, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import { MultiAddress, contracts, subtensor } from "@polkadot-api/descriptors"; +import { type InkClient, getInkClient } from "@polkadot-api/ink-contracts"; import { Binary, type TypedApi } from "polkadot-api"; import { - addNewSubnetwork, BITTENSOR_WASM_PATH, + addNewSubnetwork, burnedRegister, convertPublicKeyToSs58, forceSetBalance, @@ -27,6 +27,13 @@ import { const bittensorBytecode = fs.readFileSync(BITTENSOR_WASM_PATH); +function requireDefined(value: T | undefined | null, name: string): T { + if (value === undefined || value === null) { + throw new Error(`${name} is not defined`); + } + return value; +} + async function fundAccount( api: TypedApi, faucet: KeyringPair, @@ -61,15 +68,8 @@ describeSuite({ } const amount = tao(100); - let message; - let dest; - if (addStakeToContract) { - message = inkClient.message("add_stake"); - dest = contractAddress; - } else { - message = inkClient.message("caller_add_stake"); - dest = convertPublicKeyToSs58(coldkey.publicKey); - } + const message = inkClient.message(addStakeToContract ? "add_stake" : "caller_add_stake"); + const dest = addStakeToContract ? contractAddress : convertPublicKeyToSs58(coldkey.publicKey); const data = message.encode({ hotkey: Binary.fromBytes(hotkey.publicKey), @@ -233,7 +233,7 @@ describeSuite({ await addStakeViaContract(true); const stake = await getContractStake(); - let amount = stake / BigInt(2); + const amount = stake / BigInt(2); const message = inkClient.message("remove_stake"); const data = message.encode({ hotkey: Binary.fromBytes(hotkey.publicKey), @@ -391,7 +391,7 @@ describeSuite({ expect(stakeAfterDest).toBeDefined(); expect(stakeAfterOrigin < stakeBeforeOrigin).toBeTruthy(); - expect(stakeAfterDest > stakeBeforeDest!).toBeTruthy(); + expect(stakeAfterDest > requireDefined(stakeBeforeDest, "stakeBeforeDest")).toBeTruthy(); }, }); @@ -570,7 +570,7 @@ describeSuite({ }); await sendWasmContractExtrinsic(api, coldkey, contractAddress, data); - let autoStakeHotkey = await api.query.SubtensorModule.AutoStakeDestination.getValue( + const autoStakeHotkey = await api.query.SubtensorModule.AutoStakeDestination.getValue( contractAddress, netuid ); @@ -589,7 +589,7 @@ describeSuite({ delegate: Binary.fromBytes(hotkey.publicKey), }); await sendWasmContractExtrinsic(api, coldkey, contractAddress, data); - let proxies = await api.query.Proxy.Proxies.getValue(contractAddress); + const proxies = await api.query.Proxy.Proxies.getValue(contractAddress); expect(proxies).toBeDefined(); expect(proxies.length > 0 && proxies[0].length > 0).toBeTruthy(); expect(proxies[0][0].delegate).toEqual(convertPublicKeyToSs58(hotkey.publicKey)); @@ -600,7 +600,7 @@ describeSuite({ }); await sendWasmContractExtrinsic(api, coldkey, contractAddress, removeData); - let proxiesAfterRemove = await api.query.Proxy.Proxies.getValue(contractAddress); + const proxiesAfterRemove = await api.query.Proxy.Proxies.getValue(contractAddress); expect(proxiesAfterRemove).toBeDefined(); expect(proxiesAfterRemove[0].length).toEqual(0); }, @@ -760,7 +760,7 @@ describeSuite({ netuid ) )?.stake; - expect(stakeAfter !== undefined && stakeAfter < stake!).toBeTruthy(); + expect(stakeAfter !== undefined && stakeAfter < requireDefined(stake, "stake")).toBeTruthy(); }, }); @@ -788,7 +788,7 @@ describeSuite({ ) )?.stake; expect(stakeAfter).toBeDefined(); - expect(stakeAfter < stakeBefore!).toBeTruthy(); + expect(stakeAfter < requireDefined(stakeBefore, "stakeBefore")).toBeTruthy(); }, }); @@ -816,7 +816,7 @@ describeSuite({ ) )?.stake; expect(stakeAfter).toBeDefined(); - expect(stakeAfter < stakeBefore!).toBeTruthy(); + expect(stakeAfter < requireDefined(stakeBefore, "stakeBefore")).toBeTruthy(); }, }); @@ -916,8 +916,8 @@ describeSuite({ ) )?.stake; expect(stakeAfterOrigin !== undefined && stakeAfterDest !== undefined).toBeTruthy(); - expect(stakeAfterOrigin < stakeBeforeOrigin!).toBeTruthy(); - expect(stakeAfterDest > stakeBeforeDest!).toBeTruthy(); + expect(stakeAfterOrigin < requireDefined(stakeBeforeOrigin, "stakeBeforeOrigin")).toBeTruthy(); + expect(stakeAfterDest > requireDefined(stakeBeforeDest, "stakeBeforeDest")).toBeTruthy(); }, }); @@ -1000,7 +1000,9 @@ describeSuite({ netuid ) )?.stake; - expect(stakeAfter !== undefined && stakeAfter > stakeBefore!).toBeTruthy(); + expect( + stakeAfter !== undefined && stakeAfter > requireDefined(stakeBefore, "stakeBefore") + ).toBeTruthy(); }, }); @@ -1033,7 +1035,9 @@ describeSuite({ netuid ) )?.stake; - expect(stakeAfter !== undefined && stakeAfter < stakeBefore!).toBeTruthy(); + expect( + stakeAfter !== undefined && stakeAfter < requireDefined(stakeBefore, "stakeBefore") + ).toBeTruthy(); }, }); @@ -1084,7 +1088,7 @@ describeSuite({ )?.stake; expect(stakeAfter !== undefined && stakeAfter2 !== undefined).toBeTruthy(); expect(stakeAfter < stakeBefore).toBeTruthy(); - expect(stakeAfter2 > stakeBefore2!).toBeTruthy(); + expect(stakeAfter2 > requireDefined(stakeBefore2, "stakeBefore2")).toBeTruthy(); }, }); @@ -1115,7 +1119,9 @@ describeSuite({ netuid ) )?.stake; - expect(stakeAfter !== undefined && stakeAfter < stakeBefore!).toBeTruthy(); + expect( + stakeAfter !== undefined && stakeAfter < requireDefined(stakeBefore, "stakeBefore") + ).toBeTruthy(); }, }); @@ -1158,7 +1164,7 @@ describeSuite({ }); await sendWasmContractExtrinsic(api, coldkey, contractAddress, removeData); proxies = await api.query.Proxy.Proxies.getValue(convertPublicKeyToSs58(coldkey.publicKey)); - expect(proxies !== undefined && proxies[0].length).toEqual(0); + expect(proxies?.[0].length).toEqual(0); }, }); diff --git a/ts-tests/suites/zombienet_evm/04-edge-cases.test.ts b/ts-tests/suites/zombienet_evm/04-edge-cases.test.ts index a3cbcf4c85..b299f8d586 100644 --- a/ts-tests/suites/zombienet_evm/04-edge-cases.test.ts +++ b/ts-tests/suites/zombienet_evm/04-edge-cases.test.ts @@ -1,9 +1,11 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; import { subtensor } from "@polkadot-api/descriptors"; -import type { KeyringPair } from "@polkadot/keyring/types"; import { ethers } from "ethers"; import type { TypedApi } from "polkadot-api"; import { + IBALANCETRANSFER_ADDRESS, + IBalanceTransferABI, convertH160ToSS58, convertPublicKeyToSs58, createEthersWallet, @@ -12,8 +14,6 @@ import { forceSetChainID, generateKeyringPair, getEthChainId, - IBALANCETRANSFER_ADDRESS, - IBalanceTransferABI, raoToEth, sendTransaction, tao, diff --git a/ts-tests/suites/zombienet_evm/05-direct-call-precompile.test.ts b/ts-tests/suites/zombienet_evm/05-direct-call-precompile.test.ts index ce7b2f5255..178d54408a 100644 --- a/ts-tests/suites/zombienet_evm/05-direct-call-precompile.test.ts +++ b/ts-tests/suites/zombienet_evm/05-direct-call-precompile.test.ts @@ -1,9 +1,13 @@ import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; import { subtensor } from "@polkadot-api/descriptors"; -import type { KeyringPair } from "@polkadot/keyring/types"; import { ethers } from "ethers"; import { Binary, type TypedApi } from "polkadot-api"; import { + IPROXY_ADDRESS, + IProxyABI, + PRECOMPILE_WRAPPER_ABI, + PRECOMPILE_WRAPPER_BYTECODE, addNewSubnetwork, convertH160ToPublicKey, convertH160ToSS58, @@ -14,10 +18,6 @@ import { generateKeyringPair, getBalance, getStake, - IPROXY_ADDRESS, - IProxyABI, - PRECOMPILE_WRAPPER_ABI, - PRECOMPILE_WRAPPER_BYTECODE, raoToEth, startCall, sudoSetLockReductionInterval, diff --git a/ts-tests/suites/zombienet_shield/00.01-basic.test.ts b/ts-tests/suites/zombienet_shield/00.01-basic.test.ts index 7801019e79..c2b53401d5 100644 --- a/ts-tests/suites/zombienet_shield/00.01-basic.test.ts +++ b/ts-tests/suites/zombienet_shield/00.01-basic.test.ts @@ -1,4 +1,9 @@ -import { expect, beforeAll, describeSuite } from "@moonwall/cli"; +import { hexToBytes as hexToU8a } from "@bittensor/sdk"; +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import { MultiAddress, subtensor } from "@polkadot-api/descriptors"; +import { Binary } from "@polkadot-api/substrate-bindings"; +import type { PolkadotClient, TypedApi } from "polkadot-api"; import { checkRuntime, encryptTransaction, @@ -6,15 +11,10 @@ import { getBalance, getNextKey, getSignerFromKeypair, + keyringPairFromUri, submitEncrypted, waitForFinalizedBlocks, } from "../../utils"; -import { Binary } from "@polkadot-api/substrate-bindings"; -import { Keyring } from "@polkadot/keyring"; -import type { KeyringPair } from "@moonwall/util"; -import { hexToU8a } from "@polkadot/util"; -import { subtensor, MultiAddress } from "@polkadot-api/descriptors"; -import type { PolkadotClient, TypedApi } from "polkadot-api"; describeSuite({ id: "00.01_basic", @@ -31,10 +31,9 @@ describeSuite({ client = context.papi("Node"); api = client.getTypedApi(subtensor); - const keyring = new Keyring({ type: "sr25519" }); - alice = keyring.addFromUri("//Alice"); - bob = keyring.addFromUri("//Bob"); - charlie = keyring.addFromUri("//Charlie"); + alice = keyringPairFromUri("//Alice"); + bob = keyringPairFromUri("//Bob"); + charlie = keyringPairFromUri("//Charlie"); await checkRuntime(api); @@ -147,7 +146,7 @@ describeSuite({ title: "Wrong key hash is not included by the block proposer", test: async () => { const nextKey = await getNextKey(api); - expect(nextKey).toBeDefined(); + if (nextKey === undefined) throw new Error("MEV Shield NextKey is not available"); const balanceBefore = await getBalance(api, bob.address); @@ -157,7 +156,7 @@ describeSuite({ value: 1_000_000_000n, }).sign(getSignerFromKeypair(alice), { nonce: nonce + 1 }); - const ciphertext = await encryptTransaction(hexToU8a(innerTxHex), nextKey!); + const ciphertext = await encryptTransaction(hexToU8a(innerTxHex), nextKey); // Tamper the first 16 bytes (key_hash). const tampered = new Uint8Array(ciphertext); @@ -187,7 +186,7 @@ describeSuite({ title: "Stale key is not included after rotation", test: async () => { const staleKey = await getNextKey(api); - expect(staleKey).toBeDefined(); + if (staleKey === undefined) throw new Error("MEV Shield NextKey is not available"); // Wait for enough blocks that the key has rotated past both // currentKey and nextKey positions. @@ -201,7 +200,7 @@ describeSuite({ value: 1_000_000_000n, }).sign(getSignerFromKeypair(alice), { nonce: nonce + 1 }); - const ciphertext = await encryptTransaction(hexToU8a(innerTxHex), staleKey!); + const ciphertext = await encryptTransaction(hexToU8a(innerTxHex), staleKey); const tx = api.tx.MevShield.submit_encrypted({ ciphertext: Binary.fromBytes(ciphertext), diff --git a/ts-tests/suites/zombienet_shield/01-scaling.test.ts b/ts-tests/suites/zombienet_shield/01-scaling.test.ts index aa12f50d46..aea69f013c 100644 --- a/ts-tests/suites/zombienet_shield/01-scaling.test.ts +++ b/ts-tests/suites/zombienet_shield/01-scaling.test.ts @@ -1,17 +1,17 @@ +import { hexToBytes as hexToU8a } from "@bittensor/sdk"; import { describeSuite } from "@moonwall/cli"; import type { KeyringPair } from "@moonwall/util"; import { MultiAddress, subtensor } from "@polkadot-api/descriptors"; -import { Keyring } from "@polkadot/keyring"; -import { hexToU8a } from "@polkadot/util"; +import { sleep } from "@zombienet/utils"; import type { PolkadotClient, TypedApi } from "polkadot-api"; import { beforeAll, expect } from "vitest"; -import { sleep } from "@zombienet/utils"; import { checkRuntime, getAccountNonce, getBalance, getNextKey, getSignerFromKeypair, + keyringPairFromUri, submitEncrypted, waitForFinalizedBlocks, } from "../../utils"; @@ -54,10 +54,9 @@ describeSuite({ let charlie: KeyringPair; beforeAll(async () => { - const keyring = new Keyring({ type: "sr25519" }); - alice = keyring.addFromUri("//Alice"); - bob = keyring.addFromUri("//Bob"); - charlie = keyring.addFromUri("//Charlie"); + alice = keyringPairFromUri("//Alice"); + bob = keyringPairFromUri("//Bob"); + charlie = keyringPairFromUri("//Charlie"); client = context.papi("Node"); api = client.getTypedApi(subtensor); diff --git a/ts-tests/suites/zombienet_shield/02-edge-cases.test.ts b/ts-tests/suites/zombienet_shield/02-edge-cases.test.ts index 951c939b97..25582444c1 100644 --- a/ts-tests/suites/zombienet_shield/02-edge-cases.test.ts +++ b/ts-tests/suites/zombienet_shield/02-edge-cases.test.ts @@ -1,16 +1,16 @@ -import { expect, beforeAll } from "vitest"; -import type { TypedApi } from "polkadot-api"; -import { hexToU8a } from "@polkadot/util"; -import { subtensor, MultiAddress } from "@polkadot-api/descriptors"; +import { hexToBytes as hexToU8a } from "@bittensor/sdk"; import { describeSuite } from "@moonwall/cli"; import type { KeyringPair } from "@moonwall/util"; -import { Keyring } from "@polkadot/keyring"; +import { MultiAddress, subtensor } from "@polkadot-api/descriptors"; +import type { TypedApi } from "polkadot-api"; +import { beforeAll, expect } from "vitest"; import { checkRuntime, getAccountNonce, getBalance, getNextKey, getSignerFromKeypair, + keyringPairFromUri, submitEncrypted, waitForFinalizedBlocks, } from "../../utils"; @@ -26,9 +26,8 @@ describeSuite({ let bob: KeyringPair; beforeAll(async () => { - const keyring = new Keyring({ type: "sr25519" }); - alice = keyring.addFromUri("//Alice"); - bob = keyring.addFromUri("//Bob"); + alice = keyringPairFromUri("//Alice"); + bob = keyringPairFromUri("//Bob"); api = context.papi("Node").getTypedApi(subtensor); diff --git a/ts-tests/suites/zombienet_shield/03-timing.test.ts b/ts-tests/suites/zombienet_shield/03-timing.test.ts index b100e02e85..6ca5e89662 100644 --- a/ts-tests/suites/zombienet_shield/03-timing.test.ts +++ b/ts-tests/suites/zombienet_shield/03-timing.test.ts @@ -1,20 +1,20 @@ -import { expect, beforeAll } from "vitest"; -import type { TypedApi } from "polkadot-api"; -import { hexToU8a } from "@polkadot/util"; -import { subtensor, MultiAddress } from "@polkadot-api/descriptors"; +import { hexToBytes as hexToU8a } from "@bittensor/sdk"; import { describeSuite } from "@moonwall/cli"; import type { KeyringPair } from "@moonwall/util"; -import { Keyring } from "@polkadot/keyring"; +import { MultiAddress, subtensor } from "@polkadot-api/descriptors"; +import { sleep } from "@zombienet/utils"; +import type { TypedApi } from "polkadot-api"; +import { beforeAll, expect } from "vitest"; import { checkRuntime, getAccountNonce, getBalance, getNextKey, getSignerFromKeypair, + keyringPairFromUri, submitEncrypted, waitForFinalizedBlocks, } from "../../utils"; -import { sleep } from "@zombienet/utils"; describeSuite({ id: "03_timing", @@ -27,9 +27,8 @@ describeSuite({ let bob: KeyringPair; beforeAll(async () => { - const keyring = new Keyring({ type: "sr25519" }); - alice = keyring.addFromUri("//Alice"); - bob = keyring.addFromUri("//Bob"); + alice = keyringPairFromUri("//Alice"); + bob = keyringPairFromUri("//Bob"); api = context.papi("Node").getTypedApi(subtensor); diff --git a/ts-tests/suites/zombienet_shield/04-mortality.test.ts b/ts-tests/suites/zombienet_shield/04-mortality.test.ts index c1fb88a161..736a8e82d8 100644 --- a/ts-tests/suites/zombienet_shield/04-mortality.test.ts +++ b/ts-tests/suites/zombienet_shield/04-mortality.test.ts @@ -1,10 +1,11 @@ -import { expect, beforeAll } from "vitest"; +import { hexToBytes as hexToU8a } from "@bittensor/sdk"; +import { describeSuite } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import { MultiAddress, subtensor } from "@polkadot-api/descriptors"; +import { sleep } from "@zombienet/utils"; import type { PolkadotClient, TypedApi } from "polkadot-api"; import { Binary } from "polkadot-api"; -import { hexToU8a } from "@polkadot/util"; -import { subtensor, MultiAddress } from "@polkadot-api/descriptors"; -import type { KeyringPair } from "@moonwall/util"; -import { Keyring } from "@polkadot/keyring"; +import { beforeAll, expect } from "vitest"; import { checkRuntime, encryptTransaction, @@ -12,10 +13,9 @@ import { getBalance, getNextKey, getSignerFromKeypair, + keyringPairFromUri, waitForFinalizedBlocks, } from "../../utils"; -import { describeSuite } from "@moonwall/cli"; -import { sleep } from "@zombienet/utils"; // MAX_SHIELD_ERA_PERIOD is 8 blocks. With 12s slots, that's ~96s. const MAX_ERA_BLOCKS = 8; @@ -37,9 +37,8 @@ describeSuite({ beforeAll( async () => { - const keyring = new Keyring({ type: "sr25519" }); - alice = keyring.addFromUri("//Alice"); - bob = keyring.addFromUri("//Bob"); + alice = keyringPairFromUri("//Alice"); + bob = keyringPairFromUri("//Bob"); apiAuthority = context.papi("Node").getTypedApi(subtensor); diff --git a/ts-tests/suites/zombienet_subnets/00-register-network.test.ts b/ts-tests/suites/zombienet_subnets/00-register-network.test.ts index 632c91f684..580bc39912 100644 --- a/ts-tests/suites/zombienet_subnets/00-register-network.test.ts +++ b/ts-tests/suites/zombienet_subnets/00-register-network.test.ts @@ -1,13 +1,14 @@ -import { beforeAll, expect } from "vitest"; import { describeSuite } from "@moonwall/cli"; import { subtensor } from "@polkadot-api/descriptors"; import type { TypedApi } from "polkadot-api"; +import { beforeAll, expect } from "vitest"; import { addNewSubnetwork, addStake, burnedRegister, forceSetBalance, generateKeyringPair, + keyringPairFromUri, rootRegister, startCall, sudoSetLockReductionInterval, @@ -15,7 +16,6 @@ import { } from "../../utils"; import { sudoSetStakeThreshold } from "../../utils/admin_utils.ts"; import { getChildren, setAutoParentDelegationEnabled, sudoSetPendingChildKeyCooldown } from "../../utils/children.ts"; -import { Keyring } from "@polkadot/keyring"; describeSuite({ id: "00_register_network", @@ -32,8 +32,7 @@ describeSuite({ id: "T01", title: "auto-delegation: validator with flag=true gets child, validator with flag=false does not", test: async () => { - const keyring = new Keyring({ type: "sr25519" }); - const rootSubnetOwner = keyring.addFromUri("//Alice"); + const rootSubnetOwner = keyringPairFromUri("//Alice"); const rootVal1Coldkey = generateKeyringPair("sr25519"); // will opt-OUT const rootVal1Hotkey = generateKeyringPair("sr25519"); diff --git a/ts-tests/utils/staking.ts b/ts-tests/utils/staking.ts index a43ed5000a..b0014e5ee1 100644 --- a/ts-tests/utils/staking.ts +++ b/ts-tests/utils/staking.ts @@ -1,7 +1,7 @@ import type { KeyringPair } from "@moonwall/util"; import type { subtensor } from "@polkadot-api/descriptors"; -import { Keyring } from "@polkadot/keyring"; import type { TypedApi } from "polkadot-api"; +import { keyringPairFromUri } from "./account.ts"; import { waitForTransactionWithRetry } from "./transactions.js"; export async function addStake( @@ -221,6 +221,10 @@ export async function swapStakeLimit( } export type RootClaimType = "Swap" | "Keep" | { type: "KeepSubnets"; subnets: number[] }; +type RootClaimTypePayload = + | { type: "Swap"; value: undefined } + | { type: "Keep"; value: undefined } + | { type: "KeepSubnets"; value: { subnets: number[] } }; export async function getRootClaimType(api: TypedApi, coldkey: string): Promise { const result = await api.query.SubtensorModule.RootClaimType.getValue(coldkey); @@ -235,12 +239,10 @@ export async function setRootClaimType( coldkey: KeyringPair, claimType: RootClaimType ): Promise { - let newRootClaimType; - if (typeof claimType === "string") { - newRootClaimType = { type: claimType, value: undefined }; - } else { - newRootClaimType = { type: "KeepSubnets", value: { subnets: claimType.subnets } }; - } + const newRootClaimType: RootClaimTypePayload = + typeof claimType === "string" + ? { type: claimType, value: undefined } + : { type: "KeepSubnets", value: { subnets: claimType.subnets } }; const tx = api.tx.SubtensorModule.set_root_claim_type({ new_root_claim_type: newRootClaimType, }); @@ -263,8 +265,7 @@ export async function getNumRootClaims(api: TypedApi): Promise } export async function sudoSetNumRootClaims(api: TypedApi, newValue: bigint): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.SubtensorModule.sudo_set_num_root_claims({ new_value: newValue, }); @@ -281,8 +282,7 @@ export async function sudoSetRootClaimThreshold( netuid: number, newValue: bigint ): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.SubtensorModule.sudo_set_root_claim_threshold({ netuid: netuid, new_value: newValue, @@ -296,8 +296,7 @@ export async function getTempo(api: TypedApi, netuid: number): } export async function sudoSetTempo(api: TypedApi, netuid: number, tempo: number): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.AdminUtils.sudo_set_tempo({ netuid: netuid, tempo: tempo, @@ -346,8 +345,7 @@ export async function sudoSetSubtokenEnabled( netuid: number, enabled: boolean ): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.AdminUtils.sudo_set_subtoken_enabled({ netuid: netuid, subtoken_enabled: enabled, @@ -365,8 +363,7 @@ export async function getAdminFreezeWindow(api: TypedApi): Pro } export async function sudoSetAdminFreezeWindow(api: TypedApi, window: number): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.AdminUtils.sudo_set_admin_freeze_window({ window: window, }); @@ -379,8 +376,7 @@ export async function sudoSetEmaPriceHalvingPeriod( netuid: number, emaPriceHalvingPeriod: number ): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.AdminUtils.sudo_set_ema_price_halving_period({ netuid: netuid, ema_halving: BigInt(emaPriceHalvingPeriod), @@ -390,8 +386,7 @@ export async function sudoSetEmaPriceHalvingPeriod( } export async function sudoSetLockReductionInterval(api: TypedApi, interval: number): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.AdminUtils.sudo_set_lock_reduction_interval({ interval: BigInt(interval), }); @@ -400,8 +395,7 @@ export async function sudoSetLockReductionInterval(api: TypedApi, alpha: bigint): Promise { - const keyring = new Keyring({ type: "sr25519" }); - const alice = keyring.addFromUri("//Alice"); + const alice = keyringPairFromUri("//Alice"); const internalCall = api.tx.AdminUtils.sudo_set_subnet_moving_alpha({ alpha: alpha, }); diff --git a/ts-tests/utils/wasm-contract.ts b/ts-tests/utils/wasm-contract.ts index add68dea0d..ef6a57cb7c 100644 --- a/ts-tests/utils/wasm-contract.ts +++ b/ts-tests/utils/wasm-contract.ts @@ -1,12 +1,12 @@ -import { MultiAddress, subtensor } from "@polkadot-api/descriptors"; -import type { KeyringPair } from "@polkadot/keyring/types"; +import type { KeyringPair } from "@moonwall/util"; +import { MultiAddress, type subtensor } from "@polkadot-api/descriptors"; import type { TypedApi } from "polkadot-api"; import { Binary } from "polkadot-api"; import { convertPublicKeyToSs58 } from "./address.ts"; import { getBalance } from "./balance.ts"; import { - sendTransaction, type TransactionResult, + sendTransaction, waitForFinalizedBlocks, waitForTransactionWithRetry, } from "./transactions.ts"; From ae80118419e595132513b0b3bae54113edf10015 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Mon, 13 Jul 2026 10:19:17 -0700 Subject: [PATCH 66/72] Update test-bittensor-ts.ts --- ts-tests/suites/dev/sdk/test-bittensor-ts.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ts-tests/suites/dev/sdk/test-bittensor-ts.ts b/ts-tests/suites/dev/sdk/test-bittensor-ts.ts index e1a2f2e2e7..b7cff15f8e 100644 --- a/ts-tests/suites/dev/sdk/test-bittensor-ts.ts +++ b/ts-tests/suites/dev/sdk/test-bittensor-ts.ts @@ -29,20 +29,20 @@ describeSuite({ } const client = await new Client(endpoint).connect(); - const alice = Keypair.fromUri("//Alice"); + const signer = Keypair.fromUri("//Ferdie"); const remark = blake2_256(Buffer.from(`bittensor-ts:${BINDING_VERSION}`)); const call = await client.composeCall("System", "remark", { remark }); try { await client.assertDescriptorSchema(); - const nonce = await client.accountNextIndex(alice.ss58Address); - const signed = await client.signExtrinsic(call, alice, { allowRawCall: true, nonce }); + const nonce = await client.accountNextIndex(signer.ss58Address); + const signed = await client.signExtrinsic(call, signer, { allowRawCall: true, nonce }); const watcher = await client.watchSigned(signed); await context.createBlock(); const included = await watcher.result; - expect(included.success).to.be.true; + expect(included.success, included.message).to.be.true; expect(included.blockHash).to.not.be.undefined; expect(included.extrinsicIndex).to.be.a("number"); From 715d6db907962a759bfb6f5493129a6e6766a1f1 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Mon, 13 Jul 2026 10:56:31 -0700 Subject: [PATCH 67/72] fixes --- sdk/bittensor-core/src/client.rs | 522 +++++++++++++++--- sdk/bittensor-core/src/transaction.rs | 141 ++++- sdk/bittensor-ts/native/src/transaction.rs | 238 ++++++-- .../scripts/check-native-parity.cjs | 1 + sdk/bittensor-ts/src/client.ts | 171 ++++-- sdk/bittensor-ts/src/native.ts | 32 +- sdk/bittensor-ts/src/transaction.ts | 55 +- sdk/bittensor-ts/test/basic.test.cjs | 44 +- .../test-execute-orders-sell-fees.ts | 1 - .../zombienet_evm/02-precompile-gas.test.ts | 7 +- ts-tests/utils/limit-orders.ts | 10 +- ts-tests/utils/subtensor.ts | 2 +- 12 files changed, 1016 insertions(+), 208 deletions(-) diff --git a/sdk/bittensor-core/src/client.rs b/sdk/bittensor-core/src/client.rs index 476f187b42..532ed4dac6 100644 --- a/sdk/bittensor-core/src/client.rs +++ b/sdk/bittensor-core/src/client.rs @@ -10,8 +10,8 @@ #![allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] use std::collections::{BTreeMap, HashMap}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, RwLock}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; use std::thread; use std::time::{Duration, Instant}; @@ -29,10 +29,18 @@ use crate::runtime::type_string::TypeSpec; use crate::runtime::{Runtime, RuntimeApiMethodInfo, StorageInfo}; const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(30); -const DEFAULT_RECEIPT_TIMEOUT: Duration = Duration::from_secs(120); +pub const DEFAULT_RECEIPT_TIMEOUT: Duration = Duration::from_secs(120); pub const DEFAULT_ERA_PERIOD: u64 = 64; const STORAGE_PAGE_SIZE: u64 = 1_000; const RAO_PER_TAO: u128 = 1_000_000_000; +const NONCE_RETRY_LIMIT: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TxWait { + Submitted, + Included, + Finalized, +} /// A decoded block header. #[derive(Debug, Clone, PartialEq, Eq)] @@ -139,12 +147,16 @@ impl Default for ExternalSigningOptions { } /// Fully specified transaction signing plan for external signers. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ExternalSigningPlan { pub call_data: Vec, pub signer_address: String, pub public_key: [u8; 32], pub crypto_type: u8, + pub runtime: Arc, + pub runtime_spec_version: u32, + pub runtime_transaction_version: u32, + pub reserved_nonce: bool, pub params: TxParams, pub payload: Vec, pub included_in_extrinsic: Vec, @@ -197,10 +209,12 @@ fn classify_inclusion_finalization( /// The native Bittensor chain client. pub struct Client { - endpoint: String, + endpoints: Vec, + active_endpoint: RwLock, http: HttpClient, next_id: AtomicU64, runtime: RwLock>, + nonce_reservations: Mutex>, genesis_hash: [u8; 32], ss58_format: u16, } @@ -209,20 +223,51 @@ impl Client { /// Connect to a Substrate JSON-RPC endpoint. Websocket URLs are accepted for /// compatibility and are mapped to HTTP on the same host/port. pub fn connect(endpoint: impl Into) -> Result { - let endpoint = http_endpoint(&endpoint.into()); + Self::connect_many([endpoint.into()]) + } + + /// Connect to the first healthy endpoint, retaining the full ordered list + /// for later failover. Websocket URLs are accepted for compatibility and + /// are mapped to HTTP on the same host/port. + pub fn connect_many(endpoints: I) -> Result + where + I: IntoIterator, + S: Into, + { + let endpoints = normalized_endpoints(endpoints)?; let http = HttpClient::builder() .timeout(DEFAULT_RPC_TIMEOUT) .build() .map_err(|error| CoreError::Rpc(format!("cannot build HTTP client: {error}")))?; - let bootstrap = RpcBootstrap { - endpoint: endpoint.clone(), - http: http.clone(), - next_id: AtomicU64::new(1), + let mut last_error = None; + let mut bootstrap_result = None; + for (index, endpoint) in endpoints.iter().enumerate() { + let bootstrap = RpcBootstrap { + endpoint: endpoint.clone(), + http: http.clone(), + next_id: AtomicU64::new(1), + }; + let result = (|| { + let ss58_format = bootstrap.ss58_format()?; + let version = bootstrap.runtime_version()?; + let metadata = bootstrap.metadata()?; + let genesis_hash = parse_h256(&bootstrap.block_hash(Some(0))?)?; + Ok((index, ss58_format, version, metadata, genesis_hash)) + })(); + match result { + Ok(result) => { + bootstrap_result = Some(result); + break; + } + Err(error) => last_error = Some(error), + } + } + let Some((active_index, ss58_format, version, metadata, genesis_hash)) = bootstrap_result + else { + return Err( + last_error.unwrap_or_else(|| CoreError::Rpc("no endpoints supplied".into())) + ); }; - let ss58_format = bootstrap.ss58_format()?; - let version = bootstrap.runtime_version()?; - let metadata = bootstrap.metadata()?; - let genesis_hash = parse_h256(&bootstrap.block_hash(Some(0))?)?; let runtime = Runtime::parse( &metadata, version.spec_version, @@ -230,17 +275,19 @@ impl Client { ss58_format, )?; Ok(Self { - endpoint, + endpoints, + active_endpoint: RwLock::new(active_index), http, next_id: AtomicU64::new(100), runtime: RwLock::new(Arc::new(runtime)), + nonce_reservations: Mutex::new(HashMap::new()), genesis_hash, ss58_format, }) } - pub fn endpoint(&self) -> &str { - &self.endpoint + pub fn endpoint(&self) -> String { + self.active_endpoint() } /// Stable names of the typed read helpers supplied by the native SDK. @@ -287,13 +334,49 @@ impl Client { .map(|runtime| Arc::clone(&runtime)) } + fn active_endpoint(&self) -> String { + let index = self.active_endpoint.read().map(|guard| *guard).unwrap_or(0); + self.endpoints + .get(index) + .cloned() + .or_else(|| self.endpoints.first().cloned()) + .unwrap_or_default() + } + + fn set_active_endpoint(&self, index: usize) -> Result<(), CoreError> { + let mut guard = self + .active_endpoint + .write() + .map_err(|_| CoreError::Rpc("endpoint lock is poisoned".into()))?; + *guard = index; + Ok(()) + } + + fn endpoint_order(&self) -> Vec<(usize, String)> { + let active = self.active_endpoint.read().map(|guard| *guard).unwrap_or(0); + (0..self.endpoints.len()) + .map(|offset| { + let index = (active + offset) % self.endpoints.len(); + (index, self.endpoints[index].clone()) + }) + .collect() + } + + pub fn current_runtime(&self) -> Result, CoreError> { + self.ensure_runtime_current()?; + self.runtime() + } + /// Refresh metadata after a runtime upgrade. Returns true when the cached /// runtime changed. pub fn refresh_runtime(&self) -> Result { let version = self.runtime_version()?; - let current = self.runtime()?; - if current.spec_version == version.spec_version - && current.transaction_version == version.transaction_version + let mut guard = self + .runtime + .write() + .map_err(|_| CoreError::Rpc("runtime metadata lock is poisoned".into()))?; + if guard.spec_version == version.spec_version + && guard.transaction_version == version.transaction_version { return Ok(false); } @@ -304,23 +387,43 @@ impl Client { version.transaction_version, self.ss58_format, )?; - let mut guard = self - .runtime - .write() - .map_err(|_| CoreError::Rpc("runtime metadata lock is poisoned".into()))?; *guard = Arc::new(next); Ok(true) } + fn ensure_runtime_current(&self) -> Result { + let version = self.runtime_version()?; + let current = self.runtime()?; + if current.spec_version == version.spec_version + && current.transaction_version == version.transaction_version + { + return Ok(false); + } + self.refresh_runtime() + } + /// Escape hatch for node RPCs not yet wrapped by the SDK. pub fn rpc_value(&self, method: &str, params: JsonValue) -> Result { - rpc_request( - &self.http, - &self.endpoint, - self.next_id.fetch_add(1, Ordering::Relaxed), - method, - params, - ) + let mut last_error = None; + for (index, endpoint) in self.endpoint_order() { + match rpc_request( + &self.http, + &endpoint, + self.next_id.fetch_add(1, Ordering::Relaxed), + method, + params.clone(), + ) { + Ok(value) => { + self.set_active_endpoint(index)?; + return Ok(value); + } + Err(error) if is_transport_rpc_error(&error) => { + last_error = Some(error); + } + Err(error) => return Err(error), + } + } + Err(last_error.unwrap_or_else(|| CoreError::Rpc("all endpoints failed".into()))) } fn runtime_version(&self) -> Result { @@ -431,15 +534,17 @@ impl Client { function: &str, params: &Value, ) -> Result, CoreError> { - self.runtime()?.compose_call(pallet, function, params) + self.current_runtime()? + .compose_call(pallet, function, params) } pub fn decode_call(&self, data: &[u8]) -> Result { - self.runtime()?.decode_spec(&TypeSpec::Call, data, true) + self.current_runtime()? + .decode_spec(&TypeSpec::Call, data, true) } pub fn decode_scale(&self, type_name: &str, data: &[u8]) -> Result { - let runtime = self.runtime()?; + let runtime = self.current_runtime()?; if type_name == "Call" { return runtime.decode_spec(&TypeSpec::Call, data, true); } @@ -450,7 +555,7 @@ impl Client { } pub fn constant(&self, pallet: &str, name: &str) -> Result { - let runtime = self.runtime()?; + let runtime = self.current_runtime()?; let info = runtime .constant(pallet, name) .cloned() @@ -465,7 +570,11 @@ impl Client { params: &[Value], block_hash: Option<&str>, ) -> Result { - let runtime = self.runtime()?; + let runtime = if block_hash.is_some() { + self.runtime()? + } else { + self.current_runtime()? + }; let info = storage_info(&runtime, pallet, storage)?; let key = runtime.storage_key(&info, params)?; let raw = self.storage_raw(&key, block_hash)?; @@ -482,7 +591,11 @@ impl Client { if param_sets.is_empty() { return Ok(Vec::new()); } - let runtime = self.runtime()?; + let runtime = if block_hash.is_some() { + self.runtime()? + } else { + self.current_runtime()? + }; let info = storage_info(&runtime, pallet, storage)?; let mut keys = Vec::with_capacity(param_sets.len()); for params in param_sets { @@ -502,7 +615,11 @@ impl Client { fixed_params: &[Value], block_hash: Option<&str>, ) -> Result, CoreError> { - let runtime = self.runtime()?; + let runtime = if block_hash.is_some() { + self.runtime()? + } else { + self.current_runtime()? + }; let info = storage_info(&runtime, pallet, storage)?; if fixed_params.len() > info.key_types.len() { return Err(CoreError::Codec(format!( @@ -534,7 +651,11 @@ impl Client { params: &[Value], block_hash: Option<&str>, ) -> Result { - let runtime = self.runtime()?; + let runtime = if block_hash.is_some() { + self.runtime()? + } else { + self.current_runtime()? + }; let method_info = runtime_api_method(&runtime, api, method)?.clone(); if method_info.inputs.len() != params.len() { return Err(CoreError::Codec(format!( @@ -560,13 +681,55 @@ impl Client { } pub fn account_next_index(&self, address: &str) -> Result { + self.account_next_index_remote(address) + } + + fn account_next_index_remote(&self, address: &str) -> Result { let value = self.rpc_value("system_accountNextIndex", json!([address]))?; json_u64(&value) } + fn reserve_nonce(&self, address: &str) -> Result { + let chain_next = self.account_next_index_remote(address)?; + let mut reservations = self + .nonce_reservations + .lock() + .map_err(|_| CoreError::Rpc("nonce reservation lock is poisoned".into()))?; + let nonce = reservations + .get(address) + .copied() + .unwrap_or(chain_next) + .max(chain_next); + reservations.insert(address.to_owned(), nonce.saturating_add(1)); + Ok(nonce) + } + + fn commit_nonce(&self, address: &str, nonce: u64) -> Result<(), CoreError> { + let mut reservations = self + .nonce_reservations + .lock() + .map_err(|_| CoreError::Rpc("nonce reservation lock is poisoned".into()))?; + let next = nonce.saturating_add(1); + reservations + .entry(address.to_owned()) + .and_modify(|reserved| *reserved = (*reserved).max(next)) + .or_insert(next); + Ok(()) + } + + fn invalidate_nonce(&self, address: &str) -> Result<(), CoreError> { + let chain_next = self.account_next_index_remote(address)?; + let mut reservations = self + .nonce_reservations + .lock() + .map_err(|_| CoreError::Rpc("nonce reservation lock is poisoned".into()))?; + reservations.insert(address.to_owned(), chain_next); + Ok(()) + } + /// Chain constants used for RFC-0078 metadata hashes and Ledger proofs. pub fn chain_info(&self) -> Result { - let runtime = self.runtime()?; + let runtime = self.current_runtime()?; self.chain_info_for_runtime(&runtime) } @@ -607,7 +770,7 @@ impl Client { ) -> Result<(TxParams, Option), CoreError> { let nonce = match options.nonce { Some(nonce) => nonce, - None => self.account_next_index(&signer.ss58_address)?, + None => self.reserve_nonce(&signer.ss58_address)?, }; let current = self.block_number()?; let (era, era_block_hash) = match options.period { @@ -684,7 +847,8 @@ impl Client { "signer public key does not match signer address".into(), )); } - let runtime = self.runtime()?; + let runtime = self.current_runtime()?; + let reserved_nonce = options.nonce.is_none(); let (params, chain_info) = self.tx_params_for_external(&runtime, &signer, options)?; let (included_in_extrinsic, included_in_signed_data) = runtime.signature_payload_parts(¶ms)?; @@ -723,6 +887,10 @@ impl Client { signer_address: signer.ss58_address, public_key: signer.public_key, crypto_type: signer.crypto_type, + runtime: Arc::clone(&runtime), + runtime_spec_version: runtime.spec_version, + runtime_transaction_version: runtime.transaction_version, + reserved_nonce, params, payload, included_in_extrinsic, @@ -739,9 +907,8 @@ impl Client { &self, plan: &ExternalSigningPlan, ) -> Result { - let runtime = self.runtime()?; self.estimate_fee_for_plan( - &runtime, + &plan.runtime, &plan.call_data, plan.public_key, plan.crypto_type, @@ -791,8 +958,7 @@ impl Client { "external signature does not verify against the Rust signing plan".into(), )); } - let runtime = self.runtime()?; - let (extrinsic, hash) = runtime.encode_signed_extrinsic( + let (extrinsic, hash) = plan.runtime.encode_signed_extrinsic( &plan.call_data, plan.public_key, signature, @@ -807,10 +973,46 @@ impl Client { plan: &ExternalSigningPlan, signature: &[u8], crypto_type: Option, - wait_for_finalization: bool, + wait: TxWait, + timeout: Duration, + ) -> Result { + self.submit_external_with_cancel(plan, signature, crypto_type, wait, timeout, None) + } + + pub fn submit_external_with_cancel( + &self, + plan: &ExternalSigningPlan, + signature: &[u8], + crypto_type: Option, + wait: TxWait, + timeout: Duration, + cancelled: Option<&AtomicBool>, ) -> Result { + if let Err(error) = check_cancelled_before_submission(cancelled) { + if plan.reserved_nonce { + self.invalidate_nonce(&plan.signer_address)?; + } + return Err(error); + } let (extrinsic, hash) = self.assemble_external_extrinsic(plan, signature, crypto_type)?; - self.submit_encoded(&extrinsic, hash, wait_for_finalization) + let outcome = match self + .submit_encoded_with_wait_cancelled(&extrinsic, hash, wait, timeout, cancelled) + { + Ok(outcome) => outcome, + Err(error) if plan.reserved_nonce && is_cancelled_before_submission_error(&error) => { + self.invalidate_nonce(&plan.signer_address)?; + return Err(error); + } + Err(error) => return Err(error), + }; + if plan.reserved_nonce { + if is_nonce_pool_rejection(&outcome) { + self.invalidate_nonce(&plan.signer_address)?; + } else if outcome.block_hash.is_some() { + self.commit_nonce(&plan.signer_address, plan.params.nonce)?; + } + } + Ok(outcome) } /// Sign without submitting. Returns `(encoded extrinsic, 0x hash)`. @@ -821,7 +1023,7 @@ impl Client { nonce: u64, period: Option, ) -> Result<(Vec, String), CoreError> { - let runtime = self.runtime()?; + let runtime = self.current_runtime()?; let current = self.block_number()?; let (era, era_block_hash) = match period { Some(period) if period > 0 => { @@ -895,14 +1097,58 @@ impl Client { signer: &Keypair, nonce: Option, period: Option, - wait_for_finalization: bool, + wait: TxWait, + timeout: Duration, ) -> Result { - let nonce = match nonce { - Some(nonce) => nonce, - None => self.account_next_index(&signer.ss58_address())?, - }; - let (extrinsic, hash) = self.sign_extrinsic(call_data, signer, nonce, period)?; - self.submit_encoded(&extrinsic, hash, wait_for_finalization) + self.submit_with_cancel(call_data, signer, nonce, period, wait, timeout, None) + } + + pub fn submit_with_cancel( + &self, + call_data: &[u8], + signer: &Keypair, + nonce: Option, + period: Option, + wait: TxWait, + timeout: Duration, + cancelled: Option<&AtomicBool>, + ) -> Result { + let address = signer.ss58_address(); + let explicit_nonce = nonce.is_some(); + let mut last = None; + for _ in 0..NONCE_RETRY_LIMIT { + check_cancelled(cancelled)?; + let nonce = match nonce { + Some(nonce) => nonce, + None => self.reserve_nonce(&address)?, + }; + let (extrinsic, hash) = self.sign_extrinsic(call_data, signer, nonce, period)?; + let outcome = match self + .submit_encoded_with_wait_cancelled(&extrinsic, hash, wait, timeout, cancelled) + { + Ok(outcome) => outcome, + Err(error) if !explicit_nonce && is_cancelled_before_submission_error(&error) => { + self.invalidate_nonce(&address)?; + return Err(error); + } + Err(error) => return Err(error), + }; + if is_nonce_pool_rejection(&outcome) && !explicit_nonce { + self.invalidate_nonce(&address)?; + last = Some(outcome); + continue; + } + if !is_nonce_pool_rejection(&outcome) && outcome.block_hash.is_some() { + self.commit_nonce(&address, nonce)?; + } + return Ok(outcome); + } + Ok(last.unwrap_or_else(|| { + TxOutcome::pool_rejection( + String::new(), + "transaction rejected after nonce retries".into(), + ) + })) } pub fn submit_encoded( @@ -911,8 +1157,44 @@ impl Client { expected_hash: String, wait_for_finalization: bool, ) -> Result { - let start_block = self.block_number()?; + self.submit_encoded_with_wait( + extrinsic, + expected_hash, + if wait_for_finalization { + TxWait::Finalized + } else { + TxWait::Included + }, + DEFAULT_RECEIPT_TIMEOUT, + ) + } + + pub fn submit_encoded_with_wait( + &self, + extrinsic: &[u8], + expected_hash: String, + wait: TxWait, + timeout: Duration, + ) -> Result { + self.submit_encoded_with_wait_cancelled(extrinsic, expected_hash, wait, timeout, None) + } + + pub fn submit_encoded_with_wait_cancelled( + &self, + extrinsic: &[u8], + expected_hash: String, + wait: TxWait, + timeout: Duration, + cancelled: Option<&AtomicBool>, + ) -> Result { + check_cancelled_before_submission(cancelled)?; + let start_block = if wait == TxWait::Submitted { + 0 + } else { + self.block_number()? + }; let xt_hex = hex_prefixed(extrinsic); + check_cancelled_before_submission(cancelled)?; let submitted = match self.rpc_value("author_submitExtrinsic", json!([xt_hex])) { Ok(value) => value, Err(error) => { @@ -922,12 +1204,27 @@ impl Client { let submitted_hash = submitted .as_str() .map_or(expected_hash, ToString::to_string); + if wait == TxWait::Submitted { + return Ok(TxOutcome { + success: true, + extrinsic_hash: submitted_hash, + block_hash: None, + block_number: None, + extrinsic_index: None, + fee_rao: None, + events: Vec::new(), + error: None, + message: "Submitted".into(), + data: BTreeMap::new(), + }); + } self.wait_for_inclusion( extrinsic, submitted_hash, start_block, - wait_for_finalization, - DEFAULT_RECEIPT_TIMEOUT, + wait == TxWait::Finalized, + timeout, + cancelled, ) } @@ -956,7 +1253,12 @@ impl Client { signer, Some(nonce), Some(DEFAULT_ERA_PERIOD), - wait_for_finalization, + if wait_for_finalization { + TxWait::Finalized + } else { + TxWait::Included + }, + DEFAULT_RECEIPT_TIMEOUT, )?; if result.success { result.data.insert("shielded".into(), Value::Bool(true)); @@ -1214,6 +1516,7 @@ impl Client { start_block: u64, wait_for_finalization: bool, timeout: Duration, + cancelled: Option<&AtomicBool>, ) -> Result { let deadline = Instant::now() + timeout; let xt_hex = hex_prefixed(extrinsic).to_ascii_lowercase(); @@ -1223,13 +1526,21 @@ impl Client { .unwrap_or_else(|_| Duration::from_millis(250)) .min(Duration::from_secs(1)); 'track_inclusion: while Instant::now() < deadline { + check_cancelled(cancelled)?; let head = self.block_number()?; while next_block <= head { + check_cancelled(cancelled)?; let included_at = next_block; let block_hash = self.block_hash(Some(included_at))?; if let Some(index) = self.find_extrinsic(&block_hash, &xt_hex)? { if wait_for_finalization { - match self.wait_until_finalized(&block_hash, included_at, deadline, poll)? { + match self.wait_until_finalized( + &block_hash, + included_at, + deadline, + poll, + cancelled, + )? { InclusionFinalization::Finalized => {} InclusionFinalization::Reorged => { // The old inclusion block is no longer canonical. @@ -1244,7 +1555,7 @@ impl Client { } next_block = next_block.saturating_add(1); } - thread::sleep(poll); + sleep_or_cancel(poll, cancelled)?; } Ok(TxOutcome::pool_rejection( hash, @@ -1280,8 +1591,10 @@ impl Client { included_at: u64, deadline: Instant, poll: Duration, + cancelled: Option<&AtomicBool>, ) -> Result { while Instant::now() < deadline { + check_cancelled(cancelled)?; // Fetch finality first, then the canonical hash at the inclusion // height. Once finality has reached that height, the best chain must // contain the same finalized ancestor at that height. @@ -1296,7 +1609,7 @@ impl Client { ) { return Ok(status); } - thread::sleep(poll); + sleep_or_cancel(poll, cancelled)?; } Err(CoreError::Rpc(format!( "block {included_at} ({inclusion_hash}) was included but not finalized before the receipt timeout" @@ -1626,6 +1939,89 @@ fn http_endpoint(endpoint: &str) -> String { endpoint.to_string() } +fn normalized_endpoints(endpoints: I) -> Result, CoreError> +where + I: IntoIterator, + S: Into, +{ + let mut out = Vec::new(); + for endpoint in endpoints { + let endpoint = endpoint.into(); + let endpoint = http_endpoint(endpoint.trim()); + if endpoint.is_empty() { + continue; + } + if !out.iter().any(|candidate| candidate == &endpoint) { + out.push(endpoint); + } + } + if out.is_empty() { + return Err(CoreError::Rpc("no endpoints supplied".into())); + } + Ok(out) +} + +fn is_transport_rpc_error(error: &CoreError) -> bool { + let CoreError::Rpc(message) = error else { + return false; + }; + message.contains(" request failed:") + || message.contains(" HTTP error:") + || message.contains(" returned invalid JSON:") + || message.contains(" response omitted result") +} + +fn is_nonce_pool_rejection(outcome: &TxOutcome) -> bool { + !outcome.success + && outcome.block_hash.is_none() + && is_nonce_pool_error_message(&outcome.message) +} + +fn is_nonce_pool_error_message(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + normalized.contains("stale") + || normalized.contains("future") + || normalized.contains("priority is too low") + || normalized.contains("priority too low") + || normalized.contains("invalid transaction") + && normalized.contains("payment") + && normalized.contains("nonce") +} + +fn check_cancelled(cancelled: Option<&AtomicBool>) -> Result<(), CoreError> { + if cancelled.is_some_and(|cancelled| cancelled.load(Ordering::Relaxed)) { + Err(CoreError::Rpc("operation cancelled".into())) + } else { + Ok(()) + } +} + +fn check_cancelled_before_submission(cancelled: Option<&AtomicBool>) -> Result<(), CoreError> { + if cancelled.is_some_and(|cancelled| cancelled.load(Ordering::Relaxed)) { + Err(CoreError::Rpc( + "operation cancelled before submission".into(), + )) + } else { + Ok(()) + } +} + +fn is_cancelled_before_submission_error(error: &CoreError) -> bool { + matches!(error, CoreError::Rpc(message) if message == "operation cancelled before submission") +} + +fn sleep_or_cancel(duration: Duration, cancelled: Option<&AtomicBool>) -> Result<(), CoreError> { + let deadline = Instant::now() + duration; + loop { + check_cancelled(cancelled)?; + let now = Instant::now(); + if now >= deadline { + return Ok(()); + } + thread::sleep((deadline - now).min(Duration::from_millis(50))); + } +} + fn parse_header_fields(value: &JsonValue) -> Result<(u64, String), CoreError> { let object = value .as_object() diff --git a/sdk/bittensor-core/src/transaction.rs b/sdk/bittensor-core/src/transaction.rs index c95b9cd855..ed55069156 100644 --- a/sdk/bittensor-core/src/transaction.rs +++ b/sdk/bittensor-core/src/transaction.rs @@ -10,7 +10,7 @@ use std::collections::BTreeSet; use crate::client::{ as_u128, Client, ExternalSigner, ExternalSigningOptions, ExternalSigningPlan, TxOutcome, - DEFAULT_ERA_PERIOD, + TxWait, DEFAULT_ERA_PERIOD, DEFAULT_RECEIPT_TIMEOUT, }; use crate::codec::Value; use crate::error::CoreError; @@ -62,6 +62,7 @@ struct SecuritySemantics { spend: Spend, netuids: Vec, affects_all_subnets: bool, + global: bool, raw: bool, } @@ -74,6 +75,7 @@ impl SecuritySemantics { // scope as unknown/all rather than letting an empty list bypass an // allowlist. affects_all_subnets: true, + global: false, raw: true, } } @@ -90,9 +92,15 @@ impl SecuritySemantics { spend, netuids, affects_all_subnets, + global: false, raw: false, } } + + fn global(mut self) -> Self { + self.global = true; + self + } } /// A metadata-resolved call plus immutable product-level transaction semantics. @@ -171,6 +179,27 @@ impl IntentCall { ) } + #[allow(clippy::too_many_arguments)] + fn trusted_global( + op: impl Into, + signer: SignerRole, + pallet: impl Into, + function: impl Into, + params: Value, + spend: Spend, + netuids: impl IntoIterator, + affects_all_subnets: bool, + ) -> Self { + Self::from_parts( + op, + signer, + pallet, + function, + params, + SecuritySemantics::trusted(spend, netuids, affects_all_subnets).global(), + ) + } + fn from_parts( op: impl Into, signer: SignerRole, @@ -253,7 +282,7 @@ impl IntentCall { /// A bounded keep-alive TAO transfer. pub fn transfer(dest: impl Into, amount_rao: u128) -> Self { - Self::trusted( + Self::trusted_global( "transfer", SignerRole::Coldkey, "Balances", @@ -270,7 +299,7 @@ impl IntentCall { /// Fund the native mirror of an EVM address with a bounded TAO amount. pub fn fund_evm_key(mirror: impl Into, amount_rao: u128) -> Self { - Self::trusted( + Self::trusted_global( "fund_evm_key", SignerRole::Coldkey, "Balances", @@ -287,7 +316,7 @@ impl IntentCall { /// A bounded TAO transfer that may reap the sender. pub fn transfer_allow_death(dest: impl Into, amount_rao: u128) -> Self { - Self::trusted( + Self::trusted_global( "transfer", SignerRole::Coldkey, "Balances", @@ -304,7 +333,7 @@ impl IntentCall { /// Transfer the account's full transferable balance. pub fn transfer_all(dest: impl Into, keep_alive: bool) -> Self { - Self::trusted( + Self::trusted_global( "transfer_all", SignerRole::Coldkey, "Balances", @@ -410,7 +439,7 @@ impl IntentCall { /// Register a subnet. The live registration cost is not known locally. pub fn register_subnet(hotkey: impl Into) -> Self { - Self::trusted( + Self::trusted_global( "register_subnet", SignerRole::Coldkey, "SubtensorModule", @@ -755,16 +784,31 @@ impl IntentCall { Value::Dict(_) => subnets_from_root_claim(&value), _ => Vec::new(), }; - Ok(Self::trusted( - "set_root_claim_type", - SignerRole::Coldkey, - "SubtensorModule", - "set_root_claim_type", - Value::record(vec![("new_root_claim_type".into(), value)]), - Spend::None, - netuids, - false, - )) + let params = Value::record(vec![("new_root_claim_type".into(), value)]); + let intent = if netuids.is_empty() { + Self::trusted_global( + "set_root_claim_type", + SignerRole::Coldkey, + "SubtensorModule", + "set_root_claim_type", + params, + Spend::None, + [], + false, + ) + } else { + Self::trusted( + "set_root_claim_type", + SignerRole::Coldkey, + "SubtensorModule", + "set_root_claim_type", + params, + Spend::None, + netuids, + false, + ) + }; + Ok(intent) } /// Set a delegate take to an absolute value, selecting the runtime's @@ -801,7 +845,7 @@ impl IntentCall { } else { "increase_take" }; - Ok(Self::trusted( + Ok(Self::trusted_global( "set_take", SignerRole::Coldkey, "SubtensorModule", @@ -844,6 +888,7 @@ impl IntentCall { let mut spend = Spend::None; let mut netuids = BTreeSet::new(); let mut affects_all_subnets = false; + let mut global = false; let mut summaries = Vec::with_capacity(children.len()); let mut raw = false; for child in &children { @@ -851,6 +896,7 @@ impl IntentCall { spend = aggregate_spend(spend, child.security.spend); netuids.extend(child.security.netuids.iter().copied()); affects_all_subnets |= child.security.affects_all_subnets; + global |= child.security.global; raw |= child.security.raw; summaries.push(child.summary.clone()); } @@ -869,6 +915,7 @@ impl IntentCall { spend, netuids: netuids.into_iter().collect(), affects_all_subnets, + global, raw, }, }) @@ -936,6 +983,7 @@ pub struct Policy { pub max_spend_rao: Option, pub allowed_netuids: Option>, pub allow_raw_calls: bool, + pub allow_global: bool, } impl Policy { @@ -965,6 +1013,12 @@ impl Policy { } } if let Some(allowed) = &self.allowed_netuids { + if intent.security.global && !self.allow_global { + violations.push( + "intent has global/account-wide scope but policy only allows explicit subnets" + .into(), + ); + } if intent.security.affects_all_subnets { violations.push( "intent affects every subnet but policy only allows an explicit subset".into(), @@ -1120,7 +1174,12 @@ impl<'a> Executor<'a> { wallet.signer(intent.signer), None, Some(DEFAULT_ERA_PERIOD), - wait_for_finalization, + if wait_for_finalization { + TxWait::Finalized + } else { + TxWait::Included + }, + DEFAULT_RECEIPT_TIMEOUT, ) } @@ -1291,8 +1350,54 @@ mod tests { policy.check(&intent, Some(0)), vec![ String::from("spend 10 rao exceeds max_spend_rao 9"), + String::from( + "intent has global/account-wide scope but policy only allows explicit subnets", + ), String::from("netuid 2 is not allowed by policy"), ] ); } + + #[test] + fn global_root_claim_modes_do_not_bypass_subnet_allowlists() { + let intent = IntentCall::set_root_claim_type("Swap", None).expect("valid root claim"); + let subnet_only = Policy { + allowed_netuids: Some(BTreeSet::from([1])), + ..Policy::default() + }; + + assert_eq!( + subnet_only.check(&intent, Some(0)), + vec![String::from( + "intent has global/account-wide scope but policy only allows explicit subnets", + )] + ); + + let allow_global = Policy { + allowed_netuids: Some(BTreeSet::from([1])), + allow_global: true, + ..Policy::default() + }; + assert!(allow_global.check(&intent, Some(0)).is_empty()); + } + + #[test] + fn keep_subnets_root_claim_remains_subnet_scoped() { + let intent = IntentCall::set_root_claim_type("KeepSubnets", Some(vec![1])) + .expect("valid root claim"); + let allowed = Policy { + allowed_netuids: Some(BTreeSet::from([1])), + ..Policy::default() + }; + assert!(allowed.check(&intent, Some(0)).is_empty()); + + let denied = Policy { + allowed_netuids: Some(BTreeSet::from([2])), + ..Policy::default() + }; + assert_eq!( + denied.check(&intent, Some(0)), + vec![String::from("netuid 1 is not allowed by policy")] + ); + } } diff --git a/sdk/bittensor-ts/native/src/transaction.rs b/sdk/bittensor-ts/native/src/transaction.rs index 93f826085e..62dcaa40ea 100644 --- a/sdk/bittensor-ts/native/src/transaction.rs +++ b/sdk/bittensor-ts/native/src/transaction.rs @@ -1,19 +1,22 @@ +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::thread; +use std::time::Duration; use bittensor_core::client::{ BlockHeader, DispatchError, ExternalSigner, ExternalSigningOptions, ExternalSigningPlan, - MetadataHashMode, SubnetInfo, SwapQuote, TxOutcome, + MetadataHashMode, SubnetInfo, SwapQuote, TxOutcome, TxWait, DEFAULT_RECEIPT_TIMEOUT, }; use bittensor_core::codec::Value; use bittensor_core::digest::ChainInfo; use bittensor_core::transaction::{Executor, IntentCall, Plan, Policy, SignerRole, Spend, Wallet}; use bittensor_core::{Client, CoreError}; -use napi::bindgen_prelude::{AsyncTask, BigInt, Buffer, Unknown}; -use napi::{Env, ScopedTask, Task}; +use napi::bindgen_prelude::{AsyncTask, BigInt, Buffer, PromiseRaw, Unknown}; +use napi::{Env, JsValue, ScopedTask, Task}; use napi_derive::napi; use serde_json::Value as JsonValue; -use crate::errors::{invalid_arg, CoreResultExt, NapiResult}; +use crate::errors::{into_napi, invalid_arg, CoreResultExt, NapiResult}; use crate::keys::NativeKeypair; use crate::runtime::{NativeRuntime, NativeSignerPayload, NativeTxParams}; use crate::values::{from_wire, to_wire}; @@ -24,6 +27,14 @@ pub struct NativePolicyOptions { pub max_spend_rao: Option, pub allowed_netuids: Option>, pub allow_raw_calls: Option, + pub allow_global: Option, +} + +#[napi(object)] +pub struct NativeSubmitOptions { + pub wait_for_inclusion: Option, + pub wait_for_finalization: Option, + pub timeout_ms: Option, } #[napi(object)] @@ -125,6 +136,31 @@ pub struct NativeExternalSigningPlan { pub(crate) inner: Arc, } +#[napi] +pub struct NativeCancellationToken { + cancelled: Arc, +} + +#[napi] +impl NativeCancellationToken { + #[napi(constructor)] + pub fn new() -> Self { + Self { + cancelled: Arc::new(AtomicBool::new(false)), + } + } + + #[napi] + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Relaxed); + } + + #[napi(getter)] + pub fn cancelled(&self) -> bool { + self.cancelled.load(Ordering::Relaxed) + } +} + type ClientJob = Box Result + Send + 'static>; fn task_already_completed() -> napi::Error { @@ -167,7 +203,7 @@ macro_rules! client_task { } pub struct ClientConnectTask { - endpoint: String, + endpoints: Vec, } impl Task for ClientConnectTask { @@ -175,7 +211,7 @@ impl Task for ClientConnectTask { type JsValue = NativeClient; fn compute(&mut self) -> napi::Result { - Client::connect(self.endpoint.clone()).napi() + Client::connect_many(self.endpoints.clone()).napi() } fn resolve(&mut self, _env: Env, output: Self::Output) -> napi::Result { @@ -194,12 +230,6 @@ client_task!(ClientHeaderTask, BlockHeader, NativeBlockHeader, |value| { client_task!(ClientBytesTask, Vec, Buffer, |value: Vec| { Ok(value.into()) }); -client_task!( - ClientTxOutcomeTask, - TxOutcome, - NativeTxOutcome, - outcome_to_native -); client_task!( ClientSignedExtrinsicTask, (Vec, String), @@ -230,6 +260,59 @@ client_task!(ClientChainInfoTask, ChainInfo, NativeChainInfo, |value| { Ok(chain_info_to_native(value)) }); +fn spawn_tx_outcome( + env: &Env, + client: Arc, + cancellation: Option<&NativeCancellationToken>, + job: F, +) -> NapiResult> +where + F: FnOnce(&Client, Option<&AtomicBool>) -> Result + Send + 'static, +{ + let cancelled = cancellation.map(|token| Arc::clone(&token.cancelled)); + let (deferred, promise) = env.create_deferred::()?; + let promise = PromiseRaw::new(env.raw(), promise.raw()); + thread::spawn(move || { + let result = job(&client, cancelled.as_deref()); + match result { + Ok(outcome) => deferred.resolve(move |_env| outcome_to_native(outcome)), + Err(error) => deferred.reject(into_napi(error)), + } + }); + Ok(promise) +} + +pub struct ClientJsonTask { + client: Arc, + job: Option>, +} + +impl ClientJsonTask { + fn new(client: Arc, job: F) -> Self + where + F: FnOnce(&Client) -> Result + Send + 'static, + { + Self { + client, + job: Some(Box::new(job)), + } + } +} + +impl<'task> ScopedTask<'task> for ClientJsonTask { + type Output = JsonValue; + type JsValue = Unknown<'task>; + + fn compute(&mut self) -> napi::Result { + let job = self.job.take().ok_or_else(task_already_completed)?; + job(&self.client).napi() + } + + fn resolve(&mut self, env: &'task Env, output: Self::Output) -> napi::Result { + env.to_js_value(&output) + } +} + pub struct ClientWireValueTask { client: Arc, job: Option>, @@ -379,6 +462,7 @@ impl NativePolicy { .allowed_netuids .map(|items| items.into_iter().collect()), allow_raw_calls: options.allow_raw_calls.unwrap_or(false), + allow_global: options.allow_global.unwrap_or(false), }, }) } @@ -388,6 +472,11 @@ impl NativePolicy { self.inner.allow_raw_calls } + #[napi(getter, js_name = "allowGlobal")] + pub fn allow_global(&self) -> bool { + self.inner.allow_global + } + #[napi(js_name = "check")] pub fn check( &self, @@ -787,6 +876,11 @@ impl NativeExternalSigningPlan { self.inner.signer_payload.clone().into() } + #[napi(getter)] + pub fn runtime(&self) -> NativeRuntime { + NativeRuntime::from_arc(Arc::clone(&self.inner.runtime)) + } + #[napi(getter, js_name = "chainInfo")] pub fn chain_info(&self) -> Option { self.inner.chain_info.clone().map(chain_info_to_native) @@ -797,6 +891,16 @@ impl NativeExternalSigningPlan { self.inner.fee_rao.map(|value| value.to_string()) } + #[napi(getter, js_name = "runtimeSpecVersion")] + pub fn runtime_spec_version(&self) -> u32 { + self.inner.runtime_spec_version + } + + #[napi(getter, js_name = "runtimeTransactionVersion")] + pub fn runtime_transaction_version(&self) -> u32 { + self.inner.runtime_transaction_version + } + #[napi(getter)] pub fn warnings(&self) -> Vec { self.inner.warnings.clone() @@ -812,7 +916,14 @@ pub struct NativeClient { impl NativeClient { #[napi] pub fn connect(endpoint: String) -> AsyncTask { - AsyncTask::new(ClientConnectTask { endpoint }) + AsyncTask::new(ClientConnectTask { + endpoints: vec![endpoint], + }) + } + + #[napi(js_name = "connectEndpoints")] + pub fn connect_endpoints(endpoints: Vec) -> AsyncTask { + AsyncTask::new(ClientConnectTask { endpoints }) } #[napi(getter)] @@ -889,6 +1000,18 @@ impl NativeClient { self.inner.runtime().napi().map(NativeRuntime::from_arc) } + #[napi(js_name = "rpcValue")] + pub fn rpc_value( + &self, + method: String, + params: JsonValue, + ) -> NapiResult> { + Ok(AsyncTask::new(ClientJsonTask::new( + Arc::clone(&self.inner), + move |client| client.rpc_value(&method, params), + ))) + } + #[napi(js_name = "chainInfo")] pub fn chain_info(&self) -> NapiResult> { Ok(AsyncTask::new(ClientChainInfoTask::new( @@ -1036,12 +1159,14 @@ impl NativeClient { #[napi(js_name = "submit")] pub fn submit( &self, + env: Env, call_data: Buffer, signer: &NativeKeypair, nonce: Option, period: Option, - wait_for_finalization: Option, - ) -> NapiResult> { + options: Option, + cancellation: Option<&NativeCancellationToken>, + ) -> NapiResult> { let nonce = nonce .as_ref() .map(|value| bigint_u64("nonce", value)) @@ -1052,26 +1177,44 @@ impl NativeClient { .transpose()?; let call_data = call_data.to_vec(); let signer = signer.inner.clone(); - let wait_for_finalization = wait_for_finalization.unwrap_or(false); - Ok(AsyncTask::new(ClientTxOutcomeTask::new( + let (wait, timeout) = submit_options(options)?; + spawn_tx_outcome( + &env, Arc::clone(&self.inner), - move |client| client.submit(&call_data, &signer, nonce, period, wait_for_finalization), - ))) + cancellation, + move |client, cancelled| { + client.submit_with_cancel( + &call_data, &signer, nonce, period, wait, timeout, cancelled, + ) + }, + ) } #[napi(js_name = "submitEncoded")] pub fn submit_encoded( &self, + env: Env, extrinsic: Buffer, expected_hash: String, - wait_for_finalization: Option, - ) -> AsyncTask { + options: Option, + cancellation: Option<&NativeCancellationToken>, + ) -> NapiResult> { let extrinsic = extrinsic.to_vec(); - let wait_for_finalization = wait_for_finalization.unwrap_or(false); - AsyncTask::new(ClientTxOutcomeTask::new( + let (wait, timeout) = submit_options(options)?; + spawn_tx_outcome( + &env, Arc::clone(&self.inner), - move |client| client.submit_encoded(&extrinsic, expected_hash, wait_for_finalization), - )) + cancellation, + move |client, cancelled| { + client.submit_encoded_with_wait_cancelled( + &extrinsic, + expected_hash, + wait, + timeout, + cancelled, + ) + }, + ) } #[napi(js_name = "externalSigningPlan")] @@ -1147,23 +1290,34 @@ impl NativeClient { #[napi(js_name = "submitExternal")] pub fn submit_external( &self, + env: Env, plan: &NativeExternalSigningPlan, signature: Buffer, - wait_for_finalization: Option, + options: Option, crypto_type: Option, - ) -> NapiResult> { + cancellation: Option<&NativeCancellationToken>, + ) -> NapiResult> { let plan = Arc::clone(&plan.inner); let signature = signature.to_vec(); let crypto_type = crypto_type .map(|value| u8::try_from(value).map_err(|_| invalid_arg("cryptoType must fit u8"))) .transpose()?; - let wait_for_finalization = wait_for_finalization.unwrap_or(false); - Ok(AsyncTask::new(ClientTxOutcomeTask::new( + let (wait, timeout) = submit_options(options)?; + spawn_tx_outcome( + &env, Arc::clone(&self.inner), - move |client| { - client.submit_external(&plan, &signature, crypto_type, wait_for_finalization) + cancellation, + move |client, cancelled| { + client.submit_external_with_cancel( + &plan, + &signature, + crypto_type, + wait, + timeout, + cancelled, + ) }, - ))) + ) } #[napi(js_name = "balanceRao")] @@ -1574,6 +1728,26 @@ fn external_signer(signer: NativeExternalSigner) -> NapiResult { }) } +fn submit_options(options: Option) -> NapiResult<(TxWait, Duration)> { + let Some(options) = options else { + return Ok((TxWait::Submitted, DEFAULT_RECEIPT_TIMEOUT)); + }; + let wait = if options.wait_for_finalization.unwrap_or(false) { + TxWait::Finalized + } else if options.wait_for_inclusion.unwrap_or(false) { + TxWait::Included + } else { + TxWait::Submitted + }; + let timeout = options + .timeout_ms + .as_ref() + .map(|value| bigint_u64("timeoutMs", value).map(Duration::from_millis)) + .transpose()? + .unwrap_or(DEFAULT_RECEIPT_TIMEOUT); + Ok((wait, timeout)) +} + fn external_signing_options( options: Option, ) -> NapiResult { diff --git a/sdk/bittensor-ts/scripts/check-native-parity.cjs b/sdk/bittensor-ts/scripts/check-native-parity.cjs index 29dd925f5e..a1017313cf 100755 --- a/sdk/bittensor-ts/scripts/check-native-parity.cjs +++ b/sdk/bittensor-ts/scripts/check-native-parity.cjs @@ -885,6 +885,7 @@ const nativeClassInterfaces = { NativePolicy: { instance: 'NativePolicyHandle', statics: 'NativePolicyConstructor' }, NativeIntentCall: { instance: 'NativeIntentCallHandle', statics: 'NativeIntentCallConstructor' }, NativeExternalSigningPlan: { instance: 'NativeExternalSigningPlanHandle' }, + NativeCancellationToken: { instance: 'NativeCancellationTokenHandle', statics: 'NativeCancellationTokenConstructor' }, NativeClient: { instance: 'NativeClientHandle', statics: 'NativeClientConstructor' }, NativeWallet: { instance: 'NativeWalletHandle', statics: 'NativeWalletConstructor' }, NativeExecutor: { instance: 'NativeExecutorHandle', statics: 'NativeExecutorConstructor' }, diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 6d0c889c4e..d85f649a7b 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -2,9 +2,9 @@ import { blake2_256, generateExtrinsicProof, hexToBytes, metadataDigest } from ' import { CRYPTO_ED25519, CRYPTO_SR25519, Keypair, publicKeyFromSs58 } from './keys' import { LedgerDevice } from './ledger' import { Runtime, decodeOptionalOpaqueMetadata, eraBirth, type RuntimeSignerPayload } from './runtime' -import { IntentCall, NativeChainClient, Policy, isIntentCall, rawCall, type PolicyOptions, type SignerRoleLike } from './transaction' +import { IntentCall, NativeChainClient, Policy, createNativeCancellationToken, isIntentCall, rawCall, type NativeCancellationToken, type PolicyOptions, type SignerRoleLike } from './transaction' import { WIRE_TAG, fromWire, toBigInt, toBuffer } from './wire' -import type { NativeChainInfo, NativeExternalSigningOptions, NativeExternalSigningPlanHandle, NativeTxOutcome } from './native' +import type { NativeChainInfo, NativeExternalSigningOptions, NativeExternalSigningPlanHandle, NativeSubmitOptions, NativeTxOutcome } from './native' import { Balance, UnitMismatchError, @@ -2236,12 +2236,14 @@ export class BrowserChainClient { export class Client extends BrowserChainClient { private readonly nodeExpectedGenesisHash?: string + private readonly nodeEndpoints: string[] private nodeNativeClient?: NativeChainClient private nodeNativeClientPromise?: Promise constructor(network: string = 'finney', options: ClientOptions = {}) { - assertNodeClientOptions(options) - super(network, { ...options, autoConnect: false }) + const fallbackEndpoints = options.fallbackEndpoints ?? [] + super(network, { ...options, fallbackEndpoints: [], autoConnect: false }) + this.nodeEndpoints = [this.endpoint, ...fallbackEndpoints.filter((endpoint) => endpoint !== this.endpoint)] this.nodeExpectedGenesisHash = normalizeGenesisHash( options.expectedGenesisHash ?? trustedGenesisHash(this.network), ) @@ -2263,12 +2265,12 @@ export class Client extends BrowserChainClient { private async requireNativeClient(): Promise { if (this.nodeNativeClient != null) return this.nodeNativeClient - this.nodeNativeClientPromise ??= NativeChainClient.connect(this.endpoint) + this.nodeNativeClientPromise ??= NativeChainClient.connectEndpoints(this.nodeEndpoints) .then((client) => { const genesis = hex(client.genesisHash) if (this.nodeExpectedGenesisHash != null && !sameHex(genesis, this.nodeExpectedGenesisHash)) { throw new EndpointValidationError( - `endpoint ${this.endpoint} genesis ${genesis} does not match expected genesis ${this.nodeExpectedGenesisHash}`, + `endpoint ${client.endpoint} genesis ${genesis} does not match expected genesis ${this.nodeExpectedGenesisHash}`, ) } this.nodeNativeClient = client @@ -2304,7 +2306,9 @@ export class Client extends BrowserChainClient { if (block != null) { return super.runtimeAt(block) } - return (await this.requireNativeClient()).runtime() + const native = await this.requireNativeClient() + await native.refreshRuntime() + return native.runtime() } override async query( @@ -2333,15 +2337,21 @@ export class Client extends BrowserChainClient { } override invalidateRuntimeCache(): void { + super.invalidateRuntimeCache() void this.nodeNativeClient?.refreshRuntime().catch(() => undefined) } + async refreshRuntime(): Promise { + super.invalidateRuntimeCache() + return (await this.requireNativeClient()).refreshRuntime() + } + override async chainInfo(_runtime?: Runtime, _blockHash?: string | null): Promise { return nativeChainInfoToChainInfo(await (await this.requireNativeClient()).chainInfo()) } override rpc(method: string, params: unknown[] = []): Promise { - return super.rpc(method, params) + return this.requireNativeClient().then((native) => native.rpcValue(method, params)) } override async runtimeCall( @@ -2429,17 +2439,23 @@ export class Client extends BrowserChainClient { } override async submit(call: CallLike, signer: SignerLike, options: SubmitOptions = {}): Promise { - assertNativeSubmitOptions(options) const native = await this.requireNativeClient() const planned = await this.nativeSigningPlan(call, signer, options, native) const signature = await this.signRustPlan(planned) - return nativeOutcomeToExtrinsicResult( - await native.submitExternal( + const cancellation = nativeCancellationForSignal(options.signal) + const outcome = await abortableNative( + native.submitExternal( planned.plan, signature.signature, - options.waitForFinalization === true, + nativeSubmitOptions(options), signature.cryptoType, + cancellation, ), + options.signal, + cancellation, + ) + return nativeOutcomeToExtrinsicResult( + outcome, options.waitForFinalization === true, ) } @@ -2462,17 +2478,23 @@ export class Client extends BrowserChainClient { options: SubmitOptions = {}, ): Promise { void signerAddress - assertNativeSubmitOptions(options) const bytes = Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array ? toBuffer(extrinsic, 'extrinsic') : toBuffer(extrinsic.bytes, 'extrinsic.bytes') const extrinsicHash = hex(blake2_256(bytes)) - return nativeOutcomeToExtrinsicResult( - await (await this.requireNativeClient()).submitEncoded( + const cancellation = nativeCancellationForSignal(options.signal) + const outcome = await abortableNative( + (await this.requireNativeClient()).submitEncoded( bytes, extrinsicHash, - options.waitForFinalization === true, + nativeSubmitOptions(options), + cancellation, ), + options.signal, + cancellation, + ) + return nativeOutcomeToExtrinsicResult( + outcome, options.waitForFinalization === true, ) } @@ -2481,27 +2503,32 @@ export class Client extends BrowserChainClient { extrinsic: SignedExtrinsicResult | SignedExtrinsic | ByteLike, options: Pick & { signerAddress?: string } = {}, ): Promise { - assertNativeWatchOptions(options) const bytes = Buffer.isBuffer(extrinsic) || extrinsic instanceof Uint8Array ? toBuffer(extrinsic, 'extrinsic') : toBuffer(extrinsic.bytes, 'extrinsic.bytes') const extrinsicHash = hex(blake2_256(bytes)) - const result = this.requireNativeClient() + const unsubscribeController = new AbortController() + const signal = joinedAbortSignal(options.signal, unsubscribeController.signal) + const cancellation = nativeCancellationForSignal(signal) + const result = abortableNative(this.requireNativeClient() .then((native) => native.submitEncoded( bytes, extrinsicHash, - options.waitForFinalization === true, + nativeSubmitOptions({ + ...options, + waitForInclusion: options.waitForFinalization === true ? undefined : true, + }), + cancellation, )) .then((outcome) => nativeOutcomeToExtrinsicResult( outcome, options.waitForFinalization === true, - )) + )), signal, cancellation) return Promise.resolve({ extrinsicHash, result, unsubscribe: async () => { - // Native receipt tracking is a bounded blocking task rather than a - // JSON-RPC subscription, so there is no remote subscription to cancel. + unsubscribeController.abort() }, }) } @@ -2531,7 +2558,7 @@ export class Client extends BrowserChainClient { if (block != null) { return super.validateDescriptorSchema(block) } - return validateDescriptorSchema((await this.requireNativeClient()).runtime()) + return validateDescriptorSchema(await this.runtimeAt()) } private async nativeSigningPlan( @@ -2585,7 +2612,7 @@ export class Client extends BrowserChainClient { planOptions, ) } - return { native, runtime, resolved, plan } + return { native, runtime: Runtime.fromNativeHandle(plan.runtime), resolved, plan } } private async signRustPlan(planned: NativeSigningPlan): Promise { @@ -3468,20 +3495,6 @@ function normalizeQueryMapOptions(value: number | QueryMapOptions): Required 0) unsupported.push('fallbackEndpoints') - return unsupported -} - function positiveInteger(value: unknown, name: string): number { const parsed = Number(value) if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new RangeError(`${name} must be a positive safe integer`) @@ -3785,6 +3798,72 @@ function nativeExternalSigningOptions(options: SubmitOptions): NativeExternalSig return out } +function nativeSubmitOptions( + options: Pick, +): NativeSubmitOptions { + const out: NativeSubmitOptions = {} + if (options.waitForFinalization === true) out.waitForFinalization = true + else if (options.waitForInclusion === true) out.waitForInclusion = true + if (options.timeoutMs != null) out.timeoutMs = BigInt(nonNegativeSafeInteger(options.timeoutMs, 'timeoutMs')) + return out +} + +function nonNegativeSafeInteger(value: unknown, name: string): number { + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`) + } + return parsed +} + +function nativeCancellationForSignal(signal?: AbortSignal): NativeCancellationToken | undefined { + return signal == null ? undefined : createNativeCancellationToken() +} + +function abortableNative( + promise: Promise, + signal?: AbortSignal, + cancellation?: NativeCancellationToken, +): Promise { + if (signal?.aborted) { + cancellation?.cancel() + throw new RequestAbortedError() + } + if (signal == null) return promise + return new Promise((resolve, reject) => { + const abort = () => { + cleanup() + cancellation?.cancel() + reject(new RequestAbortedError()) + } + const cleanup = () => signal.removeEventListener('abort', abort) + signal.addEventListener('abort', abort, { once: true }) + promise.then( + (value) => { + cleanup() + resolve(value) + }, + (error) => { + cleanup() + reject(error) + }, + ) + }) +} + +function joinedAbortSignal(left?: AbortSignal, right?: AbortSignal): AbortSignal | undefined { + if (left == null) return right + if (right == null) return left + const controller = new AbortController() + const abort = () => controller.abort() + if (left.aborted || right.aborted) abort() + else { + left.addEventListener('abort', abort, { once: true }) + right.addEventListener('abort', abort, { once: true }) + } + return controller.signal +} + function nativePlanSignerContext( client: ChainClient, runtime: Runtime, @@ -3836,22 +3915,6 @@ function nativeChainInfoToChainInfo(info: NativeChainInfo): ChainInfo { } } -function assertNativeSubmitOptions(options: SubmitOptions): void { - if (options.signal != null || options.timeoutMs != null) { - throw new ChainError( - 'native Rust transaction execution does not support signal or timeoutMs; use submitSigned()/watchSigned() for custom transport cancellation', - ) - } -} - -function assertNativeWatchOptions(options: Pick): void { - if (options.signal != null || options.timeoutMs != null) { - throw new ChainError( - 'native Rust submit-and-watch does not support signal or timeoutMs; use BrowserChainClient for custom transport cancellation', - ) - } -} - function extensionSignPayload(context: SignerPayloadContext): ExtensionSignPayloadRequest { return context.signerPayload ?? context.runtime.signerPayload(context.address, context.callData, context.txParams) } diff --git a/sdk/bittensor-ts/src/native.ts b/sdk/bittensor-ts/src/native.ts index f8761700f1..0cf8ca1a26 100644 --- a/sdk/bittensor-ts/src/native.ts +++ b/sdk/bittensor-ts/src/native.ts @@ -271,6 +271,7 @@ export interface NativePolicyOptions { maxSpendRao?: bigint | null allowedNetuids?: number[] | null allowRawCalls?: boolean | null + allowGlobal?: boolean | null } export interface NativePlan { @@ -295,6 +296,21 @@ export interface NativeExternalSigningOptions { metadataHash?: Buffer | null } +export interface NativeSubmitOptions { + waitForInclusion?: boolean | null + waitForFinalization?: boolean | null + timeoutMs?: bigint | null +} + +export interface NativeCancellationTokenHandle { + readonly cancelled: boolean + cancel(): void +} + +export interface NativeCancellationTokenConstructor { + new(): NativeCancellationTokenHandle +} + export interface NativeExternalSigner { signerAddress: string publicKey: Buffer @@ -321,10 +337,13 @@ export interface NativeExternalSigningPlanHandle { readonly includedInSignedData: Buffer readonly metadataHash?: Buffer | null readonly metadataProof?: Buffer | null + readonly runtime: NativeRuntimeHandle readonly txParams: NativeTxParams readonly signerPayload: NativeSignerPayload readonly chainInfo?: NativeChainInfo | null readonly feeRao?: string | null + readonly runtimeSpecVersion: number + readonly runtimeTransactionVersion: number readonly warnings: string[] } @@ -350,6 +369,7 @@ export interface NativeTxOutcome { export interface NativePolicyHandle { readonly allowRawCalls: boolean + readonly allowGlobal: boolean check(intent: NativeIntentCallHandle, feeRao?: bigint | null): string[] } @@ -452,6 +472,7 @@ export interface NativeClientHandle { readCatalog(): string[] refreshRuntime(): Promise runtime(): NativeRuntimeHandle + rpcValue(method: string, params: unknown): Promise chainInfo(): Promise composeCall(pallet: string, callFunction: string, params: unknown): Promise decodeScale(typeName: string, data: Buffer): unknown @@ -473,12 +494,14 @@ export interface NativeClientHandle { signer: NativeKeypairHandle, nonce?: bigint | null, period?: bigint | null, - waitForFinalization?: boolean | null, + options?: NativeSubmitOptions | null, + cancellation?: NativeCancellationTokenHandle | null, ): Promise submitEncoded( extrinsic: Buffer, expectedHash: string, - waitForFinalization?: boolean | null, + options?: NativeSubmitOptions | null, + cancellation?: NativeCancellationTokenHandle | null, ): Promise externalSigningPlan( callData: Buffer, @@ -500,8 +523,9 @@ export interface NativeClientHandle { submitExternal( plan: NativeExternalSigningPlanHandle, signature: Buffer, - waitForFinalization?: boolean | null, + options?: NativeSubmitOptions | null, cryptoType?: number | null, + cancellation?: NativeCancellationTokenHandle | null, ): Promise balanceRao(address: string): Promise existentialDepositRao(): Promise @@ -516,6 +540,7 @@ export interface NativeClientHandle { export interface NativeClientConstructor { connect(endpoint: string): Promise + connectEndpoints(endpoints: string[]): Promise } export interface NativeWalletHandle {} @@ -555,6 +580,7 @@ export interface NativeBinding { NativePolicy: NativePolicyConstructor NativeIntentCall: NativeIntentCallConstructor NativeExternalSigningPlan: { readonly prototype: NativeExternalSigningPlanHandle } + NativeCancellationToken: NativeCancellationTokenConstructor NativeClient: NativeClientConstructor NativeWallet: NativeWalletConstructor NativeExecutor: NativeExecutorConstructor diff --git a/sdk/bittensor-ts/src/transaction.ts b/sdk/bittensor-ts/src/transaction.ts index 7c6c242477..e02eb22508 100644 --- a/sdk/bittensor-ts/src/transaction.ts +++ b/sdk/bittensor-ts/src/transaction.ts @@ -2,6 +2,7 @@ import native, { type NativeBlockHeader, type NativeChainInfo, type NativeClientHandle, + type NativeCancellationTokenHandle, type NativeExecutorHandle, type NativeExternalSigner, type NativeExternalSigningOptions, @@ -12,6 +13,7 @@ import native, { type NativePolicyHandle, type NativePolicyOptions, type NativeSignedExtrinsic, + type NativeSubmitOptions, type NativeSubnetInfo, type NativeSwapQuote, type NativeTxOutcome, @@ -26,6 +28,8 @@ import { Runtime } from './runtime' export type SignerRoleName = 'coldkey' | 'hotkey' export type SignerRoleLike = SignerRoleName | number +export type NativeCancellationToken = NativeCancellationTokenHandle + export const SignerRole = Object.freeze({ Coldkey: native.NativeSignerRole.Coldkey, Hotkey: native.NativeSignerRole.Hotkey, @@ -33,11 +37,16 @@ export const SignerRole = Object.freeze({ hotkey: native.NativeSignerRole.Hotkey, }) +export function createNativeCancellationToken(): NativeCancellationToken { + return nativeCall(() => new native.NativeCancellationToken()) +} + export interface PolicyOptions { maxFeeRao?: bigint | number | string | null maxSpendRao?: bigint | number | string | null allowedNetuids?: number[] | null allowRawCalls?: boolean | null + allowGlobal?: boolean | null } export interface RawCallOptions { @@ -62,10 +71,18 @@ export class Policy { return this.native.allowRawCalls } + get allowGlobal(): boolean { + return this.native.allowGlobal + } + withRawCalls(): Policy { return this.allowRawCalls ? this : new Policy({ ...this.options, allowRawCalls: true }) } + withGlobal(): Policy { + return this.allowGlobal ? this : new Policy({ ...this.options, allowGlobal: true }) + } + hasOpaqueByteRestrictions(): boolean { return ( this.options.maxFeeRao != null || @@ -338,6 +355,10 @@ export class NativeChainClient { return new NativeChainClient(await nativeAsync(() => native.NativeClient.connect(endpoint))) } + static async connectEndpoints(endpoints: string[]): Promise { + return new NativeChainClient(await nativeAsync(() => native.NativeClient.connectEndpoints(endpoints))) + } + get endpoint(): string { return this.native.endpoint } @@ -362,6 +383,10 @@ export class NativeChainClient { return Runtime.fromNativeHandle(nativeCall(() => this.native.runtime())) } + rpcValue(method: string, params: unknown[] = []): Promise { + return nativeAsync(() => this.native.rpcValue(method, params)) + } + chainInfo(): Promise { return nativeAsync(() => this.native.chainInfo()) } @@ -457,7 +482,8 @@ export class NativeChainClient { signer: Keypair, nonce?: bigint | number | null, period?: bigint | number | null, - waitForFinalization = false, + options?: NativeSubmitOptions | null, + cancellation?: NativeCancellationToken | null, ): Promise { return nativeAsync(() => this.native.submit( @@ -465,13 +491,21 @@ export class NativeChainClient { nativeKeypairHandle(signer), nonce == null ? undefined : bigintValue(nonce, 'nonce'), period == null ? undefined : bigintValue(period, 'period'), - waitForFinalization, + options ?? undefined, + cancellation ?? undefined, ), ) } - submitEncoded(extrinsic: Buffer, expectedHash: string, waitForFinalization = false): Promise { - return nativeAsync(() => this.native.submitEncoded(extrinsic, expectedHash, waitForFinalization)) + submitEncoded( + extrinsic: Buffer, + expectedHash: string, + options?: NativeSubmitOptions | null, + cancellation?: NativeCancellationToken | null, + ): Promise { + return nativeAsync(() => + this.native.submitEncoded(extrinsic, expectedHash, options ?? undefined, cancellation ?? undefined), + ) } externalSigningPlan( @@ -537,11 +571,18 @@ export class NativeChainClient { submitExternal( plan: NativeExternalSigningPlanHandle, signature: Buffer, - waitForFinalization = false, + options?: NativeSubmitOptions | null, cryptoType?: number | null, + cancellation?: NativeCancellationToken | null, ): Promise { return nativeAsync(() => - this.native.submitExternal(plan, signature, waitForFinalization, cryptoType ?? undefined), + this.native.submitExternal( + plan, + signature, + options ?? undefined, + cryptoType ?? undefined, + cancellation ?? undefined, + ), ) } @@ -652,6 +693,7 @@ function policyOptionsToNative(options: PolicyOptions): NativePolicyOptions { maxSpendRao: options.maxSpendRao == null ? undefined : bigintValue(options.maxSpendRao, 'maxSpendRao'), allowedNetuids: options.allowedNetuids ?? undefined, allowRawCalls: options.allowRawCalls ?? undefined, + allowGlobal: options.allowGlobal ?? undefined, } } @@ -661,6 +703,7 @@ function normalizePolicyOptions(options: PolicyOptions): PolicyOptions { maxSpendRao: options.maxSpendRao ?? undefined, allowedNetuids: options.allowedNetuids == null ? undefined : [...options.allowedNetuids], allowRawCalls: options.allowRawCalls ?? undefined, + allowGlobal: options.allowGlobal ?? undefined, } } diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index bdd0a73184..562dccda73 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -1058,13 +1058,10 @@ test('chain client surface is exported without Polkadot.js glue', () => { }) test('Node Client keeps the Rust native backend authoritative', async () => { - assert.throws( - () => new core.Client('local', { - endpoint: 'http://primary', - fallbackEndpoints: ['http://fallback'], - }), - /fallbackEndpoints/, - ) + assert.doesNotThrow(() => new core.Client('local', { + endpoint: 'http://primary', + fallbackEndpoints: ['http://fallback'], + })) assert.doesNotThrow(() => new core.Client('local', { endpoint: 'ws://node-a', requestTimeoutMs: 10, @@ -1089,8 +1086,8 @@ test('Node Client keeps the Rust native backend authoritative', async () => { calls.push({ method: 'queryMap', pallet, storage, params, block }) return [[1, true]] }, - submitEncoded(bytes, expectedHash, waitForFinalization) { - calls.push({ method: 'submitEncoded', bytes, expectedHash, waitForFinalization }) + submitEncoded(bytes, expectedHash, options) { + calls.push({ method: 'submitEncoded', bytes, expectedHash, options }) return { success: true, extrinsicHash: expectedHash, @@ -1129,7 +1126,7 @@ test('Node Client keeps the Rust native backend authoritative', async () => { method: 'submitEncoded', bytes: Buffer.from([1, 2, 3]), expectedHash: watcher.extrinsicHash, - waitForFinalization: true, + options: { waitForFinalization: true }, }) }) @@ -2668,6 +2665,7 @@ test('Client submit delegates automatic Keypair nonces to the Rust client', asyn includedInSignedData: Buffer.alloc(0), metadataHash: null, metadataProof: null, + runtime, txParams: { era: '00', nonce: 77n, @@ -2687,26 +2685,26 @@ test('Client submit delegates automatic Keypair nonces to the Rust client', asyn calls.push({ stage: 'plan', submittedCallData, signerAddress, publicKey, cryptoType, requiresMetadataProof, options }) return plan }, - submitExternal(submittedPlan, signature, waitForFinalization, cryptoType) { - calls.push({ stage: 'submit', submittedPlan, signature, waitForFinalization, cryptoType }) + submitExternal(submittedPlan, signature, options, cryptoType) { + calls.push({ stage: 'submit', submittedPlan, signature, options, cryptoType }) return { success: true, extrinsicHash: `0x${'11'.repeat(32)}`, - blockHash: `0x${'22'.repeat(32)}`, - blockNumber: 7n, - extrinsicIndex: 2, + blockHash: null, + blockNumber: null, + extrinsicIndex: null, feeRao: null, events: [], error: null, - message: 'Success', + message: 'Submitted', } }, }) const result = await client.submit(callData, signer, { allowRawCall: true }) - assert.equal(result.status, 'inBlock') - assert.equal(result.extrinsicId, '7-0002') + assert.equal(result.status, 'submitted') + assert.equal(result.extrinsicId, undefined) assert.equal(calls.length, 2) assert.equal(calls[0].stage, 'plan') assert.deepEqual(calls[0].submittedCallData, callData) @@ -2718,7 +2716,7 @@ test('Client submit delegates automatic Keypair nonces to the Rust client', asyn assert.equal(calls[1].stage, 'submit') assert.equal(calls[1].submittedPlan, plan) assert.equal(calls[1].signature.length, 64) - assert.equal(calls[1].waitForFinalization, false) + assert.deepEqual(calls[1].options, {}) assert.equal(calls[1].cryptoType, signer.cryptoType) }) @@ -2757,6 +2755,7 @@ test('Client submit uses the Rust signing plan for external signers', async () = includedInSignedData: Buffer.from([2]), metadataHash: null, metadataProof: null, + runtime, txParams: { era: '00', nonce: 44n, @@ -2777,8 +2776,8 @@ test('Client submit uses the Rust signing plan for external signers', async () = calls.push({ stage: 'plan', submittedCallData, signerAddress, submittedPublicKey, cryptoType, requiresMetadataProof, options }) return plan }, - submitExternal(submittedPlan, signature, waitForFinalization, cryptoType) { - calls.push({ stage: 'submit', submittedPlan, signature, waitForFinalization, cryptoType }) + submitExternal(submittedPlan, signature, options, cryptoType) { + calls.push({ stage: 'submit', submittedPlan, signature, options, cryptoType }) return { success: true, extrinsicHash: `0x${'33'.repeat(32)}`, @@ -2801,7 +2800,7 @@ test('Client submit uses the Rust signing plan for external signers', async () = }, } - const result = await client.submit(callData, signer, { allowRawCall: true }) + const result = await client.submit(callData, signer, { allowRawCall: true, waitForInclusion: true }) assert.equal(result.status, 'inBlock') assert.equal(result.fee.rao, 125n) @@ -2815,6 +2814,7 @@ test('Client submit uses the Rust signing plan for external signers', async () = assert.equal(calls[2].stage, 'submit') assert.equal(calls[2].submittedPlan, plan) assert.deepEqual(calls[2].signature, Buffer.alloc(64, 6)) + assert.deepEqual(calls[2].options, { waitForInclusion: true }) assert.equal(calls[2].cryptoType, core.CRYPTO_SR25519) }) diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts index 761af62de8..e8d02b874c 100644 --- a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-sell-fees.ts @@ -5,7 +5,6 @@ import { generateKeyringPair, tao } from "../../../../utils"; import { devForceSetBalance, devAddStake, - devGetAlphaStake, devAssociateHotKey, devEnableSubtoken, devRegisterSubnet, diff --git a/ts-tests/suites/zombienet_evm/02-precompile-gas.test.ts b/ts-tests/suites/zombienet_evm/02-precompile-gas.test.ts index 06ff11592e..56d0481aaa 100644 --- a/ts-tests/suites/zombienet_evm/02-precompile-gas.test.ts +++ b/ts-tests/suites/zombienet_evm/02-precompile-gas.test.ts @@ -33,12 +33,15 @@ async function assertPrecompileGasScaling( // block count: when GRANDPA lags best by more than 2 blocks, a fixed // wait ends before the fee deduction is in finalized state and the // balanceAfter < balanceBefore assertion sees identical balances. - await waitUntilBlockFinalized(api, receipt!.blockNumber); + if (receipt == null) { + throw new Error("precompile gas transaction did not produce a receipt"); + } + await waitUntilBlockFinalized(api, receipt.blockNumber); const balanceAfter = await getBalance(api, convertH160ToSS58(wallet.address)); expect(balanceAfter).toBeLessThan(balanceBefore); - const gasUsed = receipt!.gasUsed; + const gasUsed = receipt.gasUsed; if (iterations === 1) { oneIterationGas = gasUsed; continue; diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts index be4e58c614..79d423b413 100644 --- a/ts-tests/utils/limit-orders.ts +++ b/ts-tests/utils/limit-orders.ts @@ -4,7 +4,6 @@ import type { subtensor } from "@polkadot-api/descriptors"; import { blake2_256, bytesToHex } from "@bittensor/sdk"; import { keyringPairFromUri } from "./account.ts"; import { waitForTransactionWithRetry } from "./transactions.js"; -import { MultiAddress } from "@polkadot-api/descriptors"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -241,12 +240,11 @@ export async function computeNetAmount( // alpha_to_tao ≈ floor(price * sell_alpha / 1e9) const sellTaoEquiv = (price * sellSideAlpha) / SCALE; return buySideTao - sellTaoEquiv; - } else { - // net_amount (alpha) = sell_alpha - tao_to_alpha(buy_tao, price) - // tao_to_alpha ≈ floor(buy_tao * 1e9 / price) - const buyAlphaEquiv = (buySideTao * SCALE) / price; - return sellSideAlpha - buyAlphaEquiv; } + // net_amount (alpha) = sell_alpha - tao_to_alpha(buy_tao, price) + // tao_to_alpha ≈ floor(buy_tao * 1e9 / price) + const buyAlphaEquiv = (buySideTao * SCALE) / price; + return sellSideAlpha - buyAlphaEquiv; } export async function executeBatchedOrders( diff --git a/ts-tests/utils/subtensor.ts b/ts-tests/utils/subtensor.ts index f6090a67ca..208cbebba5 100644 --- a/ts-tests/utils/subtensor.ts +++ b/ts-tests/utils/subtensor.ts @@ -1,4 +1,4 @@ -import { subtensor } from "@polkadot-api/descriptors"; +import type { subtensor } from "@polkadot-api/descriptors"; import type { TypedApi } from "polkadot-api"; export async function getProxies(api: TypedApi, address: string): Promise { From fb8e11cdaaabc4b1c028776dcc57c67d7ccf1841 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Mon, 13 Jul 2026 11:03:15 -0700 Subject: [PATCH 68/72] fix browser smoke --- sdk/bittensor-ts/scripts/browser-smoke.cjs | 26 ++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/sdk/bittensor-ts/scripts/browser-smoke.cjs b/sdk/bittensor-ts/scripts/browser-smoke.cjs index c71810167c..ed0352152f 100644 --- a/sdk/bittensor-ts/scripts/browser-smoke.cjs +++ b/sdk/bittensor-ts/scripts/browser-smoke.cjs @@ -268,6 +268,28 @@ function launchChrome(chrome, url) { return { child, devtools } } +function stopChrome(child) { + if (child == null || child.exitCode != null) return Promise.resolve() + return new Promise((resolve) => { + let settled = false + let force + let giveUp + const finish = () => { + if (settled) return + settled = true + clearTimeout(force) + clearTimeout(giveUp) + resolve() + } + child.once('exit', finish) + force = setTimeout(() => { + if (child.exitCode == null) child.kill('SIGKILL') + }, 2_000) + giveUp = setTimeout(finish, 5_000) + child.kill() + }) +} + function cdp(wsUrl) { const socket = new WebSocket(wsUrl) let nextId = 1 @@ -358,7 +380,7 @@ async function runBrowserPage(chromePath, url, mode) { assert.ok(result.extrinsicLength > result.callLength) } finally { client?.close() - if (chrome?.child.exitCode == null) chrome?.child.kill() + await stopChrome(chrome?.child) } } @@ -377,7 +399,7 @@ async function main() { await runBrowserPage(chromePath, new URL('/default.html', served.url).href, 'default') } finally { server?.close() - fs.rmSync(tmp, { recursive: true, force: true }) + fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }) } } From 4d9633e4f4e2e2ebfb16f22d078a697dfb695584 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Mon, 13 Jul 2026 11:24:51 -0700 Subject: [PATCH 69/72] use hyperparams v3 --- sdk/bittensor-core/src/client.rs | 162 +++++++++++++++++- sdk/bittensor-core/tests/e2e.rs | 24 ++- sdk/bittensor-ts/native/src/transaction.rs | 64 ++++++- sdk/bittensor-ts/scripts/browser-smoke.cjs | 20 ++- sdk/bittensor-ts/src/client.ts | 82 ++++++++- sdk/bittensor-ts/src/native.ts | 8 +- sdk/bittensor-ts/src/transaction.ts | 35 +++- sdk/bittensor-ts/src/types.ts | 18 ++ sdk/bittensor-ts/test/basic.test.cjs | 19 +- .../bittensor/_generated/runtime_apis.py | 3 - .../bittensor/_transport/runtime_api.py | 4 +- sdk/python/bittensor/cli/hyperparams_view.py | 32 +++- sdk/python/bittensor/namespaces.pyi | 4 +- sdk/python/bittensor/reads/subnets.py | 6 +- sdk/python/scripts/record_golden.py | 2 +- sdk/python/tests/unit/test_reads_table.py | 2 +- 16 files changed, 442 insertions(+), 43 deletions(-) diff --git a/sdk/bittensor-core/src/client.rs b/sdk/bittensor-core/src/client.rs index 532ed4dac6..86dd107ee9 100644 --- a/sdk/bittensor-core/src/client.rs +++ b/sdk/bittensor-core/src/client.rs @@ -59,6 +59,26 @@ pub struct SubnetInfo { pub neuron_count: u16, } +/// One V3 subnet hyperparameter entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubnetHyperparameter { + pub name: String, + pub value: SubnetHyperparameterValue, +} + +/// Typed value variants returned by `SubnetInfoRuntimeApi.get_subnet_hyperparams_v3`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubnetHyperparameterValue { + Bool(bool), + U16(u16), + U32(u32), + U64(u64), + U128(u128), + TaoBalance(u128), + I32F32Bits(i64), + U64F64Bits(u128), +} + /// Swap simulation returned by the runtime API. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SwapQuote { @@ -1335,13 +1355,14 @@ impl Client { &self, netuid: u16, block_hash: Option<&str>, - ) -> Result { - self.runtime_call( + ) -> Result>, CoreError> { + let value = self.runtime_call( "SubnetInfoRuntimeApi", - "get_subnet_hyperparams", + "get_subnet_hyperparams_v3", &[Value::Uint(u128::from(netuid))], block_hash, - ) + )?; + subnet_hyperparameters_v3(&value) } pub fn stake_rao( @@ -2208,6 +2229,93 @@ pub fn as_bool(value: &Value) -> Option { } } +fn as_i128(value: &Value) -> Option { + match value { + Value::Int(value) => Some(*value), + Value::Uint(value) => i128::try_from(*value).ok(), + _ => None, + } +} + +fn subnet_hyperparameters_v3( + value: &Value, +) -> Result>, CoreError> { + match value { + Value::Null => Ok(None), + Value::List(entries) => entries + .iter() + .map(subnet_hyperparameter_entry) + .collect::, _>>() + .map(Some), + other => Err(CoreError::Codec(format!( + "SubnetInfoRuntimeApi.get_subnet_hyperparams_v3 returned {other:?}, expected Option>" + ))), + } +} + +fn subnet_hyperparameter_entry(value: &Value) -> Result { + let name = field(value, "name") + .and_then(value_bytes) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or_else(|| CoreError::Codec("hyperparameter entry omitted UTF-8 name".into()))?; + let value = field(value, "value") + .ok_or_else(|| CoreError::Codec(format!("hyperparameter {name} omitted value"))) + .and_then(subnet_hyperparameter_value)?; + Ok(SubnetHyperparameter { name, value }) +} + +fn subnet_hyperparameter_value(value: &Value) -> Result { + let Value::Dict(entries) = value else { + return Err(CoreError::Codec(format!( + "hyperparameter value {value:?} is not a V3 enum variant" + ))); + }; + let [(Value::Str(variant), payload)] = entries.as_slice() else { + return Err(CoreError::Codec(format!( + "hyperparameter value {value:?} is not a single V3 enum variant" + ))); + }; + match variant.as_str() { + "Bool" => as_bool(payload) + .map(SubnetHyperparameterValue::Bool) + .ok_or_else(|| CoreError::Codec("Bool hyperparameter is not a bool".into())), + "U16" => as_u128(payload) + .and_then(|value| u16::try_from(value).ok()) + .map(SubnetHyperparameterValue::U16) + .ok_or_else(|| CoreError::Codec("U16 hyperparameter is outside u16".into())), + "U32" => as_u128(payload) + .and_then(|value| u32::try_from(value).ok()) + .map(SubnetHyperparameterValue::U32) + .ok_or_else(|| CoreError::Codec("U32 hyperparameter is outside u32".into())), + "U64" => as_u128(payload) + .and_then(|value| u64::try_from(value).ok()) + .map(SubnetHyperparameterValue::U64) + .ok_or_else(|| CoreError::Codec("U64 hyperparameter is outside u64".into())), + "U128" => as_u128(payload) + .map(SubnetHyperparameterValue::U128) + .ok_or_else(|| { + CoreError::Codec("U128 hyperparameter is not an unsigned integer".into()) + }), + "TaoBalance" => as_u128(payload) + .map(SubnetHyperparameterValue::TaoBalance) + .ok_or_else(|| { + CoreError::Codec("TaoBalance hyperparameter is not an unsigned integer".into()) + }), + "I32F32" => field(payload, "bits") + .and_then(as_i128) + .and_then(|value| i64::try_from(value).ok()) + .map(SubnetHyperparameterValue::I32F32Bits) + .ok_or_else(|| CoreError::Codec("I32F32 hyperparameter omitted i64 bits".into())), + "U64F64" => field(payload, "bits") + .and_then(as_u128) + .map(SubnetHyperparameterValue::U64F64Bits) + .ok_or_else(|| CoreError::Codec("U64F64 hyperparameter omitted u128 bits".into())), + other => Err(CoreError::Codec(format!( + "unknown hyperparameter V3 value variant {other}" + ))), + } +} + pub fn value_bytes(value: &Value) -> Option> { match value { Value::Bytes(bytes) => Some(bytes.clone()), @@ -2410,7 +2518,10 @@ fn hex_prefixed(bytes: &[u8]) -> String { #[cfg(test)] mod reorg_finalization_tests { - use super::{classify_inclusion_finalization, InclusionFinalization}; + use super::{ + classify_inclusion_finalization, subnet_hyperparameters_v3, InclusionFinalization, + SubnetHyperparameter, SubnetHyperparameterValue, Value, + }; #[test] fn canonical_inclusion_remains_pending_below_finalized_height() { @@ -2443,4 +2554,45 @@ mod reorg_finalization_tests { Some(InclusionFinalization::Reorged) ); } + + #[test] + fn subnet_hyperparameters_v3_are_typed_entries() { + let raw = Value::List(vec![ + Value::record(vec![ + ("name".into(), Value::str("tempo")), + ( + "value".into(), + Value::record(vec![("U16".into(), Value::Int(12))]), + ), + ]), + Value::record(vec![ + ("name".into(), Value::str("burn_increase_mult")), + ( + "value".into(), + Value::record(vec![( + "U64F64".into(), + Value::record(vec![("bits".into(), Value::Uint(1_u128 << 64))]), + )]), + ), + ]), + ]); + + assert_eq!( + subnet_hyperparameters_v3(&raw).expect("valid V3 value"), + Some(vec![ + SubnetHyperparameter { + name: "tempo".into(), + value: SubnetHyperparameterValue::U16(12), + }, + SubnetHyperparameter { + name: "burn_increase_mult".into(), + value: SubnetHyperparameterValue::U64F64Bits(1_u128 << 64), + }, + ]) + ); + assert_eq!( + subnet_hyperparameters_v3(&Value::Null).expect("null V3 value"), + None + ); + } } diff --git a/sdk/bittensor-core/tests/e2e.rs b/sdk/bittensor-core/tests/e2e.rs index 25e913c624..9fd3373e63 100644 --- a/sdk/bittensor-core/tests/e2e.rs +++ b/sdk/bittensor-core/tests/e2e.rs @@ -12,7 +12,10 @@ use std::collections::BTreeSet; use std::thread; use std::time::{Duration, Instant}; -use bittensor_core::client::{as_str, as_u128, field, value_bytes, variant_name}; +use bittensor_core::client::{ + as_str, as_u128, field, value_bytes, variant_name, SubnetHyperparameter, + SubnetHyperparameterValue, +}; use bittensor_core::codec::Value; use bittensor_core::transaction::{Executor, IntentCall, Policy, SignerRole, Spend, Wallet}; use bittensor_core::CoreError; @@ -64,6 +67,17 @@ fn value_contains_u128(value: &Value, expected: u128) -> bool { } } +fn hyperparameter<'a>( + params: &'a Option>, + name: &str, +) -> Option<&'a SubnetHyperparameterValue> { + params + .as_ref()? + .iter() + .find(|entry| entry.name == name) + .map(|entry| &entry.value) +} + macro_rules! intent_plan_test { ($name:ident, $op:literal) => { #[test] @@ -1087,7 +1101,10 @@ fn test_typed_reads() { .client .subnet_hyperparameters(1, None) .expect("hyperparameters read"); - assert!(field(&hp, "tempo").is_some()); + assert!(matches!( + hyperparameter(&hp, "tempo"), + Some(SubnetHyperparameterValue::U16(_)) + )); let rate = ctx .client @@ -1451,7 +1468,8 @@ fn test_owner_sets_hyperparameter() { .client .subnet_hyperparameters(netuid, None) .expect("hyperparameters read"); - let applied = field(&hyperparameters, "immunity_period").and_then(as_u128) == Some(42); + let applied = hyperparameter(&hyperparameters, "immunity_period") + == Some(&SubnetHyperparameterValue::U16(42)); let message = result.message.to_lowercase(); let throttled = !result.success && ["rate limit", "prohibited", "freeze"] diff --git a/sdk/bittensor-ts/native/src/transaction.rs b/sdk/bittensor-ts/native/src/transaction.rs index 62dcaa40ea..9a1a278db8 100644 --- a/sdk/bittensor-ts/native/src/transaction.rs +++ b/sdk/bittensor-ts/native/src/transaction.rs @@ -5,7 +5,8 @@ use std::time::Duration; use bittensor_core::client::{ BlockHeader, DispatchError, ExternalSigner, ExternalSigningOptions, ExternalSigningPlan, - MetadataHashMode, SubnetInfo, SwapQuote, TxOutcome, TxWait, DEFAULT_RECEIPT_TIMEOUT, + MetadataHashMode, SubnetHyperparameter, SubnetHyperparameterValue, SubnetInfo, SwapQuote, + TxOutcome, TxWait, DEFAULT_RECEIPT_TIMEOUT, }; use bittensor_core::codec::Value; use bittensor_core::digest::ChainInfo; @@ -87,6 +88,13 @@ pub struct NativeSubnetInfo { pub neuron_count: u16, } +#[napi(object)] +pub struct NativeSubnetHyperparameter { + pub name: String, + pub value_type: String, + pub value: JsonValue, +} + #[napi(object)] pub struct NativeSwapQuote { pub tao_amount: String, @@ -253,6 +261,19 @@ client_task!( Vec, |items: Vec| Ok(items.into_iter().map(subnet_to_native).collect()) ); +client_task!( + ClientSubnetHyperparametersTask, + Option>, + Option>, + |items: Option>| items + .map(|items| { + items + .into_iter() + .map(subnet_hyperparameter_to_native) + .collect::>>() + }) + .transpose() +); client_task!(ClientSwapQuoteTask, SwapQuote, NativeSwapQuote, |value| { Ok(quote_to_native(value)) }); @@ -1374,8 +1395,8 @@ impl NativeClient { &self, netuid: u16, block_hash: Option, - ) -> AsyncTask { - AsyncTask::new(ClientWireValueTask::new( + ) -> AsyncTask { + AsyncTask::new(ClientSubnetHyperparametersTask::new( Arc::clone(&self.inner), move |client| client.subnet_hyperparameters(netuid, block_hash.as_deref()), )) @@ -1705,6 +1726,43 @@ fn subnet_to_native(subnet: SubnetInfo) -> NativeSubnetInfo { } } +fn subnet_hyperparameter_to_native( + entry: SubnetHyperparameter, +) -> NapiResult { + let (value_type, value) = match entry.value { + SubnetHyperparameterValue::Bool(value) => { + ("Bool", bittensor_core::codec::Value::Bool(value)) + } + SubnetHyperparameterValue::U16(value) => { + ("U16", bittensor_core::codec::Value::Uint(u128::from(value))) + } + SubnetHyperparameterValue::U32(value) => { + ("U32", bittensor_core::codec::Value::Uint(u128::from(value))) + } + SubnetHyperparameterValue::U64(value) => { + ("U64", bittensor_core::codec::Value::Uint(u128::from(value))) + } + SubnetHyperparameterValue::U128(value) => { + ("U128", bittensor_core::codec::Value::Uint(value)) + } + SubnetHyperparameterValue::TaoBalance(value) => { + ("TaoBalance", bittensor_core::codec::Value::Uint(value)) + } + SubnetHyperparameterValue::I32F32Bits(value) => ( + "I32F32", + bittensor_core::codec::Value::Int(i128::from(value)), + ), + SubnetHyperparameterValue::U64F64Bits(value) => { + ("U64F64", bittensor_core::codec::Value::Uint(value)) + } + }; + Ok(NativeSubnetHyperparameter { + name: entry.name, + value_type: value_type.to_owned(), + value: to_wire(&value)?, + }) +} + fn quote_to_native(quote: SwapQuote) -> NativeSwapQuote { NativeSwapQuote { tao_amount: quote.tao_amount.to_string(), diff --git a/sdk/bittensor-ts/scripts/browser-smoke.cjs b/sdk/bittensor-ts/scripts/browser-smoke.cjs index ed0352152f..0afec58269 100644 --- a/sdk/bittensor-ts/scripts/browser-smoke.cjs +++ b/sdk/bittensor-ts/scripts/browser-smoke.cjs @@ -384,6 +384,24 @@ async function runBrowserPage(chromePath, url, mode) { } } +async function removeTmp() { + let lastError + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + fs.rmSync(tmp, { recursive: true, force: true }) + return + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + try { + fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 50, retryDelay: 100 }) + } catch { + throw lastError + } +} + async function main() { let server try { @@ -399,7 +417,7 @@ async function main() { await runBrowserPage(chromePath, new URL('/default.html', served.url).href, 'default') } finally { server?.close() - fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }) + await removeTmp() } } diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index d85f649a7b..6656a6cfc6 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -17,7 +17,15 @@ import { taoTransactionAmountRao, transactionAmountRao, } from './balance' -import type { ByteLike, ChainInfo, ScaleValue, SignedExtrinsic, StorageEntry, TransactionParams } from './types' +import type { + ByteLike, + ChainInfo, + ScaleValue, + SignedExtrinsic, + StorageEntry, + SubnetHyperparameters, + TransactionParams, +} from './types' export const SS58_FORMAT = 42 export const DEFAULT_ERA_PERIOD = 64 @@ -1968,11 +1976,11 @@ export class BrowserChainClient { return this.subnetExists(netuid, block) } - getSubnetHyperparameters(netuid: number, block?: number | string | null): Promise { + getSubnetHyperparameters(netuid: number, block?: number | string | null): Promise { return this.subnets.hyperparameters(netuid, block) } - get_subnet_hyperparameters(netuid: number, block?: number | string | null): Promise { + get_subnet_hyperparameters(netuid: number, block?: number | string | null): Promise { return this.getSubnetHyperparameters(netuid, block) } @@ -2786,20 +2794,25 @@ export class SubnetsNamespace { return this.client.runtime(runtimeApi.SubnetInfoRuntimeApi.get_metagraph, [netuid], block) } - async hyperparameters(netuid: number, block?: number | string | null): Promise { + async hyperparameters(netuid: number, block?: number | string | null): Promise { const native = await nativeRustClient(this.client) if (native != null) { const blockHash = await this.client.resolveReadBlockHash(block) return await native.subnetHyperparameters(netuid, blockHash) as T } - return this.client.runtime(runtimeApi.SubnetInfoRuntimeApi.get_subnet_hyperparams_v3, [netuid], block) + const value = await this.client.runtime( + runtimeApi.SubnetInfoRuntimeApi.get_subnet_hyperparams_v3, + [netuid], + block, + ) + return subnetHyperparametersFromRuntime(value) as T } - subnetHyperparameters(netuid: number, block?: number | string | null): Promise { + subnetHyperparameters(netuid: number, block?: number | string | null): Promise { return this.hyperparameters(netuid, block) } - subnet_hyperparameters(netuid: number, block?: number | string | null): Promise { + subnet_hyperparameters(netuid: number, block?: number | string | null): Promise { return this.hyperparameters(netuid, block) } @@ -3798,6 +3811,61 @@ function nativeExternalSigningOptions(options: SubmitOptions): NativeExternalSig return out } +const SUBNET_HYPERPARAMETER_VALUE_TYPES = new Set([ + 'Bool', + 'U16', + 'U32', + 'U64', + 'U128', + 'TaoBalance', + 'I32F32', + 'U64F64', +]) + +function subnetHyperparametersFromRuntime(value: ScaleValue): SubnetHyperparameters | null { + if (value == null) return null + if (!Array.isArray(value)) throw new ChainError('get_subnet_hyperparams_v3 returned a non-list value') + return value.map((entry) => { + const record = scaleRecord(entry, 'hyperparameter entry') + const name = hyperparameterName(record.name) + const variant = scaleRecord(record.value, `hyperparameter ${name} value`) + const keys = Object.keys(variant) + if (keys.length !== 1) { + throw new ChainError(`hyperparameter ${name} value is not a single V3 enum variant`) + } + const valueType = keys[0] + if (!SUBNET_HYPERPARAMETER_VALUE_TYPES.has(valueType)) { + throw new ChainError(`unknown subnet hyperparameter V3 value type ${valueType}`) + } + const raw = variant[valueType] + const value = valueType === 'I32F32' || valueType === 'U64F64' + ? scaleRecord(raw, `hyperparameter ${name} ${valueType} value`).bits + : raw + return { name, valueType, value } as SubnetHyperparameters[number] + }) +} + +function scaleRecord(value: ScaleValue, name: string): Record { + if ( + value == null || + typeof value !== 'object' || + Buffer.isBuffer(value) || + value instanceof Uint8Array || + Array.isArray(value) || + value instanceof Map + ) { + throw new ChainError(`${name} must be an object`) + } + return value as Record +} + +function hyperparameterName(value: ScaleValue): string { + if (typeof value === 'string') return value + if (Buffer.isBuffer(value) || value instanceof Uint8Array) return toBuffer(value, 'hyperparameter name').toString('utf8') + if (Array.isArray(value)) return Buffer.from(value.map((byte) => Number(byte))).toString('utf8') + throw new ChainError('hyperparameter entry name must be bytes or string') +} + function nativeSubmitOptions( options: Pick, ): NativeSubmitOptions { diff --git a/sdk/bittensor-ts/src/native.ts b/sdk/bittensor-ts/src/native.ts index 0cf8ca1a26..e4e3d7dfa3 100644 --- a/sdk/bittensor-ts/src/native.ts +++ b/sdk/bittensor-ts/src/native.ts @@ -44,6 +44,12 @@ export interface NativeSubnetInfo { neuronCount: number } +export interface NativeSubnetHyperparameter { + name: string + valueType: string + value: unknown +} + export interface NativeSwapQuote { taoAmount: string alphaAmount: string @@ -532,7 +538,7 @@ export interface NativeClientHandle { subnets(blockHash?: string | null): Promise metagraph(netuid: number, blockHash?: string | null): Promise neurons(netuid: number, blockHash?: string | null): Promise - subnetHyperparameters(netuid: number, blockHash?: string | null): Promise + subnetHyperparameters(netuid: number, blockHash?: string | null): Promise stakeRao(coldkey: string, hotkey: string, netuid: number, blockHash?: string | null): Promise quoteStake(netuid: number, amountRao: bigint, blockHash?: string | null): Promise composeIntent(intent: NativeIntentCallHandle): Promise diff --git a/sdk/bittensor-ts/src/transaction.ts b/sdk/bittensor-ts/src/transaction.ts index e02eb22508..ee9fb2bbfc 100644 --- a/sdk/bittensor-ts/src/transaction.ts +++ b/sdk/bittensor-ts/src/transaction.ts @@ -14,6 +14,7 @@ import native, { type NativePolicyOptions, type NativeSignedExtrinsic, type NativeSubmitOptions, + type NativeSubnetHyperparameter, type NativeSubnetInfo, type NativeSwapQuote, type NativeTxOutcome, @@ -21,7 +22,7 @@ import native, { } from './native' import { nativeAsync, nativeCall } from './errors' import { fromWire, toWire } from './wire' -import type { ScaleValue } from './types' +import type { ScaleValue, SubnetHyperparameterValueType, SubnetHyperparameters } from './types' import { Keypair, nativeKeypairHandle } from './keys' import { Runtime } from './runtime' @@ -606,8 +607,14 @@ export class NativeChainClient { return (await nativeAsync(() => this.native.neurons(netuid, blockHash ?? undefined))).map(fromWire) } - async subnetHyperparameters(netuid: number, blockHash?: string | null): Promise { - return fromWire(await nativeAsync(() => this.native.subnetHyperparameters(netuid, blockHash ?? undefined))) + async subnetHyperparameters( + netuid: number, + blockHash?: string | null, + ): Promise { + const entries = await nativeAsync(() => + this.native.subnetHyperparameters(netuid, blockHash ?? undefined), + ) + return entries == null ? null : entries.map(nativeSubnetHyperparameter) } async stakeRao(coldkey: string, hotkey: string, netuid: number, blockHash?: string | null): Promise { @@ -707,6 +714,28 @@ function normalizePolicyOptions(options: PolicyOptions): PolicyOptions { } } +const SUBNET_HYPERPARAMETER_VALUE_TYPES = new Set([ + 'Bool', + 'U16', + 'U32', + 'U64', + 'U128', + 'TaoBalance', + 'I32F32', + 'U64F64', +]) + +function nativeSubnetHyperparameter(entry: NativeSubnetHyperparameter) { + if (!SUBNET_HYPERPARAMETER_VALUE_TYPES.has(entry.valueType)) { + throw new TypeError(`unknown subnet hyperparameter V3 value type ${entry.valueType}`) + } + return { + name: entry.name, + valueType: entry.valueType as SubnetHyperparameterValueType, + value: fromWire(entry.value as ScaleValue), + } +} + function bigintValue(value: bigint | number | string, name: string): bigint { if (typeof value === 'bigint') { if (value < 0n) throw new RangeError(`${name} must be non-negative`) diff --git a/sdk/bittensor-ts/src/types.ts b/sdk/bittensor-ts/src/types.ts index d6f40a5d58..e28db06db2 100644 --- a/sdk/bittensor-ts/src/types.ts +++ b/sdk/bittensor-ts/src/types.ts @@ -120,6 +120,24 @@ export interface MapPair { value: V } +export type SubnetHyperparameterValueType = + | 'Bool' + | 'U16' + | 'U32' + | 'U64' + | 'U128' + | 'TaoBalance' + | 'I32F32' + | 'U64F64' + +export interface SubnetHyperparameter { + name: string + valueType: SubnetHyperparameterValueType + value: T +} + +export type SubnetHyperparameters = SubnetHyperparameter[] + export interface ModuleError { name: string docs: string[] diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index 562dccda73..f08c7c37e2 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -1041,10 +1041,6 @@ test('chain client surface is exported without Polkadot.js glue', () => { 'SubnetInfoRuntimeApi', 'get_subnet_hyperparams_v3', ]) - assert.equal( - Object.prototype.hasOwnProperty.call(core.runtimeApi.SubnetInfoRuntimeApi, 'get_subnet_hyperparams'), - false, - ) assert.deepEqual(core.calls.subtensor.rootRegister('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'), [ 'SubtensorModule', 'root_register', @@ -1139,11 +1135,20 @@ test('Client subnet hyperparameters read uses the v3 runtime API', async () => { const calls = [] client.runtime = async (method, params, block) => { calls.push({ method, params, block }) - return { ok: true } + return [ + { name: 'tempo', value: { U16: 12 } }, + { name: 'burn_increase_mult', value: { U64F64: { bits: 18446744073709551616n } } }, + ] } - assert.deepEqual(await client.subnets.hyperparameters(7, '0xabc'), { ok: true }) - assert.deepEqual(await client.getSubnetHyperparameters(8), { ok: true }) + assert.deepEqual(await client.subnets.hyperparameters(7, '0xabc'), [ + { name: 'tempo', valueType: 'U16', value: 12 }, + { name: 'burn_increase_mult', valueType: 'U64F64', value: 18446744073709551616n }, + ]) + assert.deepEqual(await client.getSubnetHyperparameters(8), [ + { name: 'tempo', valueType: 'U16', value: 12 }, + { name: 'burn_increase_mult', valueType: 'U64F64', value: 18446744073709551616n }, + ]) assert.deepEqual(calls, [ { method: ['SubnetInfoRuntimeApi', 'get_subnet_hyperparams_v3'], diff --git a/sdk/python/bittensor/_generated/runtime_apis.py b/sdk/python/bittensor/_generated/runtime_apis.py index 3c7a55460b..de7a111a65 100644 --- a/sdk/python/bittensor/_generated/runtime_apis.py +++ b/sdk/python/bittensor/_generated/runtime_apis.py @@ -127,8 +127,6 @@ class SubnetInfoRuntimeApi: get_subnets_info = Method('SubnetInfoRuntimeApi', 'get_subnets_info') get_subnet_info_v2 = Method('SubnetInfoRuntimeApi', 'get_subnet_info_v2') get_subnets_info_v2 = Method('SubnetInfoRuntimeApi', 'get_subnets_info_v2') - get_subnet_hyperparams = Method('SubnetInfoRuntimeApi', 'get_subnet_hyperparams') - get_subnet_hyperparams_v2 = Method('SubnetInfoRuntimeApi', 'get_subnet_hyperparams_v2') get_all_dynamic_info = Method('SubnetInfoRuntimeApi', 'get_all_dynamic_info') get_all_metagraphs = Method('SubnetInfoRuntimeApi', 'get_all_metagraphs') get_metagraph = Method('SubnetInfoRuntimeApi', 'get_metagraph') @@ -168,4 +166,3 @@ class TransactionPaymentCallApi: query_call_fee_details = Method('TransactionPaymentCallApi', 'query_call_fee_details') query_weight_to_fee = Method('TransactionPaymentCallApi', 'query_weight_to_fee') query_length_to_fee = Method('TransactionPaymentCallApi', 'query_length_to_fee') - diff --git a/sdk/python/bittensor/_transport/runtime_api.py b/sdk/python/bittensor/_transport/runtime_api.py index 1357989996..0aead9eb91 100644 --- a/sdk/python/bittensor/_transport/runtime_api.py +++ b/sdk/python/bittensor/_transport/runtime_api.py @@ -135,9 +135,9 @@ def decode(raw: bytes, codec: RuntimeCodec) -> Any: }, }, "SubnetInfoRuntimeApi": { - "get_subnet_hyperparams": { + "get_subnet_hyperparams_v3": { "params": [{"name": "netuid", "type": "u16"}], - "decoder": _registry_decoder("Option"), + "decoder": _registry_decoder("Option>"), }, "get_subnet_info": { "params": [{"name": "netuid", "type": "u16"}], diff --git a/sdk/python/bittensor/cli/hyperparams_view.py b/sdk/python/bittensor/cli/hyperparams_view.py index 7ca3f07575..b77686061b 100644 --- a/sdk/python/bittensor/cli/hyperparams_view.py +++ b/sdk/python/bittensor/cli/hyperparams_view.py @@ -21,7 +21,7 @@ def show_hyperparameters( app_ctx: AppContext, netuid: int, - params: dict[str, Any], + params: Any, name: Optional[str], hint: Optional[str] = None, ) -> None: @@ -30,6 +30,7 @@ def show_hyperparameters( ``hint`` overrides the listing's default help line (the `sudo set` prompt flow reuses the listing with its own guidance). """ + params = _params_by_name(params) if name is None: _listing(app_ctx, netuid, params, hint) return @@ -94,6 +95,35 @@ def _single(app_ctx: AppContext, netuid: int, name: str, raw: Any) -> None: ) +def _params_by_name(params: Any) -> dict[str, Any]: + if params is None: + return {} + if isinstance(params, dict): + return params + if not isinstance(params, list): + raise TypeError("subnet hyperparameters must be a V3 list or dict") + out: dict[str, Any] = {} + for entry in params: + if not isinstance(entry, dict): + continue + name = entry.get("name") + if isinstance(name, (bytes, bytearray)): + name = bytes(name).decode("utf8") + if not isinstance(name, str): + continue + out[name] = _v3_value(entry.get("value")) + return out + + +def _v3_value(value: Any) -> Any: + if not isinstance(value, dict) or len(value) != 1: + return value + payload = next(iter(value.values())) + if isinstance(payload, dict) and set(payload) == {"bits"}: + return payload["bits"] + return payload + + def _example_value(kind: str, fraction: Optional[float], raw: Any) -> str: """The current value in its most natural input form, for the set hint.""" if fraction is not None: diff --git a/sdk/python/bittensor/namespaces.pyi b/sdk/python/bittensor/namespaces.pyi index 8155e026ca..37d68ebf02 100644 --- a/sdk/python/bittensor/namespaces.pyi +++ b/sdk/python/bittensor/namespaces.pyi @@ -430,8 +430,8 @@ class Subnets(_ReadNamespace): async def subnet(self, netuid: int, *, block: Optional[int] = None) -> SubnetInfo: """Tempo, burn, and neuron count for one subnet (the three reads run concurrently).""" - async def subnet_hyperparameters(self, netuid: int, *, block: Optional[int] = None) -> dict: - """All hyperparameters for a subnet (named fields; version-dependent set).""" + async def subnet_hyperparameters(self, netuid: int, *, block: Optional[int] = None) -> Optional[list[dict]]: + """All hyperparameters for a subnet as V3 named entries.""" async def subnet_identity(self, netuid: int, *, block: Optional[int] = None) -> Optional[dict]: """The identity metadata of a subnet, or None.""" diff --git a/sdk/python/bittensor/reads/subnets.py b/sdk/python/bittensor/reads/subnets.py index 87f7fff1d9..6196459a7d 100644 --- a/sdk/python/bittensor/reads/subnets.py +++ b/sdk/python/bittensor/reads/subnets.py @@ -100,9 +100,9 @@ async def commit_reveal_enabled(view, netuid: int) -> bool: category="Subnets", param_docs={"netuid": "Subnet to query."}, ) -async def subnet_hyperparameters(view, netuid: int) -> dict: - """All hyperparameters for a subnet (named fields; version-dependent set).""" - return await view.runtime(api.SubnetInfoRuntimeApi.get_subnet_hyperparams, [netuid]) +async def subnet_hyperparameters(view, netuid: int) -> list[dict] | None: + """All hyperparameters for a subnet as V3 named entries.""" + return await view.runtime(api.SubnetInfoRuntimeApi.get_subnet_hyperparams_v3, [netuid]) @read( diff --git a/sdk/python/scripts/record_golden.py b/sdk/python/scripts/record_golden.py index f8de6bddb0..68320926ba 100644 --- a/sdk/python/scripts/record_golden.py +++ b/sdk/python/scripts/record_golden.py @@ -135,7 +135,7 @@ def jsonable(value: Any) -> Any: RUNTIME_CALL_CASES = [ ("NeuronInfoRuntimeApi", "get_neurons_lite", [1]), - ("SubnetInfoRuntimeApi", "get_subnet_hyperparams", [1]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v3", [1]), ( "StakeInfoRuntimeApi", "get_stake_info_for_hotkey_coldkey_netuid", diff --git a/sdk/python/tests/unit/test_reads_table.py b/sdk/python/tests/unit/test_reads_table.py index 44c4bf2b73..d2f332a664 100644 --- a/sdk/python/tests/unit/test_reads_table.py +++ b/sdk/python/tests/unit/test_reads_table.py @@ -38,7 +38,7 @@ def seeded_substrate() -> FakeSubstrate: ("NeuronInfoRuntimeApi", "get_neurons", []), ("SubnetInfoRuntimeApi", "get_all_dynamic_info", []), ("SubnetInfoRuntimeApi", "get_dynamic_info", None), - ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v2", None), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v3", None), ("SubnetInfoRuntimeApi", "get_subnet_state", None), ("StakeInfoRuntimeApi", "get_stake_info_for_coldkey", []), ("StakeInfoRuntimeApi", "get_stake_info_for_coldkeys", []), From ca6453eb25c110a049de53e4e7cbc420e00da714 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Mon, 13 Jul 2026 12:00:27 -0700 Subject: [PATCH 70/72] fix CI --- sdk/bittensor-core/src/client.rs | 13 +------- sdk/bittensor-ts/src/client.ts | 12 ++----- ts-tests/suites/dev/sdk/test-bittensor-ts.ts | 34 ++++++++++++++++---- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/sdk/bittensor-core/src/client.rs b/sdk/bittensor-core/src/client.rs index 86dd107ee9..2d12ae419a 100644 --- a/sdk/bittensor-core/src/client.rs +++ b/sdk/bittensor-core/src/client.rs @@ -807,21 +807,10 @@ impl Client { } _ => (Value::str("00"), self.genesis_hash), }; - let supports_metadata_hash = runtime - .extrinsic - .signed_extensions - .iter() - .any(|extension| extension.identifier == "CheckMetadataHash"); - let default_metadata_hash = supports_metadata_hash || signer.requires_metadata_proof; + let default_metadata_hash = signer.requires_metadata_proof; let mut chain_info = None; let metadata_hash = match options.metadata_hash { MetadataHashMode::Explicit(hash) => Some(hash), - MetadataHashMode::Disabled if supports_metadata_hash => { - return Err(CoreError::Policy( - "metadataHash cannot be disabled when the runtime declares CheckMetadataHash" - .into(), - )); - } MetadataHashMode::Disabled if signer.requires_metadata_proof => { return Err(CoreError::Policy( "metadataHash cannot be disabled for a signer that requires metadata proof" diff --git a/sdk/bittensor-ts/src/client.ts b/sdk/bittensor-ts/src/client.ts index 6656a6cfc6..3b2bfdbdc4 100644 --- a/sdk/bittensor-ts/src/client.ts +++ b/sdk/bittensor-ts/src/client.ts @@ -1620,13 +1620,10 @@ export class BrowserChainClient { const tip = taoTransactionAmountRao(options.tip ?? 0n, 'tip') const tipAssetId = options.tipAssetId == null ? null : assetIdValue(options.tipAssetId, 'tipAssetId') const metadataHashOptionProvided = hasOwn(options, 'metadataHash') - const supportsMetadataHash = runtimeSupportsMetadataHash(runtime) - const defaultMetadataHash = supportsMetadataHash || resolved.requiresMetadataProof + const defaultMetadataHash = resolved.requiresMetadataProof if (metadataHashOptionProvided && options.metadataHash == null && defaultMetadataHash) { throw new ChainError( - supportsMetadataHash - ? 'metadataHash cannot be disabled when the runtime declares CheckMetadataHash' - : 'metadataHash cannot be disabled for a signer that requires metadata proof', + 'metadataHash cannot be disabled for a signer that requires metadata proof', ) } const chainInfo = resolved.requiresMetadataProof || (!metadataHashOptionProvided && defaultMetadataHash) @@ -3636,11 +3633,6 @@ function parseNonce(value: unknown, name: string): number { return nonce } -function runtimeSupportsMetadataHash(runtime: Runtime): boolean { - const identifiers = runtime.signedExtensionIdentifiers?.() ?? [] - return identifiers.includes('CheckMetadataHash') -} - function isMetadataAtVersionUnavailable(error: unknown): boolean { const message = String(error instanceof Error ? error.message : error) const data = String(error instanceof JsonRpcError && error.data != null ? error.data : '') diff --git a/ts-tests/suites/dev/sdk/test-bittensor-ts.ts b/ts-tests/suites/dev/sdk/test-bittensor-ts.ts index b7cff15f8e..95b2d099d3 100644 --- a/ts-tests/suites/dev/sdk/test-bittensor-ts.ts +++ b/ts-tests/suites/dev/sdk/test-bittensor-ts.ts @@ -1,5 +1,8 @@ import { BINDING_VERSION, Client, Keypair, blake2_256, storage } from "@bittensor/sdk"; -import { describeSuite, expect } from "@moonwall/cli"; +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import { tao } from "../../../utils"; +import { devForceSetBalance } from "../../../utils/dev-helpers.js"; function getSdkEndpoint(): string | undefined { if (process.env.BT_CHAIN_ENDPOINT) { @@ -18,6 +21,12 @@ describeSuite({ title: "Rust-backed bittensor-ts integration", foundationMethods: "dev", testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + + beforeAll(() => { + polkadotJs = context.polkadotJs(); + }); + it({ id: "T01", title: "connects, constructs, submits, and reads with the SDK chain client", @@ -30,18 +39,31 @@ describeSuite({ const client = await new Client(endpoint).connect(); const signer = Keypair.fromUri("//Ferdie"); + await devForceSetBalance(polkadotJs, context, signer.ss58Address, tao(1_000)); const remark = blake2_256(Buffer.from(`bittensor-ts:${BINDING_VERSION}`)); const call = await client.composeCall("System", "remark", { remark }); try { await client.assertDescriptorSchema(); - const nonce = await client.accountNextIndex(signer.ss58Address); - const signed = await client.signExtrinsic(call, signer, { allowRawCall: true, nonce }); - const watcher = await client.watchSigned(signed); + const includedPromise = client.submit(call, signer, { + allowRawCall: true, + waitForInclusion: true, + timeoutMs: 30_000, + }); - await context.createBlock(); + let included: Awaited | undefined; + for (let attempt = 0; attempt < 10 && included === undefined; attempt++) { + await context.createBlock(); + const raced = await Promise.race([ + includedPromise.then((result) => ({ result })), + new Promise((resolve) => setTimeout(() => resolve(null), 500)), + ]); + if (raced !== null) { + included = raced.result; + } + } - const included = await watcher.result; + included ??= await includedPromise; expect(included.success, included.message).to.be.true; expect(included.blockHash).to.not.be.undefined; expect(included.extrinsicIndex).to.be.a("number"); From d3afb99282a1f282d820e5760a8bb328160e9828 Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Mon, 13 Jul 2026 12:44:42 -0700 Subject: [PATCH 71/72] Fix TS SDK test and clippy allow --- pallets/subtensor/src/tests/migration.rs | 1 - sdk/bittensor-ts/test/basic.test.cjs | 24 +++++++++--------------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/pallets/subtensor/src/tests/migration.rs b/pallets/subtensor/src/tests/migration.rs index 9d55f52bad..85eb1290c8 100644 --- a/pallets/subtensor/src/tests/migration.rs +++ b/pallets/subtensor/src/tests/migration.rs @@ -1255,7 +1255,6 @@ fn test_per_u16_encodes_identically_to_u16() { #[allow(deprecated)] #[test] -#[allow(deprecated)] fn test_migrate_last_tx_block_delegate_take() { new_test_ext(1).execute_with(|| { // ------------------------------ diff --git a/sdk/bittensor-ts/test/basic.test.cjs b/sdk/bittensor-ts/test/basic.test.cjs index f08c7c37e2..3a5032ded1 100644 --- a/sdk/bittensor-ts/test/basic.test.cjs +++ b/sdk/bittensor-ts/test/basic.test.cjs @@ -2447,7 +2447,7 @@ test('Client callData composes trusted Rust intent calls', async () => { assert.equal(captures.composeCall.block, 99) }) -test('Client enables metadata hash by default for software signers when supported', async () => { +test('Client does not enable metadata hash by default for software signers', async () => { const callData = Buffer.from([5, 6, 7]) const metadataBytes = goldenMetadataBytes() const { runtime, captures } = fakeSigningRuntime({ @@ -2471,20 +2471,14 @@ test('Client enables metadata hash by default for software signers when supporte await client.signExtrinsic(callData, signer, { period: null, nonce: 12, allowRawCall: true }) - const expectedMetadataHash = core.metadataDigest(metadataBytes, { - specVersion: runtime.specVersion, - specName: 'node-subtensor', - base58Prefix: 42, - decimals: 9, - tokenSymbol: 'TAO', - }) - assert.equal(captures.encoded.params.metadataHashEnabled, true) - assert.deepEqual(captures.payloadParams.metadataHash, expectedMetadataHash) - assert.equal(request.metadataHash, `0x${expectedMetadataHash.toString('hex')}`) - await assert.rejects( - () => client.signExtrinsic(callData, signer, { period: null, nonce: 12, allowRawCall: true, metadataHash: null }), - /metadataHash cannot be disabled/, - ) + assert.equal(captures.encoded.params.metadataHashEnabled, false) + assert.equal(captures.payloadParams.metadataHash, null) + assert.equal(request.metadataHash, undefined) + + await client.signExtrinsic(callData, signer, { period: null, nonce: 12, allowRawCall: true, metadataHash: null }) + assert.equal(captures.encoded.params.metadataHashEnabled, false) + assert.equal(captures.payloadParams.metadataHash, null) + assert.equal(request.metadataHash, undefined) const explicitMetadataHash = Buffer.alloc(32, 3) await client.signExtrinsic(callData, signer, { period: null, nonce: 12, allowRawCall: true, metadataHash: explicitMetadataHash }) From 547179fa0c587aea2e76fc06724a7efd7f7a5daf Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Mon, 13 Jul 2026 13:00:42 -0700 Subject: [PATCH 72/72] clippy --- sdk/bittensor-core/src/client.rs | 74 ++++++++++++++++------ sdk/bittensor-core/src/transaction.rs | 11 +++- sdk/bittensor-ts/native/src/transaction.rs | 19 +++++- 3 files changed, 81 insertions(+), 23 deletions(-) diff --git a/sdk/bittensor-core/src/client.rs b/sdk/bittensor-core/src/client.rs index 2d12ae419a..5b154ba117 100644 --- a/sdk/bittensor-core/src/client.rs +++ b/sdk/bittensor-core/src/client.rs @@ -42,6 +42,27 @@ pub enum TxWait { Finalized, } +#[derive(Debug, Clone, Copy)] +pub struct TxSubmitOptions<'a> { + pub nonce: Option, + pub period: Option, + pub wait: TxWait, + pub timeout: Duration, + pub cancelled: Option<&'a AtomicBool>, +} + +impl Default for TxSubmitOptions<'_> { + fn default() -> Self { + Self { + nonce: None, + period: Some(DEFAULT_ERA_PERIOD), + wait: TxWait::Included, + timeout: DEFAULT_RECEIPT_TIMEOUT, + cancelled: None, + } + } +} + /// A decoded block header. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BlockHeader { @@ -1109,32 +1130,43 @@ impl Client { wait: TxWait, timeout: Duration, ) -> Result { - self.submit_with_cancel(call_data, signer, nonce, period, wait, timeout, None) + self.submit_with_cancel( + call_data, + signer, + TxSubmitOptions { + nonce, + period, + wait, + timeout, + cancelled: None, + }, + ) } pub fn submit_with_cancel( &self, call_data: &[u8], signer: &Keypair, - nonce: Option, - period: Option, - wait: TxWait, - timeout: Duration, - cancelled: Option<&AtomicBool>, + options: TxSubmitOptions<'_>, ) -> Result { let address = signer.ss58_address(); - let explicit_nonce = nonce.is_some(); + let explicit_nonce = options.nonce.is_some(); let mut last = None; for _ in 0..NONCE_RETRY_LIMIT { - check_cancelled(cancelled)?; - let nonce = match nonce { + check_cancelled(options.cancelled)?; + let nonce = match options.nonce { Some(nonce) => nonce, None => self.reserve_nonce(&address)?, }; - let (extrinsic, hash) = self.sign_extrinsic(call_data, signer, nonce, period)?; - let outcome = match self - .submit_encoded_with_wait_cancelled(&extrinsic, hash, wait, timeout, cancelled) - { + let (extrinsic, hash) = + self.sign_extrinsic(call_data, signer, nonce, options.period)?; + let outcome = match self.submit_encoded_with_wait_cancelled( + &extrinsic, + hash, + options.wait, + options.timeout, + options.cancelled, + ) { Ok(outcome) => outcome, Err(error) if !explicit_nonce && is_cancelled_before_submission_error(&error) => { self.invalidate_nonce(&address)?; @@ -2566,8 +2598,12 @@ mod reorg_finalization_tests { ]), ]); + let typed = match subnet_hyperparameters_v3(&raw) { + Ok(value) => value, + Err(error) => panic!("valid V3 value failed to decode: {error}"), + }; assert_eq!( - subnet_hyperparameters_v3(&raw).expect("valid V3 value"), + typed, Some(vec![ SubnetHyperparameter { name: "tempo".into(), @@ -2579,9 +2615,11 @@ mod reorg_finalization_tests { }, ]) ); - assert_eq!( - subnet_hyperparameters_v3(&Value::Null).expect("null V3 value"), - None - ); + + let empty = match subnet_hyperparameters_v3(&Value::Null) { + Ok(value) => value, + Err(error) => panic!("null V3 value failed to decode: {error}"), + }; + assert_eq!(empty, None); } } diff --git a/sdk/bittensor-core/src/transaction.rs b/sdk/bittensor-core/src/transaction.rs index ed55069156..44543909c0 100644 --- a/sdk/bittensor-core/src/transaction.rs +++ b/sdk/bittensor-core/src/transaction.rs @@ -1360,7 +1360,10 @@ mod tests { #[test] fn global_root_claim_modes_do_not_bypass_subnet_allowlists() { - let intent = IntentCall::set_root_claim_type("Swap", None).expect("valid root claim"); + let intent = match IntentCall::set_root_claim_type("Swap", None) { + Ok(intent) => intent, + Err(error) => panic!("valid root claim failed: {error}"), + }; let subnet_only = Policy { allowed_netuids: Some(BTreeSet::from([1])), ..Policy::default() @@ -1383,8 +1386,10 @@ mod tests { #[test] fn keep_subnets_root_claim_remains_subnet_scoped() { - let intent = IntentCall::set_root_claim_type("KeepSubnets", Some(vec![1])) - .expect("valid root claim"); + let intent = match IntentCall::set_root_claim_type("KeepSubnets", Some(vec![1])) { + Ok(intent) => intent, + Err(error) => panic!("valid root claim failed: {error}"), + }; let allowed = Policy { allowed_netuids: Some(BTreeSet::from([1])), ..Policy::default() diff --git a/sdk/bittensor-ts/native/src/transaction.rs b/sdk/bittensor-ts/native/src/transaction.rs index 9a1a278db8..52d8366d29 100644 --- a/sdk/bittensor-ts/native/src/transaction.rs +++ b/sdk/bittensor-ts/native/src/transaction.rs @@ -6,7 +6,7 @@ use std::time::Duration; use bittensor_core::client::{ BlockHeader, DispatchError, ExternalSigner, ExternalSigningOptions, ExternalSigningPlan, MetadataHashMode, SubnetHyperparameter, SubnetHyperparameterValue, SubnetInfo, SwapQuote, - TxOutcome, TxWait, DEFAULT_RECEIPT_TIMEOUT, + TxOutcome, TxSubmitOptions, TxWait, DEFAULT_RECEIPT_TIMEOUT, }; use bittensor_core::codec::Value; use bittensor_core::digest::ChainInfo; @@ -169,6 +169,12 @@ impl NativeCancellationToken { } } +impl Default for NativeCancellationToken { + fn default() -> Self { + Self::new() + } +} + type ClientJob = Box Result + Send + 'static>; fn task_already_completed() -> napi::Error { @@ -1177,6 +1183,7 @@ impl NativeClient { )) } + #[allow(clippy::too_many_arguments)] #[napi(js_name = "submit")] pub fn submit( &self, @@ -1205,7 +1212,15 @@ impl NativeClient { cancellation, move |client, cancelled| { client.submit_with_cancel( - &call_data, &signer, nonce, period, wait, timeout, cancelled, + &call_data, + &signer, + TxSubmitOptions { + nonce, + period, + wait, + timeout, + cancelled, + }, ) }, )