diff --git a/Cargo.lock b/Cargo.lock index 09d3a67..d970a9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1069,6 +1069,7 @@ dependencies = [ "sp-core 34.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "sp-io 38.0.2", "sp-runtime 39.0.5", + "substrate-bip39 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", "time", ] diff --git a/contracts/bridge/Cargo.toml b/contracts/bridge/Cargo.toml index cdb72f2..512efbc 100755 --- a/contracts/bridge/Cargo.toml +++ b/contracts/bridge/Cargo.toml @@ -12,8 +12,10 @@ sp-core = { version = "34.0.0", default-features = false } sp-io = { version = "38.0.0", default-features = false } sp-runtime = { version = "39.0.0", default-features = false } -# Pin transitive deps to versions compatible with rustc 1.85.0 +# Direct optional pins steer cargo-contract resolution to versions compatible with rustc 1.85.0 +# without enabling these crates in no_std contract builds. time = { version = ">=0.3.0, <0.3.37", default-features = false, optional = true } +substrate-bip39 = { version = "=0.6.0", default-features = false, optional = true } [dev-dependencies] ink_e2e = { version = "5.1.1" } diff --git a/contracts/bridge/integration-tests/.papi/contracts/bridge.json b/contracts/bridge/integration-tests/.papi/contracts/bridge.json index 122aa5b..a3b3aa4 100644 --- a/contracts/bridge/integration-tests/.papi/contracts/bridge.json +++ b/contracts/bridge/integration-tests/.papi/contracts/bridge.json @@ -1,10 +1,10 @@ { "source": { - "hash": "0xdb4c9dcad6cd05baff5d20e42c067d914483023f2da3da65919170607aba8346", + "hash": "0x569380f6a3c38b039008e032991a7c891ad6c9c043014d4fe9d5b087d20d6c02", "language": "ink! 5.1.1", "compiler": "rustc 1.85.0", "build_info": { - "build_mode": "Debug", + "build_mode": "Release", "cargo_contract_version": "5.0.3", "rust_toolchain": "stable-aarch64-apple-darwin", "wasm_opt_settings": { @@ -118,37 +118,13 @@ "events": [ { "args": [ - { - "docs": [], - "indexed": true, - "label": "chain_id", - "type": { - "displayName": [ - "ChainId" - ], - "type": 3 - } - }, - { - "docs": [], - "indexed": false, - "label": "escrow_contract", - "type": { - "displayName": [ - "ink", - "primitives", - "AccountId" - ], - "type": 0 - } - }, { "docs": [], "indexed": false, "label": "deposit_nonce", "type": { "displayName": [ - "DepositNonce" + "Nonce" ], "type": 6 } @@ -166,19 +142,6 @@ "type": 0 } }, - { - "docs": [], - "indexed": true, - "label": "recipient", - "type": { - "displayName": [ - "ink", - "primitives", - "AccountId" - ], - "type": 0 - } - }, { "docs": [], "indexed": false, @@ -192,7 +155,7 @@ }, { "docs": [], - "indexed": false, + "indexed": true, "label": "deposit_request_id", "type": { "displayName": [ @@ -208,12 +171,13 @@ "**Guardian Action Required**: Monitor this event on Bittensor chain.", "When seen, call attest_deposit on Hippius pallet with:", "- request_id: deposit_request_id from this event", - "- recipient: recipient from this event", - "- amount: amount from this event (alphaRao converted to halphaRao)" + "- recipient: sender from this event (sender == recipient)", + "- amount: amount from this event (alphaRao converted to halphaRao)", + "- nonce: deposit_nonce from this event" ], "label": "DepositRequestCreated", "module_path": "bridge::events", - "signature_topic": "0x739f750c8fc30427bf8e06588886b81420e42526df0bb35290671717d64680d8" + "signature_topic": "0xb379fe936c523c93893917cf8c96a5e497768a23882517aaaf1b7a46a39599f4" }, { "args": [ @@ -807,6 +771,15 @@ ], "type": 6 } + }, + { + "label": "nonce", + "type": { + "displayName": [ + "Nonce" + ], + "type": 6 + } } ], "default": false, @@ -820,7 +793,8 @@ " # Arguments", " * `request_id` - The withdrawal request ID from Hippius", " * `recipient` - Recipient to release Alpha to", - " * `amount` - Amount to release (in alphaRao)" + " * `amount` - Amount to release (in alphaRao)", + " * `nonce` - Nonce from the withdrawal request (used for ID verification)" ], "label": "attest_withdrawal", "mutates": true, @@ -852,7 +826,10 @@ "", " After calling this, admin should manually release Alpha via admin_manual_release.", "", - " NOTE: Intentionally does not check pause state — admin must operate during emergencies.", + " # Pause Behavior", + " Intentionally does NOT check pause state. Admin emergency/recovery", + " functions must remain operational when the bridge is paused, since", + " pausing is the first step in incident response.", "", " # Arguments", " * `request_id` - The deposit request ID to fail" @@ -903,12 +880,23 @@ "docs": [ " Admin manually releases Alpha to a recipient (for stuck deposits)", "", - " NOTE: Intentionally does not check pause state — admin must operate during emergencies.", + " # Pause Behavior", + " Intentionally does NOT check pause state. Admin emergency/recovery", + " functions must remain operational when the bridge is paused, since", + " pausing is the first step in incident response.", + "", + " The `deposit_request_id` parameter is intentionally optional and is NOT", + " validated against storage — it exists solely for the audit trail in", + " emitted events. This allows emergency releases even when the deposit", + " request record may be missing or corrupted.", + "", + " This bypass is limited to bridge pause/storage recovery checks. It still", + " refuses to move Alpha that Bittensor reports as conviction-locked.", "", " # Arguments", " * `recipient` - Account to receive Alpha", " * `amount` - Amount to release (in alphaRao)", - " * `deposit_request_id` - Optional deposit request ID for audit trail" + " * `deposit_request_id` - Optional deposit request ID for audit trail (not validated)" ], "label": "admin_manual_release", "mutates": true, @@ -947,7 +935,10 @@ "docs": [ " Admin cancels a withdrawal that is stuck", "", - " NOTE: Intentionally does not check pause state — admin must operate during emergencies.", + " # Pause Behavior", + " Intentionally does NOT check pause state. Admin emergency/recovery", + " functions must remain operational when the bridge is paused, since", + " pausing is the first step in incident response.", "", " # Arguments", " * `request_id` - The withdrawal ID to cancel" @@ -1281,7 +1272,7 @@ "label": "nonce", "type": { "displayName": [ - "DepositNonce" + "Nonce" ], "type": 6 } @@ -1921,7 +1912,7 @@ { "name": "nonce", "type": 6, - "typeName": "DepositNonce" + "typeName": "Nonce" }, { "name": "hotkey", @@ -2336,7 +2327,7 @@ { "name": "next_deposit_nonce", "type": 6, - "typeName": ",>>::Type" + "typeName": ",>>::Type" }, { "name": "deposit_requests", @@ -2346,7 +2337,7 @@ { "name": "nonce_to_deposit_request_id", "type": 16, - "typeName": " as::ink::storage::traits::\nAutoStorableHint<::ink::storage::traits::ManualKey<3491510221u32,\n()>,>>::Type" + "typeName": " as::ink::storage::traits::\nAutoStorableHint<::ink::storage::traits::ManualKey<3491510221u32,\n()>,>>::Type" }, { "name": "withdrawals", @@ -2557,55 +2548,55 @@ }, { "index": 9, - "name": "InvalidWithdrawalDetails" + "name": "InvalidTTL" }, { "index": 10, - "name": "InvalidTTL" + "name": "BridgePaused" }, { "index": 11, - "name": "BridgePaused" + "name": "DepositRequestNotFound" }, { "index": 12, - "name": "DepositRequestNotFound" + "name": "WithdrawalNotFound" }, { "index": 13, - "name": "WithdrawalNotFound" + "name": "DepositRequestAlreadyFinalized" }, { "index": 14, - "name": "DepositRequestAlreadyFinalized" + "name": "WithdrawalAlreadyFinalized" }, { "index": 15, - "name": "WithdrawalAlreadyFinalized" + "name": "Overflow" }, { "index": 16, - "name": "Overflow" + "name": "RuntimeCallFailed" }, { "index": 17, - "name": "RuntimeCallFailed" + "name": "StakeQueryFailed" }, { "index": 18, - "name": "StakeQueryFailed" + "name": "TransferFailed" }, { "index": 19, - "name": "TransferFailed" + "name": "StakeConsolidationFailed" }, { "index": 20, - "name": "StakeConsolidationFailed" + "name": "CodeUpgradeFailed" }, { "index": 21, - "name": "CodeUpgradeFailed" + "name": "InvalidRequestId" }, { "index": 22, @@ -2614,6 +2605,14 @@ { "index": 23, "name": "TTLNotExpired" + }, + { + "index": 24, + "name": "StakeUnavailable" + }, + { + "index": 25, + "name": "StakeAvailabilityQueryFailed" } ] } diff --git a/contracts/bridge/integration-tests/.papi/descriptors/package.json b/contracts/bridge/integration-tests/.papi/descriptors/package.json index 9e1e4d9..b02ef19 100644 --- a/contracts/bridge/integration-tests/.papi/descriptors/package.json +++ b/contracts/bridge/integration-tests/.papi/descriptors/package.json @@ -1,5 +1,5 @@ { - "version": "0.1.0-autogenerated.16562465824776759199", + "version": "0.1.0-autogenerated.11465987119306498744", "name": "@polkadot-api/descriptors", "files": [ "dist" diff --git a/contracts/bridge/integration-tests/.papi/metadata/devnet.scale b/contracts/bridge/integration-tests/.papi/metadata/devnet.scale index e65ff76..422c354 100644 Binary files a/contracts/bridge/integration-tests/.papi/metadata/devnet.scale and b/contracts/bridge/integration-tests/.papi/metadata/devnet.scale differ diff --git a/contracts/bridge/integration-tests/.papi/polkadot-api.json b/contracts/bridge/integration-tests/.papi/polkadot-api.json index 7c57c01..62b2a2b 100644 --- a/contracts/bridge/integration-tests/.papi/polkadot-api.json +++ b/contracts/bridge/integration-tests/.papi/polkadot-api.json @@ -5,8 +5,8 @@ "devnet": { "wsUrl": "ws://localhost:9944", "metadata": ".papi/metadata/devnet.scale", - "genesis": "0x8d485d9a1af12ac341709b0594d287ea058d953e033e1b628b07a0393039c1a4", - "codeHash": "0x1a34b4748c9af1cc638773dd45bf51d8f6b3bd73be129397cc27acbd616641fc" + "genesis": "0x3cb23ebc9e793060c1dde51947b3c7859e47a33402634d9ae751e5ec70ea180f", + "codeHash": "0xd6327a645a01d7fca323c34bead4e71b7c66bb7d37b6f20f9bdb3ac0d86abb95" } }, "ink": { diff --git a/contracts/bridge/integration-tests/src/tests/bridge.integration.test.ts b/contracts/bridge/integration-tests/src/tests/bridge.integration.test.ts index 4f3d338..85e786c 100644 --- a/contracts/bridge/integration-tests/src/tests/bridge.integration.test.ts +++ b/contracts/bridge/integration-tests/src/tests/bridge.integration.test.ts @@ -27,11 +27,17 @@ import { type Wallet, } from "../utils"; import { + getStakeAvailability, getStakeBalance, + lockStake, moveStake, } from "../utils/stake-helpers"; +import { blake2b256, ss58Decode } from "@polkadot-labs/hdkd-helpers"; const MIN_DEPOSIT = 1_000_000_000n; +const TRANSFER_TOLERANCE = 10n; +const WITHDRAWAL_REQUEST_DOMAIN = new TextEncoder().encode("WITHDRAWAL_REQUEST-V1"); +let nextWithdrawalNonce = 1n; async function trackTx(observable: Observable): Promise { return new Promise((resolve, reject) => { @@ -46,7 +52,7 @@ async function trackTx(observable: Observable): Promise { subscription.unsubscribe(); reject(error); }, - }); + }); }); } @@ -63,6 +69,41 @@ function randomHash(label: string): Binary { return Binary.fromHex(hash.slice(0, 64)); } +function u64Le(value: bigint): Uint8Array { + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setBigUint64(0, value, true); + return bytes; +} + +function u128Le(value: bigint): Uint8Array { + const bytes = new Uint8Array(16); + const view = new DataView(bytes.buffer); + const lowMask = (1n << 64n) - 1n; + view.setBigUint64(0, value & lowMask, true); + view.setBigUint64(8, value >> 64n, true); + return bytes; +} + +function withdrawalIdFor(recipient: string, amount: bigint, nonce: bigint): Binary { + const [accountId] = ss58Decode(recipient); + const canonical = new Uint8Array(WITHDRAWAL_REQUEST_DOMAIN.length + accountId.length + 16 + 8); + let offset = 0; + canonical.set(WITHDRAWAL_REQUEST_DOMAIN, offset); + offset += WITHDRAWAL_REQUEST_DOMAIN.length; + canonical.set(accountId, offset); + offset += accountId.length; + canonical.set(u128Le(amount), offset); + offset += 16; + canonical.set(u64Le(nonce), offset); + return Binary.fromBytes(blake2b256(canonical)); +} + +function withdrawalRequest(recipient: string, amount: bigint): { id: Binary; nonce: bigint } { + const nonce = nextWithdrawalNonce; + nextWithdrawalNonce += 1n; + return { id: withdrawalIdFor(recipient, amount, nonce), nonce }; +} + function toBinary(value: string | Uint8Array | Binary | { toHex?: () => string }): Binary { if (value instanceof Binary) { return value; @@ -300,6 +341,20 @@ describe("Bridge Contract Integration", () => { netuid, context.accounts.bob.address ); + expect(bobHotkey.address).not.toBe(context.contractHotkey.address); + + const contractStakeOnContractHotkeyBefore = await getStakeBalance( + context.api, + context.contractHotkey.address, + netuid, + context.contractAddress! + ); + const contractStakeOnBobHotkeyBefore = await getStakeBalance( + context.api, + bobHotkey.address, + netuid, + context.contractAddress! + ); const tx = contract.send("deposit", { origin: context.accounts.bob.address, @@ -356,13 +411,25 @@ describe("Bridge Contract Integration", () => { ); expect(bobStakeAfter).toBeLessThan(bobStakeBefore); - const contractStake = await getStakeBalance( + const contractStakeOnContractHotkeyAfter = await getStakeBalance( context.api, context.contractHotkey.address, netuid, context.contractAddress! ); - expect(contractStake).toBeGreaterThan(0n); + const contractStakeOnBobHotkeyAfter = await getStakeBalance( + context.api, + bobHotkey.address, + netuid, + context.contractAddress! + ); + + expect(contractStakeOnContractHotkeyAfter).toBeGreaterThan( + contractStakeOnContractHotkeyBefore + ); + expect(contractStakeOnBobHotkeyAfter).toBeLessThanOrEqual( + contractStakeOnBobHotkeyBefore + TRANSFER_TOLERANCE + ); }); it("rejects deposits below minimum amount", async () => { @@ -380,6 +447,51 @@ describe("Bridge Contract Integration", () => { } }); + it("rejects conviction-locked stake but allows currently available stake", async () => { + const lockAmount = MIN_DEPOSIT * 2n; + await lockStake( + context.api, + daveHotkey.address, + netuid, + lockAmount, + context.accounts.dave.signer, + ); + + const availability = await getStakeAvailability( + context.api, + context.accounts.dave.address, + netuid, + ); + expect(availability.locked).toBeGreaterThan(0n); + expect(availability.available).toBeGreaterThan(MIN_DEPOSIT); + + const unavailableAmount = availability.available + 1n; + const unavailableResult = await contract.query("deposit", { + origin: context.accounts.dave.address, + data: { + amount: unavailableAmount, + hotkey: daveHotkey.address, + }, + }); + + expect(unavailableResult.success).toBe(false); + if (!unavailableResult.success) { + expect(unavailableResult.value.type).toBe("FlagReverted"); + } + + const availableTx = contract.send("deposit", { + origin: context.accounts.dave.address, + data: { + amount: MIN_DEPOSIT, + hotkey: daveHotkey.address, + }, + }); + const finalized = await signAndFinalize(availableTx, context.accounts.dave.signer); + const depositEvent = expectDepositRequestCreatedEvent(contract, finalized); + expect(depositEvent.sender).toBe(context.accounts.dave.address); + expect(depositEvent.amount).toBe(MIN_DEPOSIT); + }); + it("rejects deposits when stake is insufficient", async () => { const randomWallet = getRandomWallet(); await fundAccount(context.api, randomWallet.address, taoToRao(5), context.accounts.alice.signer); @@ -416,8 +528,9 @@ describe("Bridge Contract Integration", () => { describe("Withdrawal Flow", () => { it("handles guardian attestations and finalizes withdrawal", async () => { - const withdrawalId = randomHash("bob-withdrawal"); const withdrawalAmount = bobDepositAmount; + const withdrawal = withdrawalRequest(context.accounts.bob.address, withdrawalAmount); + const withdrawalId = withdrawal.id; const contractStakeBefore = await getStakeBalance( context.api, @@ -433,6 +546,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.bob.address, amount: withdrawalAmount, + nonce: withdrawal.nonce, }, }); const attest1Finalized = await signAndFinalize(attest1Tx, context.guardians[0].signer); @@ -460,6 +574,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.bob.address, amount: withdrawalAmount, + nonce: withdrawal.nonce, }, }); const attest2Finalized = await signAndFinalize(attest2Tx, context.guardians[1].signer); @@ -488,7 +603,8 @@ describe("Bridge Contract Integration", () => { }); it("rejects attestation with mismatched recipient", async () => { - const withdrawalId = randomHash("mismatch-recipient"); + const withdrawal = withdrawalRequest(context.accounts.bob.address, MIN_DEPOSIT); + const withdrawalId = withdrawal.id; // First guardian attests with correct recipient const attest1Tx = contract.send("attest_withdrawal", { @@ -497,6 +613,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.bob.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attest1Tx, context.guardians[0].signer); @@ -508,6 +625,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.charlie.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); @@ -518,7 +636,8 @@ describe("Bridge Contract Integration", () => { }); it("rejects attestation with mismatched amount", async () => { - const withdrawalId = randomHash("mismatch-amount"); + const withdrawal = withdrawalRequest(context.accounts.bob.address, MIN_DEPOSIT); + const withdrawalId = withdrawal.id; // First guardian attests const attest1Tx = contract.send("attest_withdrawal", { @@ -527,6 +646,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.bob.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attest1Tx, context.guardians[0].signer); @@ -538,6 +658,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.bob.address, amount: MIN_DEPOSIT * 2n, + nonce: withdrawal.nonce, }, }); @@ -633,7 +754,8 @@ describe("Bridge Contract Integration", () => { }); it("admin cancels a stuck withdrawal", async () => { - const withdrawalId = randomHash("cancel-withdrawal"); + const withdrawal = withdrawalRequest(context.accounts.dave.address, MIN_DEPOSIT); + const withdrawalId = withdrawal.id; // First guardian creates a pending withdrawal const attestTx = contract.send("attest_withdrawal", { @@ -642,6 +764,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.dave.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attestTx, context.guardians[0].signer); @@ -703,12 +826,14 @@ describe("Bridge Contract Integration", () => { } // attest_withdrawal should also be rejected when paused + const pausedWithdrawal = withdrawalRequest(context.accounts.dave.address, MIN_DEPOSIT); const attestAttempt = await contract.query("attest_withdrawal", { origin: context.guardians[0].address, data: { - request_id: randomHash("paused-attest"), + request_id: pausedWithdrawal.id, recipient: context.accounts.dave.address, amount: MIN_DEPOSIT, + nonce: pausedWithdrawal.nonce, }, }); expect(attestAttempt.success).toBe(false); @@ -843,13 +968,15 @@ describe("Bridge Contract Integration", () => { describe("Access Control Regression", () => { it("prevents non-guardians from attesting withdrawals", async () => { const nonGuardian = context.accounts.eve; + const withdrawal = withdrawalRequest(nonGuardian.address, MIN_DEPOSIT); const attestAttempt = await contract.query("attest_withdrawal", { origin: nonGuardian.address, data: { - request_id: randomHash("unauthorized-attest"), + request_id: withdrawal.id, recipient: nonGuardian.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); expect(attestAttempt.success).toBe(false); @@ -859,7 +986,8 @@ describe("Bridge Contract Integration", () => { }); it("rejects duplicate guardian attestations on the same withdrawal", async () => { - const withdrawalId = randomHash("duplicate-attest"); + const withdrawal = withdrawalRequest(context.accounts.dave.address, MIN_DEPOSIT); + const withdrawalId = withdrawal.id; // Temporarily raise threshold so withdrawal stays pending const adjustTx = contract.send("set_guardians_and_threshold", { @@ -878,6 +1006,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.dave.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attest1Tx, context.guardians[0].signer); @@ -889,6 +1018,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.dave.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); expect(duplicateAttempt.success).toBe(false); @@ -917,7 +1047,8 @@ describe("Bridge Contract Integration", () => { }); it("rejects attestation on an already completed withdrawal", async () => { - const withdrawalId = randomHash("already-completed"); + const withdrawal = withdrawalRequest(context.accounts.eve.address, MIN_DEPOSIT); + const withdrawalId = withdrawal.id; // First guardian attests const attest1Tx = contract.send("attest_withdrawal", { @@ -926,6 +1057,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.eve.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attest1Tx, context.guardians[0].signer); @@ -937,6 +1069,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.eve.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attest2Tx, context.guardians[1].signer); @@ -948,6 +1081,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.eve.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); expect(attempt.success).toBe(false); @@ -973,7 +1107,8 @@ describe("Bridge Contract Integration", () => { }); it("rejects admin_cancel_withdrawal from non-owner", async () => { - const withdrawalId = randomHash("cancel-non-owner"); + const withdrawal = withdrawalRequest(context.accounts.dave.address, MIN_DEPOSIT); + const withdrawalId = withdrawal.id; // Create a pending withdrawal const attestTx = contract.send("attest_withdrawal", { @@ -982,6 +1117,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.dave.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attestTx, context.guardians[0].signer); @@ -1095,7 +1231,8 @@ describe("Bridge Contract Integration", () => { }); it("rejects admin_cancel_withdrawal on already-cancelled withdrawal", async () => { - const withdrawalId = randomHash("double-cancel"); + const withdrawal = withdrawalRequest(context.accounts.dave.address, MIN_DEPOSIT); + const withdrawalId = withdrawal.id; // Create and cancel a withdrawal const attestTx = contract.send("attest_withdrawal", { @@ -1104,6 +1241,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.dave.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attestTx, context.guardians[0].signer); @@ -1143,7 +1281,8 @@ describe("Bridge Contract Integration", () => { taoToRao(5), ); - const withdrawalId = randomHash("cancel-completed"); + const withdrawal = withdrawalRequest(context.accounts.eve.address, MIN_DEPOSIT); + const withdrawalId = withdrawal.id; // Create and complete a withdrawal const attest1Tx = contract.send("attest_withdrawal", { @@ -1152,6 +1291,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.eve.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attest1Tx, context.guardians[0].signer); @@ -1162,6 +1302,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.eve.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attest2Tx, context.guardians[1].signer); @@ -1301,7 +1442,8 @@ describe("Bridge Contract Integration", () => { }); it("cleans up a completed withdrawal after TTL expires", async () => { - const withdrawalId = randomHash("cleanup-completed"); + const withdrawal = withdrawalRequest(context.accounts.bob.address, MIN_DEPOSIT); + const withdrawalId = withdrawal.id; // Create and complete a withdrawal const attest1Tx = contract.send("attest_withdrawal", { @@ -1310,6 +1452,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.bob.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attest1Tx, context.guardians[0].signer); @@ -1320,6 +1463,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.bob.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attest2Tx, context.guardians[1].signer); @@ -1352,7 +1496,8 @@ describe("Bridge Contract Integration", () => { }, 120000); it("cleans up a cancelled withdrawal after TTL expires", async () => { - const withdrawalId = randomHash("cleanup-cancelled"); + const withdrawal = withdrawalRequest(context.accounts.dave.address, MIN_DEPOSIT); + const withdrawalId = withdrawal.id; // Create and cancel a withdrawal const attestTx = contract.send("attest_withdrawal", { @@ -1361,6 +1506,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.dave.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attestTx, context.guardians[0].signer); @@ -1390,7 +1536,8 @@ describe("Bridge Contract Integration", () => { }, 120000); it("rejects cleanup of a pending (non-finalized) withdrawal", async () => { - const withdrawalId = randomHash("cleanup-pending"); + const withdrawal = withdrawalRequest(context.accounts.dave.address, MIN_DEPOSIT); + const withdrawalId = withdrawal.id; // Create a pending withdrawal (not finalized) const attestTx = contract.send("attest_withdrawal", { @@ -1399,6 +1546,7 @@ describe("Bridge Contract Integration", () => { request_id: withdrawalId, recipient: context.accounts.dave.address, amount: MIN_DEPOSIT, + nonce: withdrawal.nonce, }, }); await signAndFinalize(attestTx, context.guardians[0].signer); diff --git a/contracts/bridge/integration-tests/src/utils.ts b/contracts/bridge/integration-tests/src/utils.ts index 4754cb9..533611b 100644 --- a/contracts/bridge/integration-tests/src/utils.ts +++ b/contracts/bridge/integration-tests/src/utils.ts @@ -126,8 +126,7 @@ async function startSubnet( return; } - const duration = await api.constants.SubtensorModule.DurationOfStartCall(); - const durationNumber = Number(duration); + const durationNumber = await getStartCallDelay(api); let currentBlock = await api.query.System.Number.getValue(); while (Number(currentBlock) - Number(registerBlock) <= durationNumber) { @@ -142,6 +141,24 @@ async function startSubnet( console.log(`Started subnet ${netuid}`); } +async function getStartCallDelay(api: TypedApi): Promise { + const storageValue = (api.query.SubtensorModule as any).StartCallDelay; + if (storageValue?.getValue) { + return Number(await storageValue.getValue()); + } + + const legacyConstant = (api.constants.SubtensorModule as any).DurationOfStartCall; + if (typeof legacyConstant === "function") { + try { + return Number(await legacyConstant()); + } catch { + return 0; + } + } + + return 0; +} + export async function registerValidator( api: TypedApi, netuid: number, @@ -263,13 +280,10 @@ export function findContractEvent( } export interface DepositRequestCreatedEvent { - chain_id: number; - escrow_contract: string; deposit_nonce: bigint; sender: string; - recipient: string; amount: bigint; - deposit_request_id: string | Uint8Array; + deposit_request_id: string | Uint8Array | { toHex?: () => string }; } export interface WithdrawalCompletedEvent { diff --git a/contracts/bridge/integration-tests/src/utils/stake-helpers.ts b/contracts/bridge/integration-tests/src/utils/stake-helpers.ts index bff28b7..6b35497 100644 --- a/contracts/bridge/integration-tests/src/utils/stake-helpers.ts +++ b/contracts/bridge/integration-tests/src/utils/stake-helpers.ts @@ -64,6 +64,76 @@ export async function moveStake( await tx.signAndSubmit(signer); } +export async function lockStake( + api: TypedApi, + hotkey: string, + netuid: number, + amount: bigint, + signer: PolkadotSigner +): Promise { + const lockStakeTx = (api.tx.SubtensorModule as any).lock_stake; + if (!lockStakeTx) { + throw new Error("SubtensorModule.lock_stake is unavailable; regenerate descriptors against a Subtensor localnet"); + } + + const tx = lockStakeTx({ + hotkey, + netuid, + amount, + }); + + await tx.signAndSubmit(signer); +} + +export type StakeAvailability = { + total: bigint; + locked: bigint; + available: bigint; +}; + +function getMapValue(mapLike: unknown, key: string | number): unknown { + if (mapLike instanceof Map) { + return mapLike.get(key); + } + + if (Array.isArray(mapLike)) { + const match = mapLike.find((entry) => { + if (!Array.isArray(entry) || entry.length < 2) { + return false; + } + return String(entry[0]) === String(key); + }); + return match?.[1]; + } + + if (mapLike && typeof mapLike === "object") { + return (mapLike as Record)[String(key)]; + } + + return undefined; +} + +export async function getStakeAvailability( + api: TypedApi, + coldkey: string, + netuid: number +): Promise { + const runtimeApi = (api.apis.StakeInfoRuntimeApi as any).get_stake_availability_for_coldkeys; + if (!runtimeApi) { + throw new Error("StakeInfoRuntimeApi.get_stake_availability_for_coldkeys is unavailable; regenerate descriptors against a Subtensor localnet"); + } + + const response = await runtimeApi([coldkey], [netuid]); + const coldkeyAvailability = getMapValue(response, coldkey); + const subnetAvailability = getMapValue(coldkeyAvailability, netuid) as Partial | undefined; + + return { + total: BigInt(subnetAvailability?.total ?? 0), + locked: BigInt(subnetAvailability?.locked ?? 0), + available: BigInt(subnetAvailability?.available ?? 0), + }; +} + export function formatStakeAmount(amount: bigint): string { const alpha = Number(amount) / 1_000_000_000; return `${alpha.toFixed(4)} Alpha`; diff --git a/contracts/bridge/spec.md b/contracts/bridge/spec.md index dbe0f48..f8689f0 100644 --- a/contracts/bridge/spec.md +++ b/contracts/bridge/spec.md @@ -42,7 +42,7 @@ Users can: 2. **First-attestation-creates-record** — No separate propose step; first guardian vote creates the record 3. **Symmetric recovery** — Both directions have admin recovery paths for stuck transactions 4. **Nonce-based unique IDs** — Monotonic nonces prevent hash collisions -5. **Hash-based ID verification** — Request IDs are deterministic blake2_256 hashes of (domain, recipient, amount, nonce), so any parameter mismatch produces a different ID +5. **Hash-based ID verification** — Request IDs are deterministic blake2_256 hashes of a chain-specific domain plus the relevant account, amount, and nonce fields, so any parameter mismatch produces a different ID ## Architecture @@ -63,7 +63,7 @@ Users can: │ │ status=Requested│ │ │ │ └─────────────────┘ │ │ │ ▼ │ -│ attest_deposit() │ +│ attest_deposit(..., nonce) │ │ │ │ │ ▼ │ │ ┌─────────────────┐ │ @@ -99,7 +99,7 @@ Users can: │ │ status=Requested │ │ │ │ └──────────────────┘ │ │ │ ▼ │ -│ attest_withdrawal() │ +│ attest_withdrawal(..., nonce) │ │ │ │ │ ▼ │ │ ┌─────────────────┐ │ @@ -198,7 +198,7 @@ Users can: | Feature | Bittensor Contract | Hippius Pallet | |---------|-------------------|----------------| | **User: Deposit/Withdraw** | `deposit(amount, hotkey)` | `withdraw(amount)` | -| **Guardian: Attest** | `attest_withdrawal(id, recipient, amount)` | `attest_deposit(id, recipient, amount)` | +| **Guardian: Attest** | `attest_withdrawal(id, recipient, amount, nonce)` | `attest_deposit(id, recipient, amount, nonce)` | | **Admin: Guardian config** | `set_guardians_and_threshold(guardians, threshold)` | `set_guardians_and_threshold(guardians, threshold)` | | **Admin: Pause** | `pause()` / `unpause()` | `pause()` / `unpause()` | | **Admin: Fail request** | `admin_fail_deposit_request(id)` | `admin_fail_withdrawal_request(id)` | @@ -233,7 +233,7 @@ Users can: | Feature | Contract Event | Pallet Event | |---------|---------------|--------------| -| **Source request created** | `DepositRequestCreated` | `WithdrawalRequestCreated` | +| **Source request created** | `DepositRequestCreated { deposit_nonce, sender, amount, deposit_request_id }` | `WithdrawalRequestCreated { id, sender, recipient, amount }` | | **Source request failed** | `DepositRequestFailed` | `WithdrawalRequestFailed` | | **Dest attested** | `WithdrawalAttested` | `DepositAttested` | | **Dest completed** | `WithdrawalCompleted` | `DepositCompleted` | @@ -270,6 +270,8 @@ Users can: | **Mint failed** | — | `MintFailed` | | **Accounting underflow** | — | `AccountingUnderflow` | | **Transfer failed** | `TransferFailed` / `TransferNotVerified` / `RuntimeCallFailed` | — | +| **Stake conviction-locked** | `StakeUnavailable` | — | +| **Stake availability query failed** | `StakeAvailabilityQueryFailed` | — | | **TTL invalid** | `InvalidTTL` | `InvalidTTL` | | **Record not finalized** | `RecordNotFinalized` | `RecordNotFinalized` | | **TTL not expired** | `TTLNotExpired` | `TTLNotExpired` | @@ -282,33 +284,43 @@ Both chains use deterministic ID generation with monotonic nonces: ``` ID = blake2_256( - DOMAIN_DEPOSIT_REQUEST || // "HIPPIUS_DEPOSIT_REQUEST-V2" - chain_id || // u16, little-endian - contract_account || // 32 bytes - nonce || // u64, little-endian - sender || // 32 bytes - recipient || // 32 bytes - amount // u64, little-endian + DOMAIN_DEPOSIT_REQUEST || // "DEPOSIT_REQUEST-V1" + sender || // encoded AccountId + halpha_amount || // u128, little-endian, amount converted to halphaRao + nonce // u64, little-endian ) ``` +Guardians use the emitted `DepositRequestCreated.deposit_nonce` as `nonce` when calling +`attest_deposit`. The pallet recomputes the ID with `recipient = sender` and the hAlpha-side +amount (`alphaRao * 1_000_000_000`). + ### Withdrawal Request ID (Hippius) ``` ID = blake2_256( - DOMAIN_WITHDRAWAL_REQUEST || // "HIPPIUS-WITHDRAWAL-REQUEST-v1" - sender || // encoded AccountId + DOMAIN_WITHDRAWAL_REQUEST || // "WITHDRAWAL_REQUEST-V1" recipient || // encoded AccountId - amount || // u64, little-endian + amount || // u128, little-endian, alphaRao nonce // u64, little-endian ) ``` +The pallet stores the generated nonce in `WithdrawalRequests[id].nonce`. Guardians include that +nonce when calling `attest_withdrawal`; the contract recomputes the ID with the Alpha-side amount +(`halphaRao / 1_000_000_000`). + ## Security Considerations ### Poisoning Prevention (Hash-Based ID Verification) -Request IDs are deterministic blake2_256 hashes over (domain, recipient, amount, nonce). Both `attest_deposit` (pallet) and `attest_withdrawal` (contract) recompute the expected ID from the supplied parameters and reject if it doesn't match: +Both guardian attestation paths recompute the expected request ID from the supplied parameters and +reject if it doesn't match: + +- `attest_deposit` verifies `DOMAIN_DEPOSIT_REQUEST || recipient || amount || nonce`, where + `amount` is in halphaRao. +- `attest_withdrawal` verifies `DOMAIN_WITHDRAWAL_REQUEST || recipient || amount || nonce`, where + `amount` is in alphaRao. ```rust // Hippius pallet - attest_deposit @@ -333,7 +345,23 @@ The pallet tracks `TotalMintedByBridge` and enforces `GlobalMintCap`: ### Stake Verification -The contract verifies stake transfers by checking balances before and after: +Before any Alpha leaves a coldkey through the bridge, the contract checks Bittensor's +rolled-forward stake availability via chain extension function `36` (`get_stake_availability`). +The transfer amount must be less than or equal to `available`; total stake that is currently +locked by conviction cannot be deposited, withdrawn, or manually released. + +Stake availability is a coldkey/subnet-wide value, matching Bittensor's conviction-lock model. +It is not hotkey-scoped. Deposit still keeps the existing `(coldkey, hotkey, netuid)` stake check +before proxy transfer, and release paths still verify the contract has enough stake on +`contract_hotkey` before transfer. The availability check complements those hotkey-specific +checks by rejecting any amount that exceeds the coldkey's currently unlocked stake on the subnet. + +Deployments must run against a Bittensor runtime that exposes chain extension function `36`. +If the extension is missing, fails, returns availability for the wrong subnet, or returns invalid +availability data such as `available > total`, the bridge fails closed with +`StakeAvailabilityQueryFailed`. + +The contract also verifies deposit stake transfers by checking balances before and after: ```rust let sender_decrease = sender_stake_before.saturating_sub(sender_stake_after); @@ -348,15 +376,20 @@ let contract_ok = contract_increase >= amount.saturating_sub(TRANSFER_TOLERANCE) ### Insufficient Stake Check -Before releasing Alpha, the contract verifies it has sufficient stake: +Before releasing Alpha, the contract verifies it has sufficient stake on the contract hotkey and +that the contract coldkey has enough available, non-conviction-locked stake: ```rust let contract_stake = self.get_stake_amount(contract_account, contract_hk, netuid)?; if contract_stake < withdrawal.amount { return Err(Error::InsufficientContractStake); } +self.ensure_alpha_available(contract_account, netuid, withdrawal.amount)?; ``` +Admin manual release bypasses pause and deposit-record validation for emergency recovery, but it +does not bypass Bittensor conviction availability. + ### Admin Pause Bypass Admin recovery functions (`admin_fail_deposit_request`, `admin_manual_release`, `admin_cancel_withdrawal` on Bittensor; `admin_cancel_deposit`, `admin_fail_withdrawal_request`, `admin_manual_mint` on Hippius) intentionally do **not** check the pause state. This is by design — admin must be able to operate during emergencies when the bridge is paused. @@ -410,7 +443,8 @@ Configurable via `set_cleanup_ttl` (admin only). TTL must be greater than zero. | Constant | Contract | Pallet | |----------|----------|--------| -| Domain separator | `"HIPPIUS_DEPOSIT_REQUEST-V2"` | `"HIPPIUS-WITHDRAWAL-REQUEST-v1"` | +| Deposit request ID domain | `"DEPOSIT_REQUEST-V1"` | Must match deposit verification | +| Withdrawal request ID domain | `"WITHDRAWAL_REQUEST-V1"` | Must match withdrawal creation | | MAX_GUARDIANS | 10 | 10 | | Transfer tolerance | 10 alphaRao | — | | Default min amount | 1 Alpha (1e9 alphaRao) | 1 hAlpha (1e9 halphaRao) | diff --git a/contracts/bridge/src/chain_extension.rs b/contracts/bridge/src/chain_extension.rs index 3d8ac35..d392db6 100644 --- a/contracts/bridge/src/chain_extension.rs +++ b/contracts/bridge/src/chain_extension.rs @@ -21,6 +21,58 @@ impl StakeInfo { let alpha_currency = self.stake.0; alpha_currency.as_u64() } + + #[cfg(test)] + pub(crate) fn new_for_tests( + hotkey: AccountId, + coldkey: AccountId, + netuid: NetUid, + stake: AlphaCurrency, + ) -> Self { + Self { + hotkey, + coldkey, + netuid: Compact(netuid), + stake: Compact(stake), + locked: Compact(0), + emission: Compact(AlphaCurrency::from(0)), + tao_emission: Compact(0), + drain: Compact(0), + is_registered: true, + } + } +} + +/// ABI-coupled to the Subtensor chain-extension `StakeAvailability` type. +/// The runtime returns plain `u64` fields here, not compact-encoded balances. +/// `total` and `locked` are retained to keep the SCALE layout pinned to the +/// runtime type; the contract consumes `total` as an ABI sanity check. +#[derive(PartialEq, Eq, Copy, Clone, Debug)] +#[ink::scale_derive(Encode, Decode, TypeInfo)] +pub struct StakeAvailability { + netuid: NetUid, + total: u64, + locked: u64, + available: u64, +} + +impl StakeAvailability { + pub fn netuid(&self) -> NetUid { + self.netuid + } + + pub fn available_amount(&self) -> u64 { + self.available + } + + pub fn total_amount(&self) -> u64 { + self.total + } + + #[cfg(test)] + pub(crate) fn new_for_tests(netuid: NetUid, total: u64, locked: u64, available: u64) -> Self { + Self { netuid, total, locked, available } + } } #[allow(clippy::cast_possible_truncation)] @@ -104,4 +156,10 @@ pub trait SubtensorExtension { destination_netuid: NetUid, alpha_amount: AlphaCurrency, ) -> Result<(), SubtensorError>; + + #[ink(function = 36)] + fn get_stake_availability( + coldkey: AccountId, + netuid: NetUid, + ) -> Result; } diff --git a/contracts/bridge/src/errors.rs b/contracts/bridge/src/errors.rs index c5a6d62..81c8fc6 100644 --- a/contracts/bridge/src/errors.rs +++ b/contracts/bridge/src/errors.rs @@ -69,6 +69,12 @@ pub enum Error { RecordNotFinalized, /// TTL has not expired yet TTLNotExpired, + + // ============ Conviction Lock Errors ============ + /// Stake is present but currently locked by conviction + StakeUnavailable, + /// Stake availability query failed or returned invalid data + StakeAvailabilityQueryFailed, } impl From for String { diff --git a/contracts/bridge/src/lib.rs b/contracts/bridge/src/lib.rs index 7f16e69..502ddcd 100755 --- a/contracts/bridge/src/lib.rs +++ b/contracts/bridge/src/lib.rs @@ -139,6 +139,7 @@ mod bridge { if sender_stake_before < amount { return Err(Error::InsufficientStake); } + self.ensure_alpha_available(sender, netuid, amount)?; let contract_stake_before = self.get_stake_amount(contract_account, hotkey, netuid)?; @@ -384,6 +385,9 @@ mod bridge { /// emitted events. This allows emergency releases even when the deposit /// request record may be missing or corrupted. /// + /// This bypass is limited to bridge pause/storage recovery checks. It still + /// refuses to move Alpha that Bittensor reports as conviction-locked. + /// /// # Arguments /// * `recipient` - Account to receive Alpha /// * `amount` - Amount to release (in alphaRao) @@ -399,6 +403,13 @@ mod bridge { let contract_hk = self.contract_hotkey; let netuid = NetUid::from(self.chain_id); + let contract_account = self.env().account_id(); + + let contract_stake = self.get_stake_amount(contract_account, contract_hk, netuid)?; + if contract_stake < amount { + return Err(Error::InsufficientContractStake); + } + self.ensure_alpha_available(contract_account, netuid, amount)?; self.env() .extension() @@ -711,6 +722,31 @@ mod bridge { } } + fn ensure_alpha_available( + &self, + coldkey: AccountId, + netuid: NetUid, + amount: Balance, + ) -> Result<(), Error> { + let availability = self + .env() + .extension() + .get_stake_availability(coldkey, netuid) + .map_err(|_| Error::StakeAvailabilityQueryFailed)?; + + if availability.netuid() != netuid + || availability.available_amount() > availability.total_amount() + { + return Err(Error::StakeAvailabilityQueryFailed); + } + + if availability.available_amount() < amount { + return Err(Error::StakeUnavailable); + } + + Ok(()) + } + fn finalize_withdrawal( &mut self, withdrawal_id: WithdrawalId, @@ -725,6 +761,7 @@ mod bridge { if contract_stake < withdrawal.amount { return Err(Error::InsufficientContractStake); } + self.ensure_alpha_available(contract_account, netuid, withdrawal.amount)?; // Transfer stake to recipient self.env() @@ -756,6 +793,187 @@ mod bridge { #[cfg(test)] mod tests { use super::*; + use crate::chain_extension::{StakeAvailability, StakeInfo}; + use ink::scale::{Decode, Encode}; + + struct MockSubtensorExtension { + stakes: Vec<(AccountId, AccountId, NetUid, Balance)>, + availabilities: Vec<(AccountId, NetUid, Balance, Balance, Balance)>, + fail_availability: bool, + returned_availability_netuid: Option, + } + + impl MockSubtensorExtension { + fn new() -> Self { + Self { + stakes: Vec::new(), + availabilities: Vec::new(), + fail_availability: false, + returned_availability_netuid: None, + } + } + + fn with_stake( + mut self, + hotkey: AccountId, + coldkey: AccountId, + netuid: NetUid, + stake: Balance, + ) -> Self { + self.stakes.push((hotkey, coldkey, netuid, stake)); + self + } + + fn with_availability( + mut self, + coldkey: AccountId, + netuid: NetUid, + total: Balance, + locked: Balance, + available: Balance, + ) -> Self { + self.availabilities.push((coldkey, netuid, total, locked, available)); + self + } + + fn with_availability_failure(mut self) -> Self { + self.fail_availability = true; + self + } + + fn with_returned_availability_netuid(mut self, netuid: NetUid) -> Self { + self.returned_availability_netuid = Some(netuid); + self + } + } + + impl ink::env::test::ChainExtension for MockSubtensorExtension { + fn ext_id(&self) -> u16 { + 0 + } + + fn call(&mut self, func_id: u16, input: &[u8], output: &mut Vec) -> u32 { + match func_id { + 0 => { + let Some((hotkey, coldkey, netuid)) = + decode_mock_input::<(AccountId, AccountId, NetUid)>(input) + else { + return 1; + }; + let stake_info = + self.stakes + .iter() + .find(|(stored_hotkey, stored_coldkey, stored_netuid, _)| { + *stored_hotkey == hotkey + && *stored_coldkey == coldkey && *stored_netuid == netuid + }) + .map(|(_, _, _, stake)| { + StakeInfo::new_for_tests( + hotkey, + coldkey, + netuid, + AlphaCurrency::from(*stake), + ) + }); + output.clear(); + output.extend(stake_info.encode()); + 0 + }, + 36 => { + if self.fail_availability { + return 1; + } + + let Some((coldkey, netuid)) = + decode_mock_input::<(AccountId, NetUid)>(input) + else { + return 1; + }; + let availability = self + .availabilities + .iter() + .find(|(stored_coldkey, stored_netuid, _, _, _)| { + *stored_coldkey == coldkey && *stored_netuid == netuid + }) + .map(|(_, _, total, locked, available)| { + let returned_netuid = + self.returned_availability_netuid.unwrap_or(netuid); + StakeAvailability::new_for_tests( + returned_netuid, + *total, + *locked, + *available, + ) + }) + .unwrap_or_else(|| StakeAvailability::new_for_tests(netuid, 0, 0, 0)); + output.clear(); + output.extend(availability.encode()); + 0 + }, + 5 => { + let Some(( + _origin_hotkey, + _destination_hotkey, + _origin_netuid, + _destination_netuid, + _amount, + )) = decode_mock_input::<(AccountId, AccountId, NetUid, NetUid, AlphaCurrency)>( + input, + ) + else { + return 1; + }; + 0 + }, + 6 => { + let Some(( + _destination_coldkey, + _hotkey, + _origin_netuid, + _destination_netuid, + _amount, + )) = decode_mock_input::<(AccountId, AccountId, NetUid, NetUid, AlphaCurrency)>( + input, + ) + else { + return 1; + }; + 0 + }, + _ => 1, + } + } + } + + fn decode_mock_input(input: &[u8]) -> Option { + decode_exact(input).or_else(|| { + let mut remaining = input; + let ink::scale::Compact(payload_len) = + ink::scale::Compact::::decode(&mut remaining).ok()?; + if remaining.len() != payload_len as usize { + return None; + } + decode_exact(remaining) + }) + } + + fn decode_exact(input: &[u8]) -> Option { + let mut remaining = input; + let decoded = T::decode(&mut remaining).ok()?; + if remaining.is_empty() { + Some(decoded) + } else { + None + } + } + + fn register_mock_extension(extension: MockSubtensorExtension) { + ink::env::test::register_chain_extension(extension); + } + + fn set_contract_account(account: AccountId) { + ink::env::test::set_callee::(account); + } /// Generate a valid withdrawal request ID using the same hash format as the contract verifies. fn generate_withdrawal_id(recipient: AccountId, amount: Balance, nonce: Nonce) -> Hash { @@ -851,6 +1069,115 @@ mod bridge { assert_eq!(bridge.update_owner(accounts.charlie), Err(Error::Unauthorized)); } + #[ink::test] + fn ensure_alpha_available_allows_unlocked_stake() { + let accounts = ink::env::test::default_accounts::(); + let bridge = Bridge::new(accounts.alice, 1, accounts.eve); + let netuid = NetUid::from(1); + register_mock_extension(MockSubtensorExtension::new().with_availability( + accounts.bob, + netuid, + 2_000_000_000, + 500_000_000, + 1_500_000_000, + )); + + assert_eq!(bridge.ensure_alpha_available(accounts.bob, netuid, 1_000_000_000), Ok(())); + } + + #[ink::test] + fn ensure_alpha_available_rejects_locked_stake() { + let accounts = ink::env::test::default_accounts::(); + let bridge = Bridge::new(accounts.alice, 1, accounts.eve); + let netuid = NetUid::from(1); + register_mock_extension(MockSubtensorExtension::new().with_availability( + accounts.bob, + netuid, + 2_000_000_000, + 1_500_000_000, + 500_000_000, + )); + + assert_eq!( + bridge.ensure_alpha_available(accounts.bob, netuid, 1_000_000_000), + Err(Error::StakeUnavailable) + ); + } + + #[ink::test] + fn ensure_alpha_available_maps_query_failure() { + let accounts = ink::env::test::default_accounts::(); + let bridge = Bridge::new(accounts.alice, 1, accounts.eve); + let netuid = NetUid::from(1); + register_mock_extension(MockSubtensorExtension::new().with_availability_failure()); + + assert_eq!( + bridge.ensure_alpha_available(accounts.bob, netuid, 1_000_000_000), + Err(Error::StakeAvailabilityQueryFailed) + ); + } + + #[ink::test] + fn ensure_alpha_available_rejects_mismatched_netuid_response() { + let accounts = ink::env::test::default_accounts::(); + let bridge = Bridge::new(accounts.alice, 1, accounts.eve); + let requested_netuid = NetUid::from(1); + let returned_netuid = NetUid::from(2); + register_mock_extension( + MockSubtensorExtension::new() + .with_availability( + accounts.bob, + requested_netuid, + 2_000_000_000, + 0, + 2_000_000_000, + ) + .with_returned_availability_netuid(returned_netuid), + ); + + assert_eq!( + bridge.ensure_alpha_available(accounts.bob, requested_netuid, 1_000_000_000), + Err(Error::StakeAvailabilityQueryFailed) + ); + } + + #[ink::test] + fn ensure_alpha_available_rejects_invalid_available_total() { + let accounts = ink::env::test::default_accounts::(); + let bridge = Bridge::new(accounts.alice, 1, accounts.eve); + let netuid = NetUid::from(1); + register_mock_extension(MockSubtensorExtension::new().with_availability( + accounts.bob, + netuid, + 1_000_000_000, + 0, + 1_000_000_001, + )); + + assert_eq!( + bridge.ensure_alpha_available(accounts.bob, netuid, 1_000_000_000), + Err(Error::StakeAvailabilityQueryFailed) + ); + } + + #[ink::test] + fn deposit_rejects_stake_locked_by_conviction() { + let accounts = ink::env::test::default_accounts::(); + set_contract_account(accounts.alice); + let mut bridge = Bridge::new(accounts.alice, 1, accounts.eve); + let netuid = NetUid::from(1); + let amount = 1_000_000_000; + + register_mock_extension( + MockSubtensorExtension::new() + .with_stake(accounts.eve, accounts.bob, netuid, amount) + .with_availability(accounts.bob, netuid, amount, amount / 2, amount / 2), + ); + + ink::env::test::set_caller::(accounts.bob); + assert_eq!(bridge.deposit(amount, accounts.eve), Err(Error::StakeUnavailable)); + } + #[ink::test] fn attest_withdrawal_creates_record_on_first_attestation() { let accounts = ink::env::test::default_accounts::(); @@ -933,7 +1260,7 @@ mod bridge { // Second attestation by charlie with wrong recipient — fails ID verification ink::env::test::set_caller::(accounts.charlie); - let wrong_id = generate_withdrawal_id(wrong_recipient, amount, nonce); + let _wrong_id = generate_withdrawal_id(wrong_recipient, amount, nonce); // Using the original withdrawal_id but wrong recipient should fail ID check let result = bridge.attest_withdrawal(withdrawal_id, wrong_recipient, amount, nonce); assert_eq!(result, Err(Error::InvalidRequestId)); @@ -1007,6 +1334,55 @@ mod bridge { assert_eq!(result, Err(Error::NotGuardian)); } + #[ink::test] + fn attest_withdrawal_rejects_contract_stake_locked_by_conviction() { + let accounts = ink::env::test::default_accounts::(); + set_contract_account(accounts.alice); + let mut bridge = Bridge::new(accounts.alice, 1, accounts.eve); + let netuid = NetUid::from(1); + let amount = 1_000_000_000; + + register_mock_extension( + MockSubtensorExtension::new() + .with_stake(accounts.eve, accounts.alice, netuid, amount) + .with_availability(accounts.alice, netuid, amount, amount / 2, amount / 2), + ); + bridge.set_guardians_and_threshold(vec![accounts.bob], 1).unwrap(); + + ink::env::test::set_caller::(accounts.bob); + let withdrawal_id = generate_withdrawal_id(accounts.charlie, amount, 0); + assert_eq!( + bridge.attest_withdrawal(withdrawal_id, accounts.charlie, amount, 0), + Err(Error::StakeUnavailable) + ); + } + + #[ink::test] + fn attest_withdrawal_allows_available_contract_stake() { + let accounts = ink::env::test::default_accounts::(); + set_contract_account(accounts.alice); + let mut bridge = Bridge::new(accounts.alice, 1, accounts.eve); + let netuid = NetUid::from(1); + let amount = 1_000_000_000; + + register_mock_extension( + MockSubtensorExtension::new() + .with_stake(accounts.eve, accounts.alice, netuid, amount) + .with_availability(accounts.alice, netuid, amount, 0, amount), + ); + bridge.set_guardians_and_threshold(vec![accounts.bob], 1).unwrap(); + + ink::env::test::set_caller::(accounts.bob); + let withdrawal_id = generate_withdrawal_id(accounts.charlie, amount, 0); + assert_eq!( + bridge.attest_withdrawal(withdrawal_id, accounts.charlie, amount, 0), + Ok(()) + ); + + let withdrawal = bridge.get_withdrawal(withdrawal_id).unwrap(); + assert_eq!(withdrawal.status, WithdrawalStatus::Completed); + } + #[ink::test] fn admin_fail_deposit_request_works() { let accounts = ink::env::test::default_accounts::(); @@ -1180,6 +1556,44 @@ mod bridge { assert_eq!(result, Err(Error::WithdrawalAlreadyFinalized)); } + #[ink::test] + fn admin_manual_release_rejects_contract_stake_locked_by_conviction() { + let accounts = ink::env::test::default_accounts::(); + set_contract_account(accounts.alice); + let mut bridge = Bridge::new(accounts.alice, 1, accounts.eve); + let netuid = NetUid::from(1); + let amount = 1_000_000_000; + + register_mock_extension( + MockSubtensorExtension::new() + .with_stake(accounts.eve, accounts.alice, netuid, amount) + .with_availability(accounts.alice, netuid, amount, amount / 2, amount / 2), + ); + + assert_eq!( + bridge.admin_manual_release(accounts.bob, amount, None), + Err(Error::StakeUnavailable) + ); + } + + #[ink::test] + fn admin_manual_release_allows_available_contract_stake() { + let accounts = ink::env::test::default_accounts::(); + set_contract_account(accounts.alice); + let mut bridge = Bridge::new(accounts.alice, 1, accounts.eve); + let netuid = NetUid::from(1); + let amount = 1_000_000_000; + + register_mock_extension( + MockSubtensorExtension::new() + .with_stake(accounts.eve, accounts.alice, netuid, amount) + .with_availability(accounts.alice, netuid, amount, 0, amount), + ); + + ink::env::test::set_caller::(accounts.alice); + assert_eq!(bridge.admin_manual_release(accounts.bob, amount, None), Ok(())); + } + #[ink::test] fn attest_withdrawal_fails_when_paused() { let accounts = ink::env::test::default_accounts::(); diff --git a/pallets/alpha-bridge/src/lib.rs b/pallets/alpha-bridge/src/lib.rs index 745622c..8d03103 100644 --- a/pallets/alpha-bridge/src/lib.rs +++ b/pallets/alpha-bridge/src/lib.rs @@ -570,17 +570,17 @@ pub mod pallet { ) -> DispatchResult { let caller = ensure_signed(origin)?; Self::ensure_guardian(&caller)?; - + let request = WithdrawalRequests::::get(request_id) .ok_or(Error::::WithdrawalRequestNotFound)?; - + // Must be finalized before guardian can clean up — protects user's burned hAlpha // during the admin recovery window. ensure!( matches!(request.status, WithdrawalRequestStatus::Failed), Error::::InvalidStatus, ); - + // TTL must have passed since creation let current_block = frame_system::Pallet::::block_number(); let ttl = CleanupTTLBlocks::::get(); @@ -917,4 +917,4 @@ pub mod pallet { Ok(()) } } -} \ No newline at end of file +} diff --git a/pallets/alpha-bridge/src/tests.rs b/pallets/alpha-bridge/src/tests.rs index 7a52f42..ae4a124 100644 --- a/pallets/alpha-bridge/src/tests.rs +++ b/pallets/alpha-bridge/src/tests.rs @@ -169,7 +169,7 @@ fn test_attestation_on_completed_deposit_fails() { amount, nonce, ), - Error::::DepositIdAlreadySettled + Error::::DepositAlreadyCompleted ); }); } @@ -878,10 +878,10 @@ fn test_set_guardians_and_threshold_replaces_existing() { }); } -/// Pending deposits that already have enough **current** guardian votes must finalize on rotation -/// (e.g. threshold lowered from 3 to 2). +/// Pending deposits are not retroactively finalized on guardian rotation; +/// the new threshold is applied when the record is next attested. #[test] -fn test_guardian_rotation_reevaluates_pending_deposits() { +fn test_guardian_rotation_applies_on_next_attestation() { new_test_ext().execute_with(|| { crate::ApproveThreshold::::put(3); @@ -915,6 +915,19 @@ fn test_guardian_rotation_reevaluates_pending_deposits() { 2, )); + let deposit = crate::Deposits::::get(deposit_id).expect("deposit"); + assert_eq!(deposit.status, DepositStatus::Pending); + assert_eq!(Balances::free_balance(&recipient), initial_balance); + assert_eq!(crate::TotalMintedByBridge::::get(), 0); + + assert_ok!(AlphaBridge::::attest_deposit( + RuntimeOrigin::signed(charlie()), + deposit_id, + recipient.clone(), + amount, + nonce, + )); + let deposit = crate::Deposits::::get(deposit_id).expect("deposit"); assert_eq!(deposit.status, DepositStatus::Completed); assert_eq!(Balances::free_balance(&recipient), initial_balance + amount); @@ -1446,10 +1459,15 @@ fn test_cleanup_withdrawal_request_after_ttl() { let request = crate::WithdrawalRequests::::get(request_id).expect("Request should exist"); + assert_ok!(AlphaBridge::::admin_fail_withdrawal_request( + RuntimeOrigin::root(), + request_id, + )); + // Advance block number past TTL System::set_block_number(request.created_at_block + 11); - // Cleanup should succeed (guardian only, no status check for withdrawal requests) + // Cleanup should succeed once the withdrawal request is failed and past TTL. assert_ok!(AlphaBridge::::cleanup_withdrawal_request( RuntimeOrigin::signed(alice()), request_id, @@ -1495,6 +1513,11 @@ fn test_cleanup_withdrawal_request_before_ttl_fails() { let request_id = crate::WithdrawalRequests::::iter().next().unwrap().0; + assert_ok!(AlphaBridge::::admin_fail_withdrawal_request( + RuntimeOrigin::root(), + request_id, + )); + // Try to cleanup immediately (before TTL expires) as guardian assert_noop!( AlphaBridge::::cleanup_withdrawal_request(