diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f04d592a64..959a86bdeb 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -141,4 +141,8 @@ jobs: - name: Run tests working-directory: e2e + env: + # Use fast-runtime binary for staking package, release binary for others + # Path is relative to package directory (e2e//) + BINARY_PATH: ${{ matrix.package == 'e2e-staking' && '../../target/fast/node-subtensor' || '../../target/release/node-subtensor' }} run: pnpm --filter ${{ matrix.package }} test diff --git a/.gitignore b/.gitignore index 3f20eb58e2..91993ddf2d 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ scripts/specs/local.json # Node modules node_modules + +# Claude Code configuration +.claude \ No newline at end of file diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index b97c90b17a..8f84477ae0 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -46,10 +46,10 @@ importers: dependencies: '@polkadot-api/descriptors': specifier: file:.papi/descriptors - version: file:.papi/descriptors(polkadot-api@1.23.3(postcss@8.5.6)(rxjs@7.8.2)) + version: file:.papi/descriptors(polkadot-api@1.23.3(postcss@8.5.6)(rxjs@7.8.2)(tsx@4.21.0)) polkadot-api: specifier: 'catalog:' - version: 1.23.3(postcss@8.5.6)(rxjs@7.8.2) + version: 1.23.3(postcss@8.5.6)(rxjs@7.8.2)(tsx@4.21.0) devDependencies: prettier: specifier: 'catalog:' @@ -59,7 +59,7 @@ importers: dependencies: '@polkadot-api/descriptors': specifier: file:../.papi/descriptors - version: file:.papi/descriptors(polkadot-api@1.23.3(postcss@8.5.6)(rxjs@7.8.2)) + version: file:.papi/descriptors(polkadot-api@1.23.3(postcss@8.5.6)(rxjs@7.8.2)(tsx@4.21.0)) '@polkadot-labs/hdkd': specifier: 'catalog:' version: 0.0.25 @@ -71,14 +71,14 @@ importers: version: 14.0.1(@polkadot/util-crypto@14.0.1(@polkadot/util@14.0.1))(@polkadot/util@14.0.1) polkadot-api: specifier: 'catalog:' - version: 1.23.3(postcss@8.5.6)(rxjs@7.8.2) + version: 1.23.3(postcss@8.5.6)(rxjs@7.8.2)(tsx@4.21.0) devDependencies: '@types/node': specifier: 'catalog:' version: 24.10.13 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13) + version: 4.0.18(@types/node@24.10.13)(tsx@4.21.0) shield: dependencies: @@ -87,7 +87,7 @@ importers: version: 2.1.1 '@polkadot-api/descriptors': specifier: file:../.papi/descriptors - version: file:.papi/descriptors(polkadot-api@1.23.3(postcss@8.5.6)(rxjs@7.8.2)) + version: file:.papi/descriptors(polkadot-api@1.23.3(postcss@8.5.6)(rxjs@7.8.2)(tsx@4.21.0)) '@polkadot/util': specifier: 'catalog:' version: 14.0.1 @@ -102,14 +102,27 @@ importers: version: 2.5.0 polkadot-api: specifier: 'catalog:' - version: 1.23.3(postcss@8.5.6)(rxjs@7.8.2) + version: 1.23.3(postcss@8.5.6)(rxjs@7.8.2)(tsx@4.21.0) devDependencies: '@types/node': specifier: 'catalog:' version: 24.10.13 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13) + version: 4.0.18(@types/node@24.10.13)(tsx@4.21.0) + + staking: + dependencies: + e2e-shared: + specifier: workspace:* + version: link:../shared + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.10.13 + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@24.10.13)(tsx@4.21.0) packages: @@ -1017,6 +1030,9 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + hosted-git-info@7.0.2: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} @@ -1230,6 +1246,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -1387,6 +1406,11 @@ packages: typescript: optional: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -1740,7 +1764,7 @@ snapshots: '@noble/hashes@2.0.1': {} - '@polkadot-api/cli@0.18.1(postcss@8.5.6)': + '@polkadot-api/cli@0.18.1(postcss@8.5.6)(tsx@4.21.0)': dependencies: '@commander-js/extra-typings': 14.0.0(commander@14.0.3) '@polkadot-api/codegen': 0.21.2 @@ -1766,7 +1790,7 @@ snapshots: read-pkg: 10.1.0 rxjs: 7.8.2 tsc-prog: 2.3.0(typescript@5.9.3) - tsup: 8.5.0(postcss@8.5.6)(typescript@5.9.3) + tsup: 8.5.0(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) typescript: 5.9.3 write-package: 7.2.0 transitivePeerDependencies: @@ -1788,9 +1812,9 @@ snapshots: '@polkadot-api/substrate-bindings': 0.17.0 '@polkadot-api/utils': 0.2.0 - '@polkadot-api/descriptors@file:.papi/descriptors(polkadot-api@1.23.3(postcss@8.5.6)(rxjs@7.8.2))': + '@polkadot-api/descriptors@file:.papi/descriptors(polkadot-api@1.23.3(postcss@8.5.6)(rxjs@7.8.2)(tsx@4.21.0))': dependencies: - polkadot-api: 1.23.3(postcss@8.5.6)(rxjs@7.8.2) + polkadot-api: 1.23.3(postcss@8.5.6)(rxjs@7.8.2)(tsx@4.21.0) '@polkadot-api/ink-contracts@0.4.6': dependencies: @@ -2183,13 +2207,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.10.13) + vite: 7.3.1(@types/node@24.10.13)(tsx@4.21.0) '@vitest/pretty-format@4.0.18': dependencies: @@ -2373,6 +2397,11 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + optional: true + hosted-git-info@7.0.2: dependencies: lru-cache: 10.4.3 @@ -2505,9 +2534,9 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - polkadot-api@1.23.3(postcss@8.5.6)(rxjs@7.8.2): + polkadot-api@1.23.3(postcss@8.5.6)(rxjs@7.8.2)(tsx@4.21.0): dependencies: - '@polkadot-api/cli': 0.18.1(postcss@8.5.6) + '@polkadot-api/cli': 0.18.1(postcss@8.5.6)(tsx@4.21.0) '@polkadot-api/ink-contracts': 0.4.6 '@polkadot-api/json-rpc-provider': 0.0.4 '@polkadot-api/known-chains': 0.9.18 @@ -2538,11 +2567,12 @@ snapshots: - utf-8-validate - yaml - postcss-load-config@6.0.1(postcss@8.5.6): + postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.6 + tsx: 4.21.0 postcss@8.5.6: dependencies: @@ -2578,6 +2608,9 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: + optional: true + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -2727,7 +2760,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.0(postcss@8.5.6)(typescript@5.9.3): + tsup@8.5.0(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.25.12) cac: 6.7.14 @@ -2738,7 +2771,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.6) + postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.21.0) resolve-from: 5.0.0 rollup: 4.57.1 source-map: 0.8.0-beta.0 @@ -2755,6 +2788,14 @@ snapshots: - tsx - yaml + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + optional: true + type-fest@4.41.0: {} type-fest@5.4.4: @@ -2780,7 +2821,7 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite@7.3.1(@types/node@24.10.13): + vite@7.3.1(@types/node@24.10.13)(tsx@4.21.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -2791,11 +2832,12 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 fsevents: 2.3.3 + tsx: 4.21.0 - vitest@4.0.18(@types/node@24.10.13): + vitest@4.0.18(@types/node@24.10.13)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.13)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.13)(tsx@4.21.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -2812,7 +2854,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@24.10.13) + vite: 7.3.1(@types/node@24.10.13)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.13 diff --git a/e2e/pnpm-workspace.yaml b/e2e/pnpm-workspace.yaml index d1504a1228..5c59deeac3 100644 --- a/e2e/pnpm-workspace.yaml +++ b/e2e/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - shared - shield + - staking catalog: "@noble/ciphers": "^2.1.1" diff --git a/e2e/shared/address.ts b/e2e/shared/address.ts new file mode 100644 index 0000000000..5585c4cb68 --- /dev/null +++ b/e2e/shared/address.ts @@ -0,0 +1,44 @@ +import { sr25519CreateDerive } from "@polkadot-labs/hdkd"; +import { DEV_PHRASE, entropyToMiniSecret, mnemonicToEntropy, KeyPair } from "@polkadot-labs/hdkd-helpers"; +import { getPolkadotSigner } from "polkadot-api/signer"; +import { PolkadotSigner } from "polkadot-api"; +import { randomBytes } from "crypto"; +import { ss58Address } from "@polkadot-labs/hdkd-helpers"; + +export const SS58_PREFIX = 42; + +// ─── KEYPAIR UTILITIES ─────────────────────────────────────────────────────── + +export function getKeypairFromPath(path: string): KeyPair { + const entropy = mnemonicToEntropy(DEV_PHRASE); + const miniSecret = entropyToMiniSecret(entropy); + const derive = sr25519CreateDerive(miniSecret); + return derive(path); +} + +export const getAlice = () => getKeypairFromPath("//Alice"); + +export function getRandomSubstrateKeypair(): KeyPair { + const seed = randomBytes(32); + const miniSecret = entropyToMiniSecret(seed); + const derive = sr25519CreateDerive(miniSecret); + return derive(""); +} + +// ─── SIGNER UTILITIES ──────────────────────────────────────────────────────── + +export function getSignerFromKeypair(keypair: KeyPair): PolkadotSigner { + return getPolkadotSigner(keypair.publicKey, "Sr25519", keypair.sign); +} + +export function getSignerFromPath(path: string): PolkadotSigner { + return getSignerFromKeypair(getKeypairFromPath(path)); +} + +export const getAliceSigner = () => getSignerFromPath("//Alice"); + +// ─── ADDRESS UTILITIES ─────────────────────────────────────────────────────── + +export function convertPublicKeyToSs58(publicKey: Uint8Array): string { + return ss58Address(publicKey, SS58_PREFIX); +} diff --git a/e2e/shared/balance.ts b/e2e/shared/balance.ts new file mode 100644 index 0000000000..d5cafa1326 --- /dev/null +++ b/e2e/shared/balance.ts @@ -0,0 +1,29 @@ +import { subtensor, MultiAddress } from "@polkadot-api/descriptors"; +import { TypedApi } from "polkadot-api"; +import { getAliceSigner } from "./address.js"; +import { waitForTransactionWithRetry } from "./transactions.js"; + +export const TAO = BigInt(1000000000); // 10^9 RAO per TAO + +export function tao(value: number): bigint { + return TAO * BigInt(value); +} + +export async function getBalance(api: TypedApi, ss58Address: string): Promise { + const account = await api.query.System.Account.getValue(ss58Address); + return account.data.free; +} + +export async function forceSetBalance( + api: TypedApi, + ss58Address: string, + amount: bigint = tao(1e10) +): Promise { + const alice = getAliceSigner(); + const internalCall = api.tx.Balances.force_set_balance({ + who: MultiAddress.Id(ss58Address), + new_free: amount, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "force_set_balance"); +} diff --git a/e2e/shared/devnet-client.ts b/e2e/shared/devnet-client.ts new file mode 100644 index 0000000000..776472fb5e --- /dev/null +++ b/e2e/shared/devnet-client.ts @@ -0,0 +1,30 @@ +import { subtensor } from "@polkadot-api/descriptors"; +import { TypedApi, PolkadotClient, createClient } from "polkadot-api"; +import { getWsProvider } from "polkadot-api/ws-provider/web"; + +export const SUB_LOCAL_URL = "ws://localhost:9944"; + +let client: PolkadotClient | undefined = undefined; +let api: TypedApi | undefined = undefined; + +export async function getClient(): Promise { + if (client === undefined) { + const provider = getWsProvider(SUB_LOCAL_URL); + client = createClient(provider); + } + return client; +} + +export async function getDevnetApi(): Promise> { + if (api === undefined) { + const c = await getClient(); + api = c.getTypedApi(subtensor); + } + return api; +} + +export function destroyClient(): void { + client?.destroy(); + client = undefined; + api = undefined; +} diff --git a/e2e/shared/index.ts b/e2e/shared/index.ts new file mode 100644 index 0000000000..1e686b816d --- /dev/null +++ b/e2e/shared/index.ts @@ -0,0 +1,35 @@ +// Node management +export { + startNode, + stop, + started, + peerCount, + finalizedBlocks, + innerEnsure, + log as nodeLog, + type NodeOptions, + type Node, +} from "./node.js"; +export * from "./chainspec.js"; +export * from "./sequencer.js"; + +// Client utilities (shield-style) +export { + connectClient, + createSigner, + getAccountNonce, + getBalance as getBalanceByAddress, + sleep, + waitForFinalizedBlocks, + type ClientConnection, + type Signer, +} from "./client.js"; + +// Blockchain API utilities (staking-tests style) +export * from "./logger.js"; +export * from "./devnet-client.js"; +export * from "./address.js"; +export * from "./transactions.js"; +export * from "./balance.js"; +export * from "./subnet.js"; +export * from "./staking.js"; diff --git a/e2e/shared/logger.ts b/e2e/shared/logger.ts new file mode 100644 index 0000000000..041443353a --- /dev/null +++ b/e2e/shared/logger.ts @@ -0,0 +1,7 @@ +const LOG_INDENT = " "; + +export const log = { + tx: (label: string, msg: string) => console.log(`${LOG_INDENT}[${label}] ${msg}`), + info: (msg: string) => console.log(`${LOG_INDENT}${msg}`), + error: (label: string, msg: string) => console.error(`${LOG_INDENT}[${label}] ${msg}`), +}; diff --git a/e2e/shared/package.json b/e2e/shared/package.json index 85889063ec..6efbfae4e6 100644 --- a/e2e/shared/package.json +++ b/e2e/shared/package.json @@ -3,10 +3,18 @@ "version": "1.0.0", "type": "module", "exports": { + ".": "./index.ts", "./node.js": "./node.ts", "./chainspec.js": "./chainspec.ts", "./sequencer.js": "./sequencer.ts", - "./client.js": "./client.ts" + "./client.js": "./client.ts", + "./logger.js": "./logger.ts", + "./devnet-client.js": "./devnet-client.ts", + "./address.js": "./address.ts", + "./transactions.js": "./transactions.ts", + "./balance.js": "./balance.ts", + "./subnet.js": "./subnet.ts", + "./staking.js": "./staking.ts" }, "dependencies": { "@polkadot/keyring": "catalog:", diff --git a/e2e/shared/staking.ts b/e2e/shared/staking.ts new file mode 100644 index 0000000000..080172ba33 --- /dev/null +++ b/e2e/shared/staking.ts @@ -0,0 +1,517 @@ +import { subtensor } from "@polkadot-api/descriptors"; +import { TypedApi } from "polkadot-api"; +import { KeyPair } from "@polkadot-labs/hdkd-helpers"; +import { getSignerFromKeypair, getAliceSigner } from "./address.js"; +import { waitForTransactionWithRetry } from "./transactions.js"; + +// U64F64 is a 128-bit fixed-point type with 64 fractional bits. +// Raw storage values must be divided by 2^64 to get the actual value. +const U64F64_FRACTIONAL_BITS = 64n; +const U64F64_MULTIPLIER = 1n << U64F64_FRACTIONAL_BITS; // 2^64 + +/** + * Convert a raw U64F64 storage value to its integer part (truncated). + */ +export function u64f64ToInt(raw: bigint): bigint { + return raw >> U64F64_FRACTIONAL_BITS; +} + +/** + * Convert an integer to U64F64 raw format for use in extrinsics. + */ +export function intToU64f64(value: bigint): bigint { + return value << U64F64_FRACTIONAL_BITS; +} + +/** + * Convert a raw U64F64 storage value to a decimal number for display. + */ +export function u64f64ToNumber(raw: bigint): number { + return Number(raw) / Number(U64F64_MULTIPLIER); +} + +export async function addStake( + api: TypedApi, + coldkey: KeyPair, + hotkey: string, + netuid: number, + amount: bigint +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.add_stake({ + hotkey: hotkey, + netuid: netuid, + amount_staked: amount, + }); + await waitForTransactionWithRetry(api, tx, signer, "add_stake"); +} + +export async function addStakeLimit( + api: TypedApi, + coldkey: KeyPair, + hotkey: string, + netuid: number, + amount: bigint, + limitPrice: bigint, + allowPartial: boolean +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.add_stake_limit({ + hotkey: hotkey, + netuid: netuid, + amount_staked: amount, + limit_price: limitPrice, + allow_partial: allowPartial, + }); + await waitForTransactionWithRetry(api, tx, signer, "add_stake_limit"); +} + +export async function removeStake( + api: TypedApi, + coldkey: KeyPair, + hotkey: string, + netuid: number, + amount: bigint +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.remove_stake({ + hotkey: hotkey, + netuid: netuid, + amount_unstaked: amount, + }); + await waitForTransactionWithRetry(api, tx, signer, "remove_stake"); +} + +export async function removeStakeLimit( + api: TypedApi, + coldkey: KeyPair, + hotkey: string, + netuid: number, + amount: bigint, + limitPrice: bigint, + allowPartial: boolean +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.remove_stake_limit({ + hotkey: hotkey, + netuid: netuid, + amount_unstaked: amount, + limit_price: limitPrice, + allow_partial: allowPartial, + }); + await waitForTransactionWithRetry(api, tx, signer, "remove_stake_limit"); +} + +export async function removeStakeFullLimit( + api: TypedApi, + coldkey: KeyPair, + hotkey: string, + netuid: number, + limitPrice: bigint | undefined +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.remove_stake_full_limit({ + hotkey: hotkey, + netuid: netuid, + limit_price: limitPrice, + }); + await waitForTransactionWithRetry(api, tx, signer, "remove_stake_full_limit"); +} + +export async function unstakeAll( + api: TypedApi, + coldkey: KeyPair, + hotkey: string +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.unstake_all({ + hotkey: hotkey, + }); + await waitForTransactionWithRetry(api, tx, signer, "unstake_all"); +} + +export async function unstakeAllAlpha( + api: TypedApi, + coldkey: KeyPair, + hotkey: string +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.unstake_all_alpha({ + hotkey: hotkey, + }); + await waitForTransactionWithRetry(api, tx, signer, "unstake_all_alpha"); +} + +/** + * Get stake shares (Alpha) for a hotkey/coldkey/netuid triplet. + * Returns the integer part of the U64F64 value. + */ +export async function getStake( + api: TypedApi, + hotkey: string, + coldkey: string, + netuid: number +): Promise { + const raw = await api.query.SubtensorModule.Alpha.getValue(hotkey, coldkey, netuid); + return u64f64ToInt(raw); +} + +/** + * Get raw stake shares (Alpha) in U64F64 format. + * Use this when you need the raw value for extrinsics like transfer_stake. + */ +export async function getStakeRaw( + api: TypedApi, + hotkey: string, + coldkey: string, + netuid: number +): Promise { + return await api.query.SubtensorModule.Alpha.getValue(hotkey, coldkey, netuid); +} + +export async function transferStake( + api: TypedApi, + originColdkey: KeyPair, + destinationColdkey: string, + hotkey: string, + originNetuid: number, + destinationNetuid: number, + amount: bigint +): Promise { + const signer = getSignerFromKeypair(originColdkey); + const tx = api.tx.SubtensorModule.transfer_stake({ + destination_coldkey: destinationColdkey, + hotkey: hotkey, + origin_netuid: originNetuid, + destination_netuid: destinationNetuid, + alpha_amount: amount, + }); + await waitForTransactionWithRetry(api, tx, signer, "transfer_stake"); +} + +export async function moveStake( + api: TypedApi, + coldkey: KeyPair, + originHotkey: string, + destinationHotkey: string, + originNetuid: number, + destinationNetuid: number, + amount: bigint +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.move_stake({ + origin_hotkey: originHotkey, + destination_hotkey: destinationHotkey, + origin_netuid: originNetuid, + destination_netuid: destinationNetuid, + alpha_amount: amount, + }); + await waitForTransactionWithRetry(api, tx, signer, "move_stake"); +} + +export async function swapStake( + api: TypedApi, + coldkey: KeyPair, + hotkey: string, + originNetuid: number, + destinationNetuid: number, + amount: bigint +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.swap_stake({ + hotkey: hotkey, + origin_netuid: originNetuid, + destination_netuid: destinationNetuid, + alpha_amount: amount, + }); + await waitForTransactionWithRetry(api, tx, signer, "swap_stake"); +} + +export async function swapStakeLimit( + api: TypedApi, + coldkey: KeyPair, + hotkey: string, + originNetuid: number, + destinationNetuid: number, + amount: bigint, + limitPrice: bigint, + allowPartial: boolean +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.swap_stake_limit({ + hotkey: hotkey, + origin_netuid: originNetuid, + destination_netuid: destinationNetuid, + alpha_amount: amount, + limit_price: limitPrice, + allow_partial: allowPartial, + }); + await waitForTransactionWithRetry(api, tx, signer, "swap_stake_limit"); +} + +export type RootClaimType = "Swap" | "Keep" | { type: "KeepSubnets"; subnets: number[] }; + +export async function getRootClaimType( + api: TypedApi, + coldkey: string +): Promise { + const result = await api.query.SubtensorModule.RootClaimType.getValue(coldkey); + if (result.type === "KeepSubnets") { + return { type: "KeepSubnets", subnets: result.value.subnets as number[] }; + } + return result.type as "Swap" | "Keep"; +} + +export async function setRootClaimType( + api: TypedApi, + coldkey: KeyPair, + claimType: RootClaimType +): Promise { + const signer = getSignerFromKeypair(coldkey); + let newRootClaimType; + if (typeof claimType === "string") { + newRootClaimType = { type: claimType, value: undefined }; + } else { + newRootClaimType = { type: "KeepSubnets", value: { subnets: claimType.subnets } }; + } + const tx = api.tx.SubtensorModule.set_root_claim_type({ + new_root_claim_type: newRootClaimType, + }); + await waitForTransactionWithRetry(api, tx, signer, "set_root_claim_type"); +} + +export async function claimRoot( + api: TypedApi, + coldkey: KeyPair, + subnets: number[] +): Promise { + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.claim_root({ + subnets: subnets, + }); + await waitForTransactionWithRetry(api, tx, signer, "claim_root"); +} + +export async function getNumRootClaims( + api: TypedApi +): Promise { + return await api.query.SubtensorModule.NumRootClaim.getValue(); +} + +export async function sudoSetNumRootClaims( + api: TypedApi, + newValue: bigint +): Promise { + const alice = getAliceSigner(); + const internalCall = api.tx.SubtensorModule.sudo_set_num_root_claims({ + new_value: newValue, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_num_root_claims"); +} + +export async function getRootClaimThreshold( + api: TypedApi, + netuid: number +): Promise { + return await api.query.SubtensorModule.RootClaimableThreshold.getValue(netuid); +} + +export async function sudoSetRootClaimThreshold( + api: TypedApi, + netuid: number, + newValue: bigint +): Promise { + const alice = getAliceSigner(); + const internalCall = api.tx.SubtensorModule.sudo_set_root_claim_threshold({ + netuid: netuid, + new_value: newValue, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_root_claim_threshold"); +} + +export async function getTempo( + api: TypedApi, + netuid: number +): Promise { + return await api.query.SubtensorModule.Tempo.getValue(netuid); +} + +export async function sudoSetTempo( + api: TypedApi, + netuid: number, + tempo: number +): Promise { + const alice = getAliceSigner(); + const internalCall = api.tx.AdminUtils.sudo_set_tempo({ + netuid: netuid, + tempo: tempo, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_tempo"); +} + +export async function waitForBlocks( + api: TypedApi, + numBlocks: number +): Promise { + const startBlock = await api.query.System.Number.getValue(); + const targetBlock = startBlock + numBlocks; + + while (true) { + const currentBlock = await api.query.System.Number.getValue(); + if (currentBlock >= targetBlock) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} + +export async function getRootClaimable( + api: TypedApi, + hotkey: string +): Promise> { + const result = await api.query.SubtensorModule.RootClaimable.getValue(hotkey); + const claimableMap = new Map(); + for (const [netuid, amount] of result) { + claimableMap.set(netuid, amount); + } + return claimableMap; +} + +export async function getRootClaimed( + api: TypedApi, + netuid: number, + hotkey: string, + coldkey: string +): Promise { + return await api.query.SubtensorModule.RootClaimed.getValue(netuid, hotkey, coldkey); +} + +export async function isSubtokenEnabled( + api: TypedApi, + netuid: number +): Promise { + return await api.query.SubtensorModule.SubtokenEnabled.getValue(netuid); +} + +export async function sudoSetSubtokenEnabled( + api: TypedApi, + netuid: number, + enabled: boolean +): Promise { + const alice = getAliceSigner(); + const internalCall = api.tx.AdminUtils.sudo_set_subtoken_enabled({ + netuid: netuid, + subtoken_enabled: enabled, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_subtoken_enabled"); +} + +export async function isNetworkAdded( + api: TypedApi, + netuid: number +): Promise { + return await api.query.SubtensorModule.NetworksAdded.getValue(netuid); +} + +export async function getAdminFreezeWindow( + api: TypedApi +): Promise { + return await api.query.SubtensorModule.AdminFreezeWindow.getValue(); +} + +export async function sudoSetAdminFreezeWindow( + api: TypedApi, + window: number +): Promise { + const alice = getAliceSigner(); + const internalCall = api.tx.AdminUtils.sudo_set_admin_freeze_window({ + window: window, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_admin_freeze_window"); +} + +export async function sudoSetEmaPriceHalvingPeriod( + api: TypedApi, + netuid: number, + emaPriceHalvingPeriod: number +): Promise { + const alice = getAliceSigner(); + const internalCall = api.tx.AdminUtils.sudo_set_ema_price_halving_period({ + netuid: netuid, + ema_halving: BigInt(emaPriceHalvingPeriod), + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_ema_price_halving_period"); +} + +export async function sudoSetLockReductionInterval( + api: TypedApi, + interval: number +): Promise { + const alice = getAliceSigner(); + const internalCall = api.tx.AdminUtils.sudo_set_lock_reduction_interval({ + interval: BigInt(interval), + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_lock_reduction_interval"); +} + +export async function sudoSetSubnetMovingAlpha( + api: TypedApi, + alpha: bigint +): Promise { + const alice = getAliceSigner(); + const internalCall = api.tx.AdminUtils.sudo_set_subnet_moving_alpha({ + alpha: alpha, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_subnet_moving_alpha"); +} + +// Debug helpers for claim_root investigation +export async function getSubnetTAO( + api: TypedApi, + netuid: number +): Promise { + return await api.query.SubtensorModule.SubnetTAO.getValue(netuid); +} + +export async function getSubnetMovingPrice( + api: TypedApi, + netuid: number +): Promise { + return await api.query.SubtensorModule.SubnetMovingPrice.getValue(netuid); +} + +export async function getPendingRootAlphaDivs( + api: TypedApi, + netuid: number +): Promise { + return await api.query.SubtensorModule.PendingRootAlphaDivs.getValue(netuid); +} + +export async function getTaoWeight( + api: TypedApi +): Promise { + return await api.query.SubtensorModule.TaoWeight.getValue(); +} + +export async function getSubnetAlphaIn( + api: TypedApi, + netuid: number +): Promise { + return await api.query.SubtensorModule.SubnetAlphaIn.getValue(netuid); +} + +export async function getTotalHotkeyAlpha( + api: TypedApi, + hotkey: string, + netuid: number +): Promise { + return await api.query.SubtensorModule.TotalHotkeyAlpha.getValue(hotkey, netuid); +} diff --git a/e2e/shared/subnet.ts b/e2e/shared/subnet.ts new file mode 100644 index 0000000000..510779c19e --- /dev/null +++ b/e2e/shared/subnet.ts @@ -0,0 +1,72 @@ +import { subtensor } from "@polkadot-api/descriptors"; +import { TypedApi } from "polkadot-api"; +import { KeyPair } from "@polkadot-labs/hdkd-helpers"; +import { getAliceSigner, getSignerFromKeypair, convertPublicKeyToSs58 } from "./address.js"; +import { waitForTransactionWithRetry } from "./transactions.js"; +import { log } from "./logger.js"; + +export async function addNewSubnetwork( + api: TypedApi, + hotkey: KeyPair, + coldkey: KeyPair +): Promise { + const alice = getAliceSigner(); + const totalNetworks = await api.query.SubtensorModule.TotalNetworks.getValue(); + + // Disable network rate limit for testing + const rateLimit = await api.query.SubtensorModule.NetworkRateLimit.getValue(); + if (rateLimit !== BigInt(0)) { + const internalCall = api.tx.AdminUtils.sudo_set_network_rate_limit({ rate_limit: BigInt(0) }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "set_network_rate_limit"); + } + + const signer = getSignerFromKeypair(coldkey); + const registerNetworkTx = api.tx.SubtensorModule.register_network({ + hotkey: convertPublicKeyToSs58(hotkey.publicKey), + }); + await waitForTransactionWithRetry(api, registerNetworkTx, signer, "register_network"); + + return totalNetworks; +} + +export async function burnedRegister( + api: TypedApi, + netuid: number, + hotkeyAddress: string, + coldkey: KeyPair +): Promise { + const registered = await api.query.SubtensorModule.Uids.getValue(netuid, hotkeyAddress); + if (registered !== undefined) { + log.tx("burned_register", `skipped: hotkey already registered on netuid ${netuid}`); + return; + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.burned_register({ hotkey: hotkeyAddress, netuid: netuid }); + await waitForTransactionWithRetry(api, tx, signer, "burned_register"); +} + +export async function startCall( + api: TypedApi, + netuid: number, + coldkey: KeyPair +): Promise { + const registerBlock = Number(await api.query.SubtensorModule.NetworkRegisteredAt.getValue(netuid)); + let currentBlock = await api.query.System.Number.getValue(); + const duration = Number(await api.constants.SubtensorModule.InitialStartCallDelay); + + while (currentBlock - registerBlock <= duration) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + currentBlock = await api.query.System.Number.getValue(); + } + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const signer = getSignerFromKeypair(coldkey); + const tx = api.tx.SubtensorModule.start_call({ netuid: netuid }); + await waitForTransactionWithRetry(api, tx, signer, "start_call"); + + await new Promise((resolve) => setTimeout(resolve, 1000)); +} diff --git a/e2e/shared/transactions.ts b/e2e/shared/transactions.ts new file mode 100644 index 0000000000..27e55749f4 --- /dev/null +++ b/e2e/shared/transactions.ts @@ -0,0 +1,71 @@ +import { subtensor } from "@polkadot-api/descriptors"; +import { TypedApi, Transaction, PolkadotSigner } from "polkadot-api"; +import { log } from "./logger.js"; + +export const TX_TIMEOUT = 5000; + +export async function waitForTransactionWithRetry( + api: TypedApi, + tx: Transaction<{}, string, string, void>, + signer: PolkadotSigner, + label: string, + maxRetries = 1 +): Promise { + let success = false; + let retries = 0; + + while (!success && retries < maxRetries) { + await waitForTransactionCompletion(tx, signer, label) + .then(() => { + success = true; + }) + .catch((error) => { + log.tx(label, `error: ${error}`); + }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + retries += 1; + } + + if (!success) { + throw new Error(`[${label}] failed after ${maxRetries} retries`); + } +} + +async function waitForTransactionCompletion( + tx: Transaction<{}, string, string, void>, + signer: PolkadotSigner, + label: string +): Promise { + return new Promise((resolve, reject) => { + let txHash = ""; + const subscription = tx.signSubmitAndWatch(signer).subscribe({ + next(value) { + txHash = value.txHash; + if (value.type === "finalized") { + log.tx(label, `finalized: ${value.txHash}`); + subscription.unsubscribe(); + clearTimeout(timeoutId); + if (!value.ok) { + const errorStr = JSON.stringify(value.dispatchError, null, 2); + log.tx(label, `dispatch error: ${errorStr}`); + reject(new Error(`[${label}] dispatch error: ${errorStr}`)); + } else { + resolve(); + } + } + }, + error(err) { + log.error(label, `failed: ${err}`); + subscription.unsubscribe(); + clearTimeout(timeoutId); + reject(err); + }, + }); + + const timeoutId = setTimeout(() => { + subscription.unsubscribe(); + log.tx(label, `timeout for tx: ${txHash}`); + reject(new Error(`[${label}] timeout`)); + }, TX_TIMEOUT); + }); +} diff --git a/e2e/staking/package.json b/e2e/staking/package.json new file mode 100644 index 0000000000..80648d3d0f --- /dev/null +++ b/e2e/staking/package.json @@ -0,0 +1,21 @@ +{ + "name": "e2e-staking", + "version": "1.0.0", + "type": "module", + "license": "ISC", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "e2e-shared": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "vitest": "catalog:" + }, + "prettier": { + "singleQuote": false, + "trailingComma": "all", + "printWidth": 120 + } +} diff --git a/e2e/staking/setup.ts b/e2e/staking/setup.ts new file mode 100644 index 0000000000..51a3434559 --- /dev/null +++ b/e2e/staking/setup.ts @@ -0,0 +1,106 @@ +import { rm, mkdir } from "node:fs/promises"; +import { + generateChainSpec, + startNode, + started, + peerCount, + finalizedBlocks, + stop, + nodeLog, + destroyClient, + getDevnetApi, + sudoSetLockReductionInterval, + log, + type Node, + type NodeOptions, +} from "e2e-shared"; + +const CHAIN_SPEC_PATH = "/tmp/subtensor-e2e/staking-tests/chain-spec.json"; +const BASE_DIR = "/tmp/subtensor-e2e/staking-tests"; + +const BINARY_PATH = process.env.BINARY_PATH || "../../target/release/node-subtensor"; + +const nodes: Node[] = []; + +// Use built-in validators "one" and "two" - they have auto-injected keys +type NodeConfig = Omit; + +const NODE_CONFIGS: NodeConfig[] = [ + { name: "one", port: 30433, rpcPort: 9944, basePath: `${BASE_DIR}/one`, validator: true }, + { name: "two", port: 30434, rpcPort: 9945, basePath: `${BASE_DIR}/two`, validator: true }, +]; + +async function startNetwork() { + nodeLog(`Setting up ${NODE_CONFIGS.length}-node network for staking E2E tests`); + nodeLog(`Binary path: ${BINARY_PATH}`); + + await mkdir(BASE_DIR, { recursive: true }); + + // Generate local chain spec (built-in has One and Two as authorities) + await generateChainSpec(BINARY_PATH, CHAIN_SPEC_PATH); + + // Clean up old base paths + for (const config of NODE_CONFIGS) { + await rm(config.basePath, { recursive: true, force: true }); + } + + // Start all validator nodes + for (const config of NODE_CONFIGS) { + const node = startNode({ + binaryPath: BINARY_PATH, + chainSpec: CHAIN_SPEC_PATH, + ...config, + }); + nodes.push(node); + await started(node); + } + + const all = Promise.all.bind(Promise); + + // Wait for nodes to peer with each other + await all(nodes.map((n) => peerCount(n, nodes.length - 1))); + nodeLog("All nodes peered"); + + // Wait for block finalization + await all(nodes.map((n) => finalizedBlocks(n, 3))); + nodeLog("All nodes finalized block 3"); +} + +async function stopNetwork() { + nodeLog("Stopping staking-tests network"); + + for (const node of nodes) { + try { + await stop(node); + } catch (e) { + nodeLog(`Warning: failed to stop ${node.name}: ${e}`); + } + } + + // Clean up the suite directory + await rm(BASE_DIR, { recursive: true, force: true }); + + nodeLog("Network stopped"); +} + +export async function setup() { + // Start the network + await startNetwork(); + + // Connect to the network and configure for tests + const api = await getDevnetApi(); + log.info("Setup: set lock reduction interval to 1 for instant lock cost decay"); + + // Set lock reduction interval to 1 block to make network registration lock cost decay instantly. + // By default, the lock cost doubles with each subnet registration and decays over 14 days (100,800 blocks). + // Without this, tests creating multiple subnets would fail with CannotAffordLockCost. + await sudoSetLockReductionInterval(api, 1); +} + +export async function teardown() { + // Destroy the API client first + destroyClient(); + + // Stop the network + await stopNetwork(); +} diff --git a/e2e/staking/test/add-stake-limit.test.ts b/e2e/staking/test/add-stake-limit.test.ts new file mode 100644 index 0000000000..63e1bb6ba0 --- /dev/null +++ b/e2e/staking/test/add-stake-limit.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { + getDevnetApi, + getRandomSubstrateKeypair, + convertPublicKeyToSs58, + forceSetBalance, + addNewSubnetwork, + startCall, + addStakeLimit, + getStake, + tao, + log, +} from "e2e-shared"; + +describe("▶ add_stake_limit extrinsic", () => { + const hotkey = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const hotkeyAddress = convertPublicKeyToSs58(hotkey.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + let netuid: number; + + beforeAll(async () => { + const api = await getDevnetApi(); + await forceSetBalance(api, hotkeyAddress); + await forceSetBalance(api, coldkeyAddress); + netuid = await addNewSubnetwork(api, hotkey, coldkey); + await startCall(api, netuid, coldkey); + }); + + it("should add stake with price limit (allow partial)", async () => { + const api = await getDevnetApi(); + + // Get initial stake + const stakeBefore = await getStake(api, hotkeyAddress, coldkeyAddress, netuid); + + // Add stake with limit price and allow partial fills, limit_price is MAX TAO per Alpha willing to pay. + const stakeAmount = tao(44); + const limitPrice = tao(6); + await addStakeLimit(api, coldkey, hotkeyAddress, netuid, stakeAmount, limitPrice, true); + + // Verify stake increased + const stakeAfter = await getStake(api, hotkeyAddress, coldkeyAddress, netuid); + expect(stakeAfter, "Stake should increase").toBeGreaterThan(stakeBefore); + + log.info("✅ Successfully added stake with limit (allow partial)."); + }); + + it("should add stake with price limit (fill or kill)", async () => { + const api = await getDevnetApi(); + + // Get initial stake + const stakeBefore = await getStake(api, hotkeyAddress, coldkeyAddress, netuid); + + // Add stake with limit price (fill or kill mode), limit_price is MAX TAO per Alpha willing to pay + const stakeAmount = tao(44); + const limitPrice = tao(6); + await addStakeLimit(api, coldkey, hotkeyAddress, netuid, stakeAmount, limitPrice, false); + + // Verify stake increased + const stakeAfter = await getStake(api, hotkeyAddress, coldkeyAddress, netuid); + expect(stakeAfter, "Stake should increase").toBeGreaterThan(stakeBefore); + + log.info("✅ Successfully added stake with limit (fill or kill)."); + }); +}); diff --git a/e2e/staking/test/add-stake.test.ts b/e2e/staking/test/add-stake.test.ts new file mode 100644 index 0000000000..fd3eecf052 --- /dev/null +++ b/e2e/staking/test/add-stake.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { + getDevnetApi, + getRandomSubstrateKeypair, + convertPublicKeyToSs58, + forceSetBalance, + addNewSubnetwork, + startCall, + addStake, + getStake, + tao, + log, +} from "e2e-shared"; + +describe("▶ add_stake extrinsic", () => { + const hotkey = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const hotkeyAddress = convertPublicKeyToSs58(hotkey.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + let netuid: number; + + beforeAll(async () => { + const api = await getDevnetApi(); + await forceSetBalance(api, hotkeyAddress); + await forceSetBalance(api, coldkeyAddress); + netuid = await addNewSubnetwork(api, hotkey, coldkey); + await startCall(api, netuid, coldkey); + }); + + it("should add stake to a hotkey", async () => { + const api = await getDevnetApi(); + + // Get initial stake + const stakeBefore = await getStake(api, hotkeyAddress, coldkeyAddress, netuid); + + // Add stake + const stakeAmount = tao(100); + await addStake(api, coldkey, hotkeyAddress, netuid, stakeAmount); + + // Verify stake increased + const stakeAfter = await getStake(api, hotkeyAddress, coldkeyAddress, netuid); + expect(stakeAfter, "Stake should increase after adding stake").toBeGreaterThan(stakeBefore); + + log.info("✅ Successfully added stake."); + }); +}); diff --git a/e2e/staking/test/claim-root.test.ts b/e2e/staking/test/claim-root.test.ts new file mode 100644 index 0000000000..5f48f20b2b --- /dev/null +++ b/e2e/staking/test/claim-root.test.ts @@ -0,0 +1,469 @@ +import { describe, it, expect } from "vitest"; +import { + getDevnetApi, + getRandomSubstrateKeypair, + convertPublicKeyToSs58, + forceSetBalance, + addNewSubnetwork, + startCall, + getRootClaimType, + setRootClaimType, + getNumRootClaims, + sudoSetNumRootClaims, + getRootClaimThreshold, + sudoSetRootClaimThreshold, + addStake, + getStake, + claimRoot, + sudoSetTempo, + waitForBlocks, + getRootClaimable, + getRootClaimed, + isSubtokenEnabled, + sudoSetSubtokenEnabled, + sudoSetAdminFreezeWindow, + sudoSetEmaPriceHalvingPeriod, + getSubnetTAO, + getSubnetMovingPrice, + getPendingRootAlphaDivs, + getTaoWeight, + getSubnetAlphaIn, + getTotalHotkeyAlpha, + sudoSetSubnetMovingAlpha, + tao, + log, +} from "e2e-shared"; + +describe("▶ set_root_claim_type extrinsic", () => { + it("should set root claim type to Keep", async () => { + const api = await getDevnetApi(); + + const coldkey = getRandomSubstrateKeypair(); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + + await forceSetBalance(api, coldkeyAddress); + + // Check initial claim type (default is "Swap") + const claimTypeBefore = await getRootClaimType(api, coldkeyAddress); + log.info(`Root claim type before: ${claimTypeBefore}`); + + // Set root claim type to Keep + await setRootClaimType(api, coldkey, "Keep"); + + // Verify claim type changed + const claimTypeAfter = await getRootClaimType(api, coldkeyAddress); + log.info(`Root claim type after: ${claimTypeAfter}`); + + expect(claimTypeAfter).toBe("Keep"); + + log.info("✅ Successfully set root claim type to Keep."); + }); + + it("should set root claim type to Swap", async () => { + const api = await getDevnetApi(); + + const coldkey = getRandomSubstrateKeypair(); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + + await forceSetBalance(api, coldkeyAddress); + + // First set to Keep so we can verify the change to Swap + await setRootClaimType(api, coldkey, "Keep"); + const claimTypeBefore = await getRootClaimType(api, coldkeyAddress); + log.info(`Root claim type before: ${claimTypeBefore}`); + expect(claimTypeBefore).toBe("Keep"); + + // Set root claim type to Swap + await setRootClaimType(api, coldkey, "Swap"); + + // Verify claim type changed + const claimTypeAfter = await getRootClaimType(api, coldkeyAddress); + log.info(`Root claim type after: ${claimTypeAfter}`); + + expect(claimTypeAfter).toBe("Swap"); + + log.info("✅ Successfully set root claim type to Swap."); + }); + + it("should set root claim type to KeepSubnets", async () => { + const api = await getDevnetApi(); + + const coldkey = getRandomSubstrateKeypair(); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + + await forceSetBalance(api, coldkeyAddress); + + // Check initial claim type (default is "Swap") + const claimTypeBefore = await getRootClaimType(api, coldkeyAddress); + log.info(`Root claim type before: ${JSON.stringify(claimTypeBefore)}`); + + // Set root claim type to KeepSubnets with specific subnets + const subnetsToKeep = [1, 2]; + await setRootClaimType(api, coldkey, { type: "KeepSubnets", subnets: subnetsToKeep }); + + // Verify claim type changed + const claimTypeAfter = await getRootClaimType(api, coldkeyAddress); + log.info(`Root claim type after: ${JSON.stringify(claimTypeAfter)}`); + + expect(typeof claimTypeAfter).toBe("object"); + expect((claimTypeAfter as { type: string }).type).toBe("KeepSubnets"); + expect((claimTypeAfter as { subnets: number[] }).subnets).toEqual(subnetsToKeep); + + log.info("✅ Successfully set root claim type to KeepSubnets."); + }); +}); + +describe("▶ sudo_set_num_root_claims extrinsic", () => { + it("should set num root claims", async () => { + const api = await getDevnetApi(); + + // Get initial value + const numClaimsBefore = await getNumRootClaims(api); + log.info(`Num root claims before: ${numClaimsBefore}`); + + // Set new value (different from current) + const newValue = numClaimsBefore + 5n; + await sudoSetNumRootClaims(api, newValue); + + // Verify value changed + const numClaimsAfter = await getNumRootClaims(api); + log.info(`Num root claims after: ${numClaimsAfter}`); + + expect(numClaimsAfter).toBe(newValue); + + log.info("✅ Successfully set num root claims."); + }); +}); + +describe("▶ sudo_set_root_claim_threshold extrinsic", () => { + it("should set root claim threshold for subnet", async () => { + const api = await getDevnetApi(); + + // Create a subnet to test with + const hotkey = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const hotkeyAddress = convertPublicKeyToSs58(hotkey.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + + await forceSetBalance(api, hotkeyAddress); + await forceSetBalance(api, coldkeyAddress); + + const netuid = await addNewSubnetwork(api, hotkey, coldkey); + await startCall(api, netuid, coldkey); + + // Get initial threshold + const thresholdBefore = await getRootClaimThreshold(api, netuid); + log.info(`Root claim threshold before: ${thresholdBefore}`); + + // Set new threshold value (MAX_ROOT_CLAIM_THRESHOLD is 10_000_000) + // The value is stored as I96F32 fixed-point with 32 fractional bits + const newThreshold = 1_000_000n; + await sudoSetRootClaimThreshold(api, netuid, newThreshold); + + // Verify threshold changed + // I96F32 encoding: newThreshold * 2^32 = 1_000_000 * 4294967296 = 4294967296000000 + const thresholdAfter = await getRootClaimThreshold(api, netuid); + log.info(`Root claim threshold after: ${thresholdAfter}`); + + const expectedStoredValue = newThreshold * (1n << 32n); // I96F32 encoding + expect(thresholdAfter).toBe(expectedStoredValue); + + log.info("✅ Successfully set root claim threshold."); + }); +}); + +// Root subnet netuid is 0 +const ROOT_NETUID = 0; + +describe("▶ claim_root extrinsic", () => { + it("should claim root dividends with Keep type (stake to dynamic subnet)", async () => { + const api = await getDevnetApi(); + + // Setup accounts + // - owner1Hotkey/owner1Coldkey: subnet 1 owner + // - owner2Hotkey/owner2Coldkey: subnet 2 owner (needed for root_sell_flag) + // - stakerColdkey: the coldkey that will stake on root and claim dividends + const owner1Hotkey = getRandomSubstrateKeypair(); + const owner1Coldkey = getRandomSubstrateKeypair(); + const owner2Hotkey = getRandomSubstrateKeypair(); + const owner2Coldkey = getRandomSubstrateKeypair(); + const stakerColdkey = getRandomSubstrateKeypair(); + const owner1HotkeyAddress = convertPublicKeyToSs58(owner1Hotkey.publicKey); + const owner1ColdkeyAddress = convertPublicKeyToSs58(owner1Coldkey.publicKey); + const owner2HotkeyAddress = convertPublicKeyToSs58(owner2Hotkey.publicKey); + const owner2ColdkeyAddress = convertPublicKeyToSs58(owner2Coldkey.publicKey); + const stakerColdkeyAddress = convertPublicKeyToSs58(stakerColdkey.publicKey); + + // Fund all accounts + await forceSetBalance(api, owner1HotkeyAddress); + await forceSetBalance(api, owner1ColdkeyAddress); + await forceSetBalance(api, owner2HotkeyAddress); + await forceSetBalance(api, owner2ColdkeyAddress); + await forceSetBalance(api, stakerColdkeyAddress); + + // Disable admin freeze window to allow enabling subtoken for ROOT + await sudoSetAdminFreezeWindow(api, 0); + log.info("Admin freeze window set to 0"); + + // Enable subtoken for ROOT subnet (required for staking on root) + const subtokenEnabledBefore = await isSubtokenEnabled(api, ROOT_NETUID); + if (!subtokenEnabledBefore) { + await sudoSetSubtokenEnabled(api, ROOT_NETUID, true); + const subtokenEnabledAfter = await isSubtokenEnabled(api, ROOT_NETUID); + log.info(`ROOT subtoken enabled: ${subtokenEnabledAfter}`); + expect(subtokenEnabledAfter).toBe(true); + } + + // Create TWO dynamic subnets - needed for root_sell_flag to become true + // root_sell_flag = sum(moving_prices) > 1.0 + // Each subnet's moving price approaches 1.0 via EMA, so 2 subnets can exceed threshold + const netuid1 = await addNewSubnetwork(api, owner1Hotkey, owner1Coldkey); + await startCall(api, netuid1, owner1Coldkey); + log.info(`Created subnet 1 with netuid: ${netuid1}`); + + const netuid2 = await addNewSubnetwork(api, owner2Hotkey, owner2Coldkey); + await startCall(api, netuid2, owner2Coldkey); + log.info(`Created subnet 2 with netuid: ${netuid2}`); + + // Set short tempo for faster emission distribution + await sudoSetTempo(api, netuid1, 1); + await sudoSetTempo(api, netuid2, 1); + log.info("Set tempo to 1 for both subnets"); + + // Set EMA price halving period to 1 for fast moving price convergence + // Formula: alpha = SubnetMovingAlpha * blocks/(blocks + halving_time) + // With halving_time=1: after 10 blocks, alpha ≈ 0.91, moving price ≈ 0.91 + // With 2 subnets at ~0.9 each, total > 1.0 enabling root_sell_flag + await sudoSetEmaPriceHalvingPeriod(api, netuid1, 1); + await sudoSetEmaPriceHalvingPeriod(api, netuid2, 1); + log.info("Set EMA halving period to 1 for fast price convergence"); + + // Set SubnetMovingAlpha to 1.0 (default is 0.000003 which is way too slow) + // I96F32 encoding: 1.0 * 2^32 = 4294967296 + const movingAlpha = BigInt(4294967296); // 1.0 in I96F32 + await sudoSetSubnetMovingAlpha(api, movingAlpha); + log.info("Set SubnetMovingAlpha to 1.0 for fast EMA convergence"); + + // Set threshold to 0 to allow claiming any amount + await sudoSetRootClaimThreshold(api, netuid1, 0n); + await sudoSetRootClaimThreshold(api, netuid2, 0n); + + // Add stake to ROOT subnet for the staker (makes them eligible for root dividends) + const rootStakeAmount = tao(100); + await addStake(api, stakerColdkey, owner1HotkeyAddress, ROOT_NETUID, rootStakeAmount); + log.info(`Added ${rootStakeAmount} stake to root subnet for staker`); + + // Verify root stake was added + const rootStake = await getStake(api, owner1HotkeyAddress, stakerColdkeyAddress, ROOT_NETUID); + log.info(`Root stake: ${rootStake}`); + expect(rootStake, "Should have stake on root subnet").toBeGreaterThan(0n); + + // Add stake to both dynamic subnets (owner stake to enable emissions flow) + const subnetStakeAmount = tao(50); + await addStake(api, owner1Coldkey, owner1HotkeyAddress, netuid1, subnetStakeAmount); + await addStake(api, owner2Coldkey, owner2HotkeyAddress, netuid2, subnetStakeAmount); + log.info(`Added ${subnetStakeAmount} owner stake to subnets ${netuid1} and ${netuid2}`); + + // Get initial stake on subnet 1 for the staker (should be 0) + const stakerSubnetStakeBefore = await getStake(api, owner1HotkeyAddress, stakerColdkeyAddress, netuid1); + log.info(`Staker subnet stake before claim: ${stakerSubnetStakeBefore}`); + + // Set root claim type to Keep (keep alpha on subnet instead of swapping to TAO) + await setRootClaimType(api, stakerColdkey, "Keep"); + const claimType = await getRootClaimType(api, stakerColdkeyAddress); + log.info(`Root claim type: ${claimType}`); + expect(claimType).toBe("Keep"); + + // Wait for blocks to: + // 1. Allow moving prices to converge (need sum > 1.0 for root_sell_flag) + // 2. Accumulate PendingRootAlphaDivs + // 3. Distribute emissions at tempo boundary + const blocksToWait = 25; + log.info(`Waiting for ${blocksToWait} blocks for moving prices to converge and emissions to accumulate...`); + await waitForBlocks(api, blocksToWait); + + // Debug: Check key storage values + const subnetTaoRoot = await getSubnetTAO(api, ROOT_NETUID); + const subnetTao1 = await getSubnetTAO(api, netuid1); + const subnetTao2 = await getSubnetTAO(api, netuid2); + log.info(`SubnetTAO - ROOT: ${subnetTaoRoot}, netuid1: ${subnetTao1}, netuid2: ${subnetTao2}`); + + const movingPrice1 = await getSubnetMovingPrice(api, netuid1); + const movingPrice2 = await getSubnetMovingPrice(api, netuid2); + log.info(`SubnetMovingPrice - netuid1: ${movingPrice1}, netuid2: ${movingPrice2}`); + // Note: Moving price is I96F32, so divide by 2^32 to get actual value + const mp1Float = Number(movingPrice1) / 2**32; + const mp2Float = Number(movingPrice2) / 2**32; + log.info(`SubnetMovingPrice (float) - netuid1: ${mp1Float}, netuid2: ${mp2Float}, sum: ${mp1Float + mp2Float}`); + + const pendingDivs1 = await getPendingRootAlphaDivs(api, netuid1); + const pendingDivs2 = await getPendingRootAlphaDivs(api, netuid2); + log.info(`PendingRootAlphaDivs - netuid1: ${pendingDivs1}, netuid2: ${pendingDivs2}`); + + const taoWeight = await getTaoWeight(api); + log.info(`TaoWeight: ${taoWeight}`); + + const alphaIn1 = await getSubnetAlphaIn(api, netuid1); + const alphaIn2 = await getSubnetAlphaIn(api, netuid2); + log.info(`SubnetAlphaIn - netuid1: ${alphaIn1}, netuid2: ${alphaIn2}`); + + const totalHotkeyAlpha1 = await getTotalHotkeyAlpha(api, owner1HotkeyAddress, netuid1); + log.info(`TotalHotkeyAlpha for hotkey1 on netuid1: ${totalHotkeyAlpha1}`); + + // Check if there are any claimable dividends + const claimable = await getRootClaimable(api, owner1HotkeyAddress); + const claimableStr = [...claimable.entries()].map(([k, v]) => `[${k}: ${v.toString()}]`).join(", "); + log.info(`RootClaimable entries for hotkey1: ${claimableStr || "(none)"}`); + + // Call claim_root to claim dividends for subnet 1 + await claimRoot(api, stakerColdkey, [netuid1]); + log.info("Called claim_root"); + + // Get stake on subnet 1 after claim + const stakerSubnetStakeAfter = await getStake(api, owner1HotkeyAddress, stakerColdkeyAddress, netuid1); + log.info(`Staker subnet stake after claim: ${stakerSubnetStakeAfter}`); + + // Check RootClaimed value + const rootClaimed = await getRootClaimed(api, netuid1, owner1HotkeyAddress, stakerColdkeyAddress); + log.info(`RootClaimed value: ${rootClaimed}`); + + // Verify dividends were claimed + expect(stakerSubnetStakeAfter, "Stake should increase after claiming root dividends").toBeGreaterThan(stakerSubnetStakeBefore); + log.info(`✅ Root claim successful: stake increased from ${stakerSubnetStakeBefore} to ${stakerSubnetStakeAfter}`); + }); + + it("should claim root dividends with Swap type (swap to TAO on ROOT)", async () => { + const api = await getDevnetApi(); + + // Setup accounts + // - owner1Hotkey/owner1Coldkey: subnet 1 owner + // - owner2Hotkey/owner2Coldkey: subnet 2 owner (needed for root_sell_flag) + // - stakerColdkey: the coldkey that will stake on root and claim dividends + const owner1Hotkey = getRandomSubstrateKeypair(); + const owner1Coldkey = getRandomSubstrateKeypair(); + const owner2Hotkey = getRandomSubstrateKeypair(); + const owner2Coldkey = getRandomSubstrateKeypair(); + const stakerColdkey = getRandomSubstrateKeypair(); + const owner1HotkeyAddress = convertPublicKeyToSs58(owner1Hotkey.publicKey); + const owner1ColdkeyAddress = convertPublicKeyToSs58(owner1Coldkey.publicKey); + const owner2HotkeyAddress = convertPublicKeyToSs58(owner2Hotkey.publicKey); + const owner2ColdkeyAddress = convertPublicKeyToSs58(owner2Coldkey.publicKey); + const stakerColdkeyAddress = convertPublicKeyToSs58(stakerColdkey.publicKey); + + // Fund all accounts + await forceSetBalance(api, owner1HotkeyAddress); + await forceSetBalance(api, owner1ColdkeyAddress); + await forceSetBalance(api, owner2HotkeyAddress); + await forceSetBalance(api, owner2ColdkeyAddress); + await forceSetBalance(api, stakerColdkeyAddress); + + // Disable admin freeze window to allow enabling subtoken for ROOT + await sudoSetAdminFreezeWindow(api, 0); + log.info("Admin freeze window set to 0"); + + // Create TWO dynamic subnets + const netuid1 = await addNewSubnetwork(api, owner1Hotkey, owner1Coldkey); + await startCall(api, netuid1, owner1Coldkey); + log.info(`Created subnet 1 with netuid: ${netuid1}`); + + const netuid2 = await addNewSubnetwork(api, owner2Hotkey, owner2Coldkey); + await startCall(api, netuid2, owner2Coldkey); + log.info(`Created subnet 2 with netuid: ${netuid2}`); + + // Set short tempo for faster emission distribution + await sudoSetTempo(api, netuid1, 1); + await sudoSetTempo(api, netuid2, 1); + log.info("Set tempo to 1 for both subnets"); + + // Set EMA price halving period to 1 for fast moving price convergence + await sudoSetEmaPriceHalvingPeriod(api, netuid1, 1); + await sudoSetEmaPriceHalvingPeriod(api, netuid2, 1); + log.info("Set EMA halving period to 1 for fast price convergence"); + + // Set SubnetMovingAlpha to 1.0 (default is 0.000003 which is way too slow) + // I96F32 encoding: 1.0 * 2^32 = 4294967296 + const movingAlpha = BigInt(4294967296); // 1.0 in I96F32 + await sudoSetSubnetMovingAlpha(api, movingAlpha); + log.info("Set SubnetMovingAlpha to 1.0 for fast EMA convergence"); + + // Set threshold to 0 to allow claiming any amount + await sudoSetRootClaimThreshold(api, netuid1, 0n); + await sudoSetRootClaimThreshold(api, netuid2, 0n); + + // Add stake to ROOT subnet for the staker + const rootStakeAmount = tao(100); + await addStake(api, stakerColdkey, owner1HotkeyAddress, ROOT_NETUID, rootStakeAmount); + log.info(`Added ${rootStakeAmount} stake to root subnet for staker`); + + // Get initial ROOT stake + const rootStakeBefore = await getStake(api, owner1HotkeyAddress, stakerColdkeyAddress, ROOT_NETUID); + log.info(`Root stake before: ${rootStakeBefore}`); + + // Add stake to both dynamic subnets (owner stake to enable emissions flow) + const subnetStakeAmount = tao(50); + await addStake(api, owner1Coldkey, owner1HotkeyAddress, netuid1, subnetStakeAmount); + await addStake(api, owner2Coldkey, owner2HotkeyAddress, netuid2, subnetStakeAmount); + log.info(`Added ${subnetStakeAmount} owner stake to subnets ${netuid1} and ${netuid2}`); + + // Set root claim type to Swap (swap alpha to TAO and add to ROOT stake) + await setRootClaimType(api, stakerColdkey, "Swap"); + const claimType = await getRootClaimType(api, stakerColdkeyAddress); + log.info(`Root claim type: ${claimType}`); + expect(claimType).toBe("Swap"); + + // Wait for blocks + const blocksToWait = 25; + log.info(`Waiting for ${blocksToWait} blocks for emissions to accumulate...`); + await waitForBlocks(api, blocksToWait); + + // Debug: Check moving prices + const movingPrice1 = await getSubnetMovingPrice(api, netuid1); + const movingPrice2 = await getSubnetMovingPrice(api, netuid2); + const mp1Float = Number(movingPrice1) / 2**32; + const mp2Float = Number(movingPrice2) / 2**32; + log.info(`SubnetMovingPrice (float) - netuid1: ${mp1Float}, netuid2: ${mp2Float}, sum: ${mp1Float + mp2Float}`); + + const pendingDivs1 = await getPendingRootAlphaDivs(api, netuid1); + log.info(`PendingRootAlphaDivs netuid1: ${pendingDivs1}`); + + // Check claimable + const claimable = await getRootClaimable(api, owner1HotkeyAddress); + const claimableStr = [...claimable.entries()].map(([k, v]) => `[${k}: ${v.toString()}]`).join(", "); + log.info(`RootClaimable entries for hotkey1: ${claimableStr || "(none)"}`); + + // Call claim_root - with Swap type, dividends are swapped to TAO and added to ROOT stake + await claimRoot(api, stakerColdkey, [netuid1]); + log.info("Called claim_root with Swap type"); + + // Get ROOT stake after claim + const rootStakeAfter = await getStake(api, owner1HotkeyAddress, stakerColdkeyAddress, ROOT_NETUID); + log.info(`Root stake after claim: ${rootStakeAfter}`); + + // Check RootClaimed value + const rootClaimed = await getRootClaimed(api, netuid1, owner1HotkeyAddress, stakerColdkeyAddress); + log.info(`RootClaimed value: ${rootClaimed}`); + + // With Swap type, ROOT stake should increase (not dynamic subnet stake) + expect(rootStakeAfter, "ROOT stake should increase after claiming with Swap type").toBeGreaterThan(rootStakeBefore); + log.info(`✅ Root claim with Swap successful: ROOT stake increased from ${rootStakeBefore} to ${rootStakeAfter}`); + }); + + it("should handle claim_root when no dividends are available", async () => { + const api = await getDevnetApi(); + + // Setup accounts + const coldkey = getRandomSubstrateKeypair(); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + + await forceSetBalance(api, coldkeyAddress); + + // Set root claim type to Keep + await setRootClaimType(api, coldkey, "Keep"); + + // Try to claim on a non-existent subnet (should succeed but be a no-op) + // According to Rust tests, claiming on unrelated subnets returns Ok but does nothing + await claimRoot(api, coldkey, [1]); + + log.info("✅ claim_root with no dividends executed successfully (no-op)."); + }); +}); diff --git a/e2e/staking/test/move-stake.test.ts b/e2e/staking/test/move-stake.test.ts new file mode 100644 index 0000000000..532ffce168 --- /dev/null +++ b/e2e/staking/test/move-stake.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { + getDevnetApi, + getRandomSubstrateKeypair, + convertPublicKeyToSs58, + forceSetBalance, + addNewSubnetwork, + burnedRegister, + startCall, + addStake, + moveStake, + getStake, + getStakeRaw, + tao, + log, +} from "e2e-shared"; + +describe("▶ move_stake extrinsic", () => { + it("should move stake to another hotkey across subnets", async () => { + const api = await getDevnetApi(); + + // Setup accounts + const originHotkey = getRandomSubstrateKeypair(); + const destinationHotkey = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const originHotkeyAddress = convertPublicKeyToSs58(originHotkey.publicKey); + const destinationHotkeyAddress = convertPublicKeyToSs58(destinationHotkey.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + + await forceSetBalance(api, originHotkeyAddress); + await forceSetBalance(api, destinationHotkeyAddress); + await forceSetBalance(api, coldkeyAddress); + + // Create first subnet with origin hotkey + const netuid1 = await addNewSubnetwork(api, originHotkey, coldkey); + await startCall(api, netuid1, coldkey); + + // Create second subnet with destination hotkey + const netuid2 = await addNewSubnetwork(api, destinationHotkey, coldkey); + await startCall(api, netuid2, coldkey); + + // Add stake to origin hotkey on first subnet + await addStake(api, coldkey, originHotkeyAddress, netuid1, tao(200)); + + // Get initial stakes (converted from U64F64 for display) + const originStakeBefore = await getStake(api, originHotkeyAddress, coldkeyAddress, netuid1); + const destStakeBefore = await getStake(api, destinationHotkeyAddress, coldkeyAddress, netuid2); + expect(originStakeBefore, "Origin hotkey should have stake before move").toBeGreaterThan(0n); + + log.info(`Origin stake (netuid1) before: ${originStakeBefore}, Destination stake (netuid2) before: ${destStakeBefore}`); + + // Move stake to destination hotkey on different subnet + // Use raw U64F64 value for the extrinsic + const originStakeRaw = await getStakeRaw(api, originHotkeyAddress, coldkeyAddress, netuid1); + const moveAmount = originStakeRaw / 2n; + await moveStake(api, coldkey, originHotkeyAddress, destinationHotkeyAddress, netuid1, netuid2, moveAmount); + + // Verify stakes changed + const originStakeAfter = await getStake(api, originHotkeyAddress, coldkeyAddress, netuid1); + const destStakeAfter = await getStake(api, destinationHotkeyAddress, coldkeyAddress, netuid2); + + log.info(`Origin stake (netuid1) after: ${originStakeAfter}, Destination stake (netuid2) after: ${destStakeAfter}`); + + expect(originStakeAfter, "Origin stake should decrease").toBeLessThan(originStakeBefore); + expect(destStakeAfter, "Destination stake should increase").toBeGreaterThan(destStakeBefore); + + log.info("✅ Successfully moved stake to another hotkey across subnets."); + }); + + it("should move stake to another hotkey on the same subnet", async () => { + const api = await getDevnetApi(); + + // Setup accounts + const originHotkey = getRandomSubstrateKeypair(); + const destinationHotkey = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const originHotkeyAddress = convertPublicKeyToSs58(originHotkey.publicKey); + const destinationHotkeyAddress = convertPublicKeyToSs58(destinationHotkey.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + + await forceSetBalance(api, originHotkeyAddress); + await forceSetBalance(api, destinationHotkeyAddress); + await forceSetBalance(api, coldkeyAddress); + + // Create subnet with origin hotkey + const netuid = await addNewSubnetwork(api, originHotkey, coldkey); + await startCall(api, netuid, coldkey); + + // Register destination hotkey on the same subnet + await burnedRegister(api, netuid, destinationHotkeyAddress, coldkey); + + // Add stake to origin hotkey + await addStake(api, coldkey, originHotkeyAddress, netuid, tao(200)); + + // Get initial stakes (converted from U64F64 for display) + const originStakeBefore = await getStake(api, originHotkeyAddress, coldkeyAddress, netuid); + const destStakeBefore = await getStake(api, destinationHotkeyAddress, coldkeyAddress, netuid); + expect(originStakeBefore, "Origin hotkey should have stake before move").toBeGreaterThan(0n); + + log.info(`Origin stake before: ${originStakeBefore}, Destination stake before: ${destStakeBefore}`); + + // Move stake to destination hotkey on the same subnet + // Use raw U64F64 value for the extrinsic + const originStakeRaw = await getStakeRaw(api, originHotkeyAddress, coldkeyAddress, netuid); + const moveAmount = originStakeRaw / 2n; + await moveStake(api, coldkey, originHotkeyAddress, destinationHotkeyAddress, netuid, netuid, moveAmount); + + // Verify stakes changed + const originStakeAfter = await getStake(api, originHotkeyAddress, coldkeyAddress, netuid); + const destStakeAfter = await getStake(api, destinationHotkeyAddress, coldkeyAddress, netuid); + + log.info(`Origin stake after: ${originStakeAfter}, Destination stake after: ${destStakeAfter}`); + + expect(originStakeAfter, "Origin stake should decrease").toBeLessThan(originStakeBefore); + expect(destStakeAfter, "Destination stake should increase").toBeGreaterThan(destStakeBefore); + + log.info("✅ Successfully moved stake to another hotkey on the same subnet."); + }); +}); diff --git a/e2e/staking/test/remove-stake-full-limit.test.ts b/e2e/staking/test/remove-stake-full-limit.test.ts new file mode 100644 index 0000000000..47af798512 --- /dev/null +++ b/e2e/staking/test/remove-stake-full-limit.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { + getDevnetApi, + getRandomSubstrateKeypair, + convertPublicKeyToSs58, + forceSetBalance, + getBalance, + addNewSubnetwork, + burnedRegister, + startCall, + addStake, + removeStakeFullLimit, + getStake, + sudoSetTempo, + tao, + log, +} from "e2e-shared"; + +describe("▶ remove_stake_full_limit extrinsic", () => { + // Separate owner and staker hotkeys to avoid minimum owner stake retention + const ownerHotkey = getRandomSubstrateKeypair(); + const stakerHotkey = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const ownerAddress = convertPublicKeyToSs58(ownerHotkey.publicKey); + const stakerAddress = convertPublicKeyToSs58(stakerHotkey.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + let netuid: number; + + beforeAll(async () => { + const api = await getDevnetApi(); + await forceSetBalance(api, ownerAddress); + await forceSetBalance(api, stakerAddress); + await forceSetBalance(api, coldkeyAddress); + netuid = await addNewSubnetwork(api, ownerHotkey, coldkey); + await startCall(api, netuid, coldkey); + // Set high tempo to prevent emissions during test + await sudoSetTempo(api, netuid, 10000); + // Register staker hotkey (not the owner) + await burnedRegister(api, netuid, stakerAddress, coldkey); + }); + + it("should remove all stake with price limit", async () => { + const api = await getDevnetApi(); + + // Add stake first + await addStake(api, coldkey, stakerAddress, netuid, tao(100)); + + // Get initial stake and balance + const stakeBefore = await getStake(api, stakerAddress, coldkeyAddress, netuid); + const balanceBefore = await getBalance(api, coldkeyAddress); + log.info(`Stake before: ${stakeBefore}, Balance before: ${balanceBefore}`); + expect(stakeBefore, "Should have stake before removal").toBeGreaterThan(0n); + + // Remove all stake with a reasonable limit price (low limit to avoid slippage rejection) + // Using a low limit price (0.09 TAO per alpha) allows the transaction to succeed + const limitPrice = tao(1) / 10n; // 0.1 TAO + await removeStakeFullLimit(api, coldkey, stakerAddress, netuid, limitPrice); + + // Verify stake is zero (staker is not owner, so all stake can be removed) + const stakeAfter = await getStake(api, stakerAddress, coldkeyAddress, netuid); + const balanceAfter = await getBalance(api, coldkeyAddress); + log.info(`Stake after: ${stakeAfter}, Balance after: ${balanceAfter}`); + + expect(stakeAfter, "Stake should be zero after full removal").toBe(0n); + expect(balanceAfter, "Balance should increase after unstaking").toBeGreaterThan(balanceBefore); + + log.info("✅ Successfully removed all stake with price limit."); + }); + + it("should remove all stake without price limit", async () => { + const api = await getDevnetApi(); + + // Add stake first + await addStake(api, coldkey, stakerAddress, netuid, tao(100)); + + // Get initial stake and balance + const stakeBefore = await getStake(api, stakerAddress, coldkeyAddress, netuid); + const balanceBefore = await getBalance(api, coldkeyAddress); + log.info(`Stake before: ${stakeBefore}, Balance before: ${balanceBefore}`); + expect(stakeBefore, "Should have stake before removal").toBeGreaterThan(0n); + + // Remove all stake without limit price (undefined = no slippage protection) + await removeStakeFullLimit(api, coldkey, stakerAddress, netuid, undefined); + + // Verify stake is zero (staker is not owner, so all stake can be removed) + const stakeAfter = await getStake(api, stakerAddress, coldkeyAddress, netuid); + const balanceAfter = await getBalance(api, coldkeyAddress); + log.info(`Stake after: ${stakeAfter}, Balance after: ${balanceAfter}`); + + expect(stakeAfter, "Stake should be zero after full removal").toBe(0n); + expect(balanceAfter, "Balance should increase after unstaking").toBeGreaterThan(balanceBefore); + + log.info("✅ Successfully removed all stake without price limit."); + }); +}); diff --git a/e2e/staking/test/remove-stake-limit.test.ts b/e2e/staking/test/remove-stake-limit.test.ts new file mode 100644 index 0000000000..9578fb8e3f --- /dev/null +++ b/e2e/staking/test/remove-stake-limit.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { + getDevnetApi, + getRandomSubstrateKeypair, + convertPublicKeyToSs58, + forceSetBalance, + getBalance, + addNewSubnetwork, + startCall, + addStake, + removeStakeLimit, + getStake, + tao, + log, +} from "e2e-shared"; + +describe("▶ remove_stake_limit extrinsic", () => { + const hotkey = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const hotkeyAddress = convertPublicKeyToSs58(hotkey.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + let netuid: number; + + beforeAll(async () => { + const api = await getDevnetApi(); + await forceSetBalance(api, hotkeyAddress); + await forceSetBalance(api, coldkeyAddress); + netuid = await addNewSubnetwork(api, hotkey, coldkey); + await startCall(api, netuid, coldkey); + }); + + it("should remove stake with price limit (allow partial)", async () => { + const api = await getDevnetApi(); + + // Add stake first (100 TAO like benchmark) + await addStake(api, coldkey, hotkeyAddress, netuid, tao(100)); + + // Get initial stake and balance + const stakeBefore = await getStake(api, hotkeyAddress, coldkeyAddress, netuid); + const balanceBefore = await getBalance(api, coldkeyAddress); + log.info(`Stake before: ${stakeBefore}, Balance before: ${balanceBefore}`); + expect(stakeBefore, "Should have stake before removal").toBeGreaterThan(0n); + + // Remove stake with limit price and allow partial fills + const unstakeAmount = tao(30); + const limitPrice = tao(1); + await removeStakeLimit(api, coldkey, hotkeyAddress, netuid, unstakeAmount, limitPrice, true); + + // Verify balance increased (received TAO from unstaking) + const balanceAfter = await getBalance(api, coldkeyAddress); + expect(balanceAfter, "Balance should increase after unstaking").toBeGreaterThan(balanceBefore); + + log.info("✅ Successfully removed stake with limit (allow partial)."); + }); + + it("should remove stake with price limit (fill or kill)", async () => { + const api = await getDevnetApi(); + + // Add stake first (100 TAO like benchmark) + await addStake(api, coldkey, hotkeyAddress, netuid, tao(100)); + + // Get initial stake and balance + const stakeBefore = await getStake(api, hotkeyAddress, coldkeyAddress, netuid); + const balanceBefore = await getBalance(api, coldkeyAddress); + log.info(`Stake before: ${stakeBefore}, Balance before: ${balanceBefore}`); + expect(stakeBefore, "Should have stake before removal").toBeGreaterThan(0n); + + // Remove stake with limit price (fill or kill mode) + const unstakeAmount = tao(30); + const limitPrice = tao(1); + await removeStakeLimit(api, coldkey, hotkeyAddress, netuid, unstakeAmount, limitPrice, false); + + // Verify balance increased (received TAO from unstaking) + const balanceAfter = await getBalance(api, coldkeyAddress); + expect(balanceAfter, "Balance should increase after unstaking").toBeGreaterThan(balanceBefore); + + log.info("✅ Successfully removed stake with limit (fill or kill)."); + }); +}); diff --git a/e2e/staking/test/remove-stake.test.ts b/e2e/staking/test/remove-stake.test.ts new file mode 100644 index 0000000000..db9f5aa150 --- /dev/null +++ b/e2e/staking/test/remove-stake.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { + getDevnetApi, + getRandomSubstrateKeypair, + convertPublicKeyToSs58, + forceSetBalance, + getBalance, + addNewSubnetwork, + startCall, + addStake, + removeStake, + getStake, + getStakeRaw, + tao, + log, +} from "e2e-shared"; + +describe("▶ remove_stake extrinsic", () => { + const hotkey = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const hotkeyAddress = convertPublicKeyToSs58(hotkey.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + let netuid: number; + + beforeAll(async () => { + const api = await getDevnetApi(); + await forceSetBalance(api, hotkeyAddress); + await forceSetBalance(api, coldkeyAddress); + netuid = await addNewSubnetwork(api, hotkey, coldkey); + await startCall(api, netuid, coldkey); + }); + + it("should remove stake from a hotkey", async () => { + const api = await getDevnetApi(); + + // Add stake first + await addStake(api, coldkey, hotkeyAddress, netuid, tao(200)); + + // Get initial stake and balance (converted from U64F64 for display) + const stakeBefore = await getStake(api, hotkeyAddress, coldkeyAddress, netuid); + const balanceBefore = await getBalance(api, coldkeyAddress); + expect(stakeBefore, "Should have stake before removal").toBeGreaterThan(0n); + + // Remove stake (amount is in alpha units - use raw U64F64 value) + const stakeRaw = await getStakeRaw(api, hotkeyAddress, coldkeyAddress, netuid); + const unstakeAmount = stakeRaw / 2n; + await removeStake(api, coldkey, hotkeyAddress, netuid, unstakeAmount); + + // Verify balance increased (received TAO from unstaking) + const balanceAfter = await getBalance(api, coldkeyAddress); + expect(balanceAfter, "Balance should increase after unstaking").toBeGreaterThan(balanceBefore); + + log.info("✅ Successfully removed stake."); + }); +}); diff --git a/e2e/staking/test/swap-stake-limit.test.ts b/e2e/staking/test/swap-stake-limit.test.ts new file mode 100644 index 0000000000..316ddff051 --- /dev/null +++ b/e2e/staking/test/swap-stake-limit.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { + getDevnetApi, + getRandomSubstrateKeypair, + convertPublicKeyToSs58, + forceSetBalance, + addNewSubnetwork, + burnedRegister, + startCall, + addStake, + swapStakeLimit, + getStake, + getStakeRaw, + tao, + log, +} from "e2e-shared"; + +describe("▶ swap_stake_limit extrinsic", () => { + it("should swap stake with price limit (allow partial)", async () => { + const api = await getDevnetApi(); + + // Setup accounts + const hotkey1 = getRandomSubstrateKeypair(); + const hotkey2 = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const hotkey1Address = convertPublicKeyToSs58(hotkey1.publicKey); + const hotkey2Address = convertPublicKeyToSs58(hotkey2.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + + await forceSetBalance(api, hotkey1Address); + await forceSetBalance(api, hotkey2Address); + await forceSetBalance(api, coldkeyAddress); + + // Create first subnet + const netuid1 = await addNewSubnetwork(api, hotkey1, coldkey); + await startCall(api, netuid1, coldkey); + + // Create second subnet + const netuid2 = await addNewSubnetwork(api, hotkey2, coldkey); + await startCall(api, netuid2, coldkey); + + // Register hotkey1 on subnet2 so we can swap stake there + await burnedRegister(api, netuid2, hotkey1Address, coldkey); + + // Add stake to hotkey1 on subnet1 + await addStake(api, coldkey, hotkey1Address, netuid1, tao(100)); + + // Get initial stakes (converted from U64F64 for display) + const stake1Before = await getStake(api, hotkey1Address, coldkeyAddress, netuid1); + const stake2Before = await getStake(api, hotkey1Address, coldkeyAddress, netuid2); + expect(stake1Before, "Should have stake on subnet1 before swap").toBeGreaterThan(0n); + + log.info(`Stake on netuid1 before: ${stake1Before}, Stake on netuid2 before: ${stake2Before}`); + + // Swap stake with limit price (0.99 TAO relative price limit, allow partial fills) + // Use raw U64F64 value for the extrinsic + const stake1Raw = await getStakeRaw(api, hotkey1Address, coldkeyAddress, netuid1); + const swapAmount = stake1Raw / 2n; + const limitPrice = (tao(1) * 99n) / 100n; // 0.99 TAO + await swapStakeLimit(api, coldkey, hotkey1Address, netuid1, netuid2, swapAmount, limitPrice, true); + + // Verify stakes changed + const stake1After = await getStake(api, hotkey1Address, coldkeyAddress, netuid1); + const stake2After = await getStake(api, hotkey1Address, coldkeyAddress, netuid2); + + log.info(`Stake on netuid1 after: ${stake1After}, Stake on netuid2 after: ${stake2After}`); + + expect(stake1After, "Stake on subnet1 should decrease").toBeLessThan(stake1Before); + expect(stake2After, "Stake on subnet2 should increase").toBeGreaterThan(stake2Before); + + log.info("✅ Successfully swapped stake with price limit (allow partial)."); + }); + + it("should swap stake with price limit (fill or kill)", async () => { + const api = await getDevnetApi(); + + // Setup accounts + const hotkey1 = getRandomSubstrateKeypair(); + const hotkey2 = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const hotkey1Address = convertPublicKeyToSs58(hotkey1.publicKey); + const hotkey2Address = convertPublicKeyToSs58(hotkey2.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + + await forceSetBalance(api, hotkey1Address); + await forceSetBalance(api, hotkey2Address); + await forceSetBalance(api, coldkeyAddress); + + // Create first subnet + const netuid1 = await addNewSubnetwork(api, hotkey1, coldkey); + await startCall(api, netuid1, coldkey); + + // Create second subnet + const netuid2 = await addNewSubnetwork(api, hotkey2, coldkey); + await startCall(api, netuid2, coldkey); + + // Register hotkey1 on subnet2 so we can swap stake there + await burnedRegister(api, netuid2, hotkey1Address, coldkey); + + // Add stake to hotkey1 on subnet1 + await addStake(api, coldkey, hotkey1Address, netuid1, tao(100)); + + // Get initial stakes (converted from U64F64 for display) + const stake1Before = await getStake(api, hotkey1Address, coldkeyAddress, netuid1); + const stake2Before = await getStake(api, hotkey1Address, coldkeyAddress, netuid2); + expect(stake1Before, "Should have stake on subnet1 before swap").toBeGreaterThan(0n); + + log.info(`Stake on netuid1 before: ${stake1Before}, Stake on netuid2 before: ${stake2Before}`); + + // Swap stake with limit price (fill or kill mode - allow_partial = false) + // Use raw U64F64 value for the extrinsic + const stake1Raw = await getStakeRaw(api, hotkey1Address, coldkeyAddress, netuid1); + const swapAmount = stake1Raw / 2n; + const limitPrice = tao(1) / 10n; // 0.1 TAO - permissive limit to allow slippage + await swapStakeLimit(api, coldkey, hotkey1Address, netuid1, netuid2, swapAmount, limitPrice, false); + + // Verify stakes changed + const stake1After = await getStake(api, hotkey1Address, coldkeyAddress, netuid1); + const stake2After = await getStake(api, hotkey1Address, coldkeyAddress, netuid2); + + log.info(`Stake on netuid1 after: ${stake1After}, Stake on netuid2 after: ${stake2After}`); + + expect(stake1After, "Stake on subnet1 should decrease").toBeLessThan(stake1Before); + expect(stake2After, "Stake on subnet2 should increase").toBeGreaterThan(stake2Before); + + log.info("✅ Successfully swapped stake with price limit (fill or kill)."); + }); +}); diff --git a/e2e/staking/test/swap-stake.test.ts b/e2e/staking/test/swap-stake.test.ts new file mode 100644 index 0000000000..44a818dd81 --- /dev/null +++ b/e2e/staking/test/swap-stake.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { + getDevnetApi, + getRandomSubstrateKeypair, + convertPublicKeyToSs58, + forceSetBalance, + addNewSubnetwork, + burnedRegister, + startCall, + addStake, + swapStake, + getStake, + getStakeRaw, + tao, + log, +} from "e2e-shared"; + +describe("▶ swap_stake extrinsic", () => { + it("should swap stake from one subnet to another", async () => { + const api = await getDevnetApi(); + + // Setup accounts + const hotkey1 = getRandomSubstrateKeypair(); + const hotkey2 = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const hotkey1Address = convertPublicKeyToSs58(hotkey1.publicKey); + const hotkey2Address = convertPublicKeyToSs58(hotkey2.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + + await forceSetBalance(api, hotkey1Address); + await forceSetBalance(api, hotkey2Address); + await forceSetBalance(api, coldkeyAddress); + + // Create first subnet + const netuid1 = await addNewSubnetwork(api, hotkey1, coldkey); + await startCall(api, netuid1, coldkey); + + // Create second subnet + const netuid2 = await addNewSubnetwork(api, hotkey2, coldkey); + await startCall(api, netuid2, coldkey); + + // Register hotkey1 on subnet2 so we can swap stake there + await burnedRegister(api, netuid2, hotkey1Address, coldkey); + + // Add stake to hotkey1 on subnet1 + await addStake(api, coldkey, hotkey1Address, netuid1, tao(100)); + + // Get initial stakes + const stake1Before = await getStake(api, hotkey1Address, coldkeyAddress, netuid1); + const stake2Before = await getStake(api, hotkey1Address, coldkeyAddress, netuid2); + expect(stake1Before, "Should have stake on subnet1 before swap").toBeGreaterThan(0n); + + log.info(`Stake on netuid1 before: ${stake1Before}, Stake on netuid2 before: ${stake2Before}`); + + // Swap half the stake from subnet1 to subnet2 + // Use raw U64F64 value for the extrinsic + const stake1Raw = await getStakeRaw(api, hotkey1Address, coldkeyAddress, netuid1); + const swapAmount = stake1Raw / 2n; + await swapStake(api, coldkey, hotkey1Address, netuid1, netuid2, swapAmount); + + // Verify stakes changed + const stake1After = await getStake(api, hotkey1Address, coldkeyAddress, netuid1); + const stake2After = await getStake(api, hotkey1Address, coldkeyAddress, netuid2); + + log.info(`Stake on netuid1 after: ${stake1After}, Stake on netuid2 after: ${stake2After}`); + + // Note: hotkey1 is the owner of netuid1, so minimum owner stake may be retained + expect(stake1After, "Stake on subnet1 should decrease after swap").toBeLessThan(stake1Before); + expect(stake2After, "Stake on subnet2 should increase after swap").toBeGreaterThan(stake2Before); + + log.info("✅ Successfully swapped stake from one subnet to another."); + }); +}); diff --git a/e2e/staking/test/transfer-stake.test.ts b/e2e/staking/test/transfer-stake.test.ts new file mode 100644 index 0000000000..8cac7a5413 --- /dev/null +++ b/e2e/staking/test/transfer-stake.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { + getDevnetApi, + getRandomSubstrateKeypair, + convertPublicKeyToSs58, + forceSetBalance, + addNewSubnetwork, + startCall, + addStake, + transferStake, + getStake, + getStakeRaw, + tao, + log, +} from "e2e-shared"; + +describe("▶ transfer_stake extrinsic", () => { + it("should transfer stake to another coldkey across subnets", async () => { + const api = await getDevnetApi(); + + // Setup accounts + const hotkey1 = getRandomSubstrateKeypair(); + const hotkey2 = getRandomSubstrateKeypair(); + const originColdkey = getRandomSubstrateKeypair(); + const destinationColdkey = getRandomSubstrateKeypair(); + const hotkey1Address = convertPublicKeyToSs58(hotkey1.publicKey); + const hotkey2Address = convertPublicKeyToSs58(hotkey2.publicKey); + const originColdkeyAddress = convertPublicKeyToSs58(originColdkey.publicKey); + const destinationColdkeyAddress = convertPublicKeyToSs58(destinationColdkey.publicKey); + + await forceSetBalance(api, hotkey1Address); + await forceSetBalance(api, hotkey2Address); + await forceSetBalance(api, originColdkeyAddress); + await forceSetBalance(api, destinationColdkeyAddress); + + // Create first subnet + const netuid1 = await addNewSubnetwork(api, hotkey1, originColdkey); + await startCall(api, netuid1, originColdkey); + + // Create second subnet + const netuid2 = await addNewSubnetwork(api, hotkey2, originColdkey); + await startCall(api, netuid2, originColdkey); + + // Add stake from origin coldkey on first subnet + await addStake(api, originColdkey, hotkey1Address, netuid1, tao(200)); + + // Get initial stakes (converted from U64F64 for display) + const originStakeBefore = await getStake(api, hotkey1Address, originColdkeyAddress, netuid1); + const destStakeBefore = await getStake(api, hotkey1Address, destinationColdkeyAddress, netuid2); + expect(originStakeBefore, "Origin should have stake before transfer").toBeGreaterThan(0n); + + log.info(`Origin stake (netuid1) before: ${originStakeBefore}, Destination stake (netuid2) before: ${destStakeBefore}`); + + // Transfer stake to destination coldkey on a different subnet + // Use raw U64F64 value for the extrinsic + const originStakeRaw = await getStakeRaw(api, hotkey1Address, originColdkeyAddress, netuid1); + const transferAmount = originStakeRaw / 2n; + await transferStake(api, originColdkey, destinationColdkeyAddress, hotkey1Address, netuid1, netuid2, transferAmount); + + // Verify stakes changed + const originStakeAfter = await getStake(api, hotkey1Address, originColdkeyAddress, netuid1); + const destStakeAfter = await getStake(api, hotkey1Address, destinationColdkeyAddress, netuid2); + + log.info(`Origin stake (netuid1) after: ${originStakeAfter}, Destination stake (netuid2) after: ${destStakeAfter}`); + + expect(originStakeAfter, "Origin stake should decrease").toBeLessThan(originStakeBefore); + expect(destStakeAfter, "Destination stake should increase").toBeGreaterThan(destStakeBefore); + + log.info("✅ Successfully transferred stake to another coldkey across subnets."); + }); + + it("should transfer stake to another coldkey", async () => { + const api = await getDevnetApi(); + + // Setup accounts + const hotkey = getRandomSubstrateKeypair(); + const originColdkey = getRandomSubstrateKeypair(); + const destinationColdkey = getRandomSubstrateKeypair(); + const hotkeyAddress = convertPublicKeyToSs58(hotkey.publicKey); + const originColdkeyAddress = convertPublicKeyToSs58(originColdkey.publicKey); + const destinationColdkeyAddress = convertPublicKeyToSs58(destinationColdkey.publicKey); + + await forceSetBalance(api, hotkeyAddress); + await forceSetBalance(api, originColdkeyAddress); + await forceSetBalance(api, destinationColdkeyAddress); + + // Create subnet + const netuid = await addNewSubnetwork(api, hotkey, originColdkey); + await startCall(api, netuid, originColdkey); + + // Add stake from origin coldkey + const stakeAmount = tao(100); + await addStake(api, originColdkey, hotkeyAddress, netuid, stakeAmount); + + // Get initial stake (converted from U64F64 for display) + const originStakeBefore = await getStake(api, hotkeyAddress, originColdkeyAddress, netuid); + expect(originStakeBefore, "Origin should have stake before transfer").toBeGreaterThan(0n); + + log.info(`Origin stake before: ${originStakeBefore}`); + + // Transfer stake to destination coldkey + // Use raw U64F64 value for the extrinsic, transfer half to avoid AmountTooLow error + const originStakeRaw = await getStakeRaw(api, hotkeyAddress, originColdkeyAddress, netuid); + const transferAmount = originStakeRaw / 2n; + await transferStake(api, originColdkey, destinationColdkeyAddress, hotkeyAddress, netuid, netuid, transferAmount); + + // Verify destination received stake + const originStakeAfter = await getStake(api, hotkeyAddress, originColdkeyAddress, netuid); + const destStakeAfter = await getStake(api, hotkeyAddress, destinationColdkeyAddress, netuid); + + log.info(`Origin stake after: ${originStakeAfter}, Destination stake after: ${destStakeAfter}`); + + expect(originStakeAfter, "Origin stake should decrease after transfer").toBeLessThan(originStakeBefore); + expect(destStakeAfter, "Destination stake should be non-zero after transfer").toBeGreaterThan(0n); + + log.info("✅ Successfully transferred stake to another coldkey."); + }); +}); diff --git a/e2e/staking/test/unstake-all-alpha.test.ts b/e2e/staking/test/unstake-all-alpha.test.ts new file mode 100644 index 0000000000..dd71a27192 --- /dev/null +++ b/e2e/staking/test/unstake-all-alpha.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { + getDevnetApi, + getRandomSubstrateKeypair, + convertPublicKeyToSs58, + forceSetBalance, + addNewSubnetwork, + burnedRegister, + startCall, + addStake, + unstakeAllAlpha, + getStake, + sudoSetTempo, + tao, + log, +} from "e2e-shared"; + +describe("▶ unstake_all_alpha extrinsic", () => { + it("should unstake all alpha from multiple subnets and restake to root", async () => { + const api = await getDevnetApi(); + + // Setup accounts + // - owner1/coldkey: owns subnet 1 + // - owner2/coldkey: owns subnet 2 + // - stakerHotkey: staker (not owner) on both subnets - used for testing unstake_all_alpha + const owner1Hotkey = getRandomSubstrateKeypair(); + const owner2Hotkey = getRandomSubstrateKeypair(); + const stakerHotkey = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const owner1Address = convertPublicKeyToSs58(owner1Hotkey.publicKey); + const owner2Address = convertPublicKeyToSs58(owner2Hotkey.publicKey); + const stakerAddress = convertPublicKeyToSs58(stakerHotkey.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + + await forceSetBalance(api, owner1Address); + await forceSetBalance(api, owner2Address); + await forceSetBalance(api, stakerAddress); + await forceSetBalance(api, coldkeyAddress); + + // Create first subnet with owner1 + const netuid1 = await addNewSubnetwork(api, owner1Hotkey, coldkey); + await startCall(api, netuid1, coldkey); + + // Create second subnet with owner2 + const netuid2 = await addNewSubnetwork(api, owner2Hotkey, coldkey); + await startCall(api, netuid2, coldkey); + + // Set very high tempo to prevent emissions during test + await sudoSetTempo(api, netuid1, 10000); + await sudoSetTempo(api, netuid2, 10000); + + // Register stakerHotkey on both subnets (it's not the owner) + await burnedRegister(api, netuid1, stakerAddress, coldkey); + await burnedRegister(api, netuid2, stakerAddress, coldkey); + + // Add stake to both subnets using stakerHotkey (not the owner) + await addStake(api, coldkey, stakerAddress, netuid1, tao(100)); + await addStake(api, coldkey, stakerAddress, netuid2, tao(50)); + + // Verify stake was added to both subnets + const stake1Before = await getStake(api, stakerAddress, coldkeyAddress, netuid1); + const stake2Before = await getStake(api, stakerAddress, coldkeyAddress, netuid2); + + expect(stake1Before, "Should have stake in subnet 1 before unstake_all_alpha").toBeGreaterThan(0n); + expect(stake2Before, "Should have stake in subnet 2 before unstake_all_alpha").toBeGreaterThan(0n); + log.info(`Stake1 before: ${stake1Before}, Stake2 before: ${stake2Before}`); + + // Unstake all alpha - this removes stake from dynamic subnets and restakes to root + await unstakeAllAlpha(api, coldkey, stakerAddress); + + // Verify stakes are removed from both dynamic subnets + const stake1After = await getStake(api, stakerAddress, coldkeyAddress, netuid1); + const stake2After = await getStake(api, stakerAddress, coldkeyAddress, netuid2); + + log.info(`Stake1 after: ${stake1After}, Stake2 after: ${stake2After}`); + + // Since stakerHotkey is not the owner of either subnet, all stake should be removed + // High tempo prevents emissions during test, so expect exact zero + expect(stake1After, "Stake1 should be zero after unstake_all_alpha").toBe(0n); + expect(stake2After, "Stake2 should be zero after unstake_all_alpha").toBe(0n); + + log.info("✅ Successfully unstaked all alpha from multiple subnets to root."); + }); +}); diff --git a/e2e/staking/test/unstake-all.test.ts b/e2e/staking/test/unstake-all.test.ts new file mode 100644 index 0000000000..146a2c3225 --- /dev/null +++ b/e2e/staking/test/unstake-all.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { + getDevnetApi, + getRandomSubstrateKeypair, + convertPublicKeyToSs58, + forceSetBalance, + getBalance, + addNewSubnetwork, + burnedRegister, + startCall, + addStake, + unstakeAll, + getStake, + sudoSetTempo, + tao, + log, +} from "e2e-shared"; + +describe("▶ unstake_all extrinsic", () => { + it("should unstake all from a hotkey across all subnets", async () => { + const api = await getDevnetApi(); + + // Setup accounts + // - owner1Hotkey/coldkey: owns subnet 1 + // - owner2Hotkey/coldkey: owns subnet 2 + // - stakerHotkey: staker (not owner) on both subnets - used for testing unstake_all + const owner1Hotkey = getRandomSubstrateKeypair(); + const owner2Hotkey = getRandomSubstrateKeypair(); + const stakerHotkey = getRandomSubstrateKeypair(); + const coldkey = getRandomSubstrateKeypair(); + const owner1Address = convertPublicKeyToSs58(owner1Hotkey.publicKey); + const owner2Address = convertPublicKeyToSs58(owner2Hotkey.publicKey); + const stakerAddress = convertPublicKeyToSs58(stakerHotkey.publicKey); + const coldkeyAddress = convertPublicKeyToSs58(coldkey.publicKey); + + await forceSetBalance(api, owner1Address); + await forceSetBalance(api, owner2Address); + await forceSetBalance(api, stakerAddress); + await forceSetBalance(api, coldkeyAddress); + + // Create first subnet with owner1 + const netuid1 = await addNewSubnetwork(api, owner1Hotkey, coldkey); + await startCall(api, netuid1, coldkey); + + // Create second subnet with owner2 + const netuid2 = await addNewSubnetwork(api, owner2Hotkey, coldkey); + await startCall(api, netuid2, coldkey); + + // Set high tempo to prevent emissions during test + await sudoSetTempo(api, netuid1, 10000); + await sudoSetTempo(api, netuid2, 10000); + + // Register stakerHotkey on both subnets (it's not the owner) + await burnedRegister(api, netuid1, stakerAddress, coldkey); + await burnedRegister(api, netuid2, stakerAddress, coldkey); + + // Add stake to both subnets using stakerHotkey (not the owner) + await addStake(api, coldkey, stakerAddress, netuid1, tao(100)); + await addStake(api, coldkey, stakerAddress, netuid2, tao(50)); + + // Verify stake was added to both subnets + const stake1Before = await getStake(api, stakerAddress, coldkeyAddress, netuid1); + const stake2Before = await getStake(api, stakerAddress, coldkeyAddress, netuid2); + const balanceBefore = await getBalance(api, coldkeyAddress); + + expect(stake1Before, "Should have stake in subnet 1 before unstake_all").toBeGreaterThan(0n); + expect(stake2Before, "Should have stake in subnet 2 before unstake_all").toBeGreaterThan(0n); + log.info(`Stake1 before: ${stake1Before}, Stake2 before: ${stake2Before}, Balance before: ${balanceBefore}`); + + // Unstake all + await unstakeAll(api, coldkey, stakerAddress); + + // Verify stakes are removed from both subnets and balance increased + const stake1After = await getStake(api, stakerAddress, coldkeyAddress, netuid1); + const stake2After = await getStake(api, stakerAddress, coldkeyAddress, netuid2); + const balanceAfter = await getBalance(api, coldkeyAddress); + + log.info(`Stake1 after: ${stake1After}, Stake2 after: ${stake2After}, Balance after: ${balanceAfter}`); + + // Since stakerHotkey is not the owner of either subnet, all stake should be removed + expect(stake1After, "Stake1 should be zero after unstake_all").toBe(0n); + expect(stake2After, "Stake2 should be zero after unstake_all").toBe(0n); + expect(balanceAfter, "Balance should increase after unstaking").toBeGreaterThan(balanceBefore); + + log.info("✅ Successfully unstaked all from multiple subnets."); + }); +}); diff --git a/e2e/staking/tsconfig.json b/e2e/staking/tsconfig.json new file mode 100644 index 0000000000..c2f86d9e2c --- /dev/null +++ b/e2e/staking/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "types": ["node", "vitest/globals"] + } +} diff --git a/e2e/staking/vitest.config.ts b/e2e/staking/vitest.config.ts new file mode 100644 index 0000000000..c33905bdbe --- /dev/null +++ b/e2e/staking/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; +import { BaseSequencer, type TestSpecification } from "vitest/node"; + +class AlphabeticalSequencer extends BaseSequencer { + async sort(files: TestSpecification[]): Promise { + return files.sort((a, b) => a.moduleId.localeCompare(b.moduleId)); + } +} + +export default defineConfig({ + test: { + globals: true, + testTimeout: 120_000, + hookTimeout: 300_000, + fileParallelism: false, + globalSetup: "./setup.ts", + include: ["test/**/*.test.ts"], + sequence: { + sequencer: AlphabeticalSequencer, + }, + }, +});