diff --git a/ts-tests/moonwall.config.json b/ts-tests/moonwall.config.json index 118705e10b..a8afdebe61 100644 --- a/ts-tests/moonwall.config.json +++ b/ts-tests/moonwall.config.json @@ -94,6 +94,32 @@ "descriptor": "subtensor" } ] + }, + { + "name": "zombienet_coldkey_swap", + "timeout": 600000, + "testFileDir": ["suites/zombienet_coldkey_swap"], + "runScripts": [ + "generate-types.sh", + "build-spec.sh" + ], + "foundation": { + "type": "zombie", + "zombieSpec": { + "configPath": "./configs/zombie_node.json", + "skipBlockCheck": [] + } + }, + "vitestArgs": { + "bail": 1 + }, + "connections": [ + { + "name": "Node", + "type": "papi", + "endpoints": ["ws://127.0.0.1:9947"] + } + ] }, { "name": "smoke_mainnet", "testFileDir": ["suites/smoke"], diff --git a/ts-tests/scripts/generate-types.sh b/ts-tests/scripts/generate-types.sh index 19983d28b1..a73480697b 100755 --- a/ts-tests/scripts/generate-types.sh +++ b/ts-tests/scripts/generate-types.sh @@ -58,4 +58,4 @@ if [ "$GENERATE_TYPES" = true ]; then exit 0 else echo "==> Types are up-to-date, nothing to do." -fi \ No newline at end of file +fi diff --git a/ts-tests/suites/zombienet_coldkey_swap/00-coldkey-swap.test.ts b/ts-tests/suites/zombienet_coldkey_swap/00-coldkey-swap.test.ts new file mode 100644 index 0000000000..78899dbce5 --- /dev/null +++ b/ts-tests/suites/zombienet_coldkey_swap/00-coldkey-swap.test.ts @@ -0,0 +1,509 @@ +import { expect, beforeAll } from "vitest"; +import { describeSuite } from "@moonwall/cli"; +import { subtensor, MultiAddress } from "@polkadot-api/descriptors"; +import type { TypedApi } from "polkadot-api"; +import { + ANNOUNCEMENT_DELAY, + REANNOUNCEMENT_DELAY, + addNewSubnetwork, + addStake, + announceColdkeySwap, + clearColdkeySwapAnnouncement, + coldkeyHashBinary, + disputeColdkeySwap, + forceSetBalance, + generateKeyringPair, + getBalance, + getColdkeySwapAnnouncement, + getHotkeyOwner, + getOwnedHotkeys, + getStake, + getStakingHotkeys, + getSubnetOwner, + sendTransaction, + startCall, + sudoSetAnnouncementDelay, + sudoSetReannouncementDelay, + swapColdkeyAnnounced, + tao, + waitForBlocks, +} from "../../utils"; + +describeSuite({ + id: "00_coldkey_swap", + title: "▶ coldkey swap extrinsic", + foundationMethods: "zombie", + testCases: ({ it, context, log }) => { + let api: TypedApi; + + beforeAll(async () => { + api = context.papi("Node").getTypedApi(subtensor); + await sudoSetAnnouncementDelay(api, ANNOUNCEMENT_DELAY); + await sudoSetReannouncementDelay(api, REANNOUNCEMENT_DELAY); + }); + + it({ + id: "T01", + title: "happy path: announce → wait → swap (verifies full state migration)", + test: async () => { + const oldColdkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + const hotkey = generateKeyringPair("sr25519"); + + await forceSetBalance(api, oldColdkey.address); + await forceSetBalance(api, hotkey.address); + + // Create a subnet (oldColdkey becomes subnet owner) + const netuid = await addNewSubnetwork(api, hotkey, oldColdkey); + await startCall(api, netuid, oldColdkey); + log(`Created subnet ${netuid}`); + + // Add stake + await addStake(api, oldColdkey, hotkey.address, netuid, tao(200)); + + // Snapshot state before swap + const stakeBefore = await getStake(api, hotkey.address, oldColdkey.address, netuid); + expect(stakeBefore, "should have stake before swap").toBeGreaterThan(0n); + expect(await getSubnetOwner(api, netuid), "old coldkey should own the subnet").toBe(oldColdkey.address); + expect(await getHotkeyOwner(api, hotkey.address), "old coldkey should own the hotkey").toBe( + oldColdkey.address + ); + expect( + await getOwnedHotkeys(api, oldColdkey.address), + "old coldkey should have owned hotkeys" + ).toContain(hotkey.address); + expect( + await getStakingHotkeys(api, oldColdkey.address), + "old coldkey should have staking hotkeys" + ).toContain(hotkey.address); + const balanceBefore = await getBalance(api, oldColdkey.address); + log(`Before swap — stake: ${stakeBefore}, balance: ${balanceBefore}`); + + // Announce + const announceTx = api.tx.SubtensorModule.announce_coldkey_swap({ + new_coldkey_hash: coldkeyHashBinary(newColdkey), + }); + const announceResult = await sendTransaction(announceTx, oldColdkey); + expect(announceResult.success, "announce should succeed").toBe(true); + log("Announced coldkey swap"); + + // Wait for delay + await waitForBlocks(api, ANNOUNCEMENT_DELAY + 1); + + // Swap + const swapTx = api.tx.SubtensorModule.swap_coldkey_announced({ + new_coldkey: newColdkey.address, + }); + const swapResult = await sendTransaction(swapTx, oldColdkey); + expect(swapResult.success, "swap should succeed").toBe(true); + log("Swap executed"); + + // Verify stake migrated + const stakeOldAfter = await getStake(api, hotkey.address, oldColdkey.address, netuid); + expect(stakeOldAfter, "old coldkey should have no stake").toBe(0n); + const stakeNewAfter = await getStake(api, hotkey.address, newColdkey.address, netuid); + expect(stakeNewAfter, "new coldkey should have the stake").toBeGreaterThan(0n); + log(`Stake migrated: old=${stakeOldAfter}, new=${stakeNewAfter}`); + + // Verify subnet ownership transferred + expect(await getSubnetOwner(api, netuid), "new coldkey should own the subnet").toBe(newColdkey.address); + log("Subnet ownership transferred"); + + // Verify hotkey ownership transferred + expect(await getHotkeyOwner(api, hotkey.address), "new coldkey should own the hotkey").toBe( + newColdkey.address + ); + expect( + await getOwnedHotkeys(api, oldColdkey.address), + "old coldkey should have no owned hotkeys" + ).not.toContain(hotkey.address); + expect(await getOwnedHotkeys(api, newColdkey.address), "new coldkey should own the hotkey").toContain( + hotkey.address + ); + log("Hotkey ownership transferred"); + + // Verify staking hotkeys transferred + expect( + await getStakingHotkeys(api, oldColdkey.address), + "old coldkey should have no staking hotkeys" + ).not.toContain(hotkey.address); + expect( + await getStakingHotkeys(api, newColdkey.address), + "new coldkey should have staking hotkeys" + ).toContain(hotkey.address); + log("Staking hotkeys transferred"); + + // Verify balance transferred + const balanceOldAfter = await getBalance(api, oldColdkey.address); + expect(balanceOldAfter, "old coldkey balance should be 0").toBe(0n); + const balanceNewAfter = await getBalance(api, newColdkey.address); + expect(balanceNewAfter, "new coldkey should have balance").toBeGreaterThan(0n); + log(`Balance: old=${balanceOldAfter}, new=${balanceNewAfter}`); + }, + }); + + it({ + id: "T02", + title: "swap too early: rejected", + test: async () => { + const oldColdkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + await forceSetBalance(api, oldColdkey.address); + + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey)); + + // Immediately try swap without waiting + const swapTx = api.tx.SubtensorModule.swap_coldkey_announced({ + new_coldkey: newColdkey.address, + }); + const result = await sendTransaction(swapTx, oldColdkey); + expect(result.success, "swap should be rejected (too early)").toBe(false); + log("Correctly rejected early swap"); + }, + }); + + it({ + id: "T03", + title: "reannouncement: too early → wait → reannounce → swap", + test: async () => { + const oldColdkey = generateKeyringPair("sr25519"); + const newColdkey1 = generateKeyringPair("sr25519"); + const newColdkey2 = generateKeyringPair("sr25519"); + await forceSetBalance(api, oldColdkey.address); + + // First announcement + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey1)); + log("First announce ok"); + + // Reannounce immediately (should fail) + const earlyAnnounceTx = api.tx.SubtensorModule.announce_coldkey_swap({ + new_coldkey_hash: coldkeyHashBinary(newColdkey2), + }); + const earlyResult = await sendTransaction(earlyAnnounceTx, oldColdkey); + expect(earlyResult.success, "early reannounce should fail").toBe(false); + log("Early reannounce rejected"); + + // Wait for reannouncement delay + await waitForBlocks(api, REANNOUNCEMENT_DELAY + 1); + + // Reannounce (should succeed) + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey2)); + log("Reannounced to new key"); + + // Wait for announcement delay + await waitForBlocks(api, ANNOUNCEMENT_DELAY + 1); + + // Swap with old hash (should fail) + const wrongSwapTx = api.tx.SubtensorModule.swap_coldkey_announced({ + new_coldkey: newColdkey1.address, + }); + const wrongResult = await sendTransaction(wrongSwapTx, oldColdkey); + expect(wrongResult.success, "swap with old hash should fail").toBe(false); + log("Old hash rejected"); + + // Swap with new hash (should succeed) + await swapColdkeyAnnounced(api, oldColdkey, newColdkey2.address); + log("Swap with reannounced key succeeded"); + }, + }); + + it({ + id: "T04", + title: "dispute blocks swap execution", + test: async () => { + const oldColdkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + await forceSetBalance(api, oldColdkey.address); + + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey)); + + // Dispute + const disputeTx = api.tx.SubtensorModule.dispute_coldkey_swap(); + const disputeResult = await sendTransaction(disputeTx, oldColdkey); + expect(disputeResult.success, "dispute should succeed").toBe(true); + log("Disputed"); + + await waitForBlocks(api, ANNOUNCEMENT_DELAY + 1); + + // Swap should fail (disputed) + const swapTx = api.tx.SubtensorModule.swap_coldkey_announced({ + new_coldkey: newColdkey.address, + }); + const swapResult = await sendTransaction(swapTx, oldColdkey); + expect(swapResult.success, "swap should fail (disputed)").toBe(false); + log("Swap blocked after dispute"); + }, + }); + + it({ + id: "T05", + title: "double dispute: second fails", + test: async () => { + const oldColdkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + await forceSetBalance(api, oldColdkey.address); + + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey)); + await disputeColdkeySwap(api, oldColdkey); + + // Second dispute should fail + const disputeTx = api.tx.SubtensorModule.dispute_coldkey_swap(); + const result = await sendTransaction(disputeTx, oldColdkey); + expect(result.success, "second dispute should fail").toBe(false); + log("Second dispute correctly rejected"); + }, + }); + + it({ + id: "T06", + title: "announce fails: insufficient balance", + test: async () => { + const poorKey = generateKeyringPair("sr25519"); + // Intentionally NOT funded + + const announceTx = api.tx.SubtensorModule.announce_coldkey_swap({ + new_coldkey_hash: coldkeyHashBinary(generateKeyringPair("sr25519")), + }); + const result = await sendTransaction(announceTx, poorKey); + expect(result.success, "announce should fail (no balance)").toBe(false); + log("Announce rejected for insufficient balance"); + }, + }); + + it({ + id: "T07", + title: "swap with wrong key: hash mismatch", + test: async () => { + const oldColdkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + const wrongKey = generateKeyringPair("sr25519"); + await forceSetBalance(api, oldColdkey.address); + + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey)); + await waitForBlocks(api, ANNOUNCEMENT_DELAY + 1); + + // Swap with wrong address + const swapTx = api.tx.SubtensorModule.swap_coldkey_announced({ + new_coldkey: wrongKey.address, + }); + const result = await sendTransaction(swapTx, oldColdkey); + expect(result.success, "swap should fail (hash mismatch)").toBe(false); + log("Hash mismatch correctly rejected"); + }, + }); + + it({ + id: "T08", + title: "dispute without announcement: fails", + test: async () => { + const someKey = generateKeyringPair("sr25519"); + await forceSetBalance(api, someKey.address); + + const disputeTx = api.tx.SubtensorModule.dispute_coldkey_swap(); + const result = await sendTransaction(disputeTx, someKey); + expect(result.success, "dispute should fail (no announcement)").toBe(false); + log("Dispute without announcement rejected"); + }, + }); + + it({ + id: "T09", + title: "clear announcement: announce → wait → clear removes announcement", + test: async () => { + const coldkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + await forceSetBalance(api, coldkey.address); + + await announceColdkeySwap(api, coldkey, coldkeyHashBinary(newColdkey)); + expect( + await getColdkeySwapAnnouncement(api, coldkey.address), + "announcement should exist" + ).not.toBeNull(); + log("Announced"); + + // Wait for reannouncement delay (measured from execution block) + await waitForBlocks(api, ANNOUNCEMENT_DELAY + REANNOUNCEMENT_DELAY + 1); + + await clearColdkeySwapAnnouncement(api, coldkey); + + expect( + await getColdkeySwapAnnouncement(api, coldkey.address), + "announcement should be removed" + ).toBeNull(); + log("Announcement cleared"); + }, + }); + + it({ + id: "T10", + title: "clear announcement too early: rejected", + test: async () => { + const coldkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + await forceSetBalance(api, coldkey.address); + + await announceColdkeySwap(api, coldkey, coldkeyHashBinary(newColdkey)); + + const clearTx = api.tx.SubtensorModule.clear_coldkey_swap_announcement(); + const result = await sendTransaction(clearTx, coldkey); + expect(result.success, "clear should be rejected (too early)").toBe(false); + + expect( + await getColdkeySwapAnnouncement(api, coldkey.address), + "announcement should still exist" + ).not.toBeNull(); + log("Correctly rejected early clear"); + }, + }); + + it({ + id: "T11", + title: "clear announcement without announcement: fails", + test: async () => { + const coldkey = generateKeyringPair("sr25519"); + await forceSetBalance(api, coldkey.address); + + const clearTx = api.tx.SubtensorModule.clear_coldkey_swap_announcement(); + const result = await sendTransaction(clearTx, coldkey); + expect(result.success, "clear should fail (no announcement)").toBe(false); + log("Clear without announcement rejected"); + }, + }); + + it({ + id: "T12", + title: "clear announcement after dispute: blocked", + test: async () => { + const coldkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + await forceSetBalance(api, coldkey.address); + + await announceColdkeySwap(api, coldkey, coldkeyHashBinary(newColdkey)); + await disputeColdkeySwap(api, coldkey); + log("Announced + disputed"); + + await waitForBlocks(api, ANNOUNCEMENT_DELAY + REANNOUNCEMENT_DELAY + 1); + + const clearTx = api.tx.SubtensorModule.clear_coldkey_swap_announcement(); + const result = await sendTransaction(clearTx, coldkey); + expect(result.success, "clear should fail (disputed)").toBe(false); + log("Clear blocked after dispute"); + }, + }); + + it({ + id: "T13", + title: "dispatch guard: active announcement blocks staking and transfer but allows swap calls", + test: async () => { + const coldkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + const hotkey = generateKeyringPair("sr25519"); + const recipient = generateKeyringPair("sr25519"); + + await forceSetBalance(api, coldkey.address); + await forceSetBalance(api, hotkey.address); + + const netuid = await addNewSubnetwork(api, hotkey, coldkey); + await startCall(api, netuid, coldkey); + + // Announce swap + await announceColdkeySwap(api, coldkey, coldkeyHashBinary(newColdkey)); + log("Announced coldkey swap"); + + // add_stake should be blocked + const stakeTx = api.tx.SubtensorModule.add_stake({ + hotkey: hotkey.address, + netuid: netuid, + amount_staked: tao(10), + }); + const stakeResult = await sendTransaction(stakeTx, coldkey); + expect(stakeResult.success, "add_stake should be blocked by guard").toBe(false); + log("add_stake blocked"); + + // transfer_keep_alive should be blocked + const transferTx = api.tx.Balances.transfer_keep_alive({ + dest: MultiAddress.Id(recipient.address), + value: tao(1), + }); + const transferResult = await sendTransaction(transferTx, coldkey); + expect(transferResult.success, "transfer should be blocked by guard").toBe(false); + log("transfer_keep_alive blocked"); + + // swap-related calls should still go through the guard + // (reannounce will fail because of reannouncement delay, but NOT because of the guard) + const reannounceTx = api.tx.SubtensorModule.announce_coldkey_swap({ + new_coldkey_hash: coldkeyHashBinary(newColdkey), + }); + const reannounceResult = await sendTransaction(reannounceTx, coldkey); + // Fails with ReannounceBeforeDelay, not ColdkeySwapAnnounced — meaning the guard allowed it through + expect(reannounceResult.success, "reannounce fails but not from the guard").toBe(false); + expect( + reannounceResult.errorMessage, + "error should be reannouncement delay, not guard block" + ).not.toContain("ColdkeySwapAnnounced"); + log("announce_coldkey_swap passed through guard (failed at pallet level as expected)"); + + // dispute should succeed (allowed through the guard) + await disputeColdkeySwap(api, coldkey); + log("dispute_coldkey_swap allowed through guard"); + }, + }); + + it({ + id: "T14", + title: "dispatch guard: disputed swap blocks ALL calls including swap-related", + test: async () => { + const coldkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + const hotkey = generateKeyringPair("sr25519"); + const recipient = generateKeyringPair("sr25519"); + + await forceSetBalance(api, coldkey.address); + await forceSetBalance(api, hotkey.address); + + const netuid = await addNewSubnetwork(api, hotkey, coldkey); + await startCall(api, netuid, coldkey); + + // Announce + dispute + await announceColdkeySwap(api, coldkey, coldkeyHashBinary(newColdkey)); + await disputeColdkeySwap(api, coldkey); + log("Announced + disputed"); + + // add_stake should be blocked + const stakeTx = api.tx.SubtensorModule.add_stake({ + hotkey: hotkey.address, + netuid: netuid, + amount_staked: tao(10), + }); + const stakeResult = await sendTransaction(stakeTx, coldkey); + expect(stakeResult.success, "add_stake should be blocked (disputed)").toBe(false); + log("add_stake blocked"); + + // transfer should be blocked + const transferTx = api.tx.Balances.transfer_keep_alive({ + dest: MultiAddress.Id(recipient.address), + value: tao(1), + }); + const transferResult = await sendTransaction(transferTx, coldkey); + expect(transferResult.success, "transfer should be blocked (disputed)").toBe(false); + log("transfer_keep_alive blocked"); + + // swap_coldkey_announced should also be blocked (unlike T13 where swap calls pass) + const swapTx = api.tx.SubtensorModule.swap_coldkey_announced({ + new_coldkey: newColdkey.address, + }); + const swapResult = await sendTransaction(swapTx, coldkey); + expect(swapResult.success, "swap should be blocked (disputed)").toBe(false); + log("swap_coldkey_announced blocked"); + + // announce should also be blocked + const announceTx = api.tx.SubtensorModule.announce_coldkey_swap({ + new_coldkey_hash: coldkeyHashBinary(newColdkey), + }); + const announceResult = await sendTransaction(announceTx, coldkey); + expect(announceResult.success, "announce should be blocked (disputed)").toBe(false); + log("announce_coldkey_swap blocked — all calls rejected under dispute"); + }, + }); + }, +}); diff --git a/ts-tests/suites/zombienet_coldkey_swap/01-coldkey-swap-sudo.test.ts b/ts-tests/suites/zombienet_coldkey_swap/01-coldkey-swap-sudo.test.ts new file mode 100644 index 0000000000..1dbf53c1e5 --- /dev/null +++ b/ts-tests/suites/zombienet_coldkey_swap/01-coldkey-swap-sudo.test.ts @@ -0,0 +1,175 @@ +import { expect, beforeAll } from "vitest"; +import { describeSuite } from "@moonwall/cli"; +import { subtensor } from "@polkadot-api/descriptors"; +import type { TypedApi } from "polkadot-api"; +import { + addNewSubnetwork, + addStake, + announceColdkeySwap, + burnedRegister, + coldkeyHashBinary, + disputeColdkeySwap, + forceSetBalance, + generateKeyringPair, + getBalance, + getColdkeySwapAnnouncement, + getColdkeySwapDispute, + getHotkeyOwner, + getOwnedHotkeys, + getStake, + getStakingHotkeys, + getSubnetOwner, + startCall, + sudoResetColdkeySwap, + sudoSwapColdkey, + tao, +} from "../../utils"; + +describeSuite({ + id: "01_coldkey_swap_sudo", + title: "▶ coldkey swap sudo operations", + foundationMethods: "zombie", + testCases: ({ it, context, log }) => { + let api: TypedApi; + + beforeAll(async () => { + api = context.papi("Node").getTypedApi(subtensor); + }); + + it({ + id: "T01", + title: "reset as root: clears announcement and dispute", + test: async () => { + const oldColdkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + await forceSetBalance(api, oldColdkey.address); + + // Announce and dispute + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey)); + await disputeColdkeySwap(api, oldColdkey); + log("Announced + disputed"); + + // Verify storage before reset + const annBefore = await getColdkeySwapAnnouncement(api, oldColdkey.address); + expect(annBefore, "announcement should exist before reset").not.toBeNull(); + + // Reset via sudo + await sudoResetColdkeySwap(api, oldColdkey.address); + log("Reset via sudo"); + + // Verify storage cleared + const annAfter = await getColdkeySwapAnnouncement(api, oldColdkey.address); + expect(annAfter, "announcement should be cleared").toBeNull(); + const dispAfter = await getColdkeySwapDispute(api, oldColdkey.address); + expect(dispAfter, "dispute should be cleared").toBeNull(); + log("Storage cleared"); + + // Re-announce should succeed + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey)); + log("Re-announce after reset succeeded"); + }, + }); + + it({ + id: "T02", + title: "instant swap as root: transfers stake and ownership across multiple subnets", + test: async () => { + const oldColdkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + const hotkey1 = generateKeyringPair("sr25519"); + const hotkey2 = generateKeyringPair("sr25519"); + + await forceSetBalance(api, oldColdkey.address); + await forceSetBalance(api, hotkey1.address); + await forceSetBalance(api, hotkey2.address); + + // Create two subnets + const netuid1 = await addNewSubnetwork(api, hotkey1, oldColdkey); + await startCall(api, netuid1, oldColdkey); + const netuid2 = await addNewSubnetwork(api, hotkey2, oldColdkey); + await startCall(api, netuid2, oldColdkey); + log(`Created subnets ${netuid1} and ${netuid2}`); + + // Register hotkey1 on subnet2 and stake on both + await burnedRegister(api, netuid2, hotkey1.address, oldColdkey); + await addStake(api, oldColdkey, hotkey1.address, netuid1, tao(100)); + await addStake(api, oldColdkey, hotkey1.address, netuid2, tao(50)); + + const stake1Before = await getStake(api, hotkey1.address, oldColdkey.address, netuid1); + const stake2Before = await getStake(api, hotkey1.address, oldColdkey.address, netuid2); + expect(stake1Before, "should have stake on subnet1").toBeGreaterThan(0n); + expect(stake2Before, "should have stake on subnet2").toBeGreaterThan(0n); + log(`Before — subnet1 stake: ${stake1Before}, subnet2 stake: ${stake2Before}`); + + // Sudo swap + await sudoSwapColdkey(api, oldColdkey.address, newColdkey.address, 0n); + log("Sudo swap executed"); + + // Verify both subnets' stake migrated + expect( + await getStake(api, hotkey1.address, oldColdkey.address, netuid1), + "old coldkey stake on subnet1 should be 0" + ).toBe(0n); + expect( + await getStake(api, hotkey1.address, newColdkey.address, netuid1), + "new coldkey should have stake on subnet1" + ).toBeGreaterThan(0n); + expect( + await getStake(api, hotkey1.address, oldColdkey.address, netuid2), + "old coldkey stake on subnet2 should be 0" + ).toBe(0n); + expect( + await getStake(api, hotkey1.address, newColdkey.address, netuid2), + "new coldkey should have stake on subnet2" + ).toBeGreaterThan(0n); + log("Stake migrated on both subnets"); + + // Verify subnet ownership transferred + expect(await getSubnetOwner(api, netuid1), "new coldkey should own subnet1").toBe(newColdkey.address); + expect(await getSubnetOwner(api, netuid2), "new coldkey should own subnet2").toBe(newColdkey.address); + + // Verify hotkey ownership transferred + expect(await getHotkeyOwner(api, hotkey1.address), "hotkey1 owner").toBe(newColdkey.address); + expect(await getHotkeyOwner(api, hotkey2.address), "hotkey2 owner").toBe(newColdkey.address); + + // Verify old coldkey is fully empty + expect( + (await getOwnedHotkeys(api, oldColdkey.address)).length, + "old coldkey should own no hotkeys" + ).toBe(0); + expect( + (await getStakingHotkeys(api, oldColdkey.address)).length, + "old coldkey should have no staking hotkeys" + ).toBe(0); + expect(await getBalance(api, oldColdkey.address), "old coldkey balance should be 0").toBe(0n); + + log("All state migrated across both subnets"); + }, + }); + + it({ + id: "T03", + title: "instant swap as root: clears pending announcement", + test: async () => { + const oldColdkey = generateKeyringPair("sr25519"); + const newColdkey = generateKeyringPair("sr25519"); + const decoy = generateKeyringPair("sr25519"); + await forceSetBalance(api, oldColdkey.address); + + // Announce for decoy + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(decoy)); + const annBefore = await getColdkeySwapAnnouncement(api, oldColdkey.address); + expect(annBefore, "announcement should exist").not.toBeNull(); + log("Pending announcement exists"); + + // Sudo swap with different key + await sudoSwapColdkey(api, oldColdkey.address, newColdkey.address, 0n); + + // Announcement should be cleared + const annAfter = await getColdkeySwapAnnouncement(api, oldColdkey.address); + expect(annAfter, "announcement should be cleared after root swap").toBeNull(); + log("Announcement cleared by root swap"); + }, + }); + }, +}); diff --git a/ts-tests/utils/coldkey_swap.ts b/ts-tests/utils/coldkey_swap.ts new file mode 100644 index 0000000000..39fa5ac8bb --- /dev/null +++ b/ts-tests/utils/coldkey_swap.ts @@ -0,0 +1,144 @@ +import { Keyring } from "@polkadot/keyring"; +import { blake2AsHex } from "@polkadot/util-crypto"; +import type { KeyringPair } from "@moonwall/util"; +import { waitForTransactionWithRetry } from "./transactions.js"; +import type { TypedApi } from "polkadot-api"; +import type { subtensor } from "@polkadot-api/descriptors"; +import { FixedSizeBinary } from "polkadot-api"; + +export const ANNOUNCEMENT_DELAY = 10; +export const REANNOUNCEMENT_DELAY = 10; + +/** Compute BLAKE2-256 hash of a keypair's public key as a FixedSizeBinary (used for announcements). */ +export function coldkeyHashBinary(pair: KeyringPair): FixedSizeBinary<32> { + return FixedSizeBinary.fromHex(blake2AsHex(pair.publicKey, 256)); +} + +/** Compute BLAKE2-256 hash of a keypair's public key as hex string. */ +export function coldkeyHash(pair: KeyringPair): string { + return blake2AsHex(pair.publicKey, 256); +} + +// ── Sudo configuration ────────────────────────────────────────────────── + +export async function sudoSetAnnouncementDelay(api: TypedApi, delay: number): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const internalCall = api.tx.AdminUtils.sudo_set_coldkey_swap_announcement_delay({ + duration: delay, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_coldkey_swap_announcement_delay"); +} + +export async function sudoSetReannouncementDelay(api: TypedApi, delay: number): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const internalCall = api.tx.AdminUtils.sudo_set_coldkey_swap_reannouncement_delay({ + duration: delay, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_coldkey_swap_reannouncement_delay"); +} + +// ── Transaction wrappers (throw on failure) ───────────────────────────── + +export async function announceColdkeySwap( + api: TypedApi, + signer: KeyringPair, + newColdkeyHash: FixedSizeBinary<32> +): Promise { + const tx = api.tx.SubtensorModule.announce_coldkey_swap({ + new_coldkey_hash: newColdkeyHash, + }); + await waitForTransactionWithRetry(api, tx, signer, "announce_coldkey_swap"); +} + +export async function swapColdkeyAnnounced( + api: TypedApi, + signer: KeyringPair, + newAddress: string +): Promise { + const tx = api.tx.SubtensorModule.swap_coldkey_announced({ + new_coldkey: newAddress, + }); + await waitForTransactionWithRetry(api, tx, signer, "swap_coldkey_announced"); +} + +export async function disputeColdkeySwap(api: TypedApi, signer: KeyringPair): Promise { + const tx = api.tx.SubtensorModule.dispute_coldkey_swap(); + await waitForTransactionWithRetry(api, tx, signer, "dispute_coldkey_swap"); +} + +export async function clearColdkeySwapAnnouncement( + api: TypedApi, + signer: KeyringPair +): Promise { + const tx = api.tx.SubtensorModule.clear_coldkey_swap_announcement(); + await waitForTransactionWithRetry(api, tx, signer, "clear_coldkey_swap_announcement"); +} + +export async function sudoResetColdkeySwap(api: TypedApi, coldkeyAddress: string): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const internalCall = api.tx.SubtensorModule.reset_coldkey_swap({ + coldkey: coldkeyAddress, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_reset_coldkey_swap"); +} + +export async function sudoSwapColdkey( + api: TypedApi, + oldAddress: string, + newAddress: string, + swapCost: bigint = 0n +): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const internalCall = api.tx.SubtensorModule.swap_coldkey({ + old_coldkey: oldAddress, + new_coldkey: newAddress, + swap_cost: swapCost, + }); + const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall }); + await waitForTransactionWithRetry(api, tx, alice, "sudo_swap_coldkey"); +} + +// ── Storage query helpers ─────────────────────────────────────────────── + +export async function getColdkeySwapAnnouncement( + api: TypedApi, + address: string +): Promise<{ when: number; hash: string } | null> { + const result = await api.query.SubtensorModule.ColdkeySwapAnnouncements.getValue(address); + if (!result) return null; + const [when, hash] = result; + return { when, hash: hash.asHex() }; +} + +export async function getColdkeySwapDispute(api: TypedApi, address: string): Promise { + const result = await api.query.SubtensorModule.ColdkeySwapDisputes.getValue(address); + if (result === undefined) return null; + return Number(result); +} + +/** Get the owner coldkey of a hotkey. */ +export async function getHotkeyOwner(api: TypedApi, hotkey: string): Promise { + return await api.query.SubtensorModule.Owner.getValue(hotkey); +} + +/** Get the list of hotkeys owned by a coldkey. */ +export async function getOwnedHotkeys(api: TypedApi, coldkey: string): Promise { + return await api.query.SubtensorModule.OwnedHotkeys.getValue(coldkey); +} + +/** Get the list of hotkeys a coldkey is staking to. */ +export async function getStakingHotkeys(api: TypedApi, coldkey: string): Promise { + return await api.query.SubtensorModule.StakingHotkeys.getValue(coldkey); +} + +/** Get the owner coldkey of a subnet. */ +export async function getSubnetOwner(api: TypedApi, netuid: number): Promise { + return await api.query.SubtensorModule.SubnetOwner.getValue(netuid); +} diff --git a/ts-tests/utils/index.ts b/ts-tests/utils/index.ts index 7e9a6b4d5e..b3aa36d528 100644 --- a/ts-tests/utils/index.ts +++ b/ts-tests/utils/index.ts @@ -4,3 +4,4 @@ export * from "./subnet.js"; export * from "./staking.js"; export * from "./shield_helpers.ts"; export * from "./account.ts"; +export * from "./coldkey_swap.ts"; diff --git a/ts-tests/utils/transactions.ts b/ts-tests/utils/transactions.ts index 2842edfd56..ae308d40e6 100644 --- a/ts-tests/utils/transactions.ts +++ b/ts-tests/utils/transactions.ts @@ -88,6 +88,63 @@ export async function waitForTransactionCompletion( }); } +export type TransactionResult = { + success: boolean; + events: any[]; + txHash?: string; + blockHash?: string; + errorMessage?: string; +}; + +/** + * Send a transaction and return a result object instead of throwing on ExtrinsicFailed. + * Use this for tests that expect failure. + */ +export async function sendTransaction( + tx: Transaction, string, string, void>, + signer: KeyringPair, + timeout: number = 3 * 60 * 1000 +): Promise { + const polkadotSigner = getPolkadotSigner(signer.publicKey, "Sr25519", signer.sign); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error("Transaction timed out")); + }, timeout); + + const subscription = tx.signSubmitAndWatch(polkadotSigner).subscribe({ + next(event) { + if (event.type === "finalized") { + clearTimeout(timer); + subscription.unsubscribe(); + + if (event.dispatchError) { + resolve({ + success: false, + events: event.events, + txHash: event.txHash, + blockHash: event.block.hash, + errorMessage: JSON.stringify(event.dispatchError), + }); + } else { + resolve({ + success: true, + events: event.events, + txHash: event.txHash, + blockHash: event.block.hash, + }); + } + } + }, + error(err) { + clearTimeout(timer); + const message = err instanceof Error ? err.message : String(err); + resolve({ success: false, events: [], errorMessage: message }); + }, + }); + }); +} + const SECOND = 1000; /** Polls the chain until `count` new finalized blocks have been produced. */