From a3e501753820271aafa71dcee2b5f2667bb79078 Mon Sep 17 00:00:00 2001 From: Loris Moulin Date: Wed, 18 Mar 2026 16:49:22 -0300 Subject: [PATCH 1/3] wip --- ts-tests/moonwall.config.json | 25 ++ ts-tests/pnpm-lock.yaml | 28 ++ .../00-coldkey-swap.test.ts | 310 ++++++++++++++++++ .../01-coldkey-swap-sudo.test.ts | 156 +++++++++ ts-tests/utils/coldkey_swap.ts | 113 +++++++ ts-tests/utils/index.ts | 1 + ts-tests/utils/transactions.ts | 68 ++++ 7 files changed, 701 insertions(+) create mode 100644 ts-tests/suites/zombienet_coldkey_swap/00-coldkey-swap.test.ts create mode 100644 ts-tests/suites/zombienet_coldkey_swap/01-coldkey-swap-sudo.test.ts create mode 100644 ts-tests/utils/coldkey_swap.ts diff --git a/ts-tests/moonwall.config.json b/ts-tests/moonwall.config.json index ebd99d2086..fc892bb206 100644 --- a/ts-tests/moonwall.config.json +++ b/ts-tests/moonwall.config.json @@ -101,6 +101,31 @@ "descriptor": "subtensor" } ] + }, + { + "name": "zombienet_coldkey_swap", + "timeout": 600000, + "testFileDir": ["suites/zombienet_coldkey_swap"], + "runScripts": [ + "build-spec.sh" + ], + "foundation": { + "type": "zombie", + "zombieSpec": { + "configPath": "./configs/zombie_node.json", + "skipBlockCheck": [] + } + }, + "vitestArgs": { + "bail": 1 + }, + "connections": [ + { + "name": "Node", + "type": "polkadotJs", + "endpoints": ["ws://127.0.0.1:9947"] + } + ] }, { "name": "smoke_mainnet", "testFileDir": ["suites/smoke"], diff --git a/ts-tests/pnpm-lock.yaml b/ts-tests/pnpm-lock.yaml index a8f86697df..9fa78d77c4 100644 --- a/ts-tests/pnpm-lock.yaml +++ b/ts-tests/pnpm-lock.yaml @@ -210,24 +210,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@ast-grep/napi-linux-arm64-musl@0.40.5': resolution: {integrity: sha512-/qKsmds5FMoaEj6FdNzepbmLMtlFuBLdrAn9GIWCqOIcVcYvM1Nka8+mncfeXB/MFZKOrzQsQdPTWqrrQzXLrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@ast-grep/napi-linux-x64-gnu@0.40.5': resolution: {integrity: sha512-DP4oDbq7f/1A2hRTFLhJfDFR6aI5mRWdEfKfHzRItmlKsR9WlcEl1qDJs/zX9R2EEtIDsSKRzuJNfJllY3/W8Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@ast-grep/napi-linux-x64-musl@0.40.5': resolution: {integrity: sha512-BRZUvVBPUNpWPo6Ns8chXVzxHPY+k9gpsubGTHy92Q26ecZULd/dTkWWdnvfhRqttsSQ9Pe/XQdi5+hDQ6RYcg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@ast-grep/napi-win32-arm64-msvc@0.40.5': resolution: {integrity: sha512-y95zSEwc7vhxmcrcH0GnK4ZHEBQrmrszRBNQovzaciF9GUqEcCACNLoBesn4V47IaOp4fYgD2/EhGRTIBFb2Ug==} @@ -284,24 +288,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@1.9.4': resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@1.9.4': resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@1.9.4': resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@1.9.4': resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} @@ -1120,36 +1128,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -1626,66 +1640,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -3361,6 +3388,7 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] napi-maybe-compressed-blob-linux-x64-gnu@0.0.11: resolution: {integrity: sha512-JKY8KcZpQtKiL1smMKfukcOmsDVeZaw9fKXKsWC+wySc2wsvH7V2wy8PffSQ0lWERkI7Yn3k7xPjB463m/VNtg==} 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..364ae0eb07 --- /dev/null +++ b/ts-tests/suites/zombienet_coldkey_swap/00-coldkey-swap.test.ts @@ -0,0 +1,310 @@ +import { expect, beforeAll } from "vitest"; +import { describeSuite } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import { + ANNOUNCEMENT_DELAY, + REANNOUNCEMENT_DELAY, + addNewSubnetwork, + addStake, + announceColdkeySwap, + coldkeyHash, + disputeColdkeySwap, + forceSetBalance, + generateKeyringPair, + getBalance, + 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: ApiPromise; + + beforeAll(async () => { + api = context.polkadotJs("Node"); + 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 announceResult = await sendTransaction( + api, + api.tx.subtensorModule.announceColdkeySwap(coldkeyHash(newColdkey)), + oldColdkey, + ); + expect(announceResult.success, "announce should succeed").toBe(true); + const announcedEvent = announceResult.events.find((e) => e.method === "ColdkeySwapAnnounced"); + expect(announcedEvent, "ColdkeySwapAnnounced event should be emitted").toBeDefined(); + log("Announced coldkey swap"); + + // Wait for delay + await waitForBlocks(api, ANNOUNCEMENT_DELAY + 1); + + // Swap + const swapResult = await sendTransaction( + api, + api.tx.subtensorModule.swapColdkeyAnnounced(newColdkey.address), + oldColdkey, + ); + expect(swapResult.success, "swap should succeed").toBe(true); + const swappedEvent = swapResult.events.find((e) => e.method === "ColdkeySwapped"); + expect(swappedEvent, "ColdkeySwapped event should be emitted").toBeDefined(); + 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, coldkeyHash(newColdkey)); + + // Immediately try swap without waiting + const result = await sendTransaction( + api, + api.tx.subtensorModule.swapColdkeyAnnounced(newColdkey.address), + 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, coldkeyHash(newColdkey1)); + log("First announce ok"); + + // Reannounce immediately (should fail) + const earlyResult = await sendTransaction( + api, + api.tx.subtensorModule.announceColdkeySwap(coldkeyHash(newColdkey2)), + 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, coldkeyHash(newColdkey2)); + log("Reannounced to new key"); + + // Wait for announcement delay + await waitForBlocks(api, ANNOUNCEMENT_DELAY + 1); + + // Swap with old hash (should fail) + const wrongResult = await sendTransaction( + api, + api.tx.subtensorModule.swapColdkeyAnnounced(newColdkey1.address), + 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, coldkeyHash(newColdkey)); + + // Dispute + const disputeResult = await sendTransaction( + api, + api.tx.subtensorModule.disputeColdkeySwap(), + oldColdkey, + ); + expect(disputeResult.success, "dispute should succeed").toBe(true); + const disputeEvent = disputeResult.events.find((e) => e.method === "ColdkeySwapDisputed"); + expect(disputeEvent, "ColdkeySwapDisputed event should be emitted").toBeDefined(); + log("Disputed"); + + await waitForBlocks(api, ANNOUNCEMENT_DELAY + 1); + + // Swap should fail (disputed) + const swapResult = await sendTransaction( + api, + api.tx.subtensorModule.swapColdkeyAnnounced(newColdkey.address), + 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, coldkeyHash(newColdkey)); + await disputeColdkeySwap(api, oldColdkey); + + // Second dispute should fail + const result = await sendTransaction( + api, + api.tx.subtensorModule.disputeColdkeySwap(), + 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 result = await sendTransaction( + api, + api.tx.subtensorModule.announceColdkeySwap( + coldkeyHash(generateKeyringPair("sr25519")), + ), + 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, coldkeyHash(newColdkey)); + await waitForBlocks(api, ANNOUNCEMENT_DELAY + 1); + + // Swap with wrong address + const result = await sendTransaction( + api, + api.tx.subtensorModule.swapColdkeyAnnounced(wrongKey.address), + 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 result = await sendTransaction( + api, + api.tx.subtensorModule.disputeColdkeySwap(), + someKey, + ); + expect(result.success, "dispute should fail (no announcement)").toBe(false); + log("Dispute without announcement rejected"); + }, + }); + }, +}); 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..dc044e2fea --- /dev/null +++ b/ts-tests/suites/zombienet_coldkey_swap/01-coldkey-swap-sudo.test.ts @@ -0,0 +1,156 @@ +import { expect, beforeAll } from "vitest"; +import { describeSuite } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import { + addNewSubnetwork, + addStake, + announceColdkeySwap, + burnedRegister, + coldkeyHash, + 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: ApiPromise; + + beforeAll(async () => { + api = context.polkadotJs("Node"); + }); + + 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, coldkeyHash(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, coldkeyHash(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, coldkeyHash(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..be45cb0f2a --- /dev/null +++ b/ts-tests/utils/coldkey_swap.ts @@ -0,0 +1,113 @@ +import { Keyring } from "@polkadot/keyring"; +import { blake2AsHex } from "@polkadot/util-crypto"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { waitForTransactionWithRetry } from "./transactions.js"; + +export const ANNOUNCEMENT_DELAY = 10; +export const REANNOUNCEMENT_DELAY = 10; + +/** Compute BLAKE2-256 hash of a keypair's public key (used for announcements). */ +export function coldkeyHash(pair: KeyringPair): string { + return blake2AsHex(pair.publicKey, 256); +} + +// ── Sudo configuration ────────────────────────────────────────────────── + +export async function sudoSetAnnouncementDelay(api: ApiPromise, delay: number): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const internalCall = api.tx.adminUtils.sudoSetColdkeySwapAnnouncementDelay(delay); + const tx = api.tx.sudo.sudo(internalCall); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_coldkey_swap_announcement_delay"); +} + +export async function sudoSetReannouncementDelay(api: ApiPromise, delay: number): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const internalCall = api.tx.adminUtils.sudoSetColdkeySwapReannouncementDelay(delay); + const tx = api.tx.sudo.sudo(internalCall); + await waitForTransactionWithRetry(api, tx, alice, "sudo_set_coldkey_swap_reannouncement_delay"); +} + +// ── Transaction wrappers (throw on failure) ───────────────────────────── + +export async function announceColdkeySwap(api: ApiPromise, signer: KeyringPair, newColdkeyHash: string): Promise { + const tx = api.tx.subtensorModule.announceColdkeySwap(newColdkeyHash); + await waitForTransactionWithRetry(api, tx, signer, "announce_coldkey_swap"); +} + +export async function swapColdkeyAnnounced(api: ApiPromise, signer: KeyringPair, newAddress: string): Promise { + const tx = api.tx.subtensorModule.swapColdkeyAnnounced(newAddress); + await waitForTransactionWithRetry(api, tx, signer, "swap_coldkey_announced"); +} + +export async function disputeColdkeySwap(api: ApiPromise, signer: KeyringPair): Promise { + const tx = api.tx.subtensorModule.disputeColdkeySwap(); + await waitForTransactionWithRetry(api, tx, signer, "dispute_coldkey_swap"); +} + +export async function sudoResetColdkeySwap(api: ApiPromise, coldkeyAddress: string): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const internalCall = api.tx.subtensorModule.resetColdkeySwap(coldkeyAddress); + const tx = api.tx.sudo.sudo(internalCall); + await waitForTransactionWithRetry(api, tx, alice, "sudo_reset_coldkey_swap"); +} + +export async function sudoSwapColdkey( + api: ApiPromise, + oldAddress: string, + newAddress: string, + swapCost: bigint = 0n, +): Promise { + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + const internalCall = api.tx.subtensorModule.swapColdkey(oldAddress, newAddress, swapCost); + const tx = api.tx.sudo.sudo(internalCall); + await waitForTransactionWithRetry(api, tx, alice, "sudo_swap_coldkey"); +} + +// ── Storage query helpers ─────────────────────────────────────────────── + +export async function getColdkeySwapAnnouncement( + api: ApiPromise, + address: string, +): Promise<{ when: number; hash: string } | null> { + const result = await api.query.subtensorModule.coldkeySwapAnnouncements(address); + if (result.isEmpty) return null; + const json = result.toJSON() as [number, string] | null; + if (!json) return null; + return { when: json[0], hash: json[1] }; +} + +export async function getColdkeySwapDispute( + api: ApiPromise, + address: string, +): Promise { + const result = await api.query.subtensorModule.coldkeySwapDisputes(address); + if (result.isEmpty) return null; + return Number(result.toString()); +} + +/** Get the owner coldkey of a hotkey. */ +export async function getHotkeyOwner(api: ApiPromise, hotkey: string): Promise { + return (await api.query.subtensorModule.owner(hotkey)).toString(); +} + +/** Get the list of hotkeys owned by a coldkey. */ +export async function getOwnedHotkeys(api: ApiPromise, coldkey: string): Promise { + const result = await api.query.subtensorModule.ownedHotkeys(coldkey); + return (result.toJSON() as string[]) ?? []; +} + +/** Get the list of hotkeys a coldkey is staking to. */ +export async function getStakingHotkeys(api: ApiPromise, coldkey: string): Promise { + const result = await api.query.subtensorModule.stakingHotkeys(coldkey); + return (result.toJSON() as string[]) ?? []; +} + +/** Get the owner coldkey of a subnet. */ +export async function getSubnetOwner(api: ApiPromise, netuid: number): Promise { + return (await api.query.subtensorModule.subnetOwner(netuid)).toString(); +} 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 215eb6cb36..4855de0340 100644 --- a/ts-tests/utils/transactions.ts +++ b/ts-tests/utils/transactions.ts @@ -113,6 +113,74 @@ 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( + api: ApiPromise, + tx: SubmittableExtrinsic, + signer: KeyringPair, + timeout: number = 3 * 60 * 1000, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error("Transaction timed out")); + }, timeout); + + let unsub: () => void; + + tx.signAndSend(signer, (result) => { + const { status, txHash } = result; + if (status.isFinalized) { + clearTimeout(timer); + unsub?.(); + + const failed = result.events.find(({ event }) => api.events.system.ExtrinsicFailed.is(event)); + + if (failed) { + const { dispatchError } = failed.event.data as any; + let errorMessage = dispatchError.toString(); + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(" ")}`; + } + resolve({ + success: false, + events: result.events.map((e) => e.event), + txHash: txHash.toHex(), + blockHash: status.asFinalized.toHex(), + errorMessage, + }); + } else { + resolve({ + success: true, + events: result.events.map((e) => e.event), + txHash: txHash.toHex(), + blockHash: status.asFinalized.toHex(), + }); + } + } + }) + .then((u) => { + unsub = u; + }) + .catch((error) => { + clearTimeout(timer); + const message = error instanceof Error ? error.message : String(error?.toHuman?.() ?? error); + resolve({ success: false, events: [], errorMessage: message }); + }); + }); +} + const SECOND = 1000; /** Polls the chain until `count` new finalized blocks have been produced. */ From 0c26d6082245cc886c91326f6d4b168b2bdaa804 Mon Sep 17 00:00:00 2001 From: Loris Moulin Date: Fri, 20 Mar 2026 18:16:30 -0300 Subject: [PATCH 2/3] fixed coldkey swap tests + use papi --- ts-tests/moonwall.config.json | 3 +- ts-tests/scripts/generate-types.sh | 2 +- .../00-coldkey-swap.test.ts | 246 ++++++++++++------ .../01-coldkey-swap-sudo.test.ts | 45 +++- ts-tests/utils/coldkey_swap.ts | 121 +++++---- ts-tests/utils/transactions.ts | 71 +++-- 6 files changed, 306 insertions(+), 182 deletions(-) diff --git a/ts-tests/moonwall.config.json b/ts-tests/moonwall.config.json index 45c24e7223..a8afdebe61 100644 --- a/ts-tests/moonwall.config.json +++ b/ts-tests/moonwall.config.json @@ -100,6 +100,7 @@ "timeout": 600000, "testFileDir": ["suites/zombienet_coldkey_swap"], "runScripts": [ + "generate-types.sh", "build-spec.sh" ], "foundation": { @@ -115,7 +116,7 @@ "connections": [ { "name": "Node", - "type": "polkadotJs", + "type": "papi", "endpoints": ["ws://127.0.0.1:9947"] } ] 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 index 364ae0eb07..9ffb747d78 100644 --- a/ts-tests/suites/zombienet_coldkey_swap/00-coldkey-swap.test.ts +++ b/ts-tests/suites/zombienet_coldkey_swap/00-coldkey-swap.test.ts @@ -1,17 +1,20 @@ import { expect, beforeAll } from "vitest"; import { describeSuite } from "@moonwall/cli"; -import type { ApiPromise } from "@polkadot/api"; +import { subtensor } from "@polkadot-api/descriptors"; +import type { TypedApi } from "polkadot-api"; import { ANNOUNCEMENT_DELAY, REANNOUNCEMENT_DELAY, addNewSubnetwork, addStake, announceColdkeySwap, - coldkeyHash, + clearColdkeySwapAnnouncement, + coldkeyHashBinary, disputeColdkeySwap, forceSetBalance, generateKeyringPair, getBalance, + getColdkeySwapAnnouncement, getHotkeyOwner, getOwnedHotkeys, getStake, @@ -31,10 +34,10 @@ describeSuite({ title: "▶ coldkey swap extrinsic", foundationMethods: "zombie", testCases: ({ it, context, log }) => { - let api: ApiPromise; + let api: TypedApi; beforeAll(async () => { - api = context.polkadotJs("Node"); + api = context.papi("Node").getTypedApi(subtensor); await sudoSetAnnouncementDelay(api, ANNOUNCEMENT_DELAY); await sudoSetReannouncementDelay(api, REANNOUNCEMENT_DELAY); }); @@ -62,35 +65,37 @@ describeSuite({ 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); + 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 announceResult = await sendTransaction( - api, - api.tx.subtensorModule.announceColdkeySwap(coldkeyHash(newColdkey)), - oldColdkey, - ); + 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); - const announcedEvent = announceResult.events.find((e) => e.method === "ColdkeySwapAnnounced"); - expect(announcedEvent, "ColdkeySwapAnnounced event should be emitted").toBeDefined(); log("Announced coldkey swap"); // Wait for delay await waitForBlocks(api, ANNOUNCEMENT_DELAY + 1); // Swap - const swapResult = await sendTransaction( - api, - api.tx.subtensorModule.swapColdkeyAnnounced(newColdkey.address), - oldColdkey, - ); + 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); - const swappedEvent = swapResult.events.find((e) => e.method === "ColdkeySwapped"); - expect(swappedEvent, "ColdkeySwapped event should be emitted").toBeDefined(); log("Swap executed"); // Verify stake migrated @@ -105,14 +110,27 @@ describeSuite({ 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); + 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); + 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 @@ -132,14 +150,13 @@ describeSuite({ const newColdkey = generateKeyringPair("sr25519"); await forceSetBalance(api, oldColdkey.address); - await announceColdkeySwap(api, oldColdkey, coldkeyHash(newColdkey)); + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey)); // Immediately try swap without waiting - const result = await sendTransaction( - api, - api.tx.subtensorModule.swapColdkeyAnnounced(newColdkey.address), - oldColdkey, - ); + 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"); }, @@ -155,15 +172,14 @@ describeSuite({ await forceSetBalance(api, oldColdkey.address); // First announcement - await announceColdkeySwap(api, oldColdkey, coldkeyHash(newColdkey1)); + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey1)); log("First announce ok"); // Reannounce immediately (should fail) - const earlyResult = await sendTransaction( - api, - api.tx.subtensorModule.announceColdkeySwap(coldkeyHash(newColdkey2)), - oldColdkey, - ); + 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"); @@ -171,18 +187,17 @@ describeSuite({ await waitForBlocks(api, REANNOUNCEMENT_DELAY + 1); // Reannounce (should succeed) - await announceColdkeySwap(api, oldColdkey, coldkeyHash(newColdkey2)); + 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 wrongResult = await sendTransaction( - api, - api.tx.subtensorModule.swapColdkeyAnnounced(newColdkey1.address), - oldColdkey, - ); + 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"); @@ -200,27 +215,21 @@ describeSuite({ const newColdkey = generateKeyringPair("sr25519"); await forceSetBalance(api, oldColdkey.address); - await announceColdkeySwap(api, oldColdkey, coldkeyHash(newColdkey)); + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey)); // Dispute - const disputeResult = await sendTransaction( - api, - api.tx.subtensorModule.disputeColdkeySwap(), - oldColdkey, - ); + const disputeTx = api.tx.SubtensorModule.dispute_coldkey_swap(); + const disputeResult = await sendTransaction(disputeTx, oldColdkey); expect(disputeResult.success, "dispute should succeed").toBe(true); - const disputeEvent = disputeResult.events.find((e) => e.method === "ColdkeySwapDisputed"); - expect(disputeEvent, "ColdkeySwapDisputed event should be emitted").toBeDefined(); log("Disputed"); await waitForBlocks(api, ANNOUNCEMENT_DELAY + 1); // Swap should fail (disputed) - const swapResult = await sendTransaction( - api, - api.tx.subtensorModule.swapColdkeyAnnounced(newColdkey.address), - oldColdkey, - ); + 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"); }, @@ -234,15 +243,12 @@ describeSuite({ const newColdkey = generateKeyringPair("sr25519"); await forceSetBalance(api, oldColdkey.address); - await announceColdkeySwap(api, oldColdkey, coldkeyHash(newColdkey)); + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey)); await disputeColdkeySwap(api, oldColdkey); // Second dispute should fail - const result = await sendTransaction( - api, - api.tx.subtensorModule.disputeColdkeySwap(), - oldColdkey, - ); + 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"); }, @@ -255,13 +261,10 @@ describeSuite({ const poorKey = generateKeyringPair("sr25519"); // Intentionally NOT funded - const result = await sendTransaction( - api, - api.tx.subtensorModule.announceColdkeySwap( - coldkeyHash(generateKeyringPair("sr25519")), - ), - poorKey, - ); + 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"); }, @@ -276,15 +279,14 @@ describeSuite({ const wrongKey = generateKeyringPair("sr25519"); await forceSetBalance(api, oldColdkey.address); - await announceColdkeySwap(api, oldColdkey, coldkeyHash(newColdkey)); + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey)); await waitForBlocks(api, ANNOUNCEMENT_DELAY + 1); // Swap with wrong address - const result = await sendTransaction( - api, - api.tx.subtensorModule.swapColdkeyAnnounced(wrongKey.address), - oldColdkey, - ); + 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"); }, @@ -297,14 +299,96 @@ describeSuite({ const someKey = generateKeyringPair("sr25519"); await forceSetBalance(api, someKey.address); - const result = await sendTransaction( - api, - api.tx.subtensorModule.disputeColdkeySwap(), - someKey, - ); + 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"); + }, + }); }, }); 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 index dc044e2fea..1dbf53c1e5 100644 --- 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 @@ -1,12 +1,13 @@ import { expect, beforeAll } from "vitest"; import { describeSuite } from "@moonwall/cli"; -import type { ApiPromise } from "@polkadot/api"; +import { subtensor } from "@polkadot-api/descriptors"; +import type { TypedApi } from "polkadot-api"; import { addNewSubnetwork, addStake, announceColdkeySwap, burnedRegister, - coldkeyHash, + coldkeyHashBinary, disputeColdkeySwap, forceSetBalance, generateKeyringPair, @@ -29,10 +30,10 @@ describeSuite({ title: "▶ coldkey swap sudo operations", foundationMethods: "zombie", testCases: ({ it, context, log }) => { - let api: ApiPromise; + let api: TypedApi; beforeAll(async () => { - api = context.polkadotJs("Node"); + api = context.papi("Node").getTypedApi(subtensor); }); it({ @@ -44,7 +45,7 @@ describeSuite({ await forceSetBalance(api, oldColdkey.address); // Announce and dispute - await announceColdkeySwap(api, oldColdkey, coldkeyHash(newColdkey)); + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey)); await disputeColdkeySwap(api, oldColdkey); log("Announced + disputed"); @@ -64,7 +65,7 @@ describeSuite({ log("Storage cleared"); // Re-announce should succeed - await announceColdkeySwap(api, oldColdkey, coldkeyHash(newColdkey)); + await announceColdkeySwap(api, oldColdkey, coldkeyHashBinary(newColdkey)); log("Re-announce after reset succeeded"); }, }); @@ -105,10 +106,22 @@ describeSuite({ 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); + 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 @@ -120,8 +133,14 @@ describeSuite({ 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 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"); @@ -138,7 +157,7 @@ describeSuite({ await forceSetBalance(api, oldColdkey.address); // Announce for decoy - await announceColdkeySwap(api, oldColdkey, coldkeyHash(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"); diff --git a/ts-tests/utils/coldkey_swap.ts b/ts-tests/utils/coldkey_swap.ts index be45cb0f2a..39fa5ac8bb 100644 --- a/ts-tests/utils/coldkey_swap.ts +++ b/ts-tests/utils/coldkey_swap.ts @@ -1,113 +1,144 @@ import { Keyring } from "@polkadot/keyring"; import { blake2AsHex } from "@polkadot/util-crypto"; import type { KeyringPair } from "@moonwall/util"; -import type { ApiPromise } from "@polkadot/api"; 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 (used for announcements). */ +/** 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: ApiPromise, delay: number): Promise { +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.sudoSetColdkeySwapAnnouncementDelay(delay); - const tx = api.tx.sudo.sudo(internalCall); + 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: ApiPromise, delay: number): Promise { +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.sudoSetColdkeySwapReannouncementDelay(delay); - const tx = api.tx.sudo.sudo(internalCall); + 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: ApiPromise, signer: KeyringPair, newColdkeyHash: string): Promise { - const tx = api.tx.subtensorModule.announceColdkeySwap(newColdkeyHash); +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: ApiPromise, signer: KeyringPair, newAddress: string): Promise { - const tx = api.tx.subtensorModule.swapColdkeyAnnounced(newAddress); +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: ApiPromise, signer: KeyringPair): Promise { - const tx = api.tx.subtensorModule.disputeColdkeySwap(); +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 sudoResetColdkeySwap(api: ApiPromise, coldkeyAddress: string): Promise { +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.resetColdkeySwap(coldkeyAddress); - const tx = api.tx.sudo.sudo(internalCall); + 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: ApiPromise, + api: TypedApi, oldAddress: string, newAddress: string, - swapCost: bigint = 0n, + swapCost: bigint = 0n ): Promise { const keyring = new Keyring({ type: "sr25519" }); const alice = keyring.addFromUri("//Alice"); - const internalCall = api.tx.subtensorModule.swapColdkey(oldAddress, newAddress, swapCost); - const tx = api.tx.sudo.sudo(internalCall); + 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: ApiPromise, - address: string, + api: TypedApi, + address: string ): Promise<{ when: number; hash: string } | null> { - const result = await api.query.subtensorModule.coldkeySwapAnnouncements(address); - if (result.isEmpty) return null; - const json = result.toJSON() as [number, string] | null; - if (!json) return null; - return { when: json[0], hash: json[1] }; + 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: ApiPromise, - address: string, -): Promise { - const result = await api.query.subtensorModule.coldkeySwapDisputes(address); - if (result.isEmpty) return null; - return Number(result.toString()); +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: ApiPromise, hotkey: string): Promise { - return (await api.query.subtensorModule.owner(hotkey)).toString(); +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: ApiPromise, coldkey: string): Promise { - const result = await api.query.subtensorModule.ownedHotkeys(coldkey); - return (result.toJSON() as string[]) ?? []; +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: ApiPromise, coldkey: string): Promise { - const result = await api.query.subtensorModule.stakingHotkeys(coldkey); - return (result.toJSON() as string[]) ?? []; +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: ApiPromise, netuid: number): Promise { - return (await api.query.subtensorModule.subnetOwner(netuid)).toString(); +export async function getSubnetOwner(api: TypedApi, netuid: number): Promise { + return await api.query.SubtensorModule.SubnetOwner.getValue(netuid); } diff --git a/ts-tests/utils/transactions.ts b/ts-tests/utils/transactions.ts index 3e6deea796..ae308d40e6 100644 --- a/ts-tests/utils/transactions.ts +++ b/ts-tests/utils/transactions.ts @@ -101,58 +101,47 @@ export type TransactionResult = { * Use this for tests that expect failure. */ export async function sendTransaction( - api: ApiPromise, - tx: SubmittableExtrinsic, + tx: Transaction, string, string, void>, signer: KeyringPair, - timeout: number = 3 * 60 * 1000, + 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); - let unsub: () => void; - - tx.signAndSend(signer, (result) => { - const { status, txHash } = result; - if (status.isFinalized) { - clearTimeout(timer); - unsub?.(); - - const failed = result.events.find(({ event }) => api.events.system.ExtrinsicFailed.is(event)); - - if (failed) { - const { dispatchError } = failed.event.data as any; - let errorMessage = dispatchError.toString(); - if (dispatchError.isModule) { - const decoded = api.registry.findMetaError(dispatchError.asModule); - errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(" ")}`; + 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, + }); } - resolve({ - success: false, - events: result.events.map((e) => e.event), - txHash: txHash.toHex(), - blockHash: status.asFinalized.toHex(), - errorMessage, - }); - } else { - resolve({ - success: true, - events: result.events.map((e) => e.event), - txHash: txHash.toHex(), - blockHash: status.asFinalized.toHex(), - }); } - } - }) - .then((u) => { - unsub = u; - }) - .catch((error) => { + }, + error(err) { clearTimeout(timer); - const message = error instanceof Error ? error.message : String(error?.toHuman?.() ?? error); + const message = err instanceof Error ? err.message : String(err); resolve({ success: false, events: [], errorMessage: message }); - }); + }, + }); }); } From f924deaf03c630f1b78e072178c191a73a9ec742 Mon Sep 17 00:00:00 2001 From: Loris Moulin Date: Mon, 23 Mar 2026 18:17:52 -0300 Subject: [PATCH 3/3] added tests for other transfer types during swap/dispute --- .../00-coldkey-swap.test.ts | 117 +++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) 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 index 9ffb747d78..78899dbce5 100644 --- a/ts-tests/suites/zombienet_coldkey_swap/00-coldkey-swap.test.ts +++ b/ts-tests/suites/zombienet_coldkey_swap/00-coldkey-swap.test.ts @@ -1,6 +1,6 @@ import { expect, beforeAll } from "vitest"; import { describeSuite } from "@moonwall/cli"; -import { subtensor } from "@polkadot-api/descriptors"; +import { subtensor, MultiAddress } from "@polkadot-api/descriptors"; import type { TypedApi } from "polkadot-api"; import { ANNOUNCEMENT_DELAY, @@ -390,5 +390,120 @@ describeSuite({ 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"); + }, + }); }, });