From 2a10c3c7f3995dccc1ad545d85cbd9ef6b0500a0 Mon Sep 17 00:00:00 2001 From: Francisco Silva Date: Wed, 17 Jun 2026 11:24:53 +0200 Subject: [PATCH] Refactor bridge hotkey stake availability flow --- Cargo.lock | 1 + contracts/bridge/Cargo.toml | 4 +- .../.papi/contracts/bridge.json | 131 +++--- .../.papi/descriptors/package.json | 2 +- .../.papi/metadata/devnet.scale | Bin 299522 -> 335796 bytes .../integration-tests/.papi/polkadot-api.json | 4 +- .../src/tests/bridge.integration.test.ts | 182 +++++++- .../bridge/integration-tests/src/utils.ts | 26 +- .../src/utils/stake-helpers.ts | 70 +++ contracts/bridge/spec.md | 72 ++- contracts/bridge/src/chain_extension.rs | 58 +++ contracts/bridge/src/errors.rs | 6 + contracts/bridge/src/lib.rs | 416 +++++++++++++++++- pallets/alpha-bridge/src/lib.rs | 8 +- pallets/alpha-bridge/src/tests.rs | 33 +- 15 files changed, 891 insertions(+), 122 deletions(-) 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 e65ff76a84dcea6586f35cd548e29920117aefb7..422c354517157ae33823a9dab764a642d410b9aa 100644 GIT binary patch delta 69663 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

yq>G`BCk028k;D}C+H10#_yS+ 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: 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(