Skip to content

Commit a4c0b53

Browse files
be strict on locks
1 parent b7b75d7 commit a4c0b53

10 files changed

Lines changed: 230 additions & 29 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integration-tests/src/gigahdx.rs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1114,7 +1114,7 @@ fn cancel_unstake_should_fail_when_no_pending() {
11141114

11151115
assert_noop!(
11161116
GigaHdx::cancel_unstake(RuntimeOrigin::signed(alice), 0),
1117-
pallet_gigahdx::Error::<Runtime>::NoPendingUnstake,
1117+
pallet_gigahdx::Error::<Runtime>::PendingUnstakeNotFound,
11181118
);
11191119
});
11201120
}
@@ -1681,3 +1681,64 @@ fn unlock_should_release_full_compounded_amount_e2e() {
16811681
assert_eq!(locked_under_ghdx(&alice), 0);
16821682
});
16831683
}
1684+
1685+
// Strict admission: any non-overlap-allowed lock (legacy staking, vesting,
1686+
// democracy, …) blocks `giga_stake` entirely, even when free_balance is
1687+
// sufficient. This prevents the lock-layering exploit at the root: the
1688+
// `stk_stks` + `ghdxlock` overlap can never be set up in the first place.
1689+
#[test]
1690+
fn giga_stake_should_fail_when_caller_has_legacy_staking_lock() {
1691+
use frame_support::traits::{LockableCurrency, WithdrawReasons};
1692+
1693+
TestNet::reset();
1694+
hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| {
1695+
reset_giga_state_for_fixture();
1696+
let alice: AccountId = ALICE.into();
1697+
1698+
assert_ok!(Balances::force_set_balance(
1699+
RawOrigin::Root.into(),
1700+
alice.clone(),
1701+
2_000 * UNITS,
1702+
));
1703+
let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone()));
1704+
<Balances as LockableCurrency<_>>::set_lock(*b"stk_stks", &alice, 1_000 * UNITS, WithdrawReasons::all());
1705+
1706+
assert_noop!(
1707+
GigaHdx::giga_stake(RuntimeOrigin::signed(alice), 500 * UNITS),
1708+
pallet_gigahdx::Error::<Runtime>::BlockedByExternalLock,
1709+
);
1710+
});
1711+
}
1712+
1713+
// `pyconvot` is in the runtime's overlap allowlist (HdxExternalClaims), so a
1714+
// conviction-voting lock must NOT block stake admission — the voter's HDX is
1715+
// only earmarked, not committed to a payout, so sharing it with a gigahdx
1716+
// stake is safe.
1717+
#[test]
1718+
fn giga_stake_should_succeed_when_caller_has_conviction_voting_lock() {
1719+
TestNet::reset();
1720+
hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| {
1721+
reset_giga_state_for_fixture();
1722+
fund_bob_for_decision_deposit();
1723+
1724+
let alice: AccountId = ALICE.into();
1725+
fund(&alice, 1_000 * UNITS);
1726+
1727+
let ref_index = begin_referendum_by_bob();
1728+
assert_ok!(ConvictionVoting::vote(
1729+
RuntimeOrigin::signed(alice.clone()),
1730+
ref_index,
1731+
aye_with_conviction(800 * UNITS, Conviction::Locked1x),
1732+
));
1733+
1734+
let conviction_lock = pallet_balances::Locks::<Runtime>::get(&alice)
1735+
.iter()
1736+
.find(|l| l.id == *b"pyconvot")
1737+
.map(|l| l.amount)
1738+
.unwrap_or(0);
1739+
assert_eq!(conviction_lock, 800 * UNITS);
1740+
1741+
assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 500 * UNITS));
1742+
assert_eq!(pallet_gigahdx::Stakes::<Runtime>::get(&alice).unwrap().hdx, 500 * UNITS,);
1743+
});
1744+
}

pallets/gigahdx/src/lib.rs

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333

3434
pub use pallet::*;
3535

36+
pub mod traits;
37+
3638
#[cfg(test)]
3739
mod tests;
3840

@@ -60,6 +62,7 @@ impl<AccountId> BenchmarkHelper<AccountId> for () {
6062

6163
#[frame_support::pallet]
6264
pub mod pallet {
65+
pub use crate::traits::ExternalClaims;
6366
pub use crate::weights::WeightInfo;
6467
use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen};
6568
use frame_support::pallet_prelude::*;
@@ -69,7 +72,7 @@ pub mod pallet {
6972
use frame_support::traits::fungibles::Mutate as FungiblesMutate;
7073
use frame_support::traits::tokens::{Fortitude, Precision, Preservation};
7174
use frame_support::traits::{
72-
fungible, fungibles, Currency, ExistenceRequirement, LockIdentifier, LockableCurrency, WithdrawReasons,
75+
fungibles, Currency, ExistenceRequirement, LockIdentifier, LockableCurrency, WithdrawReasons,
7376
};
7477
use frame_support::{transactional, PalletId};
7578
use frame_system::pallet_prelude::*;
@@ -121,11 +124,7 @@ pub mod pallet {
121124

122125
#[pallet::config]
123126
pub trait Config: frame_system::Config<RuntimeEvent: From<Event<Self>>> {
124-
/// Native (HDX) lockable currency. The `fungible::Inspect` bound is
125-
/// required so `giga_stake` can use `reducible_balance` (free balance
126-
/// minus transfer-blocking locks) instead of raw `free_balance`.
127-
type NativeCurrency: LockableCurrency<Self::AccountId, Balance = Balance, Moment = BlockNumberFor<Self>>
128-
+ fungible::Inspect<Self::AccountId, Balance = Balance>;
127+
type NativeCurrency: LockableCurrency<Self::AccountId, Balance = Balance, Moment = BlockNumberFor<Self>>;
129128

130129
/// Multi-asset register that holds stHDX (and any other registered
131130
/// fungible). Only this pallet mints / burns stHDX through it.
@@ -163,6 +162,12 @@ pub mod pallet {
163162
#[pallet::constant]
164163
type MaxPendingUnstakes: Get<u32>;
165164

165+
/// Inspector returning the sum of non-overlapping HDX claims on the
166+
/// caller. Any non-zero value blocks `giga_stake` admission — the
167+
/// strict policy rejects stakes whenever the account carries a lock
168+
/// the runtime has not whitelisted for overlap (e.g. `pyconvot`).
169+
type ExternalClaims: crate::traits::ExternalClaims<Self::AccountId>;
170+
166171
type WeightInfo: WeightInfo;
167172

168173
/// Benchmark helper for setting up state that can't be created from
@@ -237,10 +242,15 @@ pub mod pallet {
237242
pub enum Error<T> {
238243
/// Stake amount is below `Config::MinStake`.
239244
BelowMinStake,
240-
/// Caller does not have enough unlocked HDX to back this stake (the
241-
/// admission check uses `reducible_balance`, which subtracts every
242-
/// transfer-blocking lock — including this pallet's own lock).
245+
/// Caller doesn't have enough unencumbered HDX to back the stake
246+
/// after subtracting their existing gigahdx commitment.
243247
InsufficientFreeBalance,
248+
/// Caller holds a non-overlapping lock (legacy staking, vesting, …)
249+
/// reported by `Config::ExternalClaims`. Strict policy: gigahdx
250+
/// admission requires the caller to have no claims on their HDX
251+
/// other than those the runtime explicitly allows to coexist
252+
/// (e.g. `pyconvot`). Release the conflicting lock before staking.
253+
BlockedByExternalLock,
244254
/// Unstake amount exceeds the caller's `Stakes.gigahdx`.
245255
InsufficientStake,
246256
/// Caller has no active stake record.
@@ -289,10 +299,12 @@ pub mod pallet {
289299
/// money market (may differ from the requested mint amount by rounding).
290300
///
291301
/// Fails with `BelowMinStake` if `amount < Config::MinStake`, with
292-
/// `InsufficientFreeBalance` if the caller's `reducible_balance` does not cover
293-
/// `amount`, with `StHdxMintFailed` if stHDX minting fails (asset not registered,
294-
/// max issuance hit), or with `MoneyMarketSupplyFailed` if the AAVE `Pool.supply`
295-
/// call reverts.
302+
/// `BlockedByExternalLock` if `Config::ExternalClaims::on(caller) > 0`
303+
/// (the caller holds a non-allowed lock — strict policy rejects
304+
/// stake admission entirely), with `InsufficientFreeBalance` if
305+
/// `free_balance − own_gigahdx_commitment < amount`, with
306+
/// `StHdxMintFailed` if stHDX minting fails, or with
307+
/// `MoneyMarketSupplyFailed` if the AAVE `Pool.supply` call reverts.
296308
///
297309
/// Parameters:
298310
/// - `amount`: HDX amount to stake. Must be at least `Config::MinStake`.
@@ -305,15 +317,19 @@ pub mod pallet {
305317
let who = ensure_signed(origin)?;
306318
ensure!(amount >= T::MinStake::get(), Error::<T>::BelowMinStake);
307319

308-
// Use `reducible_balance` so the check respects every transfer-blocking
309-
// lock — including this pallet's own combined `LockId` lock (active
310-
// stake + pending unstake) and any unrelated conviction/vesting locks.
311-
let usable = <T::NativeCurrency as fungible::Inspect<T::AccountId>>::reducible_balance(
312-
&who,
313-
Preservation::Expendable,
314-
Fortitude::Polite,
315-
);
316-
ensure!(usable >= amount, Error::<T>::InsufficientFreeBalance);
320+
// Strict policy: refuse if the caller carries any lock the runtime
321+
// does not whitelist for overlap. Lock-layering via `max()` would
322+
// otherwise let the same HDX back both a gigahdx stake and another
323+
// pallet's claim after a single transfer of the unlocked portion.
324+
ensure!(T::ExternalClaims::on(&who) == 0, Error::<T>::BlockedByExternalLock);
325+
326+
// Own commitment (active + pending unstakes) still has to fit
327+
// under free_balance — re-staking under the same `ghdxlock` must
328+
// not exceed what the user actually owns.
329+
let stake = Stakes::<T>::get(&who).unwrap_or_default();
330+
let own_claim = stake.hdx.saturating_add(stake.unstaking);
331+
let stakeable = T::NativeCurrency::free_balance(&who).saturating_sub(own_claim);
332+
ensure!(stakeable >= amount, Error::<T>::InsufficientFreeBalance);
317333

318334
Self::do_stake(&who, amount)?;
319335
Ok(())
@@ -613,9 +629,12 @@ pub mod pallet {
613629
/// `Staked`.
614630
///
615631
/// Caller invariant: `amount` HDX must already be in `who`'s free
616-
/// balance. This helper does not enforce the `MinStake` floor — callers
617-
/// requiring that check (e.g. the `giga_stake` extrinsic) must perform
618-
/// it before invoking `do_stake`.
632+
/// balance. This helper performs **no admission control** — neither
633+
/// the `MinStake` floor nor the `ExternalClaims`/headroom checks that
634+
/// `giga_stake` applies. It is intended for trusted internal callers
635+
/// (e.g. `cancel_unstake` rearranging already-locked HDX, or
636+
/// `pallet-gigahdx-rewards` compounding accrued rewards). Untrusted
637+
/// callers must replicate the `giga_stake` checks before invoking.
619638
#[transactional]
620639
pub fn do_stake(who: &T::AccountId, amount: Balance) -> Result<Balance, DispatchError> {
621640
ensure!(amount > 0, Error::<T>::ZeroAmount);

pallets/gigahdx/src/tests/mock.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,26 @@ impl MoneyMarketOperations<AccountId, AssetId, Balance> for TestMoneyMarket {
181181
}
182182
}
183183

184+
// ---------- TestExternalClaims ----------
185+
186+
thread_local! {
187+
pub static EXTERNAL_CLAIMS: RefCell<Balance> = const { RefCell::new(0) };
188+
}
189+
190+
pub struct TestExternalClaims;
191+
192+
impl TestExternalClaims {
193+
pub fn set(value: Balance) {
194+
EXTERNAL_CLAIMS.with(|v| *v.borrow_mut() = value);
195+
}
196+
}
197+
198+
impl pallet_gigahdx::traits::ExternalClaims<AccountId> for TestExternalClaims {
199+
fn on(_who: &AccountId) -> Balance {
200+
EXTERNAL_CLAIMS.with(|v| *v.borrow())
201+
}
202+
}
203+
184204
// ---------- pallet-gigahdx config ----------
185205

186206
parameter_types! {
@@ -203,6 +223,7 @@ impl pallet_gigahdx::Config for Test {
203223
type MinStake = GigaHdxMinStake;
204224
type CooldownPeriod = GigaHdxCooldownPeriod;
205225
type MaxPendingUnstakes = GigaHdxMaxPendingUnstakes;
226+
type ExternalClaims = TestExternalClaims;
206227
type WeightInfo = ();
207228
#[cfg(feature = "runtime-benchmarks")]
208229
type BenchmarkHelper = ();
@@ -282,6 +303,7 @@ impl ExtBuilder {
282303
let mut ext: sp_io::TestExternalities = t.into();
283304
ext.execute_with(|| {
284305
TestMoneyMarket::reset();
306+
TestExternalClaims::set(0);
285307
System::set_block_number(1);
286308
});
287309
ext

pallets/gigahdx/src/tests/stake.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,63 @@ fn giga_stake_should_revert_storage_when_mm_supply_fails() {
185185
assert_eq!(TestMoneyMarket::balance_of(&ALICE), 0);
186186
});
187187
}
188+
189+
#[test]
190+
fn giga_stake_should_subtract_own_existing_stake() {
191+
// Alice has 1000 ONE total. Two 500 stakes max out her balance; a third
192+
// stake of 1 must fail because own_claim now equals her whole balance.
193+
ExtBuilder::default().build().execute_with(|| {
194+
assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 500 * ONE));
195+
assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 500 * ONE));
196+
assert_noop!(
197+
GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), ONE),
198+
Error::<Test>::InsufficientFreeBalance,
199+
);
200+
});
201+
}
202+
203+
#[test]
204+
fn giga_stake_should_fail_when_external_claims_nonzero() {
205+
// Strict policy: any non-zero external claim blocks admission,
206+
// regardless of how much free balance the caller has.
207+
ExtBuilder::default().build().execute_with(|| {
208+
TestExternalClaims::set(ONE);
209+
assert_noop!(
210+
GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE),
211+
Error::<Test>::BlockedByExternalLock,
212+
);
213+
});
214+
}
215+
216+
#[test]
217+
fn giga_stake_should_fail_when_external_claim_appears_after_stake() {
218+
// Existing staker who later acquires another lock (e.g. legacy
219+
// staking) can't grow their gigahdx position.
220+
ExtBuilder::default().build().execute_with(|| {
221+
assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 400 * ONE));
222+
TestExternalClaims::set(ONE);
223+
assert_noop!(
224+
GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), ONE),
225+
Error::<Test>::BlockedByExternalLock,
226+
);
227+
});
228+
}
229+
230+
#[test]
231+
fn giga_stake_should_treat_unstaking_as_own_claim() {
232+
// After a full unstake, stake.hdx → 0 and stake.unstaking holds the pending
233+
// amount. A fresh stake must still see that pending portion as committed.
234+
ExtBuilder::default().build().execute_with(|| {
235+
assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 600 * ONE));
236+
assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 600 * ONE));
237+
let s = Stakes::<Test>::get(ALICE).unwrap();
238+
assert_eq!(s.hdx, 0);
239+
assert_eq!(s.unstaking, 600 * ONE);
240+
241+
assert_noop!(
242+
GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 500 * ONE),
243+
Error::<Test>::InsufficientFreeBalance,
244+
);
245+
assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 400 * ONE));
246+
});
247+
}

pallets/gigahdx/src/traits.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! Hooks injected by the runtime to customize gigahdx admission logic.
4+
5+
use primitives::Balance;
6+
7+
/// Sum of HDX claimed by other pallets on `who`. `giga_stake` subtracts this
8+
/// from the caller's free balance to ensure the new stake doesn't overlap
9+
/// with HDX already pledged elsewhere. The runtime decides which lock ids
10+
/// count as claims (legacy staking, vesting, …) and which are allowed to
11+
/// overlap with a gigahdx stake (e.g. conviction voting).
12+
pub trait ExternalClaims<AccountId> {
13+
fn on(who: &AccountId) -> Balance;
14+
}
15+
16+
impl<AccountId> ExternalClaims<AccountId> for () {
17+
fn on(_who: &AccountId) -> Balance {
18+
0
19+
}
20+
}

runtime/hydradx/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "hydradx-runtime"
3-
version = "418.0.0"
3+
version = "420.0.0"
44
authors = ["GalacticCouncil"]
55
edition = "2021"
66
license = "Apache 2.0"

runtime/hydradx/src/assets.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1907,6 +1907,7 @@ impl pallet_gigahdx::Config for Runtime {
19071907
type MinStake = GigaHdxMinStake;
19081908
type CooldownPeriod = GigaHdxCooldownPeriod;
19091909
type MaxPendingUnstakes = GigaHdxMaxPendingUnstakes;
1910+
type ExternalClaims = crate::gigahdx::HdxExternalClaims;
19101911
type WeightInfo = weights::pallet_gigahdx::HydraWeight<Runtime>;
19111912
#[cfg(feature = "runtime-benchmarks")]
19121913
type BenchmarkHelper = GigaHdxBenchmarkHelper;

runtime/hydradx/src/gigahdx.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use crate::Runtime;
2020
use evm::ExitReason::Succeed;
2121
use frame_support::sp_runtime::traits::Convert;
2222
use frame_support::sp_runtime::DispatchError;
23+
use frame_support::traits::LockIdentifier;
2324
use frame_support::weights::Weight;
2425
use hydradx_traits::evm::{CallContext, CallResult, Erc20Mapping, InspectEvmAccounts, ERC20, EVM};
2526
use hydradx_traits::gigahdx::MoneyMarketOperations;
@@ -189,3 +190,20 @@ impl ReferendaTrackInspect<ReferendumIndex, u16> for RuntimeReferenda {
189190
}
190191
}
191192
}
193+
194+
/// `ExternalClaims` impl: sum of HDX claimed by other pallets that should NOT
195+
/// overlap with a gigahdx stake. `ghdxlock` is excluded because the pallet
196+
/// accounts for it from its own ledger; `pyconvot` is excluded because a
197+
/// conviction vote is intentionally permitted to share HDX with a stake.
198+
pub struct HdxExternalClaims;
199+
200+
impl pallet_gigahdx::traits::ExternalClaims<AccountId> for HdxExternalClaims {
201+
fn on(who: &AccountId) -> Balance {
202+
const ALLOWED_OVERLAP: &[LockIdentifier] = &[*b"ghdxlock", *b"pyconvot"];
203+
pallet_balances::Locks::<Runtime>::get(who)
204+
.iter()
205+
.filter(|l| !ALLOWED_OVERLAP.contains(&l.id))
206+
.map(|l| l.amount)
207+
.fold(0, Balance::saturating_add)
208+
}
209+
}

runtime/hydradx/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion {
129129
spec_name: Cow::Borrowed("hydradx"),
130130
impl_name: Cow::Borrowed("hydradx"),
131131
authoring_version: 1,
132-
spec_version: 418,
132+
spec_version: 420,
133133
impl_version: 0,
134134
apis: RUNTIME_API_VERSIONS,
135135
transaction_version: 1,

0 commit comments

Comments
 (0)