diff --git a/Cargo.lock b/Cargo.lock index 831ea09459..e56a183d7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5779,7 +5779,7 @@ dependencies = [ [[package]] name = "hydradx-runtime" -version = "420.0.0" +version = "422.0.0" dependencies = [ "cumulus-pallet-aura-ext", "cumulus-pallet-parachain-system", @@ -5858,9 +5858,12 @@ dependencies = [ "pallet-evm-precompile-call-permit", "pallet-evm-precompile-dispatch", "pallet-evm-precompile-flash-loan", + "pallet-evm-precompile-lock-manager", "pallet-evm-precompile-modexp", "pallet-evm-precompile-simple", "pallet-genesis-history", + "pallet-gigahdx", + "pallet-gigahdx-rewards", "pallet-hsm", "pallet-identity", "pallet-lbp", @@ -5940,7 +5943,7 @@ dependencies = [ [[package]] name = "hydradx-traits" -version = "4.7.1" +version = "4.8.0" dependencies = [ "frame-support", "frame-system", @@ -9619,6 +9622,25 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-evm-precompile-lock-manager" +version = "1.0.0" +dependencies = [ + "fp-evm", + "frame-support", + "frame-system", + "log", + "num_enum", + "pallet-evm", + "pallet-gigahdx", + "parity-scale-codec", + "precompile-utils", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-evm-precompile-modexp" version = "2.0.0-dev" @@ -9674,6 +9696,53 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-gigahdx" +version = "0.1.1" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "hydra-dx-math", + "hydradx-traits", + "log", + "orml-tokens", + "orml-traits", + "pallet-balances", + "parity-scale-codec", + "primitives", + "proptest", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + +[[package]] +name = "pallet-gigahdx-rewards" +version = "0.1.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "hydra-dx-math", + "hydradx-traits", + "log", + "orml-tokens", + "orml-traits", + "pallet-balances", + "pallet-conviction-voting", + "pallet-gigahdx", + "parity-scale-codec", + "primitives", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-grandpa" version = "41.0.0" @@ -12836,7 +12905,7 @@ dependencies = [ [[package]] name = "primitives" -version = "6.3.0" +version = "6.4.0" dependencies = [ "frame-support", "hex-literal", @@ -13863,6 +13932,8 @@ dependencies = [ "pallet-evm", "pallet-evm-accounts", "pallet-evm-precompile-call-permit", + "pallet-gigahdx", + "pallet-gigahdx-rewards", "pallet-hsm", "pallet-im-online", "pallet-lbp", diff --git a/Cargo.toml b/Cargo.toml index bcdff1e55c..38704d681a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,12 +46,15 @@ members = [ 'pallets/liquidation', 'precompiles/call-permit', 'precompiles/flash-loan', + 'precompiles/lock-manager', 'runtime-mock', 'pallets/broadcast', 'liquidation-worker-support', 'pallets/hsm', "pallets/signet", - "pallets/dispenser" + "pallets/dispenser", + "pallets/gigahdx", + "pallets/gigahdx-rewards", ] resolver = "2" @@ -164,6 +167,8 @@ pallet-hsm = { path = "pallets/hsm", default-features = false } pallet-parameters = { path = "pallets/parameters", default-features = false } pallet-signet = { path = "pallets/signet", default-features = false } pallet-dispenser = { path = "pallets/dispenser", default-features = false } +pallet-gigahdx = { path = "pallets/gigahdx", default-features = false } +pallet-gigahdx-rewards = { path = "pallets/gigahdx-rewards", default-features = false } hydra-dx-build-script-utils = { path = "utils/build-script-utils", default-features = false } scraper = { path = "scraper", default-features = false } @@ -173,6 +178,7 @@ integration-tests = { path = "integration-tests", default-features = false } pallet-evm-precompile-call-permit = { path = "precompiles/call-permit", default-features = false } pallet-evm-precompile-flash-loan = { path = "precompiles/flash-loan", default-features = false } +pallet-evm-precompile-lock-manager = { path = "precompiles/lock-manager", default-features = false } precompile-utils = { path = "precompiles/utils", default-features = false } # Frame diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index 51a8c72d02..265ba77aec 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -47,6 +47,8 @@ pallet-evm-accounts = { workspace = true } pallet-xyk-liquidity-mining = { workspace = true } pallet-transaction-pause = { workspace = true } pallet-liquidation = { workspace = true } +pallet-gigahdx = { workspace = true } +pallet-gigahdx-rewards = { workspace = true } liquidation-worker-support = { workspace = true } pallet-broadcast = { workspace = true } pallet-duster = { workspace = true } @@ -228,6 +230,8 @@ std = [ "precompile-utils/std", "pallet-transaction-pause/std", "pallet-liquidation/std", + "pallet-gigahdx/std", + "pallet-gigahdx-rewards/std", "pallet-broadcast/std", "pallet-dispatcher/std", "pallet-hsm/std", diff --git a/integration-tests/snapshots/gigahdx/gigahdx b/integration-tests/snapshots/gigahdx/gigahdx new file mode 100644 index 0000000000..549118cbf8 Binary files /dev/null and b/integration-tests/snapshots/gigahdx/gigahdx differ diff --git a/integration-tests/src/evm.rs b/integration-tests/src/evm.rs index fbc6968cfc..b8a5d5014e 100644 --- a/integration-tests/src/evm.rs +++ b/integration-tests/src/evm.rs @@ -1954,7 +1954,7 @@ mod chainlink_precompile { use hydradx_traits::{router::AssetPair, AggregatedPriceOracle, OraclePeriod}; use pallet_ema_oracle::Price; use pallet_lbp::AssetId; - use primitives::constants::chain::{OMNIPOOL_SOURCE, XYK_SOURCE}; + use primitives::constants::chain::{GIGAHDX_SOURCE, OMNIPOOL_SOURCE, XYK_SOURCE}; use primitives::EvmAddress; fn assert_prices_are_same(ema_price: Price, precompile_price: U256, asset_a_decimals: u8, asset_b_decimals: u8) { @@ -2692,6 +2692,118 @@ mod chainlink_precompile { pretty_assertions::assert_eq!(r, expected_decimals); }); } + + const STHDX: AssetId = 670; + const HDX_ID: AssetId = 0; + + fn register_st_hdx() { + if hydradx_runtime::AssetRegistry::decimals(STHDX).is_some() { + return; + } + assert_ok!(hydradx_runtime::AssetRegistry::register( + RuntimeOrigin::root(), + Some(STHDX), + Some(b"stHDX".to_vec().try_into().unwrap()), + pallet_asset_registry::AssetType::Token, + Some(1u128), + Some(b"stHDX".to_vec().try_into().unwrap()), + Some(12u8), + None, + None, + true, + )); + } + + fn seed_gigapot_and_supply(gigapot_hdx: Balance, st_hdx_supply: Balance) { + // `total_gigahdx_supply` reads orml-tokens issuance directly. + orml_tokens::TotalIssuance::::set(STHDX, st_hdx_supply); + let gigapot = pallet_gigahdx::Pallet::::gigapot_account_id(); + assert_ok!(Balances::force_set_balance(RuntimeOrigin::root(), gigapot, gigapot_hdx,)); + } + + fn chainlink_latest_answer(address: EvmAddress) -> U256 { + let data = EvmDataWriter::new_with_selector(AggregatorInterface::LatestAnswer).build(); + let mut handle = MockHandle { + input: data, + context: Context { + address: evm_address(), + caller: address, + apparent_value: U256::from(0), + }, + code_address: address, + is_static: true, + }; + let PrecompileOutput { output, exit_status } = + ChainlinkOraclePrecompile::::execute(&mut handle).unwrap(); + pretty_assertions::assert_eq!(exit_status, ExitSucceed::Returned); + U256::from_big_endian(&output) + } + + #[test] + fn chainlink_precompile_should_return_gigahdx_exchange_rate_when_source_is_gigahdx() { + TestNet::reset(); + + Hydra::execute_with(|| { + register_st_hdx(); + // 110 HDX in gigapot, 100 stHDX issued ⇒ rate = 1.1. + seed_gigapot_and_supply(110 * UNITS, 100 * UNITS); + + pretty_assertions::assert_eq!( + pallet_gigahdx::Pallet::::exchange_rate().cmp(&hydra_dx_math::ratio::Ratio::new(11, 10)), + std::cmp::Ordering::Equal + ); + + let address = encode_oracle_address(STHDX, HDX_ID, OraclePeriod::TenMinutes, GIGAHDX_SOURCE); + + // Pin the canonical mainnet feed address. + pretty_assertions::assert_eq!( + address, + EvmAddress::from(hex!("0000010267696761686478730000029e00000000")) + ); + + pretty_assertions::assert_eq!(chainlink_latest_answer(address), U256::from(110_000_000u128)); + }); + } + + #[test] + fn chainlink_precompile_should_floor_at_one_when_gigahdx_reserves_drained() { + TestNet::reset(); + + Hydra::execute_with(|| { + register_st_hdx(); + // Full drain: native rate would be 0 — pallet floor must clamp to 1.0. + seed_gigapot_and_supply(0, 100 * UNITS); + + pretty_assertions::assert_eq!( + pallet_gigahdx::Pallet::::exchange_rate().cmp(&hydra_dx_math::ratio::Ratio::one()), + std::cmp::Ordering::Equal + ); + + let address = encode_oracle_address(STHDX, HDX_ID, OraclePeriod::TenMinutes, GIGAHDX_SOURCE); + + pretty_assertions::assert_eq!(chainlink_latest_answer(address), U256::from(100_000_000u128)); + }); + } + + #[test] + fn chainlink_precompile_should_floor_at_one_when_gigahdx_partially_drained() { + TestNet::reset(); + + Hydra::execute_with(|| { + register_st_hdx(); + // Partial drain: native rate would be 0.5 — clamp must hit 1.0, not 50_000_000. + seed_gigapot_and_supply(50 * UNITS, 100 * UNITS); + + pretty_assertions::assert_eq!( + pallet_gigahdx::Pallet::::exchange_rate().cmp(&hydra_dx_math::ratio::Ratio::one()), + std::cmp::Ordering::Equal + ); + + let address = encode_oracle_address(STHDX, HDX_ID, OraclePeriod::TenMinutes, GIGAHDX_SOURCE); + + pretty_assertions::assert_eq!(chainlink_latest_answer(address), U256::from(100_000_000u128)); + }); + } } mod contract_deployment { diff --git a/integration-tests/src/gigahdx.rs b/integration-tests/src/gigahdx.rs new file mode 100644 index 0000000000..b2df819b7d --- /dev/null +++ b/integration-tests/src/gigahdx.rs @@ -0,0 +1,2017 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Integration tests for `pallet-gigahdx` against a mainnet-state snapshot +// with the AAVE V3 fork deployed (GIGAHDX listed as a reserve, +// `LockableAToken` consuming the lock-manager precompile at 0x0806). + +use crate::polkadot_test_net::{hydra_live_ext, TestNet, ALICE, BOB, CHARLIE, DAVE, HDX, UNITS}; +use frame_support::traits::OnInitialize; +use frame_support::traits::StorePreimage; +use frame_support::{assert_noop, assert_ok}; +use frame_system::RawOrigin; +use hex_literal::hex; +use hydra_dx_math::ratio::Ratio; +use hydradx_runtime::evm::{ + aave_trade_executor::Function as AaveFunction, precompiles::erc20_mapping::HydraErc20Mapping, + precompiles::handle::EvmDataWriter, Executor, +}; +use hydradx_runtime::{ + Balances, ConvictionVoting, Currencies, Democracy, EVMAccounts, GigaHdx, Preimage, Referenda, Runtime, + RuntimeOrigin, Scheduler, Staking, System, +}; +use hydradx_traits::evm::{CallContext, Erc20Mapping, InspectEvmAccounts, EVM}; +use orml_traits::MultiCurrency; +use pallet_conviction_voting::{AccountVote, Conviction, Vote}; +use primitives::constants::time::DAYS; +use primitives::{AccountId, AssetId, Balance, EvmAddress}; +use sp_core::{H160, H256, U256}; +use xcm_emulator::Network; + +pub const PATH_TO_SNAPSHOT: &str = "snapshots/gigahdx/gigahdx"; + +#[allow(dead_code)] +pub const ST_HDX: AssetId = 670; +pub const GIGAHDX: AssetId = 67; + +/// Asserts that the actual `Ratio` equals `expected_n / expected_d` via +/// cross-multiplication (Ratio's `PartialEq` is field-wise, so direct +/// `==` only matches when n and d are pointwise equal). +fn assert_rate_eq(actual: Ratio, expected_n: u128, expected_d: u128) { + let expected = Ratio::new(expected_n, expected_d); + assert_eq!( + actual.cmp(&expected), + std::cmp::Ordering::Equal, + "rate mismatch: got {:?}, expected {expected_n}/{expected_d}", + actual, + ); +} + +/// AAVE pool address from the snapshot. Tests must not set it themselves — +/// a missing entry indicates a misconfigured snapshot and should fail loud. +fn pool_contract() -> EvmAddress { + pallet_gigahdx::GigaHdxPoolContract::::get().expect("snapshot must have GigaHdxPoolContract pre-populated") +} + +pub const GIGAHDX_LOCK_ID: frame_support::traits::LockIdentifier = *b"ghdxlock"; + +fn lock_amount(account: &AccountId, id: frame_support::traits::LockIdentifier) -> Balance { + pallet_balances::Locks::::get(account) + .iter() + .find(|l| l.id == id) + .map(|l| l.amount) + .unwrap_or(0) +} + +/// Fund Alice with HDX and bind her EVM address. Snapshot already +/// configures the AAVE pool contract. +fn init_gigahdx() { + let alice: AccountId = ALICE.into(); + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 1_000 * UNITS, + )); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice)); +} + +/// Fund Bob with enough HDX to cover an OpenGov decision deposit. +fn fund_bob_for_decision_deposit() { + let bob: AccountId = BOB.into(); + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + bob, + 2_000_000_000 * UNITS, + )); +} + +fn locked_under_ghdx(account: &AccountId) -> Balance { + pallet_balances::Locks::::get(account) + .iter() + .find(|l| l.id == GIGAHDX_LOCK_ID) + .map(|l| l.amount) + .unwrap_or(0) +} + +/// Flattened view of a single pending-unstake entry for tests that assume one. +#[derive(Clone, Debug)] +struct PendingView { + #[allow(dead_code)] + id: u32, + amount: Balance, + expires_at: hydradx_runtime::BlockNumber, +} + +/// Read the only pending-unstake position for `who`. Panics if zero or more than one. +fn only_pending_position(who: &AccountId) -> PendingView { + let mut iter = pallet_gigahdx::PendingUnstakes::::iter_prefix(who); + let (id, p) = iter.next().expect("expected one pending position"); + assert!(iter.next().is_none(), "expected exactly one pending position"); + let cooldown: hydradx_runtime::BlockNumber = ::CooldownPeriod::get(); + PendingView { + id, + amount: p.amount, + expires_at: id + cooldown, + } +} + +fn pending_count(who: &AccountId) -> u16 { + pallet_gigahdx::Stakes::::get(who) + .map(|s| s.unstaking_count) + .unwrap_or(0) +} + +#[test] +fn giga_stake_should_lock_hdx_in_user_account_when_called() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_gigahdx(); + + let alice: AccountId = ALICE.into(); + let alice_hdx_before = Balances::free_balance(&alice); + let alice_atoken_before = Currencies::free_balance(GIGAHDX, &alice); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + // Lock model: HDX stays in Alice's account; `free_balance` doesn't subtract locks. + assert_eq!( + Balances::free_balance(&alice), + alice_hdx_before, + "HDX must remain in Alice's account (lock model)" + ); + assert_eq!(locked_under_ghdx(&alice), 100 * UNITS); + + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake should exist"); + assert_eq!(stake.hdx, 100 * UNITS); + assert_eq!(stake.gigahdx, 100 * UNITS); + + let alice_atoken_after = Currencies::free_balance(GIGAHDX, &alice); + assert!( + alice_atoken_after > alice_atoken_before, + "Alice should hold GIGAHDX after stake" + ); + }); +} + +#[test] +fn giga_unstake_should_burn_atoken_when_full_exit() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_gigahdx(); + + let alice: AccountId = ALICE.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + let atoken_after_stake = Currencies::free_balance(GIGAHDX, &alice); + + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + // Stakes record persists (zero-active) until `unlock` cleans it up. + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake remains until unlock"); + assert_eq!(stake.hdx, 0); + assert_eq!(stake.gigahdx, 0); + + let entry = only_pending_position(&alice); + assert_eq!(locked_under_ghdx(&alice), entry.amount); + + let atoken_after_unstake = Currencies::free_balance(GIGAHDX, &alice); + assert!( + atoken_after_unstake < atoken_after_stake, + "GIGAHDX should be burned on unstake" + ); + }); +} + +#[test] +fn giga_unstake_should_keep_proportional_state_when_partial() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_gigahdx(); + + let alice: AccountId = ALICE.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 40 * UNITS)); + + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake should exist"); + // gigahdx always drops by exactly the unstaked amount. + assert_eq!(stake.gigahdx, 60 * UNITS); + + // Position payout depends on snapshot rate. With a richly-funded gigapot + // it can exceed Alice's active 100, draining her active stake to zero; + // with a near-bootstrap rate the active stake just shrinks. Either way + // the combined lock equals active + position. + let entry = only_pending_position(&alice); + assert_eq!(locked_under_ghdx(&alice), stake.hdx + entry.amount); + assert!(entry.amount >= 40 * UNITS, "payout covers at least the principal share"); + }); +} + +#[test] +fn lock_manager_precompile_should_report_gigahdx_when_account_has_stake() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_gigahdx(); + + let alice: AccountId = ALICE.into(); + let alice_evm = EVMAccounts::evm_address(&alice); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + // `getLockedBalance(token, account)` — `token` must be the GIGAHDX + // aToken; the precompile returns 0 otherwise so unrelated aTokens + // can't accidentally consume gigahdx-stake state. + let lock_manager: EvmAddress = H160(hex!("0000000000000000000000000000000000000806")); + let gigahdx_token = HydraErc20Mapping::asset_address(GIGAHDX); + let selector: [u8; 4] = sp_io::hashing::keccak_256(b"getLockedBalance(address,address)")[0..4] + .try_into() + .unwrap(); + let mut data = selector.to_vec(); + data.extend_from_slice(H256::from(gigahdx_token).as_bytes()); + data.extend_from_slice(H256::from(alice_evm).as_bytes()); + + let result = Executor::::view(CallContext::new_view(lock_manager), data, 100_000); + assert!( + matches!(result.exit_reason, fp_evm::ExitReason::Succeed(_)), + "precompile call must succeed, got {:?}", + result.exit_reason + ); + let reported = U256::from_big_endian(&result.value); + assert_eq!(reported, U256::from(100 * UNITS), "lock-manager must report gigahdx"); + + // Wrong token must return zero (no state leak to unrelated aTokens). + let mut wrong = selector.to_vec(); + wrong.extend_from_slice(H256::from(EvmAddress::zero()).as_bytes()); + wrong.extend_from_slice(H256::from(alice_evm).as_bytes()); + let wrong_result = Executor::::view(CallContext::new_view(lock_manager), wrong, 100_000); + assert!(matches!(wrong_result.exit_reason, fp_evm::ExitReason::Succeed(_))); + assert_eq!(U256::from_big_endian(&wrong_result.value), U256::zero()); + }); +} + +#[test] +fn lock_manager_precompile_should_resolve_bound_evm_address_to_substrate_stake() { + // Round-trip with an EVM-bound user (the realistic shape: a MetaMask + // user calls `bind_evm_address` so their AAVE-side activity maps back to + // a stable substrate AccountId). The precompile receives the **bound** + // EVM address as `account` and must resolve it to the same substrate + // AccountId that `pallet-gigahdx::Stakes` is keyed by — otherwise + // `LockableAToken.freeBalance` would read zero for the very users who + // participated through the EVM front door, defeating the lock. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_gigahdx(); + + let alice: AccountId = ALICE.into(); + + let alice_evm = EVMAccounts::evm_address(&alice); + assert_eq!( + EVMAccounts::bound_account_id(alice_evm), + Some(alice.clone()), + "precondition: Alice's EVM address must be bound before staking" + ); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + // `Stakes` is keyed by substrate AccountId; the precompile resolves + // the bound H160 via `AddressMapping::into_account_id`, which must + // yield Alice's AccountId so `locked_gigahdx` is non-zero. + let lock_manager: EvmAddress = H160(hex!("0000000000000000000000000000000000000806")); + let gigahdx_token = HydraErc20Mapping::asset_address(GIGAHDX); + let selector: [u8; 4] = sp_io::hashing::keccak_256(b"getLockedBalance(address,address)")[0..4] + .try_into() + .unwrap(); + let mut data = selector.to_vec(); + data.extend_from_slice(H256::from(gigahdx_token).as_bytes()); + data.extend_from_slice(H256::from(alice_evm).as_bytes()); + + let result = Executor::::view(CallContext::new_view(lock_manager), data, 100_000); + assert!( + matches!(result.exit_reason, fp_evm::ExitReason::Succeed(_)), + "precompile call must succeed, got {:?}", + result.exit_reason + ); + let reported = U256::from_big_endian(&result.value); + assert_eq!( + reported, + U256::from(100 * UNITS), + "bound EVM caller must resolve to Alice's substrate stake" + ); + }); +} + +#[test] +fn giga_unstake_should_create_pending_position_when_called() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_gigahdx(); + + let alice: AccountId = ALICE.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 40 * UNITS)); + + let entry = only_pending_position(&alice); + // Snapshot's gigapot may already hold yield → payout ≥ principal. + assert!(entry.amount >= 40 * UNITS, "position covers at least principal"); + + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake remains"); + assert_eq!(lock_amount(&alice, GIGAHDX_LOCK_ID), stake.hdx + entry.amount); + }); +} + +#[test] +fn realize_yield_should_fold_accrued_into_principal_when_rate_increased() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_gigahdx(); + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + // Inject yield into the gigapot so rate = (100 + 100) / 100 = 2. + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + GigaHdx::gigapot_account_id(), + 100 * UNITS, + )); + + let rate_before = GigaHdx::exchange_rate(); + let stake_before = pallet_gigahdx::Stakes::::get(&alice).expect("stake exists"); + assert_eq!(stake_before.hdx, 100 * UNITS); + + assert_ok!(GigaHdx::realize_yield(RuntimeOrigin::signed(alice.clone()))); + + let stake_after = pallet_gigahdx::Stakes::::get(&alice).expect("stake exists"); + assert_eq!(stake_after.hdx, 200 * UNITS); + assert_eq!(stake_after.gigahdx, stake_before.gigahdx, "gigahdx unchanged"); + assert_eq!(locked_under_ghdx(&alice), 200 * UNITS); + assert_eq!(Balances::free_balance(GigaHdx::gigapot_account_id()), 0); + assert_eq!(GigaHdx::exchange_rate(), rate_before, "exchange rate unchanged"); + }); +} + +/// Setup for the realize-yield equivalence test with a deliberately +/// non-terminating rate. Alice stakes 100 HDX and Bob 70 HDX (so Alice's +/// GIGAHDX is *not* the whole supply and her per-account conversion actually +/// floor-rounds), then 100 HDX of yield is injected into the gigapot. The +/// resulting rate is 270/170 = 27/17 — `Ratio::new` does not reduce, and 17 +/// divides neither 100·UNITS nor the pot evenly. Returns Alice and her GIGAHDX. +fn stake_two_then_set_rounding_rate() -> (AccountId, Balance) { + init_gigahdx(); + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + fund(&bob, 1_000 * UNITS); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(bob.clone()), 70 * UNITS)); + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + GigaHdx::gigapot_account_id(), + 100 * UNITS, + )); + // total_staked_hdx = TotalLocked(170) + gigapot(100) = 270; supply = 170. + assert_rate_eq(GigaHdx::exchange_rate(), 270 * UNITS, 170 * UNITS); + + let gigahdx = pallet_gigahdx::Stakes::::get(&alice) + .expect("stake exists") + .gigahdx; + assert_eq!(gigahdx, 100 * UNITS); + (alice, gigahdx) +} + +#[test] +fn realize_yield_should_match_direct_unstake_exactly_when_rate_causes_rounding() { + // Alice's 100·UNITS GIGAHDX at rate 27/17 converts to + // floor(10^14 · 27 / 17) = 158_823_529_411_764 (remainder 12/17 — a real + // floor-round, not a clean boundary). The three paths must still agree to + // the atomic unit: realize_yield leaves total_staked_hdx and the supply + // untouched, so all three evaluate the *same* floored conversion. + let expected: Balance = 158_823_529_411_764; + // Yield Alice consumes from the pot; the rest backs Bob's still-live stake. + let alice_accrued: Balance = expected - 100 * UNITS; // 58_823_529_411_764 + let pot_residual: Balance = 100 * UNITS - alice_accrued; // 41_176_470_588_236 + + // Scenario 1: unstake everything directly — payout folds in the yield + // (100·UNITS principal + `alice_accrued` pulled from the gigapot). + let x1 = { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let (alice, gigahdx) = stake_two_then_set_rounding_rate(); + + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), gigahdx)); + + let entry = only_pending_position(&alice); + assert_eq!( + Balances::free_balance(GigaHdx::gigapot_account_id()), + pot_residual, + "only Alice's share leaves the pot; Bob's backing stays" + ); + entry.amount + }) + }; + + // Scenario 2: realize yield — accrued HDX is folded into the locked + // principal, GIGAHDX and the rate left unchanged. + let x2 = { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let (alice, _) = stake_two_then_set_rounding_rate(); + + assert_ok!(GigaHdx::realize_yield(RuntimeOrigin::signed(alice.clone()))); + + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake exists"); + assert_eq!(stake.hdx, locked_under_ghdx(&alice), "realized principal fully locked"); + assert_eq!( + Balances::free_balance(GigaHdx::gigapot_account_id()), + pot_residual, + "only Alice's share leaves the pot; Bob's backing stays" + ); + stake.hdx + }) + }; + + // Scenario 3: realize yield, then unstake everything — Alice's pot share + // is already folded in, so the full payout comes from principal alone and + // the pot is untouched by the unstake. + let x3 = { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let (alice, gigahdx) = stake_two_then_set_rounding_rate(); + + assert_ok!(GigaHdx::realize_yield(RuntimeOrigin::signed(alice.clone()))); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), gigahdx)); + + let entry = only_pending_position(&alice); + assert_eq!( + Balances::free_balance(GigaHdx::gigapot_account_id()), + pot_residual, + "only Alice's share leaves the pot; Bob's backing stays" + ); + entry.amount + }) + }; + + // The conversion really floor-rounds — otherwise the test is vacuous. + assert_ne!(expected % UNITS, 0, "rate must actually floor-round"); + + assert_eq!(x1, expected, "direct-unstake pending payout"); + assert_eq!(x2, expected, "realized locked principal"); + assert_eq!(x3, expected, "realize-then-unstake pending payout"); + assert_eq!(x1, x2, "realize_yield must match direct unstake"); + assert_eq!(x1, x3, "realize-then-unstake must match direct unstake"); +} + +#[test] +fn unlock_should_release_lock_when_cooldown_elapsed() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_gigahdx(); + + let alice: AccountId = ALICE.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let entry = only_pending_position(&alice); + System::set_block_number(entry.expires_at); + + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()), entry.id)); + + assert_eq!(pending_count(&alice), 0); + // Stakes was zero-active after full unstake → cleaned up by unlock. + assert!(pallet_gigahdx::Stakes::::get(&alice).is_none()); + assert_eq!(lock_amount(&alice, GIGAHDX_LOCK_ID), 0); + }); +} + +#[test] +fn vote_should_succeed_with_locked_hdx_when_max_lock_semantics() { + // HDX locked under `ghdxlock` must remain usable for conviction voting + // via `LockableCurrency::max` semantics. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_gigahdx(); + fund_bob_for_decision_deposit(); + + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + use frame_support::traits::Bounded; + use frame_support::traits::StorePreimage; + use hydradx_runtime::Preimage; + let proposal_call = hydradx_runtime::RuntimeCall::System(frame_system::Call::remark { remark: vec![1, 2, 3] }); + let bounded: Bounded<_, ::Hashing> = Preimage::bound(proposal_call).unwrap(); + + let now = System::block_number(); + let ref_index = pallet_referenda::ReferendumCount::::get(); + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(alice.clone()), + Box::new(RawOrigin::Root.into()), + bounded, + frame_support::traits::schedule::DispatchTime::At(now + 100), + )); + + assert_ok!(Referenda::place_decision_deposit(RuntimeOrigin::signed(bob), ref_index,)); + + // Vote with 50 HDX — strictly less than the gigaHDX-locked 100 — so + // the conviction-vote lock layers onto the already-locked balance. + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + ref_index, + AccountVote::Standard { + vote: Vote { + aye: true, + conviction: Conviction::Locked3x, + }, + balance: 50 * UNITS, + }, + )); + + // Both locks coexist: `ghdxlock` and conviction-voting's lock. + assert_eq!(locked_under_ghdx(&alice), 100 * UNITS); + let conviction_lock = pallet_balances::Locks::::get(&alice) + .iter() + .find(|l| l.id == *b"pyconvot") + .map(|l| l.amount) + .unwrap_or(0); + assert_eq!( + conviction_lock, + 50 * UNITS, + "conviction-voting must lock the voted balance" + ); + }); +} + +/// Reset gigapot balance and stHDX issuance so rate-sensitive scenarios run +/// from a clean baseline. The snapshot may carry pre-existing yield. +fn reset_giga_state_for_fixture() { + orml_tokens::TotalIssuance::::set(ST_HDX, 0); + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + GigaHdx::gigapot_account_id(), + 0, + )); +} + +fn fund(account: &AccountId, amount: Balance) { + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + account.clone(), + amount, + )); + // Mirrors the production pre-condition: stake/unstake callers are bound. + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(account.clone())); +} + +#[allow(dead_code)] +fn next_block() { + System::set_block_number(System::block_number() + 1); + Scheduler::on_initialize(System::block_number()); + Democracy::on_initialize(System::block_number()); +} + +#[allow(dead_code)] +fn fast_forward_to(n: u32) { + while System::block_number() < n { + next_block(); + } +} + +#[allow(dead_code)] +fn aye_with_conviction(amount: Balance, conviction: Conviction) -> AccountVote { + AccountVote::Standard { + vote: Vote { aye: true, conviction }, + balance: amount, + } +} + +/// Submit a referendum by Bob, place its decision deposit (Dave), and +/// fast-forward into the deciding period. Returns the referendum index. +#[allow(dead_code)] +fn begin_referendum_by_bob() -> u32 { + let bob: AccountId = BOB.into(); + let dave: AccountId = DAVE.into(); + let now = System::block_number(); + let ref_index = pallet_referenda::ReferendumCount::::get(); + + fund(&bob, 1_000_000 * UNITS); + let proposal = { + use frame_support::traits::Bounded; + let inner = pallet_balances::Call::::force_set_balance { + who: AccountId::from(CHARLIE), + new_free: 2, + }; + let outer = hydradx_runtime::RuntimeCall::Balances(inner); + let bounded: Bounded<_, ::Hashing> = Preimage::bound(outer).unwrap(); + bounded + }; + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(bob), + Box::new(RawOrigin::Root.into()), + proposal, + frame_support::traits::schedule::DispatchTime::At(now + 10 * DAYS), + )); + + fund(&dave, 2_000_000_000 * UNITS); + assert_ok!(Referenda::place_decision_deposit( + RuntimeOrigin::signed(dave), + ref_index, + )); + + fast_forward_to(now + 5 * DAYS); + ref_index +} + +/// Stake `stake_amount` HDX as ALICE — funds Alice with exactly that much +/// HDX first, so all of it lands in the gigahdx system. +#[allow(dead_code)] +fn setup_alice_with_only_gigahdx(stake_amount: Balance) { + let alice: AccountId = ALICE.into(); + fund(&alice, stake_amount); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice), stake_amount)); +} + +fn build_aave_withdraw_calldata(asset: H160, amount: Balance, to: H160) -> Vec { + EvmDataWriter::new_with_selector(AaveFunction::Withdraw) + .write(asset) + .write(amount) + .write(to) + .build() +} + +fn build_erc20_transfer_calldata(to: H160, amount: Balance) -> Vec { + let mut data = sp_io::hashing::keccak_256(b"transfer(address,uint256)")[..4].to_vec(); + data.extend_from_slice(&[0u8; 12]); + data.extend_from_slice(to.as_bytes()); + data.extend_from_slice(&U256::from(amount).to_big_endian()); + data +} + +#[test] +fn giga_stake_should_mint_gigahdx_when_called_on_mainnet_snapshot() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + let stake_amount = 1_000 * UNITS; + assert_ok!(>::deposit(HDX, &alice, 10_000 * UNITS)); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); + + let hdx_before = Currencies::free_balance(HDX, &alice); + let total_staked_hdx_before = GigaHdx::total_staked_hdx(); + let total_st_hdx_before = GigaHdx::total_gigahdx_supply(); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), stake_amount)); + + // Lock model: HDX stays in Alice's account, just locked. + assert_eq!(Currencies::free_balance(HDX, &alice), hdx_before); + assert_eq!(locked_under_ghdx(&alice), stake_amount); + + // stHDX is held by AAVE (the user never touches it directly). + assert_eq!(Currencies::free_balance(ST_HDX, &alice), 0); + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), stake_amount); + + assert_eq!(GigaHdx::total_staked_hdx(), total_staked_hdx_before + stake_amount); + assert_eq!(GigaHdx::total_gigahdx_supply(), total_st_hdx_before + stake_amount); + assert_rate_eq(GigaHdx::exchange_rate(), 1, 1); + }); +} + +#[test] +fn giga_unstake_should_succeed_when_full_exit() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 10 * UNITS)); + + let gigahdx_balance = Currencies::free_balance(GIGAHDX, &alice); + assert!(gigahdx_balance > 0); + + assert_ok!(GigaHdx::giga_unstake( + RuntimeOrigin::signed(alice.clone()), + gigahdx_balance, + )); + + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), 0); + let entry = only_pending_position(&alice); + assert!(entry.amount > 0); + }); +} + +#[test] +fn giga_unstake_should_fail_when_amount_exceeds_balance() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let gigahdx_before = Currencies::free_balance(GIGAHDX, &alice); + let hdx_before = Balances::free_balance(&alice); + + assert!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 200 * UNITS).is_err()); + + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), gigahdx_before); + assert_eq!(Balances::free_balance(&alice), hdx_before); + assert_eq!(pending_count(&alice), 0); + }); +} + +#[test] +fn giga_stake_should_fail_when_amount_below_min_on_snapshot() { + // 10 UNITS is safely above any AAVE-internal min-supply rounding. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000_000 * UNITS); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 10 * UNITS)); + assert!(Currencies::free_balance(GIGAHDX, &alice) > 0); + + let min_stake = ::MinStake::get(); + assert_noop!( + GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), min_stake - 1), + pallet_gigahdx::Error::::BelowMinStake + ); + }); +} + +#[test] +fn giga_stake_should_succeed_when_supply_zeroed_after_full_exit() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + fund(&alice, 1_000_000 * UNITS); + fund(&bob, 1_000_000 * UNITS); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + assert_eq!(GigaHdx::total_gigahdx_supply(), 100 * UNITS); + + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS,)); + + // Supply zeroed → rate falls back to bootstrap 1.0. + assert_eq!(GigaHdx::total_gigahdx_supply(), 0); + assert_rate_eq(GigaHdx::exchange_rate(), 1, 1); + + // Bob's stake is independent of Alice's cooldown. + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(bob.clone()), 100 * UNITS)); + assert_eq!(Currencies::free_balance(GIGAHDX, &bob), 100 * UNITS); + assert_eq!(GigaHdx::total_gigahdx_supply(), 100 * UNITS); + }); +} + +#[test] +fn exchange_rate_should_inflate_when_hdx_transferred_directly_to_gigapot() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + let charlie: AccountId = CHARLIE.into(); + let gigapot = GigaHdx::gigapot_account_id(); + + fund(&gigapot, UNITS); + fund(&alice, 1_000_000 * UNITS); + fund(&bob, 1_000_000 * UNITS); + + // rate becomes (100 + 1) / 100 = 1.01 + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice), 100 * UNITS)); + assert_rate_eq(GigaHdx::exchange_rate(), 101, 100); + + assert_ok!(Currencies::transfer( + RuntimeOrigin::signed(bob.clone()), + gigapot, + HDX, + 1_000 * UNITS, + )); + assert_rate_eq(GigaHdx::exchange_rate(), 1101, 100); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(bob.clone()), 100 * UNITS)); + assert!( + Currencies::free_balance(GIGAHDX, &bob) < 10 * UNITS, + "inflated rate should mint far fewer atokens" + ); + + fund(&charlie, 1_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(charlie), 100 * UNITS)); + }); +} + +#[test] +fn giga_unstake_should_succeed_with_inflated_payout_when_pot_donated() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + let gigapot = GigaHdx::gigapot_account_id(); + fund(&gigapot, UNITS); + fund(&alice, 1_000_000 * UNITS); + fund(&bob, 1_000_000 * UNITS); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + let gigahdx_minted = Currencies::free_balance(GIGAHDX, &alice); + assert!(gigahdx_minted > 0); + + assert_ok!(Currencies::transfer( + RuntimeOrigin::signed(bob), + gigapot, + HDX, + 500 * UNITS, + )); + assert!(GigaHdx::exchange_rate() > Ratio::one()); + + // A grief donation is a bonus to Alice on exit, not a DoS. + assert_ok!(GigaHdx::giga_unstake( + RuntimeOrigin::signed(alice.clone()), + gigahdx_minted, + )); + + let entry = only_pending_position(&alice); + assert!( + entry.amount > 100 * UNITS, + "payout reflects inflated rate: got {} (staked 100 UNITS)", + entry.amount, + ); + }); +} + +#[test] +fn giga_unstake_should_succeed_when_exchange_rate_extreme() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + let gigapot = GigaHdx::gigapot_account_id(); + fund(&alice, 1_000_000 * UNITS); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), 100 * UNITS); + + fund(&gigapot, 1_000_000_000_000_000 * UNITS); + + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS,)); + + let entry = only_pending_position(&alice); + // payout = 100 * (10^15 + 100) / 100 = 10^15 + 100 + assert_eq!(entry.amount, 1_000_000_000_000_000 * UNITS + 100 * UNITS); + }); +} + +#[test] +fn aave_withdraw_should_revert_when_atokens_are_locked_by_active_stake() { + // Direct EVM Pool.withdraw must be rejected while the user has an active + // stake — `LockableAToken.burn`'s freeBalance check sees `0` because + // `gigahdx` equals atoken balance. Without this the cooldown can be bypassed. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + let stake_amount = 1_000 * UNITS; + fund(&alice, stake_amount); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), stake_amount,)); + + let alice_evm = EVMAccounts::evm_address(&alice); + let gigahdx_balance = Currencies::free_balance(GIGAHDX, &alice); + assert_eq!(gigahdx_balance, stake_amount); + + let pool = pool_contract(); + let sthdx_evm = HydraErc20Mapping::asset_address(ST_HDX); + let sthdx_before = Currencies::free_balance(ST_HDX, &alice); + + let data = build_aave_withdraw_calldata(sthdx_evm, gigahdx_balance, alice_evm); + let result = Executor::::call(CallContext::new_call(pool, alice_evm), data, U256::zero(), 500_000); + + assert!( + matches!(result.exit_reason, fp_evm::ExitReason::Revert(_)), + "AAVE withdraw must revert on locked GIGAHDX (lock-manager precompile not honored?). exit_reason={:?}", + result.exit_reason, + ); + + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), gigahdx_balance); + assert_eq!(Currencies::free_balance(ST_HDX, &alice), sthdx_before); + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake remains"); + assert_eq!(stake.gigahdx, stake_amount); + }); +} + +#[test] +fn atoken_evm_transfer_should_fail_when_staked() { + // While staked, atokens are 100% locked per the lock-manager precompile, + // so any ERC20 transfer of GIGAHDX must revert. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + let stake_amount = 1_000 * UNITS; + fund(&alice, stake_amount); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), stake_amount,)); + + fund(&bob, UNITS); + let bob_evm = EVMAccounts::evm_address(&bob); + let bob_gigahdx_before = Currencies::free_balance(GIGAHDX, &bob); + + let alice_evm = EVMAccounts::evm_address(&alice); + let gigahdx_balance = Currencies::free_balance(GIGAHDX, &alice); + let gigahdx_token = HydraErc20Mapping::asset_address(GIGAHDX); + + let data = build_erc20_transfer_calldata(bob_evm, gigahdx_balance); + let result = Executor::::call( + CallContext::new_call(gigahdx_token, alice_evm), + data, + U256::zero(), + 500_000, + ); + + assert!( + matches!(result.exit_reason, fp_evm::ExitReason::Revert(_)), + "GIGAHDX ERC20 transfer must revert while staked. exit_reason={:?}", + result.exit_reason, + ); + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), gigahdx_balance); + assert_eq!(Currencies::free_balance(GIGAHDX, &bob), bob_gigahdx_before); + }); +} + +#[test] +fn partial_unstake_should_not_leak_when_locks_aggregated_via_max() { + // Regression: an earlier per-unstake-lock-id design let + // `min(active_stake, cooldown)` HDX leak out during cooldown via + // pallet-balances' max-of-locks semantics. The single combined lock + // (`active + position`) closes the leak. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 1_000 * UNITS,)); + assert_eq!(locked_under_ghdx(&alice), 1_000 * UNITS); + + // pot empty → payout equals principal, no yield is paid out. + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 500 * UNITS,)); + + let stake = pallet_gigahdx::Stakes::::get(&alice).unwrap(); + let entry = only_pending_position(&alice); + assert_eq!(stake.hdx, 500 * UNITS); + assert_eq!(entry.amount, 500 * UNITS); + + // Combined lock = 1000; the old buggy max(500, 500) = 500 leaked 500 HDX. + assert_eq!( + locked_under_ghdx(&alice), + 1_000 * UNITS, + "combined lock must cover BOTH active stake and pending position", + ); + + use frame_support::traits::fungible::Inspect; + use frame_support::traits::tokens::{Fortitude, Preservation}; + let spendable = + >::reducible_balance(&alice, Preservation::Expendable, Fortitude::Polite); + assert_eq!(spendable, 0, "no HDX may leak out of the gigahdx system"); + }); +} + +#[test] +fn giga_unstake_should_keep_lock_layers_consistent_when_vote_active() { + // gigahdx lock (active + position) and conviction lock must coexist; + // spendable = balance − max(both). + // + // Stake must exceed the conviction-vote balance so that `pallet-gigahdx-rewards`' + // freeze guard (frozen = min(vote_balance, stake.hdx)) doesn't block the + // partial unstake. With stake=1000 and vote=800, frozen=800 and the + // projected post-unstake hdx (=900) stays above frozen. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + fund_bob_for_decision_deposit(); + + let alice: AccountId = ALICE.into(); + fund(&alice, 1_200 * UNITS); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 1_000 * UNITS,)); + + // Conviction balance 800 < stake 1000; freeze = 800, partial unstake of + // 100 leaves hdx = 900 ≥ frozen, so the guard passes. + let ref_index = begin_referendum_by_bob(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + ref_index, + aye_with_conviction(800 * UNITS, Conviction::Locked1x), + )); + + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS,)); + + // ghdx lock = active (900) + pending position (100) = 1000. + assert_eq!(locked_under_ghdx(&alice), 1_000 * UNITS); + let conviction_lock = pallet_balances::Locks::::get(&alice) + .iter() + .find(|l| l.id == *b"pyconvot") + .map(|l| l.amount) + .unwrap_or(0); + assert_eq!(conviction_lock, 800 * UNITS); + + // spendable = 1200 − max(ghdx=1000, vote=800) = 200 + use frame_support::traits::fungible::Inspect; + use frame_support::traits::tokens::{Fortitude, Preservation}; + let spendable = + >::reducible_balance(&alice, Preservation::Expendable, Fortitude::Polite); + assert_eq!(spendable, 200 * UNITS); + }); +} + +#[test] +fn partial_unstake_should_drain_active_when_payout_exceeds_active() { + // Case 2 with partial unstake: payout for a fraction of atokens exceeds + // active stake; active → 0, remainder from gigapot, leaving + // `Stakes = { hdx: 0, gigahdx > 0 }` — atokens with zero cost basis. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + let gigapot = GigaHdx::gigapot_account_id(); + fund(&alice, 1_000_000 * UNITS); + + // Stake at bootstrap 1.0, then inflate pot → rate = (100 + 200) / 100 = 3.0. + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS,)); + fund(&gigapot, 200 * UNITS); + assert_rate_eq(GigaHdx::exchange_rate(), 3, 1); + + let alice_balance_before = Balances::free_balance(&alice); + + // Unstake half: payout = 50 × 3 = 150 > active 100 → active drained, + // 50 yield from pot, position = 150. + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 50 * UNITS,)); + + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("record persists"); + assert_eq!(stake.hdx, 0, "active stake drained because payout exceeded principal"); + assert_eq!(stake.gigahdx, 50 * UNITS, "remaining atokens have zero cost basis now"); + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), 50 * UNITS); + + let entry = only_pending_position(&alice); + assert_eq!(entry.amount, 150 * UNITS); + + assert_eq!(Balances::free_balance(&alice), alice_balance_before + 50 * UNITS,); + assert_eq!(Balances::free_balance(&gigapot), 150 * UNITS); + + assert_eq!(locked_under_ghdx(&alice), 150 * UNITS); + + assert_rate_eq(GigaHdx::exchange_rate(), 3, 1); + }); +} + +#[test] +fn full_lifecycle_should_conserve_value_when_rate_inflated() { + // End-to-end value conservation: stake 100 @ rate 1.0 → pot inflates to + // rate 3.0 → drain across two payout-exceeds-active unstakes split by + // the cooldown. Total receipts must equal original_stake × rate, gigapot + // drained. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + let gigapot = GigaHdx::gigapot_account_id(); + let starting_balance = 1_000_000 * UNITS; + fund(&alice, starting_balance); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS,)); + fund(&gigapot, 200 * UNITS); + assert_rate_eq(GigaHdx::exchange_rate(), 3, 1); + + // First unstake: 50 stHDX → payout 150, active drained. + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 50 * UNITS,)); + let entry1 = only_pending_position(&alice); + assert_eq!(entry1.amount, 150 * UNITS); + + System::set_block_number(entry1.expires_at); + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()), entry1.id)); + assert_eq!(pending_count(&alice), 0); + + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("atokens remain"); + assert_eq!(stake.hdx, 0); + assert_eq!(stake.gigahdx, 50 * UNITS); + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), 50 * UNITS); + + // Second unstake: pot 150, supply 50 → rate stays 3.0, payout = 150. + assert_rate_eq(GigaHdx::exchange_rate(), 3, 1); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 50 * UNITS,)); + let entry2 = only_pending_position(&alice); + assert_eq!(entry2.amount, 150 * UNITS); + + System::set_block_number(entry2.expires_at); + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()), entry2.id)); + + // Conservation: principal stayed in Alice's account (locked then unlocked); + // yield transferred = 50 + 150 = 200 = original_stake × (rate − 1). + assert_eq!(Balances::free_balance(&alice), starting_balance + 200 * UNITS); + + assert!(pallet_gigahdx::Stakes::::get(&alice).is_none()); + assert_eq!(pending_count(&alice), 0); + assert_eq!(locked_under_ghdx(&alice), 0); + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), 0); + assert_eq!(pallet_gigahdx::TotalLocked::::get(), 0); + assert_eq!(GigaHdx::total_gigahdx_supply(), 0); + assert_eq!(Balances::free_balance(&gigapot), 0); + }); +} + +#[test] +fn giga_stake_should_fail_when_evm_address_unbound() { + // Without a bound EVM address, AAVE rejects `Pool.supply` (truncated + // `onBehalfOf` fails preconditions), the adapter surfaces + // `MoneyMarketSupplyFailed`, and `with_transaction` rolls back the stHDX + // mint. Pins this so atokens can't silently land on a phantom account. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + // Bypass `fund()` to leave Alice unbound. + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 1_000 * UNITS, + )); + + let alice_evm = EVMAccounts::evm_address(&alice); + assert!( + EVMAccounts::bound_account_id(alice_evm).is_none(), + "precondition: Alice must be unbound for this scenario", + ); + + let atoken_before = Currencies::free_balance(GIGAHDX, &alice); + let sthdx_before = >::total_issuance(ST_HDX); + + assert_noop!( + GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS), + pallet_gigahdx::Error::::MoneyMarketSupplyFailed, + ); + + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), atoken_before); + assert_eq!(>::total_issuance(ST_HDX), sthdx_before); + assert!(pallet_gigahdx::Stakes::::get(&alice).is_none()); + }); +} + +#[test] +fn first_staker_inflation_grief_should_be_self_defeating_against_real_aave() { + // Audit lead: attacker leaves a 1-wei stHDX residual, donates HDX to + // inflate the rate, then expects new stakers to round-to-zero atokens. + // Self-defeating against real AAVE V3: `Pool.withdraw(1)` reverts on + // AAVE's min-amount check, so the attacker can never reclaim the donation. + // Pinned so any change to AAVE config that makes this profitable trips here. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + + fund(&alice, 1_000_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + let alice_gigahdx = Currencies::free_balance(GIGAHDX, &alice); + assert_eq!(alice_gigahdx, 100 * UNITS); + + // Partial-unstake leaving 1 wei (bulk burn passes AAVE's min-amount). + assert_ok!(GigaHdx::giga_unstake( + RuntimeOrigin::signed(alice.clone()), + alice_gigahdx - 1, + )); + let position1 = only_pending_position(&alice); + System::set_block_number(position1.expires_at); + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()), position1.id)); + assert_eq!(GigaHdx::total_gigahdx_supply(), 1); + + let gigapot = GigaHdx::gigapot_account_id(); + let donation = 500_000 * UNITS; + assert_ok!(Currencies::transfer( + RuntimeOrigin::signed(alice.clone()), + gigapot.clone(), + HDX, + donation, + )); + assert!( + GigaHdx::exchange_rate() > Ratio::new(donation, 1), + "rate should be heavily inflated after donation", + ); + + // `gigahdx_to_mint` floors to 0 → pallet's `ZeroAmount` guard fires + // before AAVE (so this holds even on forks that accept `Pool.supply(0)`). + fund(&bob, 100 * UNITS); + let bob_hdx_before = Balances::free_balance(&bob); + assert_noop!( + GigaHdx::giga_stake(RuntimeOrigin::signed(bob.clone()), 100 * UNITS), + pallet_gigahdx::Error::::ZeroAmount, + ); + assert_eq!(Balances::free_balance(&bob), bob_hdx_before); + assert_eq!(Currencies::free_balance(GIGAHDX, &bob), 0); + assert!(pallet_gigahdx::Stakes::::get(&bob).is_none()); + + // Self-defeat: attacker cannot exit the 1-wei residual. + assert_noop!( + GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 1), + pallet_gigahdx::Error::::MoneyMarketWithdrawFailed, + ); + + assert_eq!(Balances::free_balance(&gigapot), donation); + assert_eq!(GigaHdx::total_gigahdx_supply(), 1); + assert!(GigaHdx::exchange_rate() > Ratio::new(donation, 1)); + }); +} + +#[test] +fn giga_unstake_should_fail_when_max_pending_positions_reached() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000_000 * UNITS); + let max: u32 = ::MaxPendingUnstakes::get(); + assert_ok!(GigaHdx::giga_stake( + RuntimeOrigin::signed(alice.clone()), + (max as Balance) * 100 * UNITS, + )); + // Advance block between unstakes so each becomes a distinct position + // (same-block unstakes compound into one). + for _ in 0..max { + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 10 * UNITS)); + System::set_block_number(System::block_number() + 1); + } + assert_noop!( + GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 10 * UNITS), + pallet_gigahdx::Error::::TooManyPendingUnstakes, + ); + }); +} + +#[test] +fn cancel_unstake_should_fail_when_no_pending() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS,)); + + assert_noop!( + GigaHdx::cancel_unstake(RuntimeOrigin::signed(alice), 0), + pallet_gigahdx::Error::::PendingUnstakeNotFound, + ); + }); +} + +#[test] +fn cancel_unstake_should_restore_position_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + let pre_lock = locked_under_ghdx(&alice); + let pre_atokens = Currencies::free_balance(GIGAHDX, &alice); + + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 40 * UNITS)); + assert_eq!(pending_count(&alice), 1); + let position_id = only_pending_position(&alice).id; + + assert_ok!(GigaHdx::cancel_unstake( + RuntimeOrigin::signed(alice.clone()), + position_id + )); + + assert_eq!(pending_count(&alice), 0); + let s = pallet_gigahdx::Stakes::::get(&alice).unwrap(); + assert_eq!(s.hdx, 100 * UNITS); + assert_eq!(locked_under_ghdx(&alice), pre_lock); + // AAVE rounding may shave a wei or two; tolerate small loss, forbid growth. + assert!(Currencies::free_balance(GIGAHDX, &alice) <= pre_atokens); + assert!(Currencies::free_balance(GIGAHDX, &alice) + 10 >= pre_atokens); + }); +} + +#[test] +fn cancel_unstake_should_work_with_inflated_rate_e2e() { + // Pre-inflate pot so unstake pays yield from gigapot; cancel folds it back as principal. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + let gigapot = GigaHdx::gigapot_account_id(); + fund(&alice, 1_000_000 * UNITS); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + fund(&gigapot, 200 * UNITS); + // rate = (100 + 200) / 100 = 3.0 + assert_rate_eq(GigaHdx::exchange_rate(), 3, 1); + + let pre_lock = locked_under_ghdx(&alice); + let pre_pot = Balances::free_balance(&gigapot); + + // Full unstake → payout 300, principal 100, yield 200 (pot drained). + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + assert_eq!(Balances::free_balance(&gigapot), pre_pot - 200 * UNITS); + assert_eq!(locked_under_ghdx(&alice), pre_lock + 200 * UNITS); + let position_id = only_pending_position(&alice).id; + + assert_ok!(GigaHdx::cancel_unstake( + RuntimeOrigin::signed(alice.clone()), + position_id + )); + + let s = pallet_gigahdx::Stakes::::get(&alice).unwrap(); + assert_eq!(s.hdx, 300 * UNITS, "yield folded into principal"); + // Re-supply at rate 1.0 (pot drained, supply 0 → bootstrap) → 300 atokens. + assert!(Currencies::free_balance(GIGAHDX, &alice) >= 300 * UNITS - 10); + assert_eq!(locked_under_ghdx(&alice), pre_lock + 200 * UNITS); + assert_eq!(Balances::free_balance(&gigapot), 0); + }); +} + +#[test] +fn repeated_unstake_cancel_cycles_should_not_grow_position_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + let initial_atokens = Currencies::free_balance(GIGAHDX, &alice); + let initial_balance = Balances::free_balance(&alice); + + for _ in 0..5 { + let s = pallet_gigahdx::Stakes::::get(&alice).unwrap(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), s.gigahdx)); + let id = only_pending_position(&alice).id; + assert_ok!(GigaHdx::cancel_unstake(RuntimeOrigin::signed(alice.clone()), id)); + } + + // AAVE rounding may shave a few wei per cycle; forbid any growth. + assert!(Currencies::free_balance(GIGAHDX, &alice) <= initial_atokens); + assert_eq!(Balances::free_balance(&alice), initial_balance); + }); +} + +#[test] +fn repeated_unstake_cancel_cycles_should_preserve_gigapot_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + + let alice: AccountId = ALICE.into(); + let gigapot = GigaHdx::gigapot_account_id(); + fund(&gigapot, 50 * UNITS); + fund(&alice, 1_000_000 * UNITS); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let initial_system_total = + pallet_gigahdx::TotalLocked::::get().saturating_add(Balances::free_balance(&gigapot)); + + for _ in 0..5 { + let s = pallet_gigahdx::Stakes::::get(&alice).unwrap(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), s.gigahdx)); + let id = only_pending_position(&alice).id; + assert_ok!(GigaHdx::cancel_unstake(RuntimeOrigin::signed(alice.clone()), id)); + } + + let final_system_total = + pallet_gigahdx::TotalLocked::::get().saturating_add(Balances::free_balance(&gigapot)); + assert_eq!(final_system_total, initial_system_total); + }); +} + +#[test] +fn cancel_unstake_should_preserve_frozen_when_user_has_active_vote_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + fund_bob_for_decision_deposit(); + + let alice: AccountId = ALICE.into(); + fund(&alice, 1_200 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 1_000 * UNITS)); + + let ref_index = begin_referendum_by_bob(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + ref_index, + aye_with_conviction(800 * UNITS, Conviction::Locked1x), + )); + let frozen_before = pallet_gigahdx::Stakes::::get(&alice).unwrap().frozen; + assert_eq!(frozen_before, 800 * UNITS); + + // Unstake the unfrozen portion (100 ≤ hdx 1000 − frozen 800). + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + assert_eq!( + pallet_gigahdx::Stakes::::get(&alice).unwrap().frozen, + frozen_before, + ); + + let position_id = only_pending_position(&alice).id; + assert_ok!(GigaHdx::cancel_unstake( + RuntimeOrigin::signed(alice.clone()), + position_id + )); + let s = pallet_gigahdx::Stakes::::get(&alice).unwrap(); + assert_eq!(s.frozen, frozen_before, "cancel must not touch frozen"); + assert_eq!(s.hdx, 1_000 * UNITS); + assert!(s.frozen <= s.hdx); + }); +} + +fn advance_block() { + System::set_block_number(System::block_number() + 1); +} + +#[test] +fn multiple_unstakes_should_create_distinct_positions_when_blocks_advance_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 300 * UNITS)); + + let mut expected_ids = vec![]; + for _ in 0..3 { + expected_ids.push(System::block_number()); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 50 * UNITS)); + advance_block(); + } + + let mut positions: Vec<(u32, Balance)> = pallet_gigahdx::PendingUnstakes::::iter_prefix(&alice) + .map(|(id, p)| (id, p.amount)) + .collect(); + positions.sort_by_key(|(id, _)| *id); + let actual_ids: Vec = positions.iter().map(|(id, _)| *id).collect(); + assert_eq!(actual_ids, expected_ids); + assert!(positions.iter().all(|(_, amt)| *amt == 50 * UNITS)); + }); +} + +#[test] +fn unstakes_should_compound_when_called_in_same_block_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 300 * UNITS)); + + let block = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 30 * UNITS)); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 20 * UNITS)); + + assert_eq!(pending_count(&alice), 1); + assert_eq!( + pallet_gigahdx::PendingUnstakes::::get(&alice, block) + .unwrap() + .amount, + 50 * UNITS, + ); + }); +} + +#[test] +fn unlock_should_release_one_position_while_others_pending_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 300 * UNITS)); + + let id_a = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 50 * UNITS)); + advance_block(); + let id_b = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 60 * UNITS)); + + let cooldown = ::CooldownPeriod::get(); + System::set_block_number(id_a + cooldown); + + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()), id_a)); + + let remaining = only_pending_position(&alice); + assert_eq!(remaining.id, id_b); + assert_eq!(remaining.amount, 60 * UNITS); + // active 190 + pending 60 = 250 + assert_eq!(locked_under_ghdx(&alice), 250 * UNITS); + }); +} + +#[test] +fn cancel_unstake_should_target_specific_position_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 300 * UNITS)); + + let id_a = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 30 * UNITS)); + advance_block(); + let id_b = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 40 * UNITS)); + advance_block(); + let id_c = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 50 * UNITS)); + + assert_ok!(GigaHdx::cancel_unstake(RuntimeOrigin::signed(alice.clone()), id_b)); + + let mut ids: Vec = pallet_gigahdx::PendingUnstakes::::iter_prefix(&alice) + .map(|(id, _)| id) + .collect(); + ids.sort(); + assert_eq!(ids, vec![id_a, id_c]); + // Cancel folded 40 UNITS back into active; lock total unchanged. + assert_eq!(locked_under_ghdx(&alice), 300 * UNITS); + assert_eq!(pallet_gigahdx::Stakes::::get(&alice).unwrap().hdx, 220 * UNITS); + }); +} + +#[test] +fn multi_position_cycle_should_preserve_lock_balance_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 300 * UNITS)); + let starting_lock = locked_under_ghdx(&alice); + + let id_a = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 50 * UNITS)); + advance_block(); + let id_b = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 60 * UNITS)); + advance_block(); + let id_c = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 70 * UNITS)); + + assert_ok!(GigaHdx::cancel_unstake(RuntimeOrigin::signed(alice.clone()), id_b)); + let cooldown = ::CooldownPeriod::get(); + System::set_block_number(id_a + cooldown); + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()), id_a)); + + let remaining = only_pending_position(&alice); + assert_eq!(remaining.id, id_c); + // Cancel keeps lock total; unlock(50) drops lock by 50. + assert_eq!(locked_under_ghdx(&alice), starting_lock - 50 * UNITS); + }); +} + +#[test] +fn vote_freeze_should_coexist_with_multiple_pending_positions_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + fund_bob_for_decision_deposit(); + let alice: AccountId = ALICE.into(); + fund(&alice, 1_500 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 1_200 * UNITS)); + + let id_a = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let ref_index = begin_referendum_by_bob(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + ref_index, + aye_with_conviction(800 * UNITS, Conviction::Locked1x), + )); + let frozen_before = pallet_gigahdx::Stakes::::get(&alice).unwrap().frozen; + assert_eq!(frozen_before, 800 * UNITS); + + advance_block(); + let id_b = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + assert_ok!(GigaHdx::cancel_unstake(RuntimeOrigin::signed(alice.clone()), id_a)); + let cooldown = ::CooldownPeriod::get(); + System::set_block_number(id_b + cooldown); + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()), id_b)); + + let s = pallet_gigahdx::Stakes::::get(&alice).unwrap(); + assert_eq!(s.frozen, frozen_before, "frozen must persist across multi-position ops"); + assert!(s.frozen <= s.hdx); + }); +} + +#[test] +fn cancel_should_handle_compounded_position_with_yield_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + let alice: AccountId = ALICE.into(); + let gigapot = GigaHdx::gigapot_account_id(); + fund(&alice, 1_000_000 * UNITS); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + fund(&gigapot, 50 * UNITS); + assert_rate_eq(GigaHdx::exchange_rate(), 3, 2); + + let block = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 40 * UNITS)); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 60 * UNITS)); + + assert_eq!(pending_count(&alice), 1); + assert_eq!( + pallet_gigahdx::PendingUnstakes::::get(&alice, block) + .unwrap() + .amount, + 150 * UNITS, + ); + assert_eq!(Balances::free_balance(&gigapot), 0); + + assert_ok!(GigaHdx::cancel_unstake(RuntimeOrigin::signed(alice.clone()), block)); + + let s = pallet_gigahdx::Stakes::::get(&alice).unwrap(); + assert_eq!(s.unstaking, 0); + assert_eq!(s.unstaking_count, 0); + assert_eq!(s.hdx, 150 * UNITS); + // AAVE may round scaled balance down by a few wei on re-supply. + assert!(s.gigahdx >= 150 * UNITS - 10); + assert_eq!(locked_under_ghdx(&alice), 150 * UNITS); + }); +} + +#[test] +fn cancel_compounded_position_should_preserve_system_value_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + let alice: AccountId = ALICE.into(); + let gigapot = GigaHdx::gigapot_account_id(); + fund(&alice, 1_000_000 * UNITS); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + fund(&gigapot, 50 * UNITS); + + let block = System::block_number(); + let baseline_total = + pallet_gigahdx::TotalLocked::::get().saturating_add(Balances::free_balance(&gigapot)); + + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 40 * UNITS)); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 60 * UNITS)); + assert_ok!(GigaHdx::cancel_unstake(RuntimeOrigin::signed(alice.clone()), block)); + + let final_total = + pallet_gigahdx::TotalLocked::::get().saturating_add(Balances::free_balance(&gigapot)); + assert_eq!(final_total, baseline_total); + }); +} + +/// Drive 10 unstake calls compounded into 4 positions across 4 blocks with +/// rate inflation between phases. Returns the position ids in unstake order +/// and the total yield transferred from the pot to alice. +fn drive_complex_unstake_scenario(alice: &AccountId) -> (Vec, Balance) { + let gigapot = GigaHdx::gigapot_account_id(); + + // b1: 3× unstake at rate 1.0 → pending 300. + let b1 = System::block_number(); + for _ in 0..3 { + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + } + + // b2: pot += 700 → rate 2.0. 2× unstake principal-covered → pending 400. + advance_block(); + let b2 = System::block_number(); + fund(&gigapot, 700 * UNITS); + for _ in 0..2 { + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + } + + // b3: pot total 1200 → rate 3.0. 3× unstake (1st drains active, rest pull yield) → pending 900. + advance_block(); + let b3 = System::block_number(); + fund(&gigapot, 1200 * UNITS); + for _ in 0..3 { + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + } + + // b4: 2× unstake, both pull yield (active is 0) → pending 600. + advance_block(); + let b4 = System::block_number(); + for _ in 0..2 { + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + } + + let s = pallet_gigahdx::Stakes::::get(alice).unwrap(); + assert_eq!(s.hdx, 0); + assert_eq!(s.gigahdx, 0); + assert_eq!(s.unstaking_count, 4); + assert_eq!(s.unstaking, 2200 * UNITS); + assert_eq!( + pallet_gigahdx::PendingUnstakes::::get(alice, b1) + .unwrap() + .amount, + 300 * UNITS + ); + assert_eq!( + pallet_gigahdx::PendingUnstakes::::get(alice, b2) + .unwrap() + .amount, + 400 * UNITS + ); + assert_eq!( + pallet_gigahdx::PendingUnstakes::::get(alice, b3) + .unwrap() + .amount, + 900 * UNITS + ); + assert_eq!( + pallet_gigahdx::PendingUnstakes::::get(alice, b4) + .unwrap() + .amount, + 600 * UNITS + ); + assert_eq!(Balances::free_balance(&gigapot), 0); + + (vec![b1, b2, b3, b4], 1200 * UNITS) +} + +#[test] +fn full_unstake_via_cancel_all_should_fold_yield_into_active_stake_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + let alice: AccountId = ALICE.into(); + let initial_alice_total: Balance = 10_000 * UNITS; + fund(&alice, initial_alice_total); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 1_000 * UNITS)); + + let (ids, total_yield) = drive_complex_unstake_scenario(&alice); + assert_eq!(total_yield, 1_200 * UNITS); + assert_eq!(Balances::free_balance(&alice), initial_alice_total + total_yield); + assert_eq!(locked_under_ghdx(&alice), 2200 * UNITS); + + for id in ids.iter().rev() { + assert_ok!(GigaHdx::cancel_unstake(RuntimeOrigin::signed(alice.clone()), *id)); + } + + let s = pallet_gigahdx::Stakes::::get(&alice).unwrap(); + assert_eq!(s.unstaking, 0); + assert_eq!(s.unstaking_count, 0); + assert_eq!(s.hdx, 2200 * UNITS); + assert_eq!(pallet_gigahdx::TotalLocked::::get(), 2200 * UNITS); + assert_eq!(locked_under_ghdx(&alice), 2200 * UNITS); + assert_eq!(Balances::free_balance(&alice), initial_alice_total + total_yield); + }); +} + +#[test] +fn full_unstake_via_unlock_all_should_release_full_amount_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + let alice: AccountId = ALICE.into(); + let initial_alice_total: Balance = 10_000 * UNITS; + fund(&alice, initial_alice_total); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 1_000 * UNITS)); + + let (ids, total_yield) = drive_complex_unstake_scenario(&alice); + assert_eq!(total_yield, 1_200 * UNITS); + + let cooldown = ::CooldownPeriod::get(); + let last_id = *ids.last().unwrap(); + System::set_block_number(last_id + cooldown); + + for id in ids.iter() { + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()), *id)); + } + + assert!(pallet_gigahdx::Stakes::::get(&alice).is_none()); + assert_eq!(pallet_gigahdx::TotalLocked::::get(), 0); + assert_eq!(locked_under_ghdx(&alice), 0); + assert_eq!(Balances::free_balance(&alice), initial_alice_total + total_yield); + }); +} + +#[test] +fn unlock_should_release_full_compounded_amount_e2e() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + let alice: AccountId = ALICE.into(); + let gigapot = GigaHdx::gigapot_account_id(); + fund(&alice, 1_000_000 * UNITS); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + fund(&gigapot, 50 * UNITS); + + let block = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 40 * UNITS)); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 60 * UNITS)); + let pre_free = Balances::free_balance(&alice); + assert_eq!(locked_under_ghdx(&alice), 150 * UNITS); + + let cooldown = ::CooldownPeriod::get(); + System::set_block_number(block + cooldown); + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()), block)); + + assert_eq!(pending_count(&alice), 0); + assert_eq!(Balances::free_balance(&alice), pre_free); + assert_eq!(locked_under_ghdx(&alice), 0); + }); +} + +// Strict admission: any non-overlap-allowed lock (legacy staking, vesting, +// democracy, …) blocks `giga_stake` entirely, even when free_balance is +// sufficient. This prevents the lock-layering exploit at the root: the +// `stk_stks` + `ghdxlock` overlap can never be set up in the first place. +#[test] +fn giga_stake_should_fail_when_caller_has_legacy_staking_lock() { + use frame_support::traits::{LockableCurrency, WithdrawReasons}; + + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + let alice: AccountId = ALICE.into(); + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 2_000 * UNITS, + )); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); + >::set_lock(*b"stk_stks", &alice, 1_000 * UNITS, WithdrawReasons::all()); + + assert_noop!( + GigaHdx::giga_stake(RuntimeOrigin::signed(alice), 500 * UNITS), + pallet_gigahdx::Error::::BlockedByExternalLock, + ); + }); +} + +// `pyconvot` is in the runtime's overlap allowlist (HdxExternalClaims), so a +// conviction-voting lock must NOT block stake admission — the voter's HDX is +// only earmarked, not committed to a payout, so sharing it with a gigahdx +// stake is safe. +#[test] +fn giga_stake_should_succeed_when_caller_has_conviction_voting_lock() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + reset_giga_state_for_fixture(); + fund_bob_for_decision_deposit(); + + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000 * UNITS); + + let ref_index = begin_referendum_by_bob(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + ref_index, + aye_with_conviction(800 * UNITS, Conviction::Locked1x), + )); + + let conviction_lock = pallet_balances::Locks::::get(&alice) + .iter() + .find(|l| l.id == *b"pyconvot") + .map(|l| l.amount) + .unwrap_or(0); + assert_eq!(conviction_lock, 800 * UNITS); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 500 * UNITS)); + assert_eq!(pallet_gigahdx::Stakes::::get(&alice).unwrap().hdx, 500 * UNITS,); + }); +} + +/// Initialize legacy staking inside the gigahdx snapshot (which doesn't ship +/// it pre-initialized). Funds the pot to clear `MissingPotBalance`. +fn init_legacy_staking() { + let pot = pallet_staking::Pallet::::pot_account_id(); + assert_ok!(Currencies::update_balance( + RawOrigin::Root.into(), + pot, + HDX, + (10_000 * UNITS) as i128, + )); + assert_ok!(Staking::initialize_staking(RawOrigin::Root.into())); +} + +#[test] +fn migrate_should_move_legacy_position_into_gigahdx_when_called() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 10_000 * UNITS, + )); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); + init_legacy_staking(); + + let stake_amount = 5_000 * UNITS; + assert_ok!(Staking::stake(RuntimeOrigin::signed(alice.clone()), stake_amount)); + assert!( + pallet_staking::Pallet::::get_user_position_id(&alice) + .unwrap() + .is_some(), + "legacy position must exist pre-migrate" + ); + assert_eq!(lock_amount(&alice, *b"stk_stks"), stake_amount); + + assert_ok!(GigaHdx::migrate(RuntimeOrigin::signed(alice.clone()))); + + // Legacy side cleaned. + assert_eq!( + pallet_staking::Pallet::::get_user_position_id(&alice).unwrap(), + None + ); + assert_eq!(lock_amount(&alice, *b"stk_stks"), 0); + + // Gigahdx side populated. No legacy rewards accrued (pot just initialized), + // so unlocked equals stake_amount exactly. + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("gigahdx stake must exist"); + assert_eq!(stake.hdx, stake_amount); + assert!(stake.gigahdx > 0, "aToken minted"); + assert_eq!(lock_amount(&alice, GIGAHDX_LOCK_ID), stake_amount); + }); +} + +#[test] +fn migrate_should_refuse_when_no_legacy_position() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 10_000 * UNITS, + )); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); + init_legacy_staking(); + + assert_noop!( + GigaHdx::migrate(RuntimeOrigin::signed(alice.clone())), + pallet_staking::Error::::InconsistentState( + pallet_staking::pallet::InconsistentStateError::PositionNotFound + ) + ); + assert!(pallet_gigahdx::Stakes::::get(&alice).is_none()); + }); +} + +#[test] +fn legacy_stake_should_refuse_when_gigahdx_lock_present() { + // Strict policy: HDX already pledged under `ghdxlock` cannot back a legacy + // stake, otherwise the same balance would earn rewards from both pallets. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 10_000 * UNITS, + )); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); + init_legacy_staking(); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + assert!(locked_under_ghdx(&alice) > 0, "ghdxlock must be set"); + + assert_noop!( + Staking::stake(RuntimeOrigin::signed(alice.clone()), 1_000 * UNITS), + pallet_staking::Error::::BlockedByExternalLock + ); + }); +} + +#[test] +fn legacy_stake_should_succeed_after_giga_position_fully_exits() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 10_000 * UNITS, + )); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); + init_legacy_staking(); + + // Stake → unstake → wait cooldown → unlock. Cleans the ghdxlock entirely. + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + let entry = only_pending_position(&alice); + System::set_block_number(entry.expires_at); + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()), entry.id)); + assert_eq!(lock_amount(&alice, GIGAHDX_LOCK_ID), 0); + + // Legacy stake now succeeds — no overlapping claim left. + assert_ok!(Staking::stake(RuntimeOrigin::signed(alice.clone()), 1_000 * UNITS)); + }); +} diff --git a/integration-tests/src/gigahdx_rewards.rs b/integration-tests/src/gigahdx_rewards.rs new file mode 100644 index 0000000000..cefc2a9b43 --- /dev/null +++ b/integration-tests/src/gigahdx_rewards.rs @@ -0,0 +1,781 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// End-to-end integration tests for `pallet-gigahdx-rewards` against the +// live mainnet-state snapshot used by `gigahdx.rs`. +// +// These tests exercise the full extrinsic path: +// `pallet-conviction-voting::vote` → `VotingHooksImpl::on_before_vote` → +// user record + stake freeze. Then: +// `pallet-conviction-voting::remove_vote` → `on_remove_vote(Completed)` → +// pool allocation + per-user pro-rata payout + `claim_rewards` compounding. +// +// They verify that the runtime wiring (`CombinedVotingHooks`, +// `RuntimeReferenda`, the two pot `PalletId`s) plumbs through correctly, +// that the freeze guard in `pallet-gigahdx::giga_unstake` is enforced under +// real dispatch, and that the `RuntimeReferenda` adapter resolves the +// current track for an ongoing referendum. + +#![cfg(test)] + +use crate::gigahdx::PATH_TO_SNAPSHOT; +use crate::polkadot_test_net::{hydra_live_ext, TestNet, ALICE, BOB, CHARLIE, DAVE, UNITS}; +use frame_support::traits::{schedule::DispatchTime, Bounded, OnInitialize, StorePreimage}; +use frame_support::{assert_noop, assert_ok}; +use frame_system::RawOrigin; +use hydradx_runtime::{ + pallet_custom_origins::Origin as CustomOrigin, Balances, BlockNumber, ConvictionVoting, Democracy, EVMAccounts, + GigaHdx, GigaHdxRewards, OriginCaller, Preimage, Referenda, Runtime, RuntimeOrigin, Scheduler, System, +}; +use pallet_conviction_voting::{AccountVote, Conviction, Vote}; +use pallet_referenda::ReferendumIndex; +use primitives::constants::time::DAYS; +use primitives::AccountId; +use xcm_emulator::Network; + +// --------------------------------------------------------------------------- +// Helpers — ported from the legacy `gigahdx_voting.rs` integration tests and +// adapted for the rewards model. Helpers that referenced `pallet-gigahdx-voting` +// (which no longer exists on this branch) have been dropped or replaced with +// the equivalent `pallet-gigahdx-rewards` API. +// --------------------------------------------------------------------------- + +type CallOf = ::RuntimeCall; +type BoundedCallOf = Bounded, ::Hashing>; + +fn set_balance_proposal(who: AccountId, value: u128) -> BoundedCallOf { + let inner = pallet_balances::Call::force_set_balance { who, new_free: value }; + let outer = hydradx_runtime::RuntimeCall::Balances(inner); + Preimage::bound(outer).unwrap() +} + +fn propose_set_balance( + who: AccountId, + dest: AccountId, + value: u128, + dispatch_time: BlockNumber, +) -> frame_support::dispatch::DispatchResult { + Referenda::submit( + RuntimeOrigin::signed(who), + Box::new(RawOrigin::Root.into()), + set_balance_proposal(dest, value), + DispatchTime::At(dispatch_time), + ) +} + +/// Submit a referendum (Alice), deposit (Dave), fast-forward into the +/// deciding period. Returns the referendum index. +fn begin_referendum() -> ReferendumIndex { + let referendum_index = pallet_referenda::pallet::ReferendumCount::::get(); + let now = System::block_number(); + + assert_ok!(propose_set_balance(ALICE.into(), CHARLIE.into(), 2, now + 10 * DAYS)); + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + DAVE.into(), + 2_000_000_000 * UNITS, + )); + + assert_ok!(Referenda::place_decision_deposit( + RuntimeOrigin::signed(DAVE.into()), + referendum_index, + )); + + fast_forward_to(now + 5 * DAYS); + + referendum_index +} + +/// Fast-forward past the decision + confirmation window so the referendum +/// transitions to a `Completed` status. +fn end_referendum() { + let now = System::block_number(); + fast_forward_to(now + 12 * DAYS); +} + +fn fast_forward_to(n: u32) { + while System::block_number() < n { + next_block(); + } +} + +fn next_block() { + System::set_block_number(System::block_number() + 1); + Scheduler::on_initialize(System::block_number()); + Democracy::on_initialize(System::block_number()); +} + +fn aye(amount: u128) -> AccountVote { + AccountVote::Standard { + vote: Vote { + aye: true, + conviction: Conviction::None, + }, + balance: amount, + } +} + +fn aye_with_conviction(amount: u128, conviction: Conviction) -> AccountVote { + AccountVote::Standard { + vote: Vote { aye: true, conviction }, + balance: amount, + } +} + +fn nay_with_conviction(amount: u128, conviction: Conviction) -> AccountVote { + AccountVote::Standard { + vote: Vote { aye: false, conviction }, + balance: amount, + } +} + +/// Submit a referendum on the given proposal origin (track is resolved from it). +fn begin_referendum_on_track(proposal_origin: OriginCaller) -> ReferendumIndex { + let referendum_index = pallet_referenda::pallet::ReferendumCount::::get(); + let now = System::block_number(); + + assert_ok!(Referenda::submit( + RuntimeOrigin::signed(ALICE.into()), + Box::new(proposal_origin), + set_balance_proposal(CHARLIE.into(), 2), + DispatchTime::At(now + 10 * DAYS), + )); + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + DAVE.into(), + 2_000_000_000 * UNITS, + )); + assert_ok!(Referenda::place_decision_deposit( + RuntimeOrigin::signed(DAVE.into()), + referendum_index, + )); + + fast_forward_to(now + 5 * DAYS); + referendum_index +} + +fn init_rewards() { + init_rewards_with_pot(100_000 * UNITS); +} + +fn init_rewards_with_pot(pot_amount: u128) { + let accumulator = GigaHdxRewards::reward_accumulator_pot(); + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + accumulator, + pot_amount, + )); + + // `giga_stake` mints aToken through AAVE; users must have a bound EVM address. + for who in [ALICE, BOB, CHARLIE] { + let account: AccountId = who.into(); + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + account.clone(), + 1_000_000 * UNITS, + )); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(account)); + } +} + +/// Root track id on this runtime. Matches `pallet-referenda`'s convention +/// (track 0 = root). Used as the explicit class arg to `remove_vote` — when +/// `class = None` conviction-voting can fail with `ClassNeeded` depending on +/// the user's voting state. +const ROOT_TRACK_CLASS: u16 = 0; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[test] +fn rewards_should_skip_non_stakers_when_voting() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards(); + + let alice: AccountId = ALICE.into(); + + // Alice never stakes — `pallet_gigahdx::Stakes[alice]` is None, so + // `VotingHooksImpl::on_before_vote` returns early on the first guard + // without creating a `UserVoteRecord`. + let r = begin_referendum(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + aye(50 * UNITS), + )); + + assert!(pallet_gigahdx_rewards::UserVoteRecords::::get(&alice, r).is_none()); + assert!(pallet_gigahdx_rewards::ReferendaTotalWeightedVotes::::get(r).is_none()); + + end_referendum(); + + let accumulator_before = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + let accumulator_after = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + assert_eq!( + accumulator_after, accumulator_before, + "non-staker remove_vote must not drain the accumulator" + ); + assert_eq!(pallet_gigahdx_rewards::PendingRewards::::get(&alice), 0); + }); +} + +#[test] +fn rewards_should_credit_pro_rata_when_two_stakers_vote() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards(); + + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(bob.clone()), 200 * UNITS)); + + let r = begin_referendum(); + + // Alice 100 HDX × Locked3x (1× base) → weighted = 100 * UNITS. + // Bob 100 HDX × Locked1x (0.25×) → weighted = 25 * UNITS. + // Total weighted = 125 * UNITS — divides 10% of the 100_000-UNIT pot + // (= 10^16) cleanly into 8/5 and 1/5 shares with no rounding dust. + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + aye_with_conviction(100 * UNITS, Conviction::Locked3x), + )); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(bob.clone()), + r, + aye_with_conviction(100 * UNITS, Conviction::Locked1x), + )); + + end_referendum(); + + let accumulator_before = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + // Root-track allocation = 10% of accumulator at first remove_vote. + let expected_allocation = accumulator_before / 10; + + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(bob.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + + let alice_reward = pallet_gigahdx_rewards::PendingRewards::::get(&alice); + let bob_reward = pallet_gigahdx_rewards::PendingRewards::::get(&bob); + + assert!(alice_reward > 0, "alice must receive a share"); + assert!(bob_reward > 0, "bob must receive a share"); + assert_eq!( + alice_reward + bob_reward, + expected_allocation, + "the entire allocation must be distributed across the two voters", + ); + // Verify the pro-rata split: alice (weighted 100) gets 4× bob (weighted 25). + assert_eq!(alice_reward, 4 * bob_reward); + + // Pool is deleted after the last claim drains it to zero. + assert!( + pallet_gigahdx_rewards::ReferendaRewardPool::::get(r).is_none(), + "pool must be cleaned up after last voter", + ); + }); +} + +#[test] +fn last_voter_should_scoop_remaining_pool() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards(); + + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(bob.clone()), 100 * UNITS)); + + let r = begin_referendum(); + + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + aye_with_conviction(100 * UNITS, Conviction::Locked1x), + )); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(bob.clone()), + r, + aye_with_conviction(100 * UNITS, Conviction::Locked1x), + )); + + end_referendum(); + + // First claimant: allocation gets snapshotted; alice's pro-rata share + // is computed against the frozen denominator. + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + let pool_after_alice = pallet_gigahdx_rewards::ReferendaRewardPool::::get(r) + .expect("pool exists between first and last voter"); + let bob_expected = pool_after_alice.remaining_reward; + assert!(bob_expected > 0); + + // Second claimant scoops *exactly* the remaining_reward — no `floor`, + // no leftover dust. Pool storage must be deleted afterwards. + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(bob.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + let bob_reward = pallet_gigahdx_rewards::PendingRewards::::get(&bob); + assert_eq!(bob_reward, bob_expected, "last voter scoops remaining_reward exactly"); + assert!(pallet_gigahdx_rewards::ReferendaRewardPool::::get(r).is_none()); + }); +} + +#[test] +fn giga_unstake_should_fail_when_stake_is_frozen_by_active_vote() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards(); + + let alice: AccountId = ALICE.into(); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let r = begin_referendum(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + aye_with_conviction(100 * UNITS, Conviction::Locked1x), + )); + + // Stake.frozen now equals 100 HDX (the staked-vote-capped balance) — + // any unstake that would bring `hdx < frozen` must fail with `StakeFrozen`. + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("alice has stake"); + assert_eq!(stake.frozen, 100 * UNITS); + + // `giga_unstake` operates on gigahdx (atokens) but the frozen check is + // against the post-payout HDX side. Burning all 100 atokens would set + // hdx → 0, well below frozen=100 → StakeFrozen. + assert_noop!( + GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS), + pallet_gigahdx::Error::::StakeFrozen, + ); + + // After remove_vote (referendum still ongoing → Status::Ongoing path), + // the freeze is released and the unstake succeeds. + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("alice still has stake"); + assert_eq!(stake.frozen, 0, "remove_vote must unfreeze the stake"); + + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + }); +} + +#[test] +fn claim_rewards_should_compound_into_gigahdx_position() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards(); + + let alice: AccountId = ALICE.into(); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let r = begin_referendum(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + aye_with_conviction(100 * UNITS, Conviction::Locked1x), + )); + + end_referendum(); + + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + + let pending = pallet_gigahdx_rewards::PendingRewards::::get(&alice); + assert!(pending > 0, "alice must have a pending reward after solo vote"); + + let stake_before = pallet_gigahdx::Stakes::::get(&alice).expect("alice staked"); + let hdx_before = stake_before.hdx; + let gigahdx_before = stake_before.gigahdx; + + assert_ok!(GigaHdxRewards::claim_rewards(RuntimeOrigin::signed(alice.clone()))); + + let stake_after = pallet_gigahdx::Stakes::::get(&alice).expect("alice still staked"); + assert_eq!( + stake_after.hdx, + hdx_before + pending, + "claimed HDX must be compounded into the active stake", + ); + assert!( + stake_after.gigahdx > gigahdx_before, + "GIGAHDX position must grow after claim", + ); + assert_eq!( + pallet_gigahdx_rewards::PendingRewards::::get(&alice), + 0, + "pending rewards must be cleared on claim", + ); + }); +} + +#[test] +fn rewards_should_ignore_split_votes() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards(); + + let alice: AccountId = ALICE.into(); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let r = begin_referendum(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + AccountVote::Split { + aye: 40 * UNITS, + nay: 30 * UNITS, + }, + )); + + // Split votes are dropped silently by `on_before_vote` — no record, + // no live-tally entry. + assert!(pallet_gigahdx_rewards::UserVoteRecords::::get(&alice, r).is_none()); + assert!(pallet_gigahdx_rewards::ReferendaTotalWeightedVotes::::get(r).is_none()); + + end_referendum(); + let accumulator_before = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + let accumulator_after = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + assert_eq!( + accumulator_after, accumulator_before, + "split votes must not trigger a pool allocation", + ); + assert_eq!(pallet_gigahdx_rewards::PendingRewards::::get(&alice), 0); + assert!(pallet_gigahdx_rewards::ReferendaRewardPool::::get(r).is_none()); + }); +} + +#[test] +fn rewards_should_use_track_specific_percentage_when_non_root_track() { + // Treasurer = track 5 → 5% (vs root's 10%). + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards(); + + let alice: AccountId = ALICE.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let r = begin_referendum_on_track(OriginCaller::Origins(CustomOrigin::Treasurer)); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + aye_with_conviction(100 * UNITS, Conviction::Locked1x), + )); + + let accumulator_before = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + let expected_allocation = accumulator_before / 20; + + end_referendum(); + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(5), + r, + )); + + let accumulator_after = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + assert_eq!(accumulator_before - accumulator_after, expected_allocation); + assert_eq!( + pallet_gigahdx_rewards::PendingRewards::::get(&alice), + expected_allocation, + ); + }); +} + +#[test] +fn rewards_should_replace_weighted_when_vote_is_edited() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards(); + + let alice: AccountId = ALICE.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 200 * UNITS)); + + let r = begin_referendum(); + + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + aye_with_conviction(100 * UNITS, Conviction::Locked1x), + )); + let tally_after_first = pallet_gigahdx_rewards::ReferendaTotalWeightedVotes::::get(r).unwrap(); + // Locked1x = 0.25× → 100 * 0.25 = 25 UNITS weighted. + assert_eq!(tally_after_first.total_weighted, 25 * UNITS); + assert_eq!(tally_after_first.voters_count, 1); + assert_eq!( + pallet_gigahdx::Stakes::::get(&alice).unwrap().frozen, + 100 * UNITS, + ); + + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + aye_with_conviction(200 * UNITS, Conviction::Locked3x), + )); + + let record = pallet_gigahdx_rewards::UserVoteRecords::::get(&alice, r).unwrap(); + assert_eq!(record.staked_vote_amount, 200 * UNITS); + // Locked3x = 1× (base) → 200 * 1 = 200 UNITS weighted. + assert_eq!(record.weighted, 200 * UNITS); + let tally_after_edit = pallet_gigahdx_rewards::ReferendaTotalWeightedVotes::::get(r).unwrap(); + assert_eq!(tally_after_edit.total_weighted, 200 * UNITS); + assert_eq!(tally_after_edit.voters_count, 1, "edit must not increment voters_count"); + assert_eq!( + pallet_gigahdx::Stakes::::get(&alice).unwrap().frozen, + 200 * UNITS, + ); + + end_referendum(); + let accumulator_before = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + let expected_allocation = accumulator_before / 10; + + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + + assert_eq!( + pallet_gigahdx_rewards::PendingRewards::::get(&alice), + expected_allocation, + ); + }); +} + +#[test] +fn rewards_should_skip_allocation_when_referendum_is_cancelled() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards(); + + let alice: AccountId = ALICE.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let r = begin_referendum(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + aye_with_conviction(100 * UNITS, Conviction::Locked1x), + )); + + assert_ok!(Referenda::cancel(RawOrigin::Root.into(), r)); + + let accumulator_before = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + let accumulator_after = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + + assert_eq!(accumulator_after, accumulator_before); + assert_eq!(pallet_gigahdx_rewards::PendingRewards::::get(&alice), 0); + assert!(pallet_gigahdx_rewards::ReferendaRewardPool::::get(r).is_none()); + assert_eq!( + pallet_gigahdx::Stakes::::get(&alice).unwrap().frozen, + 0, + "cancelled referendum must still unfreeze the stake", + ); + }); +} + +#[test] +fn pending_rewards_should_accumulate_across_multiple_referenda() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards(); + + let alice: AccountId = ALICE.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let r1 = begin_referendum(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r1, + aye_with_conviction(100 * UNITS, Conviction::Locked1x), + )); + end_referendum(); + + let pot_before_r1 = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + let expected_r1 = pot_before_r1 / 10; + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r1, + )); + + let r2 = begin_referendum(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r2, + aye_with_conviction(100 * UNITS, Conviction::Locked1x), + )); + end_referendum(); + + let pot_before_r2 = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + let expected_r2 = pot_before_r2 / 10; + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r2, + )); + + assert_eq!( + pallet_gigahdx_rewards::PendingRewards::::get(&alice), + expected_r1 + expected_r2, + ); + + let stake_before = pallet_gigahdx::Stakes::::get(&alice).unwrap(); + assert_ok!(GigaHdxRewards::claim_rewards(RuntimeOrigin::signed(alice.clone()))); + let stake_after = pallet_gigahdx::Stakes::::get(&alice).unwrap(); + assert_eq!(stake_after.hdx, stake_before.hdx + expected_r1 + expected_r2); + assert_eq!(pallet_gigahdx_rewards::PendingRewards::::get(&alice), 0); + }); +} + +#[test] +fn rewards_should_ignore_split_abstain_votes() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards(); + + let alice: AccountId = ALICE.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let r = begin_referendum(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + AccountVote::SplitAbstain { + aye: 20 * UNITS, + nay: 20 * UNITS, + abstain: 30 * UNITS, + }, + )); + + assert!(pallet_gigahdx_rewards::UserVoteRecords::::get(&alice, r).is_none()); + assert!(pallet_gigahdx_rewards::ReferendaTotalWeightedVotes::::get(r).is_none()); + + end_referendum(); + let accumulator_before = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + let accumulator_after = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + assert_eq!(accumulator_after, accumulator_before); + assert_eq!(pallet_gigahdx_rewards::PendingRewards::::get(&alice), 0); + assert!(pallet_gigahdx_rewards::ReferendaRewardPool::::get(r).is_none()); + }); +} + +#[test] +fn rewards_should_credit_nay_voters_same_as_aye() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards(); + + let alice: AccountId = ALICE.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let r = begin_referendum(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + nay_with_conviction(100 * UNITS, Conviction::Locked1x), + )); + + let record = pallet_gigahdx_rewards::UserVoteRecords::::get(&alice, r).unwrap(); + assert_eq!(record.staked_vote_amount, 100 * UNITS); + // Locked1x = 0.25× → 100 * 0.25 = 25 UNITS weighted (nay/aye treated symmetrically). + assert_eq!(record.weighted, 25 * UNITS); + + end_referendum(); + let pot_before = Balances::free_balance(&GigaHdxRewards::reward_accumulator_pot()); + let expected = pot_before / 10; + + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + + assert_eq!(pallet_gigahdx_rewards::PendingRewards::::get(&alice), expected); + }); +} + +#[test] +fn rewards_should_cleanup_with_zero_payout_when_accumulator_is_empty() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + init_rewards_with_pot(0); + + let alice: AccountId = ALICE.into(); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + let r = begin_referendum(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + r, + aye_with_conviction(100 * UNITS, Conviction::Locked1x), + )); + + end_referendum(); + let allocated_pot_before = Balances::free_balance(&GigaHdxRewards::allocated_rewards_pot()); + assert_ok!(ConvictionVoting::remove_vote( + RuntimeOrigin::signed(alice.clone()), + Some(ROOT_TRACK_CLASS), + r, + )); + + assert_eq!( + Balances::free_balance(&GigaHdxRewards::allocated_rewards_pot()), + allocated_pot_before, + ); + assert_eq!(pallet_gigahdx_rewards::PendingRewards::::get(&alice), 0); + assert!(pallet_gigahdx_rewards::ReferendaRewardPool::::get(r).is_none()); + assert_noop!( + GigaHdxRewards::claim_rewards(RuntimeOrigin::signed(alice)), + pallet_gigahdx_rewards::Error::::NoPendingRewards, + ); + }); +} diff --git a/integration-tests/src/lib.rs b/integration-tests/src/lib.rs index b585590361..51dfd054e5 100644 --- a/integration-tests/src/lib.rs +++ b/integration-tests/src/lib.rs @@ -20,6 +20,8 @@ mod evm; mod evm_permit; mod exchange_asset; mod fee_calculation; +mod gigahdx; +mod gigahdx_rewards; mod global_withdraw_limit; mod hsm; mod insufficient_assets_ed; diff --git a/pallets/gigahdx-rewards/Cargo.toml b/pallets/gigahdx-rewards/Cargo.toml new file mode 100644 index 0000000000..1801d963fc --- /dev/null +++ b/pallets/gigahdx-rewards/Cargo.toml @@ -0,0 +1,72 @@ +[package] +name = "pallet-gigahdx-rewards" +version = "0.1.0" +description = "Conviction-voting reward distribution for gigahdx stakers." +authors = ["GalacticCouncil"] +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/galacticcouncil/hydration-node" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { workspace = true } +scale-info = { workspace = true } +log = { workspace = true } + +# Substrate +frame-support = { workspace = true } +frame-system = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } +sp-core = { workspace = true } +sp-io = { workspace = true } + +# Conviction voting / referenda +pallet-conviction-voting = { workspace = true } + +# Local +pallet-gigahdx = { workspace = true } +primitives = { workspace = true } + +frame-benchmarking = { workspace = true, optional = true } + +[dev-dependencies] +sp-io = { workspace = true, features = ["std"] } +sp-core = { workspace = true, features = ["std"] } +sp-runtime = { workspace = true, features = ["std"] } +pallet-balances = { workspace = true, features = ["std"] } +orml-traits = { workspace = true, features = ["std"] } +orml-tokens = { workspace = true, features = ["std"] } +hydra-dx-math = { workspace = true, features = ["std"] } +hydradx-traits = { workspace = true, features = ["std"] } + +[features] +default = ["std"] +std = [ + "codec/std", + "scale-info/std", + "frame-support/std", + "frame-system/std", + "sp-runtime/std", + "sp-std/std", + "sp-core/std", + "sp-io/std", + "frame-benchmarking?/std", + "pallet-conviction-voting/std", + "pallet-gigahdx/std", + "primitives/std", +] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "pallet-conviction-voting/runtime-benchmarks", + "pallet-gigahdx/runtime-benchmarks", +] +try-runtime = [ + "frame-support/try-runtime", + "pallet-conviction-voting/try-runtime", + "pallet-gigahdx/try-runtime", +] diff --git a/pallets/gigahdx-rewards/src/benchmarking.rs b/pallets/gigahdx-rewards/src/benchmarking.rs new file mode 100644 index 0000000000..2cc4b0ab47 --- /dev/null +++ b/pallets/gigahdx-rewards/src/benchmarking.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Benchmarks for `pallet-gigahdx-rewards`. +//! +//! Setup mirrors `pallet-gigahdx`'s benchmark style (v2 macro). For +//! `claim_rewards` the worst-case path is: non-zero `PendingRewards[who]` +//! plus an existing `Stakes[who]` entry so the compound path inside +//! `pallet_gigahdx::do_stake` walks the full storage update +//! (mint stHDX, money-market supply, mutate `Stakes`, bump `TotalLocked`, +//! refresh the balance lock). + +use super::*; +use crate::pallet::PendingRewards; +use frame_benchmarking::v2::*; +use frame_support::assert_ok; +use frame_support::traits::Currency; +use frame_system::RawOrigin; +use pallet_gigahdx::BenchmarkHelper as _; +use primitives::{Balance, EvmAddress}; + +const ONE: Balance = 1_000_000_000_000; + +#[benchmarks(where T: Config, ::NativeCurrency: Currency)] +mod benches { + use super::*; + + fn fund(who: &T::AccountId, amount: Balance) + where + ::NativeCurrency: Currency, + { + let _ = ::NativeCurrency::deposit_creating(who, amount); + } + + /// Set a dummy AAVE pool address so `pallet-gigahdx`'s `pool not set` + /// precondition passes. The runtime's `BenchmarkMoneyMarket` does not + /// dispatch to it. + fn set_dummy_pool() { + pallet_gigahdx::GigaHdxPoolContract::::put(EvmAddress::from([0xAAu8; 20])); + } + + #[benchmark] + fn claim_rewards() { + // Register stHDX via the gigahdx-side benchmark helper. `Config` for + // rewards extends `pallet_gigahdx::Config`, so the helper trait is + // accessible as `::BenchmarkHelper`. + assert_ok!(::BenchmarkHelper::register_assets()); + set_dummy_pool::(); + + let caller: T::AccountId = whitelisted_caller(); + + // Endow the caller heavily so they can `giga_stake` (which moves HDX + // into the lock) and still cover the post-claim free-balance state. + let initial_stake: Balance = 1_000 * ONE; + fund::(&caller, initial_stake.saturating_mul(10)); + + // Build an existing stake position so the compound path inside + // `do_stake` hits the full mutate-existing-record code branch. + assert_ok!(pallet_gigahdx::Pallet::::giga_stake( + RawOrigin::Signed(caller.clone()).into(), + initial_stake, + )); + + // Fund the allocated-rewards pot so the payout transfer succeeds. + let reward: Balance = 1_000 * ONE; + fund::(&Pallet::::allocated_rewards_pot(), reward.saturating_mul(2)); + + // Seed pending rewards: this is the value drained by `take(&who)`. + PendingRewards::::insert(&caller, reward); + + #[extrinsic_call] + claim_rewards(RawOrigin::Signed(caller.clone())); + + // Pending was drained. + assert_eq!(PendingRewards::::get(&caller), 0); + // Stake grew by the claimed amount. + let stake = pallet_gigahdx::Stakes::::get(&caller).expect("stake recorded"); + assert!(stake.hdx >= initial_stake.saturating_add(reward)); + assert!(stake.gigahdx > 0); + } +} diff --git a/pallets/gigahdx-rewards/src/lib.rs b/pallets/gigahdx-rewards/src/lib.rs new file mode 100644 index 0000000000..b84f660a7b --- /dev/null +++ b/pallets/gigahdx-rewards/src/lib.rs @@ -0,0 +1,306 @@ +// This file is part of https://github.com/galacticcouncil/hydration-node + +// Copyright (C) 2025 Intergalactic, Limited (GIB). +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! # pallet-gigahdx-rewards +//! +//! Distributes HDX rewards to `pallet-gigahdx` stakers based on their +//! conviction-voting activity. +//! +//! - Two HDX-only pot accounts: an accumulator (externally funded) and an +//! allocated pot that holds per-referendum allocations. +//! - On every vote by a gigahdx staker, the reward weight is snapshotted +//! into `UserVoteRecords` and the corresponding stake is frozen on the +//! gigahdx side to prevent stake → vote → unstake exploits. +//! - First `on_remove_vote` for a completed referendum lazily transfers +//! `track_pct × accumulator_balance` into the allocated pot and snapshots +//! the frozen denominator. +//! - Per-user shares are pro-rata against that frozen denominator. The last +//! claimant scoops any remaining dust, draining the pool to exactly zero +//! and triggering storage cleanup. +//! - `claim_rewards` atomically compounds the user's accumulated HDX back +//! into their gigahdx position. + +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +pub mod traits; +pub mod types; +pub mod voting_hooks; +pub mod weights; + +#[cfg(test)] +mod tests; + +#[cfg(feature = "runtime-benchmarks")] +pub mod benchmarking; + +#[frame_support::pallet] +pub mod pallet { + use crate::traits::{ReferendaTrackInspect, TrackRewardTable}; + use crate::types::{ReferendaReward, ReferendumIndex, ReferendumLiveTally, UserVoteRecord}; + pub use crate::weights::WeightInfo; + use codec::HasCompact; + use frame_support::pallet_prelude::*; + use frame_support::sp_runtime::helpers_128bit::multiply_by_rational_with_rounding; + use frame_support::sp_runtime::traits::AccountIdConversion; + use frame_support::sp_runtime::Rounding; + use frame_support::traits::{Currency, ExistenceRequirement}; + use frame_support::PalletId; + use frame_system::pallet_prelude::*; + use pallet_gigahdx::traits::ExternalClaims; + use pallet_gigahdx::MoneyMarketOperations; + use primitives::Balance; + use scale_info::TypeInfo; + use sp_std::fmt::Debug; + + pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config>> + pallet_gigahdx::Config { + type TrackId: Parameter + + Member + + Copy + + MaybeSerializeDeserialize + + Debug + + MaxEncodedLen + + TypeInfo + + Ord + + HasCompact; + + /// Lookup of referendum → track id. + type Referenda: ReferendaTrackInspect; + + /// Track id → reward percentage table. + type TrackRewardConfig: TrackRewardTable; + + /// PalletId of the externally-funded accumulator pot. Source of + /// every per-referendum allocation. The allocated-rewards pot is + /// derived deterministically as a sub-account of this id. + #[pallet::constant] + type RewardPotPalletId: Get; + + type WeightInfo: WeightInfo; + } + + /// Live tally maintained during the voting period. Deleted at allocation + /// time; the values move into `ReferendaRewardPool`. + #[pallet::storage] + pub type ReferendaTotalWeightedVotes = + StorageMap<_, Blake2_128Concat, ReferendumIndex, ReferendumLiveTally, OptionQuery>; + + /// Track id cached at vote time. Deleted at allocation; lives on in + /// `ReferendaRewardPool[ref].track_id`. + #[pallet::storage] + pub type ReferendumTracks = StorageMap<_, Blake2_128Concat, ReferendumIndex, T::TrackId, OptionQuery>; + + /// Per-referendum frozen snapshot. Presence doubles as "allocation has + /// run." Deleted when the last voter claims their share. + #[pallet::storage] + pub type ReferendaRewardPool = + StorageMap<_, Blake2_128Concat, ReferendumIndex, ReferendaReward, OptionQuery>; + + /// Per (user, referendum) snapshot of the eligible vote weight at cast + /// time. Updated on vote edits; taken on `on_remove_vote`. + #[pallet::storage] + pub type UserVoteRecords = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Blake2_128Concat, + ReferendumIndex, + UserVoteRecord, + OptionQuery, + >; + + /// Running sum of HDX owed to `who` across all completed referenda. + #[pallet::storage] + pub type PendingRewards = StorageMap<_, Blake2_128Concat, T::AccountId, Balance, ValueQuery>; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// Rewards for a completed referendum have been allocated from the + /// accumulator pot. + RewardPoolAllocated { + ref_index: ReferendumIndex, + track_id: T::TrackId, + total_reward: Balance, + total_weighted_votes: u128, + voters_remaining: u32, + }, + /// A user's share of a referendum's reward pool was added to their + /// `PendingRewards`. + UserRewardRecorded { + who: T::AccountId, + ref_index: ReferendumIndex, + reward_amount: Balance, + }, + /// A user converted their accumulated HDX rewards into GIGAHDX via + /// `claim_rewards`. + RewardsClaimed { + who: T::AccountId, + total_hdx: Balance, + gigahdx_received: Balance, + }, + } + + #[pallet::error] + pub enum Error { + /// `PendingRewards[who]` is zero. + NoPendingRewards, + /// Not enough rewards in the allocated pot to cover the amount owed. + PotInsufficient, + /// Arithmetic overflow during share computation. + Overflow, + } + + #[pallet::call] + impl Pallet { + /// Convert the caller's accumulated HDX rewards into GIGAHDX by staking + /// them on the caller's behalf. + /// + /// The full `PendingRewards[who]` sum is taken atomically. The HDX is + /// transferred from the allocated-rewards pot to the caller's free + /// balance, then `pallet_gigahdx::do_stake` is called to mint GIGAHDX + /// into the caller's stake position. + /// + /// Parameters: + /// - `origin`: signed by the user claiming. + /// + /// Emits `RewardsClaimed` event when successful. + #[pallet::call_index(0)] + #[pallet::weight( + ::WeightInfo::claim_rewards() + .saturating_add(::MoneyMarket::supply_weight()) + )] + pub fn claim_rewards(origin: OriginFor) -> DispatchResult { + let who = ensure_signed(origin)?; + // Mirror `giga_stake`: a pre-existing HDX lock would shadow ours + // under FRAME's max-of-locks, leaving the staked HDX transferable. + ensure!( + ::ExternalClaims::on(&who) == 0, + pallet_gigahdx::Error::::BlockedByExternalLock + ); + + let total = PendingRewards::::take(&who); + ensure!(total > 0, Error::::NoPendingRewards); + + ::NativeCurrency::transfer( + &Self::allocated_rewards_pot(), + &who, + total, + ExistenceRequirement::AllowDeath, + ) + .map_err(|_| Error::::PotInsufficient)?; + + let gigahdx_received = pallet_gigahdx::Pallet::::do_stake(&who, total)?; + + Self::deposit_event(Event::RewardsClaimed { + who, + total_hdx: total, + gigahdx_received, + }); + Ok(()) + } + } + + impl Pallet { + /// Account id of the externally-funded accumulator pot. + pub fn reward_accumulator_pot() -> T::AccountId { + T::RewardPotPalletId::get().into_account_truncating() + } + + /// Account id of the allocated-rewards pot. Derived deterministically + /// as a sub-account of the accumulator pot. The discriminator is + /// non-zero so the sub-account does not collide with the parent + /// (zero-padded) account. + pub fn allocated_rewards_pot() -> T::AccountId { + T::RewardPotPalletId::get().into_sub_account_truncating(*b"alc") + } + + /// Compute weighted contribution: `staked_vote × multiplier / 10`, + /// where multiplier follows the reward table in `crate::types`. + pub(crate) fn weighted(staked_vote: Balance, conviction: pallet_conviction_voting::Conviction) -> u128 { + let mult = crate::types::conviction_reward_multiplier(conviction); + multiply_by_rational_with_rounding(staked_vote, mult, crate::types::REWARD_MULTIPLIER_SCALE, Rounding::Down) + .unwrap_or(0) + } + + /// Pro-rata weighted share of the pool. Returns the amount credited + /// to `PendingRewards[who]`. Rounding dust after the last claimant is + /// recycled to the accumulator pot, never scooped to a single voter. + pub(crate) fn record_user_reward( + who: &T::AccountId, + ref_index: ReferendumIndex, + record: &UserVoteRecord, + ) -> Result { + let Some(mut pool) = ReferendaRewardPool::::take(ref_index) else { + return Ok(0); + }; + + debug_assert!(pool.voters_remaining > 0, "record_user_reward with empty pool"); + if pool.voters_remaining == 0 { + return Ok(0); + } + pool.voters_remaining = pool.voters_remaining.saturating_sub(1); + + let user_reward: Balance = if pool.total_weighted_votes == 0 || record.weighted == 0 { + 0 + } else { + let share = multiply_by_rational_with_rounding( + record.weighted, + pool.total_reward, + pool.total_weighted_votes, + Rounding::Down, + ) + .ok_or(Error::::Overflow)?; + let capped = share.min(pool.remaining_reward); + pool.remaining_reward = pool.remaining_reward.saturating_sub(capped); + capped + }; + + if user_reward > 0 { + PendingRewards::::mutate(who, |b| *b = b.saturating_add(user_reward)); + Self::deposit_event(Event::UserRewardRecorded { + who: who.clone(), + ref_index, + reward_amount: user_reward, + }); + } + + if pool.voters_remaining == 0 { + if pool.remaining_reward > 0 { + ::NativeCurrency::transfer( + &Self::allocated_rewards_pot(), + &Self::reward_accumulator_pot(), + pool.remaining_reward, + ExistenceRequirement::AllowDeath, + )?; + } + } else { + ReferendaRewardPool::::insert(ref_index, pool); + } + + Ok(user_reward) + } + } +} diff --git a/pallets/gigahdx-rewards/src/tests/claim.rs b/pallets/gigahdx-rewards/src/tests/claim.rs new file mode 100644 index 0000000000..7c4162f670 --- /dev/null +++ b/pallets/gigahdx-rewards/src/tests/claim.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::pallet::{Error, Event, PendingRewards}; + +use frame_support::{assert_err, assert_noop, assert_ok}; +use frame_system::RawOrigin; + +fn seed_pending_reward(who: AccountId, amount: u128) { + // Mint HDX into the allocated pot and credit `PendingRewards[who]` + // directly — this short-circuits the full vote → remove_vote flow. + use frame_support::traits::Currency; + let _ = >::deposit_creating(&allocated_pot(), amount); + PendingRewards::::insert(who, amount); +} + +#[test] +fn claim_rewards_should_compound_pending_into_gigahdx() { + ExtBuilder::default().build().execute_with(|| { + use frame_system::RawOrigin; + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + + let stake_before = pallet_gigahdx::Stakes::::get(ALICE).unwrap(); + let alloc_before = account_balance(&allocated_pot()); + seed_pending_reward(ALICE, 10 * ONE); + assert_eq!(account_balance(&allocated_pot()), alloc_before + 10 * ONE); + + assert_ok!(GigaHdxRewards::claim_rewards(RawOrigin::Signed(ALICE).into())); + + // PendingRewards drained. + assert_eq!(PendingRewards::::get(ALICE), 0); + // Stake compounded. + let stake_after = pallet_gigahdx::Stakes::::get(ALICE).unwrap(); + assert_eq!(stake_after.hdx, stake_before.hdx + 10 * ONE); + assert!(stake_after.gigahdx > stake_before.gigahdx); + }); +} + +#[test] +fn claim_rewards_should_fail_when_no_pending() { + ExtBuilder::default().build().execute_with(|| { + assert_noop!( + GigaHdxRewards::claim_rewards(RawOrigin::Signed(ALICE).into()), + Error::::NoPendingRewards + ); + }); +} + +#[test] +fn claim_rewards_should_revert_and_preserve_pending_when_conversion_rounds_to_zero() { + ExtBuilder::default() + .with_pot_balance(1_000 * ONE) // heavy gigapot → rate >> 1 + .build() + .execute_with(|| { + // Build up an appreciated rate: tiny stake against an inflated gigapot. + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + // Rate ≈ (100 + 1000) / 100 = 11 HDX per gigahdx. + + // Seed a `PendingRewards` of `10` (in raw units), below the rate floor. + seed_pending_reward(ALICE, 10u128); + + assert_err!( + GigaHdxRewards::claim_rewards(RawOrigin::Signed(ALICE).into()), + pallet_gigahdx::Error::::ZeroAmount + ); + + // Transactional revert: pending intact for retry. + assert_eq!(PendingRewards::::get(ALICE), 10u128); + }); +} + +#[test] +fn claim_rewards_should_revert_and_preserve_pending_when_money_market_fails() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + seed_pending_reward(ALICE, 10 * ONE); + + TestMoneyMarket::fail_supply(); + + assert_err!( + GigaHdxRewards::claim_rewards(RawOrigin::Signed(ALICE).into()), + pallet_gigahdx::Error::::MoneyMarketSupplyFailed + ); + + // Pending preserved; allocated pot balance preserved by transactional revert. + assert_eq!(PendingRewards::::get(ALICE), 10 * ONE); + }); +} + +#[test] +fn claim_rewards_should_fail_when_caller_has_external_claim() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + seed_pending_reward(ALICE, 10 * ONE); + + // Simulate the caller holding HDX claimed by another pallet (legacy + // staking lock, vesting, etc.). `claim_rewards` must refuse to compound + // because FRAME's max-of-locks semantics would let the larger external + // lock shadow the freshly-applied gigahdx lock. + TestExternalClaims::set(50 * ONE); + + assert_noop!( + GigaHdxRewards::claim_rewards(RawOrigin::Signed(ALICE).into()), + pallet_gigahdx::Error::::BlockedByExternalLock + ); + + // `PendingRewards` untouched — guard runs before `take`, no rewards burned. + assert_eq!(PendingRewards::::get(ALICE), 10 * ONE); + + // After the external claim clears, the same caller can compound. + TestExternalClaims::reset(); + assert_ok!(GigaHdxRewards::claim_rewards(RawOrigin::Signed(ALICE).into())); + assert_eq!(PendingRewards::::get(ALICE), 0); + }); +} + +#[test] +fn claim_rewards_should_emit_event_with_gigahdx_received() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + seed_pending_reward(ALICE, 10 * ONE); + + assert_ok!(GigaHdxRewards::claim_rewards(RawOrigin::Signed(ALICE).into())); + + let recent = last_events(10); + let found = recent.iter().any(|e| { + matches!( + e, + RuntimeEvent::GigaHdxRewards(Event::RewardsClaimed { who, total_hdx, gigahdx_received }) + if *who == ALICE && *total_hdx == 10 * ONE && *gigahdx_received > 0 + ) + }); + assert!(found, "expected RewardsClaimed event; got {recent:?}"); + }); +} diff --git a/pallets/gigahdx-rewards/src/tests/hooks.rs b/pallets/gigahdx-rewards/src/tests/hooks.rs new file mode 100644 index 0000000000..250a2477a7 --- /dev/null +++ b/pallets/gigahdx-rewards/src/tests/hooks.rs @@ -0,0 +1,624 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::pallet::{ + Event, PendingRewards, ReferendaRewardPool, ReferendaTotalWeightedVotes, ReferendumTracks, UserVoteRecords, +}; +use crate::types::REWARD_MULTIPLIER_SCALE; +use crate::voting_hooks::VotingHooksImpl; + +use frame_support::assert_ok; +use frame_system::RawOrigin; +use pallet_conviction_voting::{AccountVote, Conviction, Status, Vote, VotingHooks}; + +const REF_A: u32 = 7; +const REF_B: u32 = 9; + +fn standard_vote(aye: bool, conviction: Conviction, balance: u128) -> AccountVote { + AccountVote::Standard { + vote: Vote { aye, conviction }, + balance, + } +} + +fn stake(who: AccountId, amount: u128) { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(who).into(), amount)); +} + +#[test] +fn on_before_vote_should_skip_when_user_has_no_stake() { + ExtBuilder::default().build().execute_with(|| { + // ALICE has not staked → no record should be created. + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 100 * ONE), + )); + assert!(UserVoteRecords::::get(ALICE, REF_A).is_none()); + assert!(ReferendaTotalWeightedVotes::::get(REF_A).is_none()); + assert!(ReferendumTracks::::get(REF_A).is_none()); + }); +} + +#[test] +fn on_before_vote_should_skip_when_vote_is_split() { + ExtBuilder::default().build().execute_with(|| { + stake(ALICE, 100 * ONE); + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + AccountVote::Split { + aye: 40 * ONE, + nay: 60 * ONE, + }, + )); + assert!(UserVoteRecords::::get(ALICE, REF_A).is_none()); + assert!(ReferendaTotalWeightedVotes::::get(REF_A).is_none()); + }); +} + +#[test] +fn on_before_vote_should_skip_when_vote_is_split_abstain() { + ExtBuilder::default().build().execute_with(|| { + stake(ALICE, 100 * ONE); + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + AccountVote::SplitAbstain { + aye: 10 * ONE, + nay: 10 * ONE, + abstain: 80 * ONE, + }, + )); + assert!(UserVoteRecords::::get(ALICE, REF_A).is_none()); + assert!(ReferendaTotalWeightedVotes::::get(REF_A).is_none()); + }); +} + +#[test] +fn on_before_vote_should_clean_up_prior_record_when_downgrading_to_split() { + ExtBuilder::default().build().execute_with(|| { + stake(ALICE, 100 * ONE); + + // First cast a tracked Standard vote — record, freeze, and tally + // row all written. + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked6x, 50 * ONE), + )); + assert!(UserVoteRecords::::get(ALICE, REF_A).is_some()); + assert_eq!(stake_record(&ALICE).frozen, 50 * ONE); + let tally = ReferendaTotalWeightedVotes::::get(REF_A).unwrap(); + assert_eq!(tally.voters_count, 1); + assert!(tally.total_weighted > 0); + + // Downgrade to Split — the prior record, the freeze, and the tally + // row must all unwind so the user is not credited a reward share they + // no longer hold. + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + AccountVote::Split { + aye: 20 * ONE, + nay: 30 * ONE, + }, + )); + assert!(UserVoteRecords::::get(ALICE, REF_A).is_none()); + assert_eq!(stake_record(&ALICE).frozen, 0); + assert!(ReferendaTotalWeightedVotes::::get(REF_A).is_none()); + }); +} + +#[test] +fn on_before_vote_should_clean_up_prior_record_when_downgrading_to_split_abstain() { + ExtBuilder::default().build().execute_with(|| { + stake(ALICE, 100 * ONE); + + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked3x, 40 * ONE), + )); + assert!(UserVoteRecords::::get(ALICE, REF_A).is_some()); + assert_eq!(stake_record(&ALICE).frozen, 40 * ONE); + + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + AccountVote::SplitAbstain { + aye: 10 * ONE, + nay: 10 * ONE, + abstain: 20 * ONE, + }, + )); + assert!(UserVoteRecords::::get(ALICE, REF_A).is_none()); + assert_eq!(stake_record(&ALICE).frozen, 0); + assert!(ReferendaTotalWeightedVotes::::get(REF_A).is_none()); + }); +} + +#[test] +fn on_before_vote_should_cap_weighted_at_min_of_vote_and_stake() { + ExtBuilder::default().build().execute_with(|| { + stake(ALICE, 50 * ONE); + // vote.balance > stake.hdx ⇒ should be capped to 50 * ONE. + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 1_000 * ONE), + )); + let rec = UserVoteRecords::::get(ALICE, REF_A).unwrap(); + assert_eq!(rec.staked_vote_amount, 50 * ONE); + // Locked1x = 25 / 100 = 0.25× → weighted = 12.5 * ONE + assert_eq!(rec.weighted, 50 * ONE * 25 / REWARD_MULTIPLIER_SCALE); + }); +} + +#[test] +fn on_before_vote_should_replace_record_when_vote_is_edited() { + ExtBuilder::default().build().execute_with(|| { + stake(ALICE, 100 * ONE); + + // First vote: 30 HDX, Locked1x (0.25×) → weighted = 7.5 * ONE. + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 30 * ONE), + )); + let tally_1 = ReferendaTotalWeightedVotes::::get(REF_A).unwrap(); + assert_eq!(tally_1.voters_count, 1); + assert_eq!(tally_1.total_weighted, 30 * ONE * 25 / REWARD_MULTIPLIER_SCALE); + + // Edit: 80 HDX, Locked2x (0.5×) → weighted = 40 * ONE. + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked2x, 80 * ONE), + )); + let rec = UserVoteRecords::::get(ALICE, REF_A).unwrap(); + assert_eq!(rec.staked_vote_amount, 80 * ONE); + assert_eq!(rec.weighted, 80 * ONE * 50 / REWARD_MULTIPLIER_SCALE); + + let tally_2 = ReferendaTotalWeightedVotes::::get(REF_A).unwrap(); + assert_eq!(tally_2.voters_count, 1, "voter count unchanged on edit"); + assert_eq!(tally_2.total_weighted, 80 * ONE * 50 / REWARD_MULTIPLIER_SCALE); + }); +} + +#[test] +fn on_before_vote_should_increment_voter_count_only_for_new_records() { + ExtBuilder::default().build().execute_with(|| { + stake(ALICE, 100 * ONE); + stake(BOB, 100 * ONE); + + // First vote by alice — inc 1. + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 10 * ONE), + )); + assert_eq!(ReferendaTotalWeightedVotes::::get(REF_A).unwrap().voters_count, 1); + + // Edit by alice — inc 0. + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 20 * ONE), + )); + assert_eq!(ReferendaTotalWeightedVotes::::get(REF_A).unwrap().voters_count, 1); + + // New voter (bob) — inc 1. + assert_ok!(VotingHooksImpl::::on_before_vote( + &BOB, + REF_A, + standard_vote(false, Conviction::Locked1x, 15 * ONE), + )); + assert_eq!(ReferendaTotalWeightedVotes::::get(REF_A).unwrap().voters_count, 2); + }); +} + +#[test] +fn on_before_vote_should_freeze_stake_for_voted_amount() { + ExtBuilder::default().build().execute_with(|| { + stake(ALICE, 100 * ONE); + assert_eq!(stake_record(&ALICE).frozen, 0); + + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 60 * ONE), + )); + assert_eq!(stake_record(&ALICE).frozen, 60 * ONE); + + // Edit lowers vote → frozen recomputed (unfreeze old, freeze new). + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 20 * ONE), + )); + assert_eq!(stake_record(&ALICE).frozen, 20 * ONE); + }); +} + +#[test] +fn on_before_vote_should_cache_track_id_when_first_voter_arrives() { + ExtBuilder::default().build().execute_with(|| { + stake(ALICE, 100 * ONE); + assert!(ReferendumTracks::::get(REF_A).is_none()); + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 10 * ONE), + )); + assert_eq!(ReferendumTracks::::get(REF_A), Some(0u16)); + }); +} + +#[test] +fn on_remove_vote_should_drop_record_and_skip_reward_when_status_is_ongoing() { + ExtBuilder::default() + .with_accumulator(1_000 * ONE) + .build() + .execute_with(|| { + stake(ALICE, 100 * ONE); + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 50 * ONE), + )); + assert!(UserVoteRecords::::get(ALICE, REF_A).is_some()); + + VotingHooksImpl::::on_remove_vote(&ALICE, REF_A, Status::Ongoing); + + assert!(UserVoteRecords::::get(ALICE, REF_A).is_none()); + assert!(ReferendaRewardPool::::get(REF_A).is_none()); + assert_eq!(PendingRewards::::get(ALICE), 0); + }); +} + +#[test] +fn on_remove_vote_should_unfreeze_stake() { + ExtBuilder::default().build().execute_with(|| { + stake(ALICE, 100 * ONE); + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 70 * ONE), + )); + assert_eq!(stake_record(&ALICE).frozen, 70 * ONE); + + VotingHooksImpl::::on_remove_vote(&ALICE, REF_A, Status::Ongoing); + assert_eq!(stake_record(&ALICE).frozen, 0); + }); +} + +#[test] +fn on_remove_vote_should_decrement_voters_count_pre_allocation() { + ExtBuilder::default().build().execute_with(|| { + stake(ALICE, 100 * ONE); + stake(BOB, 100 * ONE); + + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 10 * ONE), + )); + assert_ok!(VotingHooksImpl::::on_before_vote( + &BOB, + REF_A, + standard_vote(true, Conviction::Locked1x, 20 * ONE), + )); + assert_eq!(ReferendaTotalWeightedVotes::::get(REF_A).unwrap().voters_count, 2); + + VotingHooksImpl::::on_remove_vote(&ALICE, REF_A, Status::Ongoing); + // One left. + assert_eq!(ReferendaTotalWeightedVotes::::get(REF_A).unwrap().voters_count, 1); + + VotingHooksImpl::::on_remove_vote(&BOB, REF_A, Status::Ongoing); + // Entry pruned when count reaches zero. + assert!(ReferendaTotalWeightedVotes::::get(REF_A).is_none()); + }); +} + +#[test] +fn on_remove_vote_should_allocate_pool_once_per_referendum() { + ExtBuilder::default() + .with_accumulator(1_000 * ONE) + .build() + .execute_with(|| { + stake(ALICE, 100 * ONE); + stake(BOB, 100 * ONE); + + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 40 * ONE), + )); + assert_ok!(VotingHooksImpl::::on_before_vote( + &BOB, + REF_A, + standard_vote(true, Conviction::Locked1x, 60 * ONE), + )); + + let acc_before = account_balance(&accumulator_pot()); + let alloc_before = account_balance(&allocated_pot()); + + // First Completed remove: transfer 10% of accumulator → allocated. + VotingHooksImpl::::on_remove_vote(&ALICE, REF_A, Status::Completed); + let pool = ReferendaRewardPool::::get(REF_A).expect("pool created"); + assert_eq!(pool.total_reward, 100 * ONE); // 10% of 1_000 * ONE + assert_eq!(pool.track_id, 0u16); + // Voter count snapshot: alice already deducted from live tally but + // re-added when populating the pool → 2. + assert_eq!(pool.voters_remaining, 1); // alice immediately decremented + let acc_after_first = account_balance(&accumulator_pot()); + let alloc_after_first = account_balance(&allocated_pot()); + assert_eq!(acc_before - acc_after_first, 100 * ONE); + assert_eq!(alloc_after_first - alloc_before, 100 * ONE); + + // Second Completed remove: no re-allocation; transfers should not happen. + VotingHooksImpl::::on_remove_vote(&BOB, REF_A, Status::Completed); + let acc_after_second = account_balance(&accumulator_pot()); + let alloc_after_second = account_balance(&allocated_pot()); + assert_eq!(acc_after_first, acc_after_second, "no further accumulator drain"); + // Allocated pot only shrinks via claim_rewards, not on_remove_vote. + assert_eq!(alloc_after_first, alloc_after_second); + + // Pool removed once last voter claimed. + assert!(ReferendaRewardPool::::get(REF_A).is_none()); + }); +} + +#[test] +fn on_remove_vote_should_record_user_reward_pro_rata() { + ExtBuilder::default() + .with_accumulator(1_000 * ONE) + .build() + .execute_with(|| { + // alice and bob with equal weighted; charlie 2x. + stake(ALICE, 100 * ONE); + stake(BOB, 100 * ONE); + stake(CHARLIE, 100 * ONE); + + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 10 * ONE), + )); + assert_ok!(VotingHooksImpl::::on_before_vote( + &BOB, + REF_A, + standard_vote(true, Conviction::Locked1x, 10 * ONE), + )); + assert_ok!(VotingHooksImpl::::on_before_vote( + &CHARLIE, + REF_A, + standard_vote(true, Conviction::Locked1x, 20 * ONE), + )); + // Total weighted = 10 + 10 + 20 = 40 * ONE. + + // total_reward = 10% of 1_000 * ONE = 100 * ONE. + VotingHooksImpl::::on_remove_vote(&ALICE, REF_A, Status::Completed); + // alice share = floor(10 * 100 / 40) = 25 * ONE. + assert_eq!(PendingRewards::::get(ALICE), 25 * ONE); + + VotingHooksImpl::::on_remove_vote(&BOB, REF_A, Status::Completed); + assert_eq!(PendingRewards::::get(BOB), 25 * ONE); + + // Charlie is the last → scoops remainder = 100 - 25 - 25 = 50 * ONE. + VotingHooksImpl::::on_remove_vote(&CHARLIE, REF_A, Status::Completed); + assert_eq!(PendingRewards::::get(CHARLIE), 50 * ONE); + }); +} + +#[test] +fn on_remove_vote_should_accumulate_pending_rewards_across_referenda() { + ExtBuilder::default() + .with_accumulator(1_000 * ONE) + .build() + .execute_with(|| { + stake(ALICE, 200 * ONE); + + // ref A: alice solo voter → scoops 100 * ONE. + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 10 * ONE), + )); + VotingHooksImpl::::on_remove_vote(&ALICE, REF_A, Status::Completed); + assert_eq!(PendingRewards::::get(ALICE), 100 * ONE); + + // Accumulator after first allocation: 1000 - 100 = 900 * ONE. + // ref B: alice solo voter → 10% of 900 = 90 * ONE. + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_B, + standard_vote(true, Conviction::Locked1x, 10 * ONE), + )); + VotingHooksImpl::::on_remove_vote(&ALICE, REF_B, Status::Completed); + assert_eq!(PendingRewards::::get(ALICE), 100 * ONE + 90 * ONE); + }); +} + +#[test] +fn on_remove_vote_should_recycle_rounding_dust_to_accumulator() { + ExtBuilder::default() + .with_accumulator(1_000 * ONE) + .build() + .execute_with(|| { + // 3 equal voters, weight=1 each, total_weighted=3. Allocation is + // 10% of 1_000 ONE = 100 ONE, so each share is floor(100 * ONE / 3) + // which leaves a 1-wei remainder after the last claimant. + stake(ALICE, 100 * ONE); + stake(BOB, 100 * ONE); + stake(CHARLIE, 100 * ONE); + + // Locked3x (1× weight) with 1-wei vote → weighted=1 each, total=3. + // 10% of 1_000 ONE allocated = 100 ONE; floor(100*ONE/3) per voter + // leaves 1 wei dust after the last claimant. + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked3x, 1), + )); + assert_ok!(VotingHooksImpl::::on_before_vote( + &BOB, + REF_A, + standard_vote(true, Conviction::Locked3x, 1), + )); + assert_ok!(VotingHooksImpl::::on_before_vote( + &CHARLIE, + REF_A, + standard_vote(true, Conviction::Locked3x, 1), + )); + + // First `on_remove_vote(Completed)` triggers the allocation pull + // from the accumulator pot into the allocated pot. + VotingHooksImpl::::on_remove_vote(&ALICE, REF_A, Status::Completed); + let accumulator_after_alloc = account_balance(&accumulator_pot()); + assert_eq!(account_balance(&allocated_pot()), 100 * ONE); + + VotingHooksImpl::::on_remove_vote(&BOB, REF_A, Status::Completed); + VotingHooksImpl::::on_remove_vote(&CHARLIE, REF_A, Status::Completed); + + // All three voters get the same pro-rata share — no scoop bonus. + let alice = PendingRewards::::get(ALICE); + let bob = PendingRewards::::get(BOB); + let charlie = PendingRewards::::get(CHARLIE); + assert_eq!(alice, bob); + assert_eq!(bob, charlie); + + let sum = alice + bob + charlie; + let dust = (100 * ONE) - sum; + assert!(dust > 0 && dust < 3, "expected 1-wei dust, got {dust}"); + + // Dust transferred from allocated pot back to accumulator pot + // rather than awarded to the last claimant. + assert_eq!(account_balance(&allocated_pot()), sum); + assert_eq!( + account_balance(&accumulator_pot()), + accumulator_after_alloc + dust, + "dust returned to accumulator pot" + ); + }); +} + +#[test] +fn on_remove_vote_should_credit_zero_to_zero_weighted_voter() { + ExtBuilder::default() + .with_accumulator(1_000 * ONE) + .build() + .execute_with(|| { + stake(ALICE, 100 * ONE); + stake(BOB, 100 * ONE); + + // ALICE: real weight (Locked6x, 10 ONE → weighted = 60 ONE). + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked6x, 10 * ONE), + )); + // BOB: vote_balance × multiplier / scale floors to 0 (smallest + // possible vote × None-conviction). With our reward multiplier + // scale this corresponds to a sub-scale vote balance. + assert_ok!(VotingHooksImpl::::on_before_vote( + &BOB, + REF_A, + standard_vote(true, Conviction::None, 1), + )); + let bob_rec = UserVoteRecords::::get(BOB, REF_A).unwrap(); + assert_eq!(bob_rec.weighted, 0, "must be a zero-weighted vote"); + + // Allocate + claim. Even if BOB is the last claimant, the audit + // exploit is to scoop the full pool — with the fix he should get + // exactly zero. + VotingHooksImpl::::on_remove_vote(&ALICE, REF_A, Status::Completed); + VotingHooksImpl::::on_remove_vote(&BOB, REF_A, Status::Completed); + + assert_eq!(PendingRewards::::get(BOB), 0, "zero-weighted voter gets zero"); + assert!(PendingRewards::::get(ALICE) > 0); + }); +} + +#[test] +fn on_remove_vote_should_delete_pool_when_last_voter_claims() { + ExtBuilder::default() + .with_accumulator(1_000 * ONE) + .build() + .execute_with(|| { + stake(ALICE, 100 * ONE); + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 10 * ONE), + )); + VotingHooksImpl::::on_remove_vote(&ALICE, REF_A, Status::Completed); + assert!(ReferendaRewardPool::::get(REF_A).is_none()); + }); +} + +#[test] +fn on_remove_vote_should_still_cleanup_when_allocation_was_zero() { + // Empty accumulator: pool inserted with total_reward = 0; last voter triggers cleanup. + ExtBuilder::default().build().execute_with(|| { + stake(ALICE, 100 * ONE); + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 10 * ONE), + )); + assert_eq!(account_balance(&accumulator_pot()), 0); + + VotingHooksImpl::::on_remove_vote(&ALICE, REF_A, Status::Completed); + + // Pool cleaned up; no pending reward issued. + assert!(ReferendaRewardPool::::get(REF_A).is_none()); + assert_eq!(PendingRewards::::get(ALICE), 0); + // Allocated pot received nothing. + assert_eq!(account_balance(&allocated_pot()), 0); + }); +} + +#[test] +fn on_remove_vote_should_delete_track_cache_at_allocation() { + ExtBuilder::default() + .with_accumulator(1_000 * ONE) + .build() + .execute_with(|| { + stake(ALICE, 100 * ONE); + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 10 * ONE), + )); + assert_eq!(ReferendumTracks::::get(REF_A), Some(0u16)); + + VotingHooksImpl::::on_remove_vote(&ALICE, REF_A, Status::Completed); + assert!(ReferendumTracks::::get(REF_A).is_none()); + }); +} + +/// Sanity: events should include `RewardPoolAllocated` on first completed +/// remove. Kept lightweight — used during debugging only. +#[test] +fn on_remove_vote_should_emit_pool_allocated_event() { + ExtBuilder::default() + .with_accumulator(1_000 * ONE) + .build() + .execute_with(|| { + stake(ALICE, 100 * ONE); + assert_ok!(VotingHooksImpl::::on_before_vote( + &ALICE, + REF_A, + standard_vote(true, Conviction::Locked1x, 10 * ONE), + )); + let _ = last_events(0); // touch helper + VotingHooksImpl::::on_remove_vote(&ALICE, REF_A, Status::Completed); + let recent = last_events(10); + assert!(recent.iter().any(|e| matches!( + e, + RuntimeEvent::GigaHdxRewards(Event::RewardPoolAllocated { ref_index, .. }) if *ref_index == REF_A + ))); + }); +} diff --git a/pallets/gigahdx-rewards/src/tests/mock.rs b/pallets/gigahdx-rewards/src/tests/mock.rs new file mode 100644 index 0000000000..f7fcd2cecd --- /dev/null +++ b/pallets/gigahdx-rewards/src/tests/mock.rs @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(test)] + +use crate as pallet_gigahdx_rewards; +use crate::traits::{ReferendaTrackInspect, TrackRewardTable}; +use crate::types::ReferendumIndex; + +use frame_support::sp_runtime::{ + traits::{AccountIdConversion, BlakeTwo256, IdentityLookup}, + BuildStorage, DispatchError, Permill, +}; +use frame_support::{ + construct_runtime, parameter_types, + traits::{ConstU32, ConstU64, Everything, LockIdentifier}, + PalletId, +}; +use frame_system::EnsureRoot; +use hydradx_traits::gigahdx::MoneyMarketOperations; +use orml_traits::parameter_type_with_key; +use primitives::{AssetId, Balance}; +use sp_core::H256; +use std::cell::RefCell; +use std::collections::HashMap; + +// 16-byte AccountId so `PalletId::into_sub_account_truncating` produces a +// pot distinct from the parent (the first 8 bytes would otherwise collide). +pub type AccountId = u128; +type Block = frame_system::mocking::MockBlock; + +#[allow(dead_code)] +pub const HDX: AssetId = 0; +pub const ST_HDX: AssetId = 670; +pub const ONE: Balance = 1_000_000_000_000; + +pub const ALICE: AccountId = 1; +pub const BOB: AccountId = 2; +pub const CHARLIE: AccountId = 3; +#[allow(dead_code)] +pub const TREASURY: AccountId = 99; + +pub const GIGAHDX_LOCK_ID: LockIdentifier = *b"ghdxlock"; + +construct_runtime!( + pub enum Test { + System: frame_system, + Balances: pallet_balances, + Tokens: orml_tokens, + GigaHdx: pallet_gigahdx, + GigaHdxRewards: pallet_gigahdx_rewards, + } +); + +impl frame_system::Config for Test { + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type RuntimeTask = RuntimeTask; + type Nonce = u64; + type Block = Block; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = ConstU64<250>; + type DbWeight = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; + type SingleBlockMigrations = (); + type MultiBlockMigrator = (); + type PreInherents = (); + type PostInherents = (); + type PostTransactions = (); + type ExtensionsWeightInfo = (); +} + +parameter_types! { + pub const ExistentialDeposit: Balance = 1; + pub const MaxLocks: u32 = 20; +} + +impl pallet_balances::Config for Test { + type Balance = Balance; + type DustRemoval = (); + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); + type MaxLocks = MaxLocks; + type MaxReserves = ConstU32<50>; + type ReserveIdentifier = [u8; 8]; + type FreezeIdentifier = (); + type MaxFreezes = (); + type RuntimeHoldReason = (); + type RuntimeFreezeReason = (); + type DoneSlashHandler = (); +} + +parameter_type_with_key! { + pub StHdxExistentialDeposits: |_currency_id: AssetId| -> Balance { + 0 + }; +} + +impl orml_tokens::Config for Test { + type Balance = Balance; + type Amount = i128; + type CurrencyId = AssetId; + type WeightInfo = (); + type ExistentialDeposits = StHdxExistentialDeposits; + type CurrencyHooks = (); + type MaxLocks = ConstU32<10>; + type MaxReserves = (); + type ReserveIdentifier = (); + type DustRemovalWhitelist = Everything; +} + +// ---------- TestMoneyMarket ---------- + +thread_local! { + pub static MM_BALANCES: RefCell> = RefCell::new(HashMap::new()); + pub static MM_SUPPLY_ROUND_NUM: RefCell = const { RefCell::new(1) }; + pub static MM_SUPPLY_ROUND_DEN: RefCell = const { RefCell::new(1) }; + pub static MM_SUPPLY_FAILS: RefCell = const { RefCell::new(false) }; + pub static MM_WITHDRAW_FAILS: RefCell = const { RefCell::new(false) }; +} + +pub struct TestMoneyMarket; + +impl TestMoneyMarket { + pub fn reset() { + MM_BALANCES.with(|m| m.borrow_mut().clear()); + MM_SUPPLY_ROUND_NUM.with(|v| *v.borrow_mut() = 1); + MM_SUPPLY_ROUND_DEN.with(|v| *v.borrow_mut() = 1); + MM_SUPPLY_FAILS.with(|v| *v.borrow_mut() = false); + MM_WITHDRAW_FAILS.with(|v| *v.borrow_mut() = false); + } + #[allow(dead_code)] + pub fn set_supply_rounding(num: u128, den: u128) { + MM_SUPPLY_ROUND_NUM.with(|v| *v.borrow_mut() = num); + MM_SUPPLY_ROUND_DEN.with(|v| *v.borrow_mut() = den); + } + pub fn fail_supply() { + MM_SUPPLY_FAILS.with(|v| *v.borrow_mut() = true); + } + #[allow(dead_code)] + pub fn fail_withdraw() { + MM_WITHDRAW_FAILS.with(|v| *v.borrow_mut() = true); + } + #[allow(dead_code)] + pub fn balance_of(who: &AccountId) -> Balance { + MM_BALANCES.with(|m| *m.borrow().get(who).unwrap_or(&0)) + } +} + +impl MoneyMarketOperations for TestMoneyMarket { + fn supply(who: &AccountId, _asset: AssetId, amount: Balance) -> Result { + if MM_SUPPLY_FAILS.with(|v| *v.borrow()) { + return Err(DispatchError::Other("MM supply failed")); + } + let num = MM_SUPPLY_ROUND_NUM.with(|v| *v.borrow()); + let den = MM_SUPPLY_ROUND_DEN.with(|v| *v.borrow()); + let actual = amount.saturating_mul(num) / den; + MM_BALANCES.with(|m| *m.borrow_mut().entry(*who).or_default() += actual); + Ok(actual) + } + + fn withdraw(who: &AccountId, _asset: AssetId, amount: Balance) -> Result { + if MM_WITHDRAW_FAILS.with(|v| *v.borrow()) { + return Err(DispatchError::Other("MM withdraw failed")); + } + MM_BALANCES.with(|m| { + let mut map = m.borrow_mut(); + let bal = map.entry(*who).or_default(); + *bal = bal.saturating_sub(amount); + }); + Ok(amount) + } + + fn balance_of(who: &AccountId) -> Balance { + MM_BALANCES.with(|m| *m.borrow().get(who).unwrap_or(&0)) + } +} + +// ---------- TestExternalClaims ---------- + +thread_local! { + pub static EXTERNAL_CLAIMS: RefCell = const { RefCell::new(0) }; +} + +pub struct TestExternalClaims; + +impl TestExternalClaims { + #[allow(dead_code)] + pub fn set(value: Balance) { + EXTERNAL_CLAIMS.with(|v| *v.borrow_mut() = value); + } + + #[allow(dead_code)] + pub fn reset() { + EXTERNAL_CLAIMS.with(|v| *v.borrow_mut() = 0); + } +} + +impl pallet_gigahdx::traits::ExternalClaims for TestExternalClaims { + fn on(_who: &AccountId) -> Balance { + EXTERNAL_CLAIMS.with(|v| *v.borrow()) + } +} + +// ---------- pallet-gigahdx config ---------- + +parameter_types! { + pub const StHdxAssetIdConst: AssetId = ST_HDX; + pub const GigaHdxPalletId: PalletId = PalletId(*b"gigahdx!"); + pub const GigaHdxLockId: LockIdentifier = GIGAHDX_LOCK_ID; + pub const GigaHdxMinStake: Balance = ONE; // 1 HDX + pub const GigaHdxCooldownPeriod: u64 = 100; + pub const GigaHdxMaxPendingUnstakes: u32 = 10; +} + +impl pallet_gigahdx::Config for Test { + type NativeCurrency = Balances; + type MultiCurrency = Tokens; + type StHdxAssetId = StHdxAssetIdConst; + type MoneyMarket = TestMoneyMarket; + type AuthorityOrigin = EnsureRoot; + type PalletId = GigaHdxPalletId; + type LockId = GigaHdxLockId; + type MinStake = GigaHdxMinStake; + type CooldownPeriod = GigaHdxCooldownPeriod; + type MaxPendingUnstakes = GigaHdxMaxPendingUnstakes; + type ExternalClaims = TestExternalClaims; + type LegacyStaking = (); + type WeightInfo = (); + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = (); +} + +// ---------- pallet-gigahdx-rewards config ---------- + +parameter_types! { + // AccountId in this mock is u128 (16 bytes) so sub-account derivation + // produces a pot distinct from the accumulator. Use a prefix that does + // not collide with gigahdx (`b"giga..."`). + pub const RewardPotPalletId: PalletId = PalletId(*b"rwd!ghdx"); +} + +pub struct TestReferendaTrackInspect; +impl ReferendaTrackInspect for TestReferendaTrackInspect { + fn track_of(_ref_index: ReferendumIndex) -> Option { + Some(0u16) + } +} + +pub struct TestTrackRewardConfig; +impl TrackRewardTable for TestTrackRewardConfig { + fn reward_percentage(_track_id: u16) -> Permill { + Permill::from_percent(10) + } +} + +impl pallet_gigahdx_rewards::Config for Test { + type TrackId = u16; + type Referenda = TestReferendaTrackInspect; + type TrackRewardConfig = TestTrackRewardConfig; + type RewardPotPalletId = RewardPotPalletId; + type WeightInfo = (); +} + +// ---------- helpers ---------- + +pub fn accumulator_pot() -> AccountId { + pallet_gigahdx_rewards::Pallet::::reward_accumulator_pot() +} + +pub fn allocated_pot() -> AccountId { + pallet_gigahdx_rewards::Pallet::::allocated_rewards_pot() +} + +#[allow(dead_code)] +pub fn gigapot() -> AccountId { + GigaHdxPalletId::get().into_account_truncating() +} + +/// Mint HDX into the accumulator pot at runtime. +#[allow(dead_code)] +pub fn fund_accumulator(amount: Balance) { + use frame_support::traits::Currency; + let _ = >::deposit_creating(&accumulator_pot(), amount); +} + +pub fn account_balance(who: &AccountId) -> Balance { + use frame_support::traits::Currency; + >::free_balance(who) +} + +/// Convenience getter for a Stake record; returns a default record if absent. +pub fn stake_record(who: &AccountId) -> pallet_gigahdx::pallet::StakeRecord { + pallet_gigahdx::Stakes::::get(who).unwrap_or_default() +} + +/// Drain all `frame_system::events()` and return them. +pub fn last_events(n: usize) -> Vec { + let evs: Vec = frame_system::Pallet::::events() + .into_iter() + .map(|e| e.event) + .collect(); + let len = evs.len(); + evs.into_iter().skip(len.saturating_sub(n)).collect() +} + +// ---------- Test ext builder ---------- + +pub struct ExtBuilder { + endowed_accounts: Vec<(AccountId, Balance)>, + pot_balance: Balance, + pre_fund_accumulator: Option, +} + +impl Default for ExtBuilder { + fn default() -> Self { + Self { + endowed_accounts: vec![ + (ALICE, 1_000 * ONE), + (BOB, 1_000 * ONE), + (CHARLIE, 1_000 * ONE), + (TREASURY, 1_000 * ONE), + ], + pot_balance: 0, + pre_fund_accumulator: None, + } + } +} + +impl ExtBuilder { + #[allow(dead_code)] + pub fn with_pot_balance(mut self, balance: Balance) -> Self { + self.pot_balance = balance; + self + } + + pub fn with_accumulator(mut self, balance: Balance) -> Self { + self.pre_fund_accumulator = Some(balance); + self + } + + pub fn build(self) -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + + let mut balances = self.endowed_accounts.clone(); + if self.pot_balance > 0 { + balances.push((gigapot(), self.pot_balance)); + } + if let Some(amt) = self.pre_fund_accumulator { + if amt > 0 { + balances.push((accumulator_pot(), amt)); + } + } + pallet_balances::GenesisConfig:: { + balances, + dev_accounts: None, + } + .assimilate_storage(&mut t) + .unwrap(); + + let mut ext: sp_io::TestExternalities = t.into(); + ext.execute_with(|| { + TestMoneyMarket::reset(); + System::set_block_number(1); + }); + ext + } +} diff --git a/pallets/gigahdx-rewards/src/tests/mod.rs b/pallets/gigahdx-rewards/src/tests/mod.rs new file mode 100644 index 0000000000..b8f3abda18 --- /dev/null +++ b/pallets/gigahdx-rewards/src/tests/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: Apache-2.0 + +mod claim; +mod hooks; +mod mock; diff --git a/pallets/gigahdx-rewards/src/traits.rs b/pallets/gigahdx-rewards/src/traits.rs new file mode 100644 index 0000000000..c91bb07706 --- /dev/null +++ b/pallets/gigahdx-rewards/src/traits.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Traits used by `pallet-gigahdx-rewards` to abstract over the runtime's +//! referenda configuration and the per-track reward percentage table. + +use sp_runtime::Permill; + +/// Lookup of referendum index → track id. +pub trait ReferendaTrackInspect { + /// Track id for an ongoing or recently completed referendum. Returns + /// `None` if the referendum index is unknown (never existed) or the + /// entry has already been pruned. In the latter case the pallet falls + /// back to its cached `ReferendumTracks[ref_index]`. + fn track_of(ref_index: RefIdx) -> Option; +} + +/// Per-track reward percentage. Implementations should be `const`-y — +/// the function is called inside `on_remove_vote`, which must be cheap. +pub trait TrackRewardTable { + /// Fraction of the accumulator pot to allocate to a completed + /// referendum on this track. Returning `Permill::zero()` is a valid + /// way to opt a track out of rewards entirely. + fn reward_percentage(track_id: TrackId) -> Permill; +} diff --git a/pallets/gigahdx-rewards/src/types.rs b/pallets/gigahdx-rewards/src/types.rs new file mode 100644 index 0000000000..fc05970d5d --- /dev/null +++ b/pallets/gigahdx-rewards/src/types.rs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Storage value types for `pallet-gigahdx-rewards`. + +use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use pallet_conviction_voting::Conviction; +use primitives::Balance; +use scale_info::TypeInfo; + +/// Mirrors `pallet_referenda::ReferendumIndex` (both are `u32`). Declared +/// here so the rewards pallet does not need a direct dependency on +/// `pallet-referenda` — only the runtime wiring does. +pub type ReferendumIndex = u32; + +/// Scale for `conviction_reward_multiplier`. Numerators read as percentages. +pub const REWARD_MULTIPLIER_SCALE: u128 = 100; + +/// Map conviction → reward weight (percentage of `Locked3x = base`). +/// `Locked3x` (28-day lock) is the unit; shorter locks earn fractions, +/// longer locks earn multiples. `None` earns nothing — voters that don't +/// commit to a lock period don't receive gigahdx-rewards. +/// +/// | Conviction | Days lock | Multiplier | +/// |------------|-----------|-----------| +/// | None | 0 | 0× | +/// | Locked1x | 7 | 0.25× | +/// | Locked2x | 14 | 0.5× | +/// | Locked3x | 28 | 1× (base) | +/// | Locked4x | 56 | 2× | +/// | Locked5x | 112 | 4× | +/// | Locked6x | 224 | 8× | +pub fn conviction_reward_multiplier(conviction: Conviction) -> u128 { + match conviction { + Conviction::None => 0, + Conviction::Locked1x => 25, + Conviction::Locked2x => 50, + Conviction::Locked3x => 100, + Conviction::Locked4x => 200, + Conviction::Locked5x => 400, + Conviction::Locked6x => 800, + } +} + +/// Live tally maintained during the voting period for each referendum. +/// Deleted at allocation time; the values move into `ReferendaReward`. +#[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, Default)] +pub struct ReferendumLiveTally { + /// Σ `weighted` over all currently-active `UserVoteRecord`s for this + /// referendum. + pub total_weighted: u128, + /// |{ who : UserVoteRecords[who, ref].is_some() }|. Snapshotted into + /// `ReferendaReward.voters_remaining` at allocation. + pub voters_count: u32, +} + +/// Frozen per-referendum snapshot, populated on first `on_remove_vote` of +/// a completed referendum. Presence doubles as the "allocation has run" +/// idempotency signal. Deleted when `voters_remaining` reaches zero. +#[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug)] +pub struct ReferendaReward { + pub track_id: TrackId, + /// Allocation snapshot (HDX). + pub total_reward: Balance, + /// Frozen denominator for pro-rata math. + pub total_weighted_votes: u128, + /// Countdown of voters who still hold a `UserVoteRecord` for this + /// referendum. When this reaches zero on a per-user payout, the last + /// claimant scoops `remaining_reward` and the pool entry is deleted. + pub voters_remaining: u32, + /// Decremented on each per-user payout. Equals `total_reward` at + /// allocation; drained to exactly zero by the final claimant. + pub remaining_reward: Balance, +} + +/// Per (user, referendum) snapshot of the eligible vote weight at cast time. +#[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug)] +pub struct UserVoteRecord { + /// `min(vote.balance(), Stakes[who].hdx)` at the moment the vote was cast. + pub staked_vote_amount: Balance, + /// Conviction at cast time. Stored for off-chain attribution; not read + /// during reward math (the multiplier is already baked into `weighted`). + pub conviction: Conviction, + /// `staked_vote_amount × conviction_reward_multiplier / REWARD_MULTIPLIER_SCALE`. + pub weighted: u128, +} diff --git a/pallets/gigahdx-rewards/src/voting_hooks.rs b/pallets/gigahdx-rewards/src/voting_hooks.rs new file mode 100644 index 0000000000..6b51a7df75 --- /dev/null +++ b/pallets/gigahdx-rewards/src/voting_hooks.rs @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `VotingHooks` integration for `pallet-gigahdx-rewards`. +//! +//! [`VotingHooksImpl`] is the pallet's own `VotingHooks` implementation: +//! it snapshots eligible votes into storage and freezes the corresponding +//! gigahdx stake. The runtime is responsible for combining this hook with +//! any other `VotingHooks` consumer (typically staking) when wiring +//! `pallet-conviction-voting::Config::VotingHooks`. + +use crate::pallet::{ + Config, Event, Pallet, ReferendaRewardPool, ReferendaTotalWeightedVotes, ReferendumTracks, UserVoteRecords, +}; +use crate::traits::{ReferendaTrackInspect, TrackRewardTable}; +use crate::types::{ReferendaReward, ReferendumIndex, ReferendumLiveTally, UserVoteRecord}; +use frame_support::dispatch::DispatchResult; +use frame_support::traits::{Currency, ExistenceRequirement}; +use pallet_conviction_voting::{AccountVote, Status, VotingHooks}; +use primitives::Balance; +use sp_std::marker::PhantomData; + +/// `VotingHooks` impl for this pallet. The runtime wires this through a +/// tuple adapter alongside any other consumer (typically staking). +pub struct VotingHooksImpl(PhantomData); + +impl VotingHooks for VotingHooksImpl { + fn on_before_vote(who: &T::AccountId, ref_index: ReferendumIndex, vote: AccountVote) -> DispatchResult { + // Voting hooks must never block voting — saturating arithmetic and + // `Ok(())` on every defensive branch. + let staked = pallet_gigahdx::Stakes::::get(who).map(|s| s.hdx).unwrap_or(0); + if staked == 0 { + return Ok(()); + } + + // Standard votes only; Split / SplitAbstain have multiple sub-balances + // without a single principled answer for the eligible amount. + // Downgrade from a tracked Standard vote: drop the prior record so + // the user's freeze and weighted share don't outlive the vote. + let (vote_balance, conviction) = match vote { + AccountVote::Standard { + vote: std_vote, + balance, + } => (balance, std_vote.conviction), + _ => { + if let Some(prev) = UserVoteRecords::::take(who, ref_index) { + pallet_gigahdx::Pallet::::unfreeze(who, prev.staked_vote_amount); + if !ReferendaRewardPool::::contains_key(ref_index) { + ReferendaTotalWeightedVotes::::mutate_exists(ref_index, |maybe| { + if let Some(tally) = maybe.as_mut() { + tally.total_weighted = tally.total_weighted.saturating_sub(prev.weighted); + tally.voters_count = tally.voters_count.saturating_sub(1); + if tally.voters_count == 0 { + *maybe = None; + } + } + }); + } + } + return Ok(()); + } + }; + + let staked_vote = vote_balance.min(staked); + let weighted = Pallet::::weighted(staked_vote, conviction); + let new_record = UserVoteRecord { + staked_vote_amount: staked_vote, + conviction, + weighted, + }; + + // Already-allocated refs cannot accept new votes (referendum is past + // Completed), but defensive: cache the track id only while the live + // tally is still active. + let live_tally_active = !ReferendaRewardPool::::contains_key(ref_index); + if live_tally_active && !ReferendumTracks::::contains_key(ref_index) { + if let Some(track) = T::Referenda::track_of(ref_index) { + ReferendumTracks::::insert(ref_index, track); + } + } + + // Diff against any previous record for (who, ref). + let prev = UserVoteRecords::::get(who, ref_index); + match prev { + Some(prev) => { + // Edit: unfreeze old, freeze new; voter count unchanged. + pallet_gigahdx::Pallet::::unfreeze(who, prev.staked_vote_amount); + if live_tally_active { + ReferendaTotalWeightedVotes::::mutate_exists(ref_index, |maybe| { + let tally = maybe.get_or_insert_with(ReferendumLiveTally::default); + tally.total_weighted = tally.total_weighted.saturating_sub(prev.weighted); + tally.total_weighted = tally.total_weighted.saturating_add(weighted); + }); + } + } + None => { + // New record: increment voter count. + if live_tally_active { + ReferendaTotalWeightedVotes::::mutate_exists(ref_index, |maybe| { + let tally = maybe.get_or_insert_with(ReferendumLiveTally::default); + tally.total_weighted = tally.total_weighted.saturating_add(weighted); + tally.voters_count = tally.voters_count.saturating_add(1); + }); + } + } + } + UserVoteRecords::::insert(who, ref_index, new_record); + pallet_gigahdx::Pallet::::freeze(who, staked_vote); + + Ok(()) + } + + fn on_remove_vote(who: &T::AccountId, ref_index: ReferendumIndex, status: Status) { + let Some(record) = UserVoteRecords::::take(who, ref_index) else { + return; // no eligible vote was tracked + }; + pallet_gigahdx::Pallet::::unfreeze(who, record.staked_vote_amount); + + // Maintain the live tally only while the ref is still pre-allocation. + // Pool presence = "allocation has run" idempotency signal. + if !ReferendaRewardPool::::contains_key(ref_index) { + ReferendaTotalWeightedVotes::::mutate_exists(ref_index, |maybe| { + if let Some(tally) = maybe.as_mut() { + tally.total_weighted = tally.total_weighted.saturating_sub(record.weighted); + tally.voters_count = tally.voters_count.saturating_sub(1); + if tally.voters_count == 0 { + *maybe = None; + } + } + }); + } + + if !matches!(status, Status::Completed) { + return; + } + + let _ = maybe_allocate_and_record::(who, ref_index, &record); + } + + fn lock_balance_on_unsuccessful_vote(_who: &T::AccountId, _ref_index: ReferendumIndex) -> Option { + // Rewards never locks user balance — it operates on the `frozen` + // field of the gigahdx stake record. Letting the tuple's `or` + // fallback pass through whatever the other hook (staking) says. + None + } + + #[cfg(feature = "runtime-benchmarks")] + fn on_vote_worst_case(_who: &T::AccountId) {} + + #[cfg(feature = "runtime-benchmarks")] + fn on_remove_vote_worst_case(_who: &T::AccountId) {} +} + +/// Allocate the pool on first call for a completed referendum, then credit +/// the caller's per-user share. Idempotent on the allocation step (subsequent +/// callers see `ReferendaRewardPool[ref_index]` populated and skip). +fn maybe_allocate_and_record( + who: &T::AccountId, + ref_index: ReferendumIndex, + record: &UserVoteRecord, +) -> Result<(), frame_support::sp_runtime::DispatchError> { + if !ReferendaRewardPool::::contains_key(ref_index) { + let track_id = ReferendumTracks::::get(ref_index).or_else(|| T::Referenda::track_of(ref_index)); + let Some(track_id) = track_id else { + // No track resolvable — nothing to allocate. Drop silently + // (this voter forfeits any reward), but keep the live tally + // intact for any other voters who might still complete. + return Ok(()); + }; + + let pct = T::TrackRewardConfig::reward_percentage(track_id); + let pot_balance = + ::NativeCurrency::free_balance(&Pallet::::reward_accumulator_pot()); + let allocation: Balance = pct * pot_balance; + + if allocation > 0 { + ::NativeCurrency::transfer( + &Pallet::::reward_accumulator_pot(), + &Pallet::::allocated_rewards_pot(), + allocation, + ExistenceRequirement::AllowDeath, + )?; + } + + // Re-add the caller's contribution to the snapshot — `on_remove_vote` + // already subtracted it from the live tally before delegating here. + let live = ReferendaTotalWeightedVotes::::get(ref_index).unwrap_or_default(); + let total_weighted = live.total_weighted.saturating_add(record.weighted); + let voters_remaining = live.voters_count.saturating_add(1); + + ReferendaRewardPool::::insert( + ref_index, + ReferendaReward { + track_id, + total_reward: allocation, + total_weighted_votes: total_weighted, + voters_remaining, + remaining_reward: allocation, + }, + ); + + ReferendaTotalWeightedVotes::::remove(ref_index); + ReferendumTracks::::remove(ref_index); + + Pallet::::deposit_event(Event::::RewardPoolAllocated { + ref_index, + track_id, + total_reward: allocation, + total_weighted_votes: total_weighted, + voters_remaining, + }); + } + + Pallet::::record_user_reward(who, ref_index, record)?; + Ok(()) +} diff --git a/pallets/gigahdx-rewards/src/weights.rs b/pallets/gigahdx-rewards/src/weights.rs new file mode 100644 index 0000000000..d6312b3f79 --- /dev/null +++ b/pallets/gigahdx-rewards/src/weights.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Placeholder weights for `pallet-gigahdx-rewards`. Replaced by +//! `cargo run --release --features runtime-benchmarks -- benchmark pallet ...` +//! during runtime upgrades. +//! +//! Magnitude tracks `pallet-gigahdx::giga_stake` (the compound path inside +//! `claim_rewards` is essentially a `do_stake` call plus one extra +//! `PendingRewards` write and one HDX pot → user transfer). + +use frame_support::weights::{constants::RocksDbWeight, Weight}; + +pub trait WeightInfo { + fn claim_rewards() -> Weight; +} + +impl WeightInfo for () { + fn claim_rewards() -> Weight { + // Ballpark: giga_stake (~122ms, 13 reads, 5 writes) + PendingRewards take + // (1 read, 1 write) + System::Account transfer (1 read, 1 write). + Weight::from_parts(140_000_000, 4764) + .saturating_add(RocksDbWeight::get().reads(15_u64)) + .saturating_add(RocksDbWeight::get().writes(7_u64)) + } +} diff --git a/pallets/gigahdx/Cargo.toml b/pallets/gigahdx/Cargo.toml new file mode 100644 index 0000000000..e791786f16 --- /dev/null +++ b/pallets/gigahdx/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "pallet-gigahdx" +version = "0.1.1" +description = "Liquid-staking primitive on top of an EVM money market." +authors = ["GalacticCouncil"] +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/galacticcouncil/hydration-node" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { workspace = true } +scale-info = { workspace = true } +log = { workspace = true } + +# Substrate +frame-support = { workspace = true } +frame-system = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } +sp-core = { workspace = true } +sp-io = { workspace = true } + +# Local +hydra-dx-math = { workspace = true } +hydradx-traits = { workspace = true } +primitives = { workspace = true } + +frame-benchmarking = { workspace = true, optional = true } + +[dev-dependencies] +sp-io = { workspace = true, features = ["std"] } +sp-core = { workspace = true, features = ["std"] } +sp-runtime = { workspace = true, features = ["std"] } +pallet-balances = { workspace = true, features = ["std"] } +orml-traits = { workspace = true, features = ["std"] } +orml-tokens = { workspace = true, features = ["std"] } +proptest = { workspace = true } + +[features] +default = ["std"] +std = [ + "codec/std", + "scale-info/std", + "frame-support/std", + "frame-system/std", + "sp-runtime/std", + "sp-std/std", + "sp-core/std", + "sp-io/std", + "frame-benchmarking?/std", + "hydra-dx-math/std", + "hydradx-traits/std", + "primitives/std", +] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", +] +try-runtime = ["frame-support/try-runtime"] diff --git a/pallets/gigahdx/proptest-regressions/tests/invariants.txt b/pallets/gigahdx/proptest-regressions/tests/invariants.txt new file mode 100644 index 0000000000..b75897bfc2 --- /dev/null +++ b/pallets/gigahdx/proptest-regressions/tests/invariants.txt @@ -0,0 +1,9 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 5c5f24cc46aaf315e4c96c8576ab2e68d2324771990ecc6f0f73b4d91abde09a # shrinks to ops = [AccrueYield { amount: 30376718729381 }, Stake { who: 0, amount: 1000000595531 }, Stake { who: 1, amount: 1000035632141 }] +cc 009af515f5b09355d2e4eb70cdb893133d64ac2173877b500c142651be1f9d61 # shrinks to ops = [AccrueYield { amount: 1000000000000 }, Stake { who: 1, amount: 1000000000001 }, Stake { who: 0, amount: 1000000000000 }, RealizeYield { who: 0 }] +cc ad55fb586f84e4dcf373f15108cbc59aa095517eba93aad82c0140489a351039 # shrinks to prefix = [AccrueYield { amount: 1000000000000 }, Stake { who: 1, amount: 1000000000000 }, RealizeYield { who: 1 }], seed = 1000000000001 diff --git a/pallets/gigahdx/src/benchmarking.rs b/pallets/gigahdx/src/benchmarking.rs new file mode 100644 index 0000000000..d93b9ee0da --- /dev/null +++ b/pallets/gigahdx/src/benchmarking.rs @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use frame_benchmarking::v2::*; +use frame_support::assert_ok; +use frame_support::sp_runtime::traits::Saturating; +use frame_support::traits::{Currency, Get}; +use frame_system::pallet_prelude::BlockNumberFor; +use frame_system::RawOrigin; +use primitives::{Balance, EvmAddress}; + +const ONE: Balance = 1_000_000_000_000; + +#[benchmarks(where T: Config, T::NativeCurrency: Currency)] +mod benches { + use super::*; + + fn fund(who: &T::AccountId, amount: Balance) + where + T::NativeCurrency: Currency, + { + let _ = T::NativeCurrency::deposit_creating(who, amount); + } + + /// Set the AAVE pool address so the adapter's `pool not set` precondition + /// passes. Address is unused by the stub `BenchmarkMoneyMarket`. + fn set_dummy_pool() { + GigaHdxPoolContract::::put(EvmAddress::from([0xAAu8; 20])); + } + + #[benchmark] + fn giga_stake() { + assert_ok!(T::BenchmarkHelper::register_assets()); + set_dummy_pool::(); + + let caller: T::AccountId = whitelisted_caller(); + let amount: Balance = 100 * ONE; + fund::(&caller, amount.saturating_mul(10)); + + #[extrinsic_call] + giga_stake(RawOrigin::Signed(caller.clone()), amount); + + let stake = Stakes::::get(&caller).expect("stake recorded"); + assert_eq!(stake.hdx, amount); + assert!(stake.gigahdx > 0); + } + + #[benchmark] + fn giga_unstake() { + assert_ok!(T::BenchmarkHelper::register_assets()); + set_dummy_pool::(); + + let caller: T::AccountId = whitelisted_caller(); + let max: u32 = T::MaxPendingUnstakes::get(); + let per_unstake: Balance = ONE; + let stake_amount: Balance = per_unstake.saturating_mul(max as Balance).saturating_mul(2); + fund::(&caller, stake_amount.saturating_mul(10)); + + assert_ok!(Pallet::::giga_stake( + RawOrigin::Signed(caller.clone()).into(), + stake_amount, + )); + + // Worst case: payout > active → yield transferred from gigapot. + let gigapot = Pallet::::gigapot_account_id(); + fund::(&gigapot, stake_amount.saturating_mul(2)); + + // Pre-populate MaxPendingUnstakes - 1 positions across distinct blocks so + // the measured call hits the admission cap and the cached-counter update + // at the per-account upper bound. + for _ in 0..max.saturating_sub(1) { + assert_ok!(Pallet::::giga_unstake( + RawOrigin::Signed(caller.clone()).into(), + per_unstake, + )); + let next = frame_system::Pallet::::block_number().saturating_add(1u32.into()); + frame_system::Pallet::::set_block_number(next); + } + let measured_amount = per_unstake; + + #[extrinsic_call] + giga_unstake(RawOrigin::Signed(caller.clone()), measured_amount); + + let s = Stakes::::get(&caller).expect("stake recorded"); + assert_eq!(s.unstaking_count as u32, max); + } + + #[benchmark] + fn unlock() { + assert_ok!(T::BenchmarkHelper::register_assets()); + set_dummy_pool::(); + + let caller: T::AccountId = whitelisted_caller(); + let max: u32 = T::MaxPendingUnstakes::get(); + let per_unstake: Balance = ONE; + let stake_amount: Balance = per_unstake.saturating_mul(max as Balance).saturating_mul(2); + fund::(&caller, stake_amount.saturating_mul(10)); + + assert_ok!(Pallet::::giga_stake( + RawOrigin::Signed(caller.clone()).into(), + stake_amount, + )); + let mut target_id: BlockNumberFor = frame_system::Pallet::::block_number(); + for i in 0..max { + let id = frame_system::Pallet::::block_number(); + assert_ok!(Pallet::::giga_unstake( + RawOrigin::Signed(caller.clone()).into(), + per_unstake, + )); + if i == 0 { + target_id = id; + } + let next = id.saturating_add(1u32.into()); + frame_system::Pallet::::set_block_number(next); + } + + let expires_at = target_id.saturating_add(T::CooldownPeriod::get()); + frame_system::Pallet::::set_block_number(expires_at); + + #[extrinsic_call] + unlock(RawOrigin::Signed(caller.clone()), target_id); + + let s = Stakes::::get(&caller).expect("stake remains"); + assert_eq!(s.unstaking_count as u32, max - 1); + } + + #[benchmark] + fn set_pool_contract() { + let new_pool = EvmAddress::from([0xBBu8; 20]); + + #[extrinsic_call] + set_pool_contract(RawOrigin::Root, new_pool); + + assert_eq!(GigaHdxPoolContract::::get(), Some(new_pool)); + } + + #[benchmark] + fn migrate() { + assert_ok!(T::BenchmarkHelper::register_assets()); + set_dummy_pool::(); + + let caller: T::AccountId = whitelisted_caller(); + // Must clear legacy `pallet_staking::MinStake` (1_000 UNITS in the runtime). + let stake_amount: Balance = 10_000 * ONE; + assert_ok!(T::BenchmarkHelper::setup_legacy_staking_position(&caller, stake_amount)); + + #[extrinsic_call] + migrate(RawOrigin::Signed(caller.clone())); + + let stake = Stakes::::get(&caller).expect("gigahdx stake recorded"); + assert!(stake.hdx >= stake_amount); + assert!(stake.gigahdx > 0); + } + + #[benchmark] + fn cancel_unstake() { + assert_ok!(T::BenchmarkHelper::register_assets()); + set_dummy_pool::(); + + let caller: T::AccountId = whitelisted_caller(); + let max: u32 = T::MaxPendingUnstakes::get(); + let per_unstake: Balance = ONE; + let stake_amount: Balance = per_unstake.saturating_mul(max as Balance).saturating_mul(2); + fund::(&caller, stake_amount.saturating_mul(10)); + + assert_ok!(Pallet::::giga_stake( + RawOrigin::Signed(caller.clone()).into(), + stake_amount, + )); + + // Worst case: yield was paid → cancel folds principal + yield back. + let gigapot = Pallet::::gigapot_account_id(); + fund::(&gigapot, stake_amount.saturating_mul(2)); + + let mut target_id: BlockNumberFor = frame_system::Pallet::::block_number(); + for i in 0..max { + let id = frame_system::Pallet::::block_number(); + assert_ok!(Pallet::::giga_unstake( + RawOrigin::Signed(caller.clone()).into(), + per_unstake, + )); + if i + 1 == max { + target_id = id; + } + let next = id.saturating_add(1u32.into()); + frame_system::Pallet::::set_block_number(next); + } + + #[extrinsic_call] + cancel_unstake(RawOrigin::Signed(caller.clone()), target_id); + + let s = Stakes::::get(&caller).expect("stake remains"); + assert_eq!(s.unstaking_count as u32, max - 1); + assert!(s.hdx > 0); + } + + #[benchmark] + fn realize_yield() { + assert_ok!(T::BenchmarkHelper::register_assets()); + set_dummy_pool::(); + + let caller: T::AccountId = whitelisted_caller(); + let amount: Balance = 100 * ONE; + fund::(&caller, amount.saturating_mul(10)); + + assert_ok!(Pallet::::giga_stake( + RawOrigin::Signed(caller.clone()).into(), + amount, + )); + + // Fund the gigapot so total_staked_hdx doubles → rate ≈ 2 → accrued ≈ amount. + fund::(&Pallet::::gigapot_account_id(), amount); + + #[extrinsic_call] + realize_yield(RawOrigin::Signed(caller.clone())); + + let s = Stakes::::get(&caller).expect("stake recorded"); + assert_eq!(s.gigahdx, amount); + assert!(s.hdx >= amount.saturating_mul(2)); + } +} diff --git a/pallets/gigahdx/src/lib.rs b/pallets/gigahdx/src/lib.rs new file mode 100644 index 0000000000..1fe32ff636 --- /dev/null +++ b/pallets/gigahdx/src/lib.rs @@ -0,0 +1,901 @@ +// This file is part of https://github.com/galacticcouncil/hydration-node + +// Copyright (C) 2025 Intergalactic, Limited (GIB). +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! # pallet-gigahdx +//! +//! Liquid-staking primitive on top of an EVM money market. +//! +//! Users `giga_stake` HDX, which: +//! 1. Locks the HDX in the user's own account under [`Config::LockId`] +//! (so it remains voteable via `pallet-conviction-voting`'s +//! `LockableCurrency::max` lock semantics). +//! 2. Mints stHDX to the pallet's gigapot account. +//! 3. Calls [`MoneyMarketOperations::supply`] which deposits the stHDX +//! into the money market and mints GIGAHDX (aToken) to the user. +//! +//! `giga_unstake` is the reverse path. + +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +pub mod traits; + +#[cfg(test)] +mod tests; + +#[cfg(feature = "runtime-benchmarks")] +pub mod benchmarking; + +pub mod weights; + +/// Hook for benchmark setup — wires runtime-side helpers (asset registry +/// registration, etc.) that the pallet itself can't perform via its +/// extrinsics. Mirror of `pallet_dispenser::BenchmarkHelper`. +#[cfg(feature = "runtime-benchmarks")] +pub trait BenchmarkHelper { + /// Register the stHDX asset so subsequent `mint_into` calls succeed. + /// Must be idempotent — benchmarks may invoke this multiple times. + fn register_assets() -> sp_runtime::DispatchResult; + + /// Used by the `migrate` benchmark. Must leave `who` with no external + /// claim that would survive `force_unstake` (otherwise migrate's + /// admission refuses). + fn setup_legacy_staking_position(who: &AccountId, amount: primitives::Balance) -> sp_runtime::DispatchResult; +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkHelper for () { + fn register_assets() -> sp_runtime::DispatchResult { + Ok(()) + } + fn setup_legacy_staking_position(_who: &AccountId, _amount: primitives::Balance) -> sp_runtime::DispatchResult { + Err(sp_runtime::DispatchError::Other( + "BenchmarkHelper: no legacy staking source configured", + )) + } +} + +#[frame_support::pallet] +pub mod pallet { + pub use crate::traits::{ExternalClaims, LegacyStakeMigrator}; + pub use crate::weights::WeightInfo; + use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; + use frame_support::pallet_prelude::*; + use frame_support::sp_runtime::helpers_128bit::multiply_by_rational_with_rounding; + use frame_support::sp_runtime::traits::{AccountIdConversion, CheckedAdd}; + use frame_support::sp_runtime::{ArithmeticError, Rounding}; + use frame_support::traits::fungibles::Mutate as FungiblesMutate; + use frame_support::traits::tokens::{Fortitude, Precision, Preservation}; + use frame_support::traits::{ + fungibles, Currency, ExistenceRequirement, LockIdentifier, LockableCurrency, WithdrawReasons, + }; + use frame_support::{transactional, PalletId}; + use frame_system::pallet_prelude::*; + use hydra_dx_math::ratio::Ratio; + pub use hydradx_traits::gigahdx::MoneyMarketOperations; + use primitives::{AssetId, Balance, EvmAddress}; + use scale_info::TypeInfo; + + /// Per-account stake record. + #[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, Default)] + pub struct StakeRecord { + /// HDX locked in this account under `Config::LockId` representing + /// active stake principal. On unstake this is reduced by the **payout** + /// (current HDX value of unstaked stHDX) up to its current value; + /// any excess comes from the gigapot as yield. + pub hdx: Balance, + /// aToken (GIGAHDX) units this account's stake backs. + /// + /// Stored as the value returned by [`MoneyMarketOperations::supply`], + /// not the input — the MM may round at supply time and the stored + /// value MUST match the account's GIGAHDX balance. + pub gigahdx: Balance, + /// HDX pinned against `do_unstake` (`post-unstake hdx >= frozen`). + /// + /// Sum of per-call freezes — *not* a max. Multiple concurrent reasons + /// (e.g. several active conviction-votes) stack, so `frozen` can + /// exceed `hdx`; that's how unstake-while-voting is blocked. Becomes + /// unstakeable again once each caller pairs its `freeze` with an + /// `unfreeze`. + pub frozen: Balance, + /// Total unstaking amount. + pub unstaking: Balance, + /// Total number of unstaking positions. + pub unstaking_count: u16, + } + + /// One pending-unstake position. Keyed by the originating block; cooldown + /// expiry is `block + Config::CooldownPeriod`. + #[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug)] + pub struct PendingUnstake { + pub amount: Balance, + } + + pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); + + /// Defensive tripwire bound for `realize_yield`. Aggregate solvency + /// guarantees the gigapot covers all accrued yield; a *per-account* + /// `realize_yield` can fall a few atomic units short purely from + /// cross-user floor-rounding (one staker's clamped negative residual + /// nudging another's rate up). Anything beyond this many atomic units is + /// an accounting bug, not rounding — `debug_assert` panics so tests and + /// fuzzing surface it; release still returns `GigapotInsufficient`. + /// 1 µHDX ≫ any realistic rounding accumulation, ≪ any real shortfall. + const MAX_GIGAPOT_ROUNDING_SHORTFALL: Balance = 1_000_000; + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config>> { + type NativeCurrency: LockableCurrency>; + + /// Multi-asset register that holds stHDX (and any other registered + /// fungible). Only this pallet mints / burns stHDX through it. + type MultiCurrency: fungibles::Mutate + + fungibles::Inspect; + + #[pallet::constant] + type StHdxAssetId: Get; + + type MoneyMarket: MoneyMarketOperations; + + /// Origin allowed to set the pool contract address. + type AuthorityOrigin: EnsureOrigin; + + /// Pallet account id used as the gigapot (yield) account. Derived + /// via `PalletId::into_account_truncating`. + #[pallet::constant] + type PalletId: Get; + + /// LockIdentifier under which user HDX (active stake + pending + /// unstake) is locked. + #[pallet::constant] + type LockId: Get; + + /// Minimum HDX that can be staked in one call (anti-dust). + #[pallet::constant] + type MinStake: Get; + + /// Cooldown period (in blocks) between `giga_unstake` and the + /// matching `unlock` call. + #[pallet::constant] + type CooldownPeriod: Get>; + + /// Maximum number of concurrent pending-unstake positions per account. + #[pallet::constant] + type MaxPendingUnstakes: Get; + + /// Inspector returning the sum of non-overlapping HDX claims on the + /// caller. Any non-zero value blocks `giga_stake` admission — the + /// strict policy rejects stakes whenever the account carries a lock + /// the runtime has not whitelisted for overlap (e.g. `pyconvot`). + type ExternalClaims: crate::traits::ExternalClaims; + + /// Bridge into the legacy NFT staking pallet. `migrate` calls + /// `force_unstake` here to destroy the caller's legacy position + /// before re-staking the freed HDX into gigahdx. + type LegacyStaking: crate::traits::LegacyStakeMigrator; + + type WeightInfo: WeightInfo; + + /// Benchmark helper for setting up state that can't be created from + /// the pallet's public API alone — primarily registering the stHDX + /// asset in the asset registry so `MultiCurrency::mint_into` succeeds. + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper: crate::BenchmarkHelper; + } + + /// Per-account stake record. Absent if the account has never staked or + /// has fully unstaked. + #[pallet::storage] + pub type Stakes = StorageMap<_, Blake2_128Concat, T::AccountId, StakeRecord, OptionQuery>; + + /// Sum of all `Stakes[a].hdx`. + #[pallet::storage] + pub type TotalLocked = StorageValue<_, Balance, ValueQuery>; + + /// Aave V3 Pool contract address. Must be set explicitly by + /// `AuthorityOrigin` before any stake/unstake — the pallet refuses to + /// silently default to the zero address. + #[pallet::storage] + pub type GigaHdxPoolContract = StorageValue<_, EvmAddress, OptionQuery>; + + /// Pending unstake positions, keyed by `(account, originating_block)`. + /// Same-block unstakes by the same account compound into one entry. + /// Per-account count bounded by `Config::MaxPendingUnstakes`. + #[pallet::storage] + pub type PendingUnstakes = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Twox64Concat, + BlockNumberFor, + PendingUnstake, + OptionQuery, + >; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + Staked { + who: T::AccountId, + amount: Balance, + gigahdx: Balance, + }, + Unstaked { + who: T::AccountId, + position_id: BlockNumberFor, + gigahdx_amount: Balance, + payout: Balance, + yield_share: Balance, + expires_at: BlockNumberFor, + }, + Unlocked { + who: T::AccountId, + position_id: BlockNumberFor, + amount: Balance, + }, + UnstakeCancelled { + who: T::AccountId, + position_id: BlockNumberFor, + amount: Balance, + gigahdx: Balance, + }, + PoolContractUpdated { + contract: EvmAddress, + }, + /// Caller migrated their legacy NFT staking position into gigahdx. + /// `hdx_unlocked` is the sum of legacy stake + previously locked + /// rewards + freshly paid rewards; `gigahdx_received` is the aToken + /// amount actually credited by the money market. + MigratedFromLegacy { + who: T::AccountId, + hdx_unlocked: Balance, + gigahdx_received: Balance, + }, + /// Accrued yield was moved from the gigapot into the caller's locked + /// stake principal. `amount` is the HDX transferred and added to + /// `Stakes[who].hdx`; `gigahdx` and the exchange rate are unchanged. + YieldRealized { + who: T::AccountId, + amount: Balance, + }, + } + + #[pallet::error] + pub enum Error { + /// Stake amount is below `Config::MinStake`. + BelowMinStake, + /// Caller doesn't have enough unencumbered HDX to back the stake + /// after subtracting their existing gigahdx commitment. + InsufficientFreeBalance, + /// Caller holds a non-overlapping lock (legacy staking, vesting, …) + /// reported by `Config::ExternalClaims`. Strict policy: gigahdx + /// admission requires the caller to have no claims on their HDX + /// other than those the runtime explicitly allows to coexist + /// (e.g. `pyconvot`). Release the conflicting lock before staking. + BlockedByExternalLock, + /// Unstake amount exceeds the caller's `Stakes.gigahdx`. + InsufficientStake, + /// Caller has no active stake record. + NoStake, + /// Amount must be strictly greater than zero. + ZeroAmount, + /// stHDX mint failed (asset not registered, max issuance hit, or + /// other `fungibles::Mutate::mint_into` precondition violated). + /// Distinct from `MoneyMarketSupplyFailed` — this is a substrate-side + /// asset-registry error, not an AAVE-side revert. + StHdxMintFailed, + /// AAVE `Pool.supply` reverted — typical causes: caller's EVM address + /// is not bound, the asset reserve is misconfigured, or `Pool` is + /// paused. + MoneyMarketSupplyFailed, + /// AAVE `Pool.withdraw` reverted. + MoneyMarketWithdrawFailed, + /// Arithmetic overflow during rate, lock, or storage update math. + Overflow, + /// The cooldown period has not yet elapsed for the targeted position. + CooldownNotElapsed, + /// No pending unstake position with the supplied id exists for the caller. + PendingUnstakeNotFound, + /// Caller has reached `Config::MaxPendingUnstakes` concurrent positions. + TooManyPendingUnstakes, + /// `set_pool_contract` was called while gigahdx (aToken / stHDX) is + /// still in circulation. The pool is settable only when total stHDX + /// supply is zero. + OutstandingStake, + /// Unstake would reduce `Stakes[who].hdx` below `Stakes[who].frozen`. + /// Some HDX is currently frozen (e.g. backing an active reward-eligible + /// vote in `pallet-gigahdx-rewards`); release the freeze first. + StakeFrozen, + /// The gigapot lacks the HDX to cover the caller's accrued yield. + /// Only reachable in a drained/floored state, not normal operation. + GigapotInsufficient, + } + + #[pallet::call] + impl Pallet { + /// Lock HDX in the caller's account, mint stHDX, and supply it to the money market. + /// + /// The pallet locks `amount` HDX in the caller's account under `Config::LockId` + /// (so it remains voteable via `LockableCurrency` semantics), mints stHDX at the + /// current exchange rate, and supplies the stHDX to the money market. The money + /// market mints GIGAHDX (aToken) to the caller's EVM-mapped address. + /// + /// `Stakes[caller].gigahdx` records the **actual** aToken amount returned by the + /// money market (may differ from the requested mint amount by rounding). + /// + /// Fails with `BelowMinStake` if `amount < Config::MinStake`, with + /// `BlockedByExternalLock` if `Config::ExternalClaims::on(caller) > 0` + /// (the caller holds a non-allowed lock — strict policy rejects + /// stake admission entirely), with `InsufficientFreeBalance` if + /// `free_balance − own_gigahdx_commitment < amount`, with + /// `StHdxMintFailed` if stHDX minting fails, or with + /// `MoneyMarketSupplyFailed` if the AAVE `Pool.supply` call reverts. + /// + /// Parameters: + /// - `amount`: HDX amount to stake. Must be at least `Config::MinStake`. + /// + /// Emits `Staked` event when successful. + /// + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::giga_stake().saturating_add(T::MoneyMarket::supply_weight()))] + pub fn giga_stake(origin: OriginFor, amount: Balance) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!(amount >= T::MinStake::get(), Error::::BelowMinStake); + + // Strict policy: refuse if the caller carries any lock the runtime + // does not whitelist for overlap. Lock-layering via `max()` would + // otherwise let the same HDX back both a gigahdx stake and another + // pallet's claim after a single transfer of the unlocked portion. + ensure!(T::ExternalClaims::on(&who) == 0, Error::::BlockedByExternalLock); + + // Own commitment (active + pending unstakes) still has to fit + // under free_balance — re-staking under the same `ghdxlock` must + // not exceed what the user actually owns. + let stake = Stakes::::get(&who).unwrap_or_default(); + let own_claim = stake.hdx.saturating_add(stake.unstaking); + let stakeable = T::NativeCurrency::free_balance(&who).saturating_sub(own_claim); + ensure!(stakeable >= amount, Error::::InsufficientFreeBalance); + + Self::do_stake(&who, amount)?; + Ok(()) + } + + /// Unstake the caller's GIGAHDX and open a pending-unstake position. + /// + /// Burns `gigahdx_amount` of the caller's GIGAHDX through the money market, which + /// returns stHDX to the caller; the pallet then burns that stHDX. The HDX value + /// (current rate × `gigahdx_amount`) is moved into a single pending-unstake + /// position with a cooldown of `Config::CooldownPeriod`. Any portion of the payout + /// that exceeds the user's active stake principal is paid as yield from the + /// gigapot account. + /// + /// At most one pending position per account — the caller must `unlock` an existing + /// position before calling again, otherwise `PendingUnstakeAlreadyExists` is + /// returned. + /// + /// Implementation detail (must match `LockableAToken.sol`): the lock-manager + /// precompile (`0x0806`) reads `Stakes[who].gigahdx` and treats it as the user's + /// locked GIGAHDX. The aToken contract rejects burns where + /// `amount > balance - locked`, so the pallet **pre-decrements `gigahdx` by + /// `gigahdx_amount` before the money-market call**. The dispatchable runs in a + /// storage layer so any failure rolls back the pre-decrement atomically. + /// + /// Fails with `NoStake` if the caller has no active stake, `ZeroAmount` if + /// `gigahdx_amount == 0`, `InsufficientStake` if `gigahdx_amount` exceeds the + /// caller's `Stakes.gigahdx`, or `MoneyMarketWithdrawFailed` if the AAVE + /// `Pool.withdraw` call reverts. + /// + /// Parameters: + /// - `gigahdx_amount`: GIGAHDX (aToken) amount to unstake. Must be greater than + /// zero and not exceed the caller's `Stakes.gigahdx`. + /// + /// Emits `Unstaked` event when successful. + /// + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::giga_unstake().saturating_add(T::MoneyMarket::withdraw_weight()))] + pub fn giga_unstake(origin: OriginFor, gigahdx_amount: Balance) -> DispatchResult { + let who = ensure_signed(origin)?; + Self::do_unstake(&who, gigahdx_amount) + } + + /// Set the AAVE V3 Pool contract address used by the money-market adapter. + /// + /// Refuses to swap the pool while gigahdx (aToken / stHDX) is still in + /// circulation — a swap mid-flight would route subsequent `giga_unstake` + /// calls to a pool that doesn't hold the user's atokens, reverting the + /// burn and leaving HDX permanently locked. Returns `OutstandingStake` + /// when total stHDX supply is non-zero. + /// + /// Note: it is not enough to check `TotalLocked == 0` (the sum of + /// `Stakes.hdx`). When an unstake payout exceeds the user's active + /// stake, the active stake is drained but `Stakes.gigahdx` (and the + /// corresponding aToken balance) can stay non-zero — those tokens + /// remain bound to the current pool. + /// + /// Parameters: + /// - `origin`: Must be `T::AuthorityOrigin`. + /// - `contract`: H160 address of the new AAVE V3 Pool contract. + /// + /// Emits `PoolContractUpdated` event when successful. + /// + #[pallet::call_index(2)] + #[pallet::weight(T::WeightInfo::set_pool_contract())] + pub fn set_pool_contract(origin: OriginFor, contract: EvmAddress) -> DispatchResult { + T::AuthorityOrigin::ensure_origin(origin)?; + ensure!(Self::total_gigahdx_supply() == 0, Error::::OutstandingStake); + GigaHdxPoolContract::::put(contract); + Self::deposit_event(Event::PoolContractUpdated { contract }); + Ok(()) + } + + /// Release a single pending-unstake position whose cooldown has elapsed. + /// + /// Fails with `PendingUnstakeNotFound` if no position with `position_id` + /// exists, or `CooldownNotElapsed` if the targeted position is still cooling. + /// + /// Parameters: + /// - `position_id`: id of the position to release (as recorded in the + /// `Unstaked` event for the originating unstake). + /// + /// Emits `Unlocked` event when successful. + /// + #[pallet::call_index(3)] + #[pallet::weight(T::WeightInfo::unlock())] + pub fn unlock(origin: OriginFor, position_id: BlockNumberFor) -> DispatchResult { + let who = ensure_signed(origin)?; + + let entry = PendingUnstakes::::get(&who, position_id).ok_or(Error::::PendingUnstakeNotFound)?; + let expires_at = position_id + .checked_add(&T::CooldownPeriod::get()) + .ok_or(Error::::Overflow)?; + ensure!( + frame_system::Pallet::::block_number() >= expires_at, + Error::::CooldownNotElapsed, + ); + PendingUnstakes::::remove(&who, position_id); + + Stakes::::mutate_exists(&who, |maybe| { + if let Some(s) = maybe.as_mut() { + s.unstaking = s.unstaking.saturating_sub(entry.amount); + s.unstaking_count = s.unstaking_count.saturating_sub(1); + if s.hdx == 0 && s.gigahdx == 0 && s.frozen == 0 && s.unstaking_count == 0 { + *maybe = None; + } + } + }); + Self::refresh_lock(&who)?; + + Self::deposit_event(Event::Unlocked { + who, + position_id, + amount: entry.amount, + }); + Ok(()) + } + + /// Cancel a single pending-unstake position, folding its `amount` HDX + /// back into the active stake at the current exchange rate. + /// + /// The pending HDX is already locked in the caller's account; this + /// extrinsic relabels it as active stake and mints fresh aTokens at + /// today's rate. The number of aTokens minted may differ from the + /// amount burned at unstake time if the exchange rate moved. + /// + /// Cooldown is not a gate — cancellation is valid throughout the + /// pending window, until the caller invokes `unlock`. + /// + /// Fails with `PendingUnstakeNotFound` if `position_id` is not present. + /// + /// Parameters: + /// - `position_id`: id of the position to cancel. + /// + /// Emits `UnstakeCancelled` event when successful. + /// + #[pallet::call_index(4)] + #[pallet::weight(T::WeightInfo::cancel_unstake().saturating_add(T::MoneyMarket::supply_weight()))] + #[transactional] + pub fn cancel_unstake(origin: OriginFor, position_id: BlockNumberFor) -> DispatchResult { + let who = ensure_signed(origin)?; + + let entry = PendingUnstakes::::take(&who, position_id).ok_or(Error::::PendingUnstakeNotFound)?; + + Stakes::::mutate(&who, |maybe| { + if let Some(s) = maybe { + s.unstaking = s.unstaking.saturating_sub(entry.amount); + s.unstaking_count = s.unstaking_count.saturating_sub(1); + } + }); + + let gigahdx = Self::do_stake(&who, entry.amount)?; + Self::deposit_event(Event::UnstakeCancelled { + who, + position_id, + amount: entry.amount, + gigahdx, + }); + Ok(()) + } + + /// Migrate the caller's legacy NFT staking position into gigahdx. + /// + /// Atomically: destroys the legacy position via `LegacyStaking:: + /// force_unstake` (paying out 100% of rewards — no sigmoid slash, no + /// unclaimable-period penalty), then re-stakes the freed HDX under + /// the same admission gate as `giga_stake`. Refuses partial migration + /// — the legacy position is consumed whole or not at all. + /// + /// Admission runs *after* `force_unstake` so the legacy lock is no + /// longer counted against `ExternalClaims`. + /// + /// Fails with `BelowMinStake` if the legacy position unlocks less + /// than `Config::MinStake`, with `BlockedByExternalLock` if the + /// caller still carries another non-overlap-whitelisted lock, with + /// `InsufficientFreeBalance` if the post-unstake balance can't cover + /// the new gigahdx claim, or with whatever error `force_unstake` + /// raises (e.g. ongoing referendum vote, no legacy position). + /// + /// Emits `MigratedFromLegacy` event when successful. + #[pallet::call_index(5)] + #[pallet::weight(T::WeightInfo::migrate().saturating_add(T::MoneyMarket::supply_weight()))] + #[transactional] + pub fn migrate(origin: OriginFor) -> DispatchResult { + let who = ensure_signed(origin)?; + + let hdx_unlocked = T::LegacyStaking::force_unstake(&who)?; + + ensure!(hdx_unlocked >= T::MinStake::get(), Error::::BelowMinStake); + ensure!(T::ExternalClaims::on(&who) == 0, Error::::BlockedByExternalLock); + + let stake = Stakes::::get(&who).unwrap_or_default(); + let own_claim = stake.hdx.saturating_add(stake.unstaking); + let stakeable = T::NativeCurrency::free_balance(&who).saturating_sub(own_claim); + ensure!(stakeable >= hdx_unlocked, Error::::InsufficientFreeBalance); + + let gigahdx_received = Self::do_stake(&who, hdx_unlocked)?; + Self::deposit_event(Event::MigratedFromLegacy { + who, + hdx_unlocked, + gigahdx_received, + }); + Ok(()) + } + + /// Realize the caller's accrued yield into their locked stake principal. + /// + /// Moves the HDX value the caller's GIGAHDX has gained since it was last + /// reconciled (`rate × gigahdx − Stakes[who].hdx`) from the gigapot into + /// the caller's account, folds it into `Stakes[who].hdx`, and refreshes + /// the lock. GIGAHDX balance and the exchange rate are unchanged. A + /// caller with no accrued yield (or no stake) is a successful no-op. + /// + /// Emits `YieldRealized` event when there was yield to realize. + /// + #[pallet::call_index(6)] + #[pallet::weight(T::WeightInfo::realize_yield())] + #[transactional] + pub fn realize_yield(origin: OriginFor) -> DispatchResult { + let who = ensure_signed(origin)?; + + let stake = Stakes::::get(&who).unwrap_or_default(); + let current_value = + Self::calculate_hdx_amount_given_gigahdx(stake.gigahdx).map_err(|_| Error::::Overflow)?; + let accrued = current_value.saturating_sub(stake.hdx); + if accrued == 0 { + return Ok(()); + } + + if T::NativeCurrency::transfer( + &Self::gigapot_account_id(), + &who, + accrued, + ExistenceRequirement::AllowDeath, + ) + .is_err() + { + let gigapot = T::NativeCurrency::free_balance(&Self::gigapot_account_id()); + let shortfall = accrued.saturating_sub(gigapot); + debug_assert!( + shortfall <= MAX_GIGAPOT_ROUNDING_SHORTFALL, + "realize_yield: gigapot short by {shortfall} (accrued {accrued}, gigapot {gigapot}) \ + — exceeds rounding tolerance, indicates an accounting bug" + ); + return Err(Error::::GigapotInsufficient.into()); + } + + Stakes::::try_mutate(&who, |maybe| -> Result<(), Error> { + let s = maybe.get_or_insert_with(StakeRecord::default); + s.hdx = s.hdx.checked_add(accrued).ok_or(Error::::Overflow)?; + Ok(()) + })?; + TotalLocked::::mutate(|x| *x = x.saturating_add(accrued)); + Self::refresh_lock(&who)?; + + Self::deposit_event(Event::YieldRealized { who, amount: accrued }); + Ok(()) + } + } + + impl Pallet { + /// Internal helper for `giga_unstake`. Uses `?` freely; the + /// `#[transactional]` attribute wraps the body in its own storage + /// layer so any Err here rolls back partial mutations. + #[transactional] + fn do_unstake(who: &T::AccountId, gigahdx_amount: Balance) -> DispatchResult { + let stake = Stakes::::get(who).ok_or(Error::::NoStake)?; + let now = frame_system::Pallet::::block_number(); + let is_new_position = !PendingUnstakes::::contains_key(who, now); + if is_new_position { + ensure!( + (stake.unstaking_count as u32) < T::MaxPendingUnstakes::get(), + Error::::TooManyPendingUnstakes + ); + } + ensure!(gigahdx_amount > 0, Error::::ZeroAmount); + ensure!(gigahdx_amount <= stake.gigahdx, Error::::InsufficientStake); + + // Payout reads live rate state — must run before any mint/burn below. + let payout = Self::calculate_hdx_amount_given_gigahdx(gigahdx_amount).map_err(|_| Error::::Overflow)?; + + // Reject the unstake up-front if it would breach the frozen guard. + // `new_hdx = stake.hdx.saturating_sub(payout)` (any excess comes from + // the gigapot as yield, not from `hdx`); we need `new_hdx >= frozen`. + let projected_hdx = stake.hdx.saturating_sub(payout); + ensure!(projected_hdx >= stake.frozen, Error::::StakeFrozen); + + // Pre-decrement `gigahdx` so `LockableAToken.burn`'s `freeBalance` + // check (via the lock-manager precompile) lets the burn through. + let new_gigahdx = stake.gigahdx.checked_sub(gigahdx_amount).ok_or(Error::::Overflow)?; + Stakes::::mutate(who, |maybe| { + if let Some(s) = maybe { + s.gigahdx = new_gigahdx; + } + }); + + let actual_withdrawn = T::MoneyMarket::withdraw(who, T::StHdxAssetId::get(), gigahdx_amount) + .map_err(|_| Error::::MoneyMarketWithdrawFailed)?; + // Mismatch breaks `burn_from(Precision::Exact)` or leaks untracked + // stHDX past cooldown accounting. + ensure!( + actual_withdrawn == gigahdx_amount, + Error::::MoneyMarketWithdrawFailed + ); + + T::MultiCurrency::burn_from( + T::StHdxAssetId::get(), + who, + gigahdx_amount, + Preservation::Expendable, + Precision::Exact, + Fortitude::Force, + )?; + + // payout ≤ active → consume from active only; + // payout > active → drain active, pull remainder from gigapot as yield. + let (new_hdx, yield_share) = if payout <= stake.hdx { + (stake.hdx - payout, 0) + } else { + let yield_amount = payout - stake.hdx; + T::NativeCurrency::transfer( + &Self::gigapot_account_id(), + who, + yield_amount, + ExistenceRequirement::AllowDeath, + )?; + (0, yield_amount) + }; + let principal_consumed = stake.hdx.saturating_sub(new_hdx); + + let expires_at = now.checked_add(&T::CooldownPeriod::get()).ok_or(Error::::Overflow)?; + + PendingUnstakes::::mutate(who, now, |maybe| { + let entry = maybe.get_or_insert(PendingUnstake { amount: 0 }); + entry.amount = entry.amount.saturating_add(payout); + }); + + Stakes::::mutate(who, |maybe| { + if let Some(s) = maybe { + s.hdx = new_hdx; + s.unstaking = s.unstaking.saturating_add(payout); + if is_new_position { + s.unstaking_count = s.unstaking_count.saturating_add(1); + } + } + }); + TotalLocked::::mutate(|x| *x = x.saturating_sub(principal_consumed)); + Self::refresh_lock(who)?; + + Self::deposit_event(Event::Unstaked { + who: who.clone(), + position_id: now, + gigahdx_amount, + payout, + yield_share, + expires_at, + }); + Ok(()) + } + } + + impl Pallet { + /// Stack a freeze of `delta` onto `Stakes[who].frozen`. Saturating; + /// infallible. Each `freeze(who, delta)` must be paired with an + /// eventual `unfreeze(who, delta)`. See `StakeRecord.frozen` for the + /// sum-stacking semantics. + pub fn freeze(who: &T::AccountId, delta: Balance) { + if delta == 0 { + return; + } + Stakes::::mutate(who, |maybe| { + let stake = maybe.get_or_insert_with(StakeRecord::default); + stake.frozen = stake.frozen.saturating_add(delta); + }); + } + + /// Subtract `delta` from `Stakes[who].frozen`. Saturating; infallible. + /// Removes the record only when every field is zero — matching + /// `unlock`'s predicate. Dropping it with `unstaking_count > 0` would + /// orphan `PendingUnstakes` entries. + pub fn unfreeze(who: &T::AccountId, delta: Balance) { + if delta == 0 { + return; + } + Stakes::::mutate_exists(who, |maybe| { + if let Some(stake) = maybe.as_mut() { + stake.frozen = stake.frozen.saturating_sub(delta); + if stake.hdx == 0 + && stake.gigahdx == 0 + && stake.frozen == 0 + && stake.unstaking == 0 + && stake.unstaking_count == 0 + { + *maybe = None; + } + } + }); + } + + /// Computes the stHDX amount at the current rate, mints it into `who`, + /// supplies it to the money market, credits the resulting aToken amount + /// to `Stakes[who]`, locks `amount` HDX under `Config::LockId`, and emits + /// `Staked`. + /// + /// Caller invariant: `amount` HDX must already be in `who`'s free + /// balance. This helper performs **no admission control** — neither + /// the `MinStake` floor nor the `ExternalClaims`/headroom checks that + /// `giga_stake` applies. It is intended for trusted internal callers + /// (e.g. `cancel_unstake` rearranging already-locked HDX, or + /// `pallet-gigahdx-rewards` compounding accrued rewards). Untrusted + /// callers must replicate the `giga_stake` checks before invoking. + #[transactional] + pub fn do_stake(who: &T::AccountId, amount: Balance) -> Result { + ensure!(amount > 0, Error::::ZeroAmount); + let gigahdx_to_mint = Self::calculate_gigahdx_given_hdx_amount(amount).map_err(|_| Error::::Overflow)?; + ensure!(gigahdx_to_mint > 0, Error::::ZeroAmount); + + T::MultiCurrency::mint_into(T::StHdxAssetId::get(), who, gigahdx_to_mint) + .map_err(|_| Error::::StHdxMintFailed)?; + let actual_minted = T::MoneyMarket::supply(who, T::StHdxAssetId::get(), gigahdx_to_mint) + .map_err(|_| Error::::MoneyMarketSupplyFailed)?; + // Silent zero-mint would strand `amount` HDX with no redeemable gigahdx. + ensure!(actual_minted > 0, Error::::MoneyMarketSupplyFailed); + + Stakes::::try_mutate(who, |maybe_stake| -> Result<(), Error> { + let stake = maybe_stake.get_or_insert_with(StakeRecord::default); + stake.hdx = stake.hdx.checked_add(amount).ok_or(Error::::Overflow)?; + stake.gigahdx = stake.gigahdx.checked_add(actual_minted).ok_or(Error::::Overflow)?; + Ok(()) + })?; + TotalLocked::::mutate(|x| *x = x.saturating_add(amount)); + Self::refresh_lock(who)?; + + Self::deposit_event(Event::Staked { + who: who.clone(), + amount, + gigahdx: actual_minted, + }); + Ok(actual_minted) + } + + /// Recompute the single combined balance lock for `who`: + /// `lock_amount = Stakes[who].hdx + Stakes[who].unstaking`. Uses + /// `set_lock` (not `extend_lock`) so the lock can shrink on unstake + /// or unlock. Removes the lock entirely when the total is zero. + #[transactional] + fn refresh_lock(who: &T::AccountId) -> DispatchResult { + let total = Stakes::::get(who) + .map(|s| s.hdx.saturating_add(s.unstaking)) + .unwrap_or(0); + if total == 0 { + T::NativeCurrency::remove_lock(T::LockId::get(), who); + } else { + T::NativeCurrency::set_lock(T::LockId::get(), who, total, WithdrawReasons::all()); + } + Ok(()) + } + + /// Account id of the gigapot (yield holder), derived from + /// `Config::PalletId`. + pub fn gigapot_account_id() -> T::AccountId { + T::PalletId::get().into_account_truncating() + } + + /// GIGAHDX (aToken) units backed by an active stake for `who`. Read by + /// the lock-manager precompile to enforce `LockableAToken`'s + /// `freeBalance = balance - locked` invariant on the EVM side. + /// + /// Returns 0 when the account has no record. Equals the user's atoken + /// balance while staked; pre-decremented by `giga_unstake` before the + /// MM withdraw call so the burn passes that invariant. + pub fn locked_gigahdx(who: &T::AccountId) -> Balance { + Stakes::::get(who).map(|s| s.gigahdx).unwrap_or(0) + } + + /// Total HDX backing all stHDX: + /// `TotalLocked + free_balance(gigapot_account_id)`. + pub fn total_staked_hdx() -> Balance { + TotalLocked::::get().saturating_add(T::NativeCurrency::free_balance(&Self::gigapot_account_id())) + } + + /// Total stHDX issued, read live from the asset registry — no pallet-side + /// counter to keep in sync. + pub fn total_gigahdx_supply() -> Balance { + >::total_issuance(T::StHdxAssetId::get()) + } + + /// HDX/GIGAHDX exchange rate as `Ratio { n: total_staked_hdx, d: total_gigahdx_supply }`, + /// floored at 1.0. + /// + /// stHDX accrues HDX value monotonically under user flows, so a sub-1 + /// rate is only reachable via privileged operations (root drain from + /// the gigapot) or migration bugs. The floor protects users and + /// downstream consumers (e.g. AAVE oracle reads) from a transient + /// sub-1 reading without leaking the artefact across pricing math. + /// + /// Compare ratios with `cmp` / `partial_cmp` — those do proper + /// cross-multiplication. Direct field-wise `==` only works when `n` + /// and `d` happen to match exactly. + pub fn exchange_rate() -> Ratio { + let gigahdx_supply = Self::total_gigahdx_supply(); + if gigahdx_supply == 0 { + return Ratio::one(); + } + let raw = Ratio::new(Self::total_staked_hdx(), gigahdx_supply); + core::cmp::max(raw, Ratio::one()) + } + + /// GIGAHDX (= stHDX) to mint for a given HDX `amount` at the current rate. + /// + /// Bootstrap (no GIGAHDX in circulation) returns `amount` 1:1. + pub fn calculate_gigahdx_given_hdx_amount(amount: Balance) -> Result { + let rate = Self::exchange_rate(); + multiply_by_rational_with_rounding(amount, rate.d, rate.n, Rounding::Down).ok_or(ArithmeticError::Overflow) + } + + /// HDX paid out for unstaking `gigahdx_amount` of GIGAHDX/stHDX at the + /// current rate. + pub fn calculate_hdx_amount_given_gigahdx(gigahdx_amount: Balance) -> Result { + let rate = Self::exchange_rate(); + multiply_by_rational_with_rounding(gigahdx_amount, rate.n, rate.d, Rounding::Down) + .ok_or(ArithmeticError::Overflow) + } + } +} diff --git a/pallets/gigahdx/src/tests/cancel_unstake.rs b/pallets/gigahdx/src/tests/cancel_unstake.rs new file mode 100644 index 0000000000..eaf7f26329 --- /dev/null +++ b/pallets/gigahdx/src/tests/cancel_unstake.rs @@ -0,0 +1,399 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Error, Event, Stakes, TotalLocked}; +use frame_support::sp_runtime::traits::AccountIdConversion; +use frame_support::traits::fungibles::Inspect as FungiblesInspect; +use frame_support::{assert_noop, assert_ok}; +use frame_system::RawOrigin; +use hydradx_traits::gigahdx::MoneyMarketOperations; +use primitives::Balance; + +fn pot() -> AccountId { + GigaHdxPalletId::get().into_account_truncating() +} + +fn locked_under_ghdx(account: AccountId) -> Balance { + pallet_balances::Locks::::get(account) + .iter() + .find(|l| l.id == GIGAHDX_LOCK_ID) + .map(|l| l.amount) + .unwrap_or(0) +} + +fn stake_alice_100() { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); +} + +fn cancel_unstake_event(who: AccountId) -> Option<(u64, Balance, Balance)> { + System::events().into_iter().rev().find_map(|r| match r.event { + RuntimeEvent::GigaHdx(Event::UnstakeCancelled { + who: w, + position_id, + amount, + gigahdx, + }) if w == who => Some((position_id, amount, gigahdx)), + _ => None, + }) +} + +// --------------------------------------------------------------------------- +// Behavior +// --------------------------------------------------------------------------- + +#[test] +fn cancel_unstake_should_fail_when_no_pending_unstake() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_noop!( + GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1), + Error::::PendingUnstakeNotFound, + ); + }); +} + +#[test] +fn cancel_unstake_should_fail_when_no_stake_and_no_pending() { + ExtBuilder::default().build().execute_with(|| { + assert_noop!( + GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1), + Error::::PendingUnstakeNotFound, + ); + }); +} + +#[test] +fn cancel_unstake_should_remove_pending_entry_when_called() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + assert_eq!(pending_count(ALICE), 1); + + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1)); + assert_eq!(pending_count(ALICE), 0); + }); +} + +#[test] +fn cancel_unstake_should_restore_state_when_partial_unstake_within_principal() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + let pre_lock = locked_under_ghdx(ALICE); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 100 * ONE); + assert_eq!(s.gigahdx, 100 * ONE); + assert_eq!(TotalLocked::::get(), 100 * ONE); + assert_eq!(locked_under_ghdx(ALICE), pre_lock); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 100 * ONE); + assert_eq!(pending_count(ALICE), 0); + }); +} + +#[test] +fn cancel_unstake_should_restore_state_when_full_unstake_no_yield() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + let pre_lock = locked_under_ghdx(ALICE); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 100 * ONE); + assert_eq!(s.gigahdx, 100 * ONE); + assert_eq!(TotalLocked::::get(), 100 * ONE); + assert_eq!(locked_under_ghdx(ALICE), pre_lock); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 100 * ONE); + }); +} + +#[test] +fn cancel_unstake_should_restore_state_when_yield_was_paid_from_gigapot() { + ExtBuilder::default() + .with_pot_balance(30 * ONE) + .build() + .execute_with(|| { + stake_alice_100(); + let pre_lock = locked_under_ghdx(ALICE); + let pre_pot = Balances::free_balance(pot()); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_eq!(Balances::free_balance(pot()), pre_pot - 30 * ONE); + assert_eq!(only_pending(ALICE).amount, 130 * ONE); + + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 130 * ONE); + assert_eq!(s.gigahdx, 130 * ONE); + assert_eq!(TotalLocked::::get(), 130 * ONE); + assert_eq!(locked_under_ghdx(ALICE), pre_lock + 30 * ONE); + assert_eq!(Balances::free_balance(pot()), 0); + assert_eq!(pending_count(ALICE), 0); + }); +} + +#[test] +fn cancel_unstake_should_emit_event() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1)); + + let (position_id, amount, gigahdx) = cancel_unstake_event(ALICE).expect("event emitted"); + assert_eq!(position_id, 1); + assert_eq!(amount, 40 * ONE); + assert_eq!(gigahdx, 40 * ONE); + }); +} + +#[test] +fn cancel_unstake_should_refresh_lock_to_active_stake_only() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_eq!(locked_under_ghdx(ALICE), 100 * ONE); + + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1)); + assert_eq!(locked_under_ghdx(ALICE), 100 * ONE); + assert_eq!(Stakes::::get(ALICE).unwrap().hdx, 100 * ONE); + }); +} + +#[test] +fn cancel_unstake_should_succeed_before_cooldown_elapses() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + let entry = only_pending(ALICE); + assert!(System::block_number() < entry.expires_at); + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1)); + }); +} + +#[test] +fn cancel_unstake_should_succeed_after_cooldown_when_not_yet_unlocked() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + let entry = only_pending(ALICE); + System::set_block_number(entry.expires_at + 10); + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1)); + }); +} + +#[test] +fn cancel_unstake_should_fail_after_unlock() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let entry = only_pending(ALICE); + System::set_block_number(entry.expires_at); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), 1)); + + assert_noop!( + GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1), + Error::::PendingUnstakeNotFound, + ); + }); +} + +#[test] +fn cancel_unstake_should_rollback_when_supply_fails() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + + let pre_pending = only_pending(ALICE); + let pre_stake = Stakes::::get(ALICE).unwrap(); + let pre_total_locked = TotalLocked::::get(); + let pre_mm = TestMoneyMarket::balance_of(&ALICE); + let pre_sthdx = Tokens::balance(ST_HDX, &ALICE); + + TestMoneyMarket::fail_supply(); + assert_noop!( + GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1), + Error::::MoneyMarketSupplyFailed, + ); + + assert_eq!(only_pending(ALICE), pre_pending); + assert_eq!(Stakes::::get(ALICE).unwrap(), pre_stake); + assert_eq!(TotalLocked::::get(), pre_total_locked); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), pre_mm); + assert_eq!(Tokens::balance(ST_HDX, &ALICE), pre_sthdx); + }); +} + +// --------------------------------------------------------------------------- +// `frozen` invariant +// --------------------------------------------------------------------------- + +#[test] +fn cancel_unstake_should_preserve_frozen_amount() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + // Freeze 30 before unstake; partial unstake of 50 leaves hdx=50 ≥ frozen=30. + GigaHdx::freeze(&ALICE, 30 * ONE); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + assert_eq!(Stakes::::get(ALICE).unwrap().frozen, 30 * ONE); + + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1)); + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.frozen, 30 * ONE, "cancel must not touch frozen"); + assert_eq!(s.hdx, 100 * ONE); + assert!(s.frozen <= s.hdx, "invariant frozen ≤ hdx preserved"); + }); +} + +// --------------------------------------------------------------------------- +// Exchange-rate sensitivity +// --------------------------------------------------------------------------- + +#[test] +fn cancel_unstake_should_yield_fewer_atokens_when_rate_increased() { + // Rate inflated between unstake and cancel → fewer aTokens minted. + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + + // Donate to pot → rate climbs above 1.0 at cancel time. + // (Supply was zeroed by full unstake; restore some via Bob to give the + // pot a non-empty denominator.) + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(BOB).into(), 100 * ONE)); + assert_ok!(Balances::transfer_keep_alive( + RawOrigin::Signed(TREASURY).into(), + pot(), + 100 * ONE, + )); + // rate now ≈ (100 + 100) / 100 = 2.0 → cancel of 100 mints 50 aTokens. + + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1)); + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 100 * ONE); + assert_eq!(s.gigahdx, 50 * ONE); + }); +} + +#[test] +fn cancel_unstake_should_yield_same_atoken_count_when_rate_unchanged() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + let pre_gigahdx = Stakes::::get(ALICE).unwrap().gigahdx; + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1)); + + assert_eq!(Stakes::::get(ALICE).unwrap().gigahdx, pre_gigahdx); + }); +} + +// --------------------------------------------------------------------------- +// Anti-extraction +// --------------------------------------------------------------------------- + +#[test] +fn unstake_cancel_cycle_should_be_value_neutral_when_rate_unchanged() { + ExtBuilder::default() + .with_pot_balance(50 * ONE) + .build() + .execute_with(|| { + stake_alice_100(); + let total_value_before = TotalLocked::::get().saturating_add(Balances::free_balance(pot())); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 1)); + + let total_value_after = TotalLocked::::get().saturating_add(Balances::free_balance(pot())); + assert_eq!(total_value_after, total_value_before); + }); +} + +fn alice_claim_hdx_value() -> Balance { + let s = match Stakes::::get(ALICE) { + Some(s) => s, + None => return 0, + }; + let supply = GigaHdx::total_gigahdx_supply(); + if supply == 0 || s.gigahdx == 0 { + return 0; + } + let system_total = TotalLocked::::get().saturating_add(Balances::free_balance(pot())); + s.gigahdx.saturating_mul(system_total) / supply +} + +#[test] +fn repeated_unstake_cancel_cycles_should_not_inflate_user_claim_value() { + // Alice can shuffle pot-yield into stake.hdx via cancel, but her HDX-claim + // value (gigahdx × rate) must not grow across cycles. + ExtBuilder::default() + .with_pot_balance(50 * ONE) + .build() + .execute_with(|| { + stake_alice_100(); + let initial_claim = alice_claim_hdx_value(); + + for _ in 0..10 { + let s = Stakes::::get(ALICE).unwrap(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), s.gigahdx,)); + let id = only_pending(ALICE).id; + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), id)); + } + + let after_claim = alice_claim_hdx_value(); + assert!( + after_claim <= initial_claim, + "claim value must not grow across cycles: {initial_claim} → {after_claim}", + ); + }); +} + +#[test] +fn repeated_unstake_cancel_cycles_should_not_drain_gigapot() { + ExtBuilder::default() + .with_pot_balance(50 * ONE) + .build() + .execute_with(|| { + stake_alice_100(); + let pot_initial = Balances::free_balance(pot()); + + for _ in 0..10 { + let s = Stakes::::get(ALICE).unwrap(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), s.gigahdx,)); + let id = only_pending(ALICE).id; + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), id)); + } + + // First cycle drains the 50 yield into Alice's principal; subsequent + // cycles have pot=0 and operate at rate 1, so pot stays empty. + let pot_after = Balances::free_balance(pot()); + let total_after = TotalLocked::::get().saturating_add(pot_after); + let total_initial = 100 * ONE + pot_initial; + assert_eq!( + total_after, total_initial, + "system total (TotalLocked + pot) must be conserved across cycles", + ); + }); +} + +#[test] +fn repeated_unstake_cancel_cycles_should_not_affect_other_stakers() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(BOB).into(), 100 * ONE)); + let bob_initial = Stakes::::get(BOB).unwrap(); + let pot_initial = Balances::free_balance(pot()); + + for _ in 0..5 { + let s = Stakes::::get(ALICE).unwrap(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), s.gigahdx,)); + let id = only_pending(ALICE).id; + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), id)); + } + + assert_eq!(Stakes::::get(BOB).unwrap(), bob_initial); + assert_eq!(Balances::free_balance(pot()), pot_initial); + }); +} diff --git a/pallets/gigahdx/src/tests/do_stake.rs b/pallets/gigahdx/src/tests/do_stake.rs new file mode 100644 index 0000000000..dfe9d1e838 --- /dev/null +++ b/pallets/gigahdx/src/tests/do_stake.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Error, Stakes, TotalLocked}; +use frame_support::{assert_noop, assert_ok}; +use frame_system::RawOrigin; +use hydradx_traits::gigahdx::MoneyMarketOperations; + +#[test] +fn do_stake_should_create_new_stake_record_when_user_had_none() { + ExtBuilder::default().build().execute_with(|| { + assert!(Stakes::::get(ALICE).is_none()); + + assert_ok!(GigaHdx::do_stake(&ALICE, 5 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 5 * ONE); + assert_eq!(s.gigahdx, 5 * ONE); // 1:1 bootstrap rate + assert_eq!(s.frozen, 0); + assert_eq!(TotalLocked::::get(), 5 * ONE); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 5 * ONE); + }); +} + +#[test] +fn do_stake_should_compound_into_existing_position() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_ok!(GigaHdx::do_stake(&ALICE, 10 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 110 * ONE); + assert_eq!(s.gigahdx, 110 * ONE); + }); +} + +#[test] +fn do_stake_should_bypass_min_stake_check() { + ExtBuilder::default().build().execute_with(|| { + // Sub-MinStake amount that giga_stake would reject: + let tiny = ONE / 10; + assert!(tiny < ::MinStake::get()); + + assert_ok!(GigaHdx::do_stake(&ALICE, tiny)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, tiny); + }); +} + +#[test] +fn do_stake_should_fail_when_amount_zero() { + ExtBuilder::default().build().execute_with(|| { + assert_noop!(GigaHdx::do_stake(&ALICE, 0), Error::::ZeroAmount); + }); +} + +#[test] +fn do_stake_should_fail_when_conversion_rounds_to_zero() { + ExtBuilder::default() + .with_pot_balance(1_000 * ONE) + .build() + .execute_with(|| { + // Set up a heavily appreciated rate: stake → fund pot → next mint floors. + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + // After this, rate ≈ (100 + 1000) / 100 = 11 HDX per gigahdx. + // To mint zero, we need amount < 11. Try with 1 picoHDX-equivalent. + let amount = 10u128; + assert_noop!(GigaHdx::do_stake(&ALICE, amount), Error::::ZeroAmount); + }); +} + +#[test] +fn do_stake_should_revert_storage_when_mm_supply_fails() { + ExtBuilder::default().build().execute_with(|| { + TestMoneyMarket::fail_supply(); + + assert_noop!( + GigaHdx::do_stake(&ALICE, 5 * ONE), + Error::::MoneyMarketSupplyFailed + ); + + // State untouched. + assert!(Stakes::::get(ALICE).is_none()); + assert_eq!(TotalLocked::::get(), 0); + }); +} + +#[test] +fn do_stake_should_lock_hdx_under_giga_lock_id() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::do_stake(&ALICE, 5 * ONE)); + + let lock = pallet_balances::Locks::::get(ALICE) + .iter() + .find(|l| l.id == GIGAHDX_LOCK_ID) + .map(|l| l.amount) + .unwrap_or(0); + assert_eq!(lock, 5 * ONE); + }); +} diff --git a/pallets/gigahdx/src/tests/freeze.rs b/pallets/gigahdx/src/tests/freeze.rs new file mode 100644 index 0000000000..8450a882d6 --- /dev/null +++ b/pallets/gigahdx/src/tests/freeze.rs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Error, Stakes}; +use frame_support::{assert_noop, assert_ok}; +use frame_system::RawOrigin; + +fn stake_alice_100() { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); +} + +#[test] +fn freeze_should_create_stake_record_when_user_had_none() { + ExtBuilder::default().build().execute_with(|| { + assert!(Stakes::::get(ALICE).is_none()); + + GigaHdx::freeze(&ALICE, 10 * ONE); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 0); + assert_eq!(s.gigahdx, 0); + assert_eq!(s.frozen, 10 * ONE); + }); +} + +#[test] +fn freeze_should_be_additive_when_called_repeatedly() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + GigaHdx::freeze(&ALICE, 30 * ONE); + GigaHdx::freeze(&ALICE, 20 * ONE); + + assert_eq!(Stakes::::get(ALICE).unwrap().frozen, 50 * ONE); + }); +} + +#[test] +fn freeze_should_noop_when_delta_zero() { + ExtBuilder::default().build().execute_with(|| { + GigaHdx::freeze(&ALICE, 0); + assert!(Stakes::::get(ALICE).is_none()); + }); +} + +#[test] +fn unfreeze_should_saturate_when_delta_exceeds_frozen() { + ExtBuilder::default().build().execute_with(|| { + GigaHdx::freeze(&ALICE, 10 * ONE); + GigaHdx::unfreeze(&ALICE, 100 * ONE); + + // saturating_sub clamped to 0; record gets cleaned up (all fields zero). + assert!(Stakes::::get(ALICE).is_none()); + }); +} + +#[test] +fn unfreeze_should_remove_record_when_all_fields_zero() { + ExtBuilder::default().build().execute_with(|| { + GigaHdx::freeze(&ALICE, 10 * ONE); + assert!(Stakes::::get(ALICE).is_some()); + + GigaHdx::unfreeze(&ALICE, 10 * ONE); + assert!(Stakes::::get(ALICE).is_none()); + }); +} + +#[test] +fn unfreeze_should_keep_record_when_hdx_or_gigahdx_nonzero() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + GigaHdx::freeze(&ALICE, 10 * ONE); + GigaHdx::unfreeze(&ALICE, 10 * ONE); + + // hdx and gigahdx still active → record persists with frozen=0. + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 100 * ONE); + assert_eq!(s.frozen, 0); + }); +} + +#[test] +fn giga_unstake_should_fail_when_below_frozen_amount() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + GigaHdx::freeze(&ALICE, 80 * ONE); + + // unstaking everything would drop hdx to 0 < frozen=80 → blocked. + assert_noop!( + GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE), + Error::::StakeFrozen + ); + + // stake state untouched + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 100 * ONE); + assert_eq!(s.frozen, 80 * ONE); + }); +} + +#[test] +fn giga_unstake_should_succeed_when_payout_keeps_hdx_above_frozen() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + GigaHdx::freeze(&ALICE, 30 * ONE); + + // unstaking 50 leaves hdx=50 ≥ frozen=30 → ok. + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 50 * ONE); + assert_eq!(s.frozen, 30 * ONE); + }); +} + +#[test] +fn giga_unstake_should_succeed_after_unfreeze_releases_stake() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + GigaHdx::freeze(&ALICE, 80 * ONE); + + assert_noop!( + GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE), + Error::::StakeFrozen + ); + + GigaHdx::unfreeze(&ALICE, 80 * ONE); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + }); +} diff --git a/pallets/gigahdx/src/tests/invariants.rs b/pallets/gigahdx/src/tests/invariants.rs new file mode 100644 index 0000000000..c70debf105 --- /dev/null +++ b/pallets/gigahdx/src/tests/invariants.rs @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Property-based invariants for stake / unstake / yield-accrual / +// realize_yield interleavings. Rounding must always favour the protocol: +// users are never over-credited (INV6) and the gigapot is never over-drawn +// (INV7). Assertions use the pallet's own math so production code is +// validated against itself, not a re-derived model. + +use super::mock::*; +use crate::{Error, Stakes, TotalLocked}; +use frame_support::assert_ok; +use frame_support::traits::Currency; +use frame_system::RawOrigin; +use primitives::Balance; +use proptest::prelude::*; + +const ACCS: [AccountId; 3] = [ALICE, BOB, TREASURY]; + +fn acc(i: usize) -> AccountId { + ACCS[i % ACCS.len()] +} + +fn locked_under_ghdx(a: AccountId) -> Balance { + pallet_balances::Locks::::get(a) + .iter() + .find(|l| l.id == GIGAHDX_LOCK_ID) + .map(|l| l.amount) + .unwrap_or(0) +} + +fn gigapot_balance() -> Balance { + Balances::free_balance(GigaHdx::gigapot_account_id()) +} + +fn current_value(g: Balance) -> Balance { + GigaHdx::calculate_hdx_amount_given_gigahdx(g).expect("rate math overflow") +} + +#[derive(Debug, Clone)] +enum Op { + Stake { who: usize, amount: Balance }, + Unstake { who: usize, frac: u8 }, + AccrueYield { amount: Balance }, + RealizeYield { who: usize }, + Unlock { who: usize }, +} + +fn op_strategy() -> impl Strategy { + prop_oneof![ + (0usize..3, ONE..=50 * ONE).prop_map(|(who, amount)| Op::Stake { who, amount }), + (0usize..3, 1u8..=4).prop_map(|(who, frac)| Op::Unstake { who, frac }), + (ONE..=100 * ONE).prop_map(|amount| Op::AccrueYield { amount }), + (0usize..3).prop_map(|who| Op::RealizeYield { who }), + (0usize..3).prop_map(|who| Op::Unlock { who }), + ] +} + +/// INV1, INV2, INV7 — asserted after every op. +fn assert_global_invariants() { + let mut sum_hdx: Balance = 0; + let mut sum_value: Balance = 0; + for &a in ACCS.iter() { + let (hdx, gigahdx, unstaking) = Stakes::::get(a) + .map(|s| (s.hdx, s.gigahdx, s.unstaking)) + .unwrap_or((0, 0, 0)); + sum_hdx = sum_hdx.saturating_add(hdx); + sum_value = sum_value.saturating_add(current_value(gigahdx)); + // INV2: the ghdxlock equals active + pending principal. + assert_eq!( + locked_under_ghdx(a), + hdx.saturating_add(unstaking), + "INV2 lock mismatch for {a}" + ); + } + // INV1: TotalLocked is the sum of active principals. + assert_eq!(TotalLocked::::get(), sum_hdx, "INV1 TotalLocked mismatch"); + // INV7: aggregate solvency — total redeemable value never exceeds total + // backing (`TotalLocked + gigapot`). This is the real protocol promise and + // holds exactly. (The *clamped* per-user sum can exceed the gigapot by a + // few atomic units of cross-user rounding dust; that surfaces as a clean + // `GigapotInsufficient` revert on `realize_yield`, never an over-draw.) + assert!( + sum_value <= GigaHdx::total_staked_hdx(), + "INV7 solvency: redeemable {sum_value} > backing {}", + GigaHdx::total_staked_hdx() + ); +} + +/// Run realize_yield with the local conservation / inertness checks +/// (INV3, INV4, INV5, INV6, INV9). +fn realize_checked(a: AccountId) { + let before = Stakes::::get(a); + let record_present = before.is_some(); + let hdx_before = before.as_ref().map(|s| s.hdx).unwrap_or(0); + let gigahdx_before = before.as_ref().map(|s| s.gigahdx).unwrap_or(0); + let frozen_before = before.as_ref().map(|s| s.frozen).unwrap_or(0); + let unstaking_before = before.as_ref().map(|s| s.unstaking).unwrap_or(0); + + let supply_before = GigaHdx::total_gigahdx_supply(); + let rate_before = GigaHdx::exchange_rate(); + let gigapot_before = gigapot_balance(); + let acct_total_before = Balances::total_balance(&a); + let issuance_before = Balances::total_issuance(); + + // Cross-user rounding can leave the gigapot a few atomic units short of a + // staker's clamped claimable. realize_yield then reverts cleanly with + // `GigapotInsufficient` (INV8: full rollback), never over-draws. + match GigaHdx::realize_yield(RawOrigin::Signed(a).into()) { + Ok(()) => {} + Err(e) => { + assert_eq!( + e, + Error::::GigapotInsufficient.into(), + "unexpected realize_yield error: {e:?}" + ); + assert_eq!(Stakes::::get(a), before, "INV8 rollback: Stakes changed"); + assert_eq!(gigapot_balance(), gigapot_before, "INV8 rollback: gigapot changed"); + assert_eq!( + GigaHdx::total_gigahdx_supply(), + supply_before, + "INV8 rollback: supply changed" + ); + return; + } + } + + let after = Stakes::::get(a); + // INV9: realize never creates or destroys a record. + assert_eq!(after.is_some(), record_present, "INV9 record lifecycle"); + + let hdx_after = after.as_ref().map(|s| s.hdx).unwrap_or(0); + let gigahdx_after = after.as_ref().map(|s| s.gigahdx).unwrap_or(0); + let frozen_after = after.as_ref().map(|s| s.frozen).unwrap_or(0); + let unstaking_after = after.as_ref().map(|s| s.unstaking).unwrap_or(0); + let accrued = hdx_after - hdx_before; + let cv_before = current_value(gigahdx_before); + + // INV3: economically inert. + assert_eq!(gigahdx_after, gigahdx_before, "INV3 gigahdx changed"); + assert_eq!(GigaHdx::total_gigahdx_supply(), supply_before, "INV3 supply changed"); + assert_eq!(GigaHdx::exchange_rate(), rate_before, "INV3 rate changed"); + assert_eq!(frozen_after, frozen_before, "INV3 frozen changed"); + assert_eq!(unstaking_after, unstaking_before, "INV3 unstaking changed"); + + // INV4: pure transfer gigapot → who, no mint. + assert_eq!(gigapot_balance(), gigapot_before - accrued, "INV4 gigapot delta"); + assert_eq!( + Balances::total_balance(&a), + acct_total_before + accrued, + "INV4 account delta" + ); + assert_eq!( + Balances::total_issuance(), + issuance_before, + "INV4 native issuance moved" + ); + + // INV6: credited exactly the clamped floor accrued — never more. In the + // no-op case (`cv_before <= hdx_before`, a stake-time rounding residual in + // the protocol's favour) nothing is credited and the residual is left + // untouched. + assert_eq!( + accrued, + cv_before.saturating_sub(hdx_before), + "INV6 over-credit: accrued exceeds floor yield" + ); + if accrued > 0 { + assert_eq!(hdx_after, cv_before, "INV6 principal != current value after realize"); + } + + // INV5: immediately idempotent. + let h = hdx_after; + let p = gigapot_balance(); + assert_ok!(GigaHdx::realize_yield(RawOrigin::Signed(a).into())); + assert_eq!( + Stakes::::get(a).map(|s| s.hdx).unwrap_or(0), + h, + "INV5 not idempotent" + ); + assert_eq!(gigapot_balance(), p, "INV5 idempotent gigapot"); +} + +fn apply(op: &Op) { + match *op { + Op::Stake { who, amount } => { + let _ = GigaHdx::giga_stake(RawOrigin::Signed(acc(who)).into(), amount); + } + Op::Unstake { who, frac } => { + let a = acc(who); + if let Some(s) = Stakes::::get(a) { + let amt = s.gigahdx / 4 * frac as Balance; + if amt > 0 { + let _ = GigaHdx::giga_unstake(RawOrigin::Signed(a).into(), amt); + } + } + } + Op::AccrueYield { amount } => { + let _ = Balances::deposit_creating(&GigaHdx::gigapot_account_id(), amount); + } + Op::RealizeYield { who } => realize_checked(acc(who)), + Op::Unlock { who } => { + let a = acc(who); + if let Some((id, _)) = crate::PendingUnstakes::::iter_prefix(a).next() { + let target = (id + GigaHdxCooldownPeriod::get() + 1).max(frame_system::Pallet::::block_number()); + frame_system::Pallet::::set_block_number(target); + let _ = GigaHdx::unlock(RawOrigin::Signed(a).into(), id); + } + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Random op sequences keep every global invariant after every step. + #[test] + fn invariants_hold_under_random_op_sequences(ops in prop::collection::vec(op_strategy(), 1..24)) { + ExtBuilder::default().build().execute_with(|| { + assert_global_invariants(); + for op in &ops { + apply(op); + assert_global_invariants(); + } + }); + } + + /// INV10 — value-neutrality: realize_yield then full unstake yields the + /// same total HDX (±1) as a direct full unstake from an identical state. + #[test] + fn realize_then_unstake_is_value_neutral( + prefix in prop::collection::vec(op_strategy(), 0..12), + seed in ONE..=200 * ONE, + ) { + let run = |realize_first: bool| -> Balance { + let mut total = 0; + ExtBuilder::default().build().execute_with(|| { + // Guarantee ALICE has a position to unstake. + let _ = GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), seed); + for op in &prefix { + apply(op); + } + if realize_first { + let _ = GigaHdx::realize_yield(RawOrigin::Signed(ALICE).into()); + } + if let Some(s) = Stakes::::get(ALICE) { + if s.gigahdx > 0 { + let _ = GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), s.gigahdx); + } + } + total = Balances::total_balance(&ALICE); + }); + total + }; + + let without = run(false); + let with = run(true); + let diff = without.abs_diff(with); + prop_assert!(diff <= 1, "INV10 value drift: without={without} with={with} diff={diff}"); + } +} diff --git a/pallets/gigahdx/src/tests/migrate.rs b/pallets/gigahdx/src/tests/migrate.rs new file mode 100644 index 0000000000..4deea42b3d --- /dev/null +++ b/pallets/gigahdx/src/tests/migrate.rs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Error, Event, Stakes, TotalLocked}; +use frame_support::{assert_err, assert_noop, assert_ok}; +use frame_system::RawOrigin; +use hydradx_traits::gigahdx::MoneyMarketOperations; +use primitives::Balance; + +fn last_events(n: usize) -> Vec { + frame_system::Pallet::::events() + .into_iter() + .rev() + .take(n) + .map(|r| r.event) + .collect() +} + +fn locked_under_ghdx(account: AccountId) -> Balance { + pallet_balances::Locks::::get(account) + .iter() + .find(|l| l.id == GIGAHDX_LOCK_ID) + .map(|l| l.amount) + .unwrap_or(0) +} + +#[test] +fn migrate_should_open_gigahdx_position_from_legacy_unlock() { + ExtBuilder::default().build().execute_with(|| { + TestLegacyStaking::set_ok(100 * ONE); + + assert_ok!(GigaHdx::migrate(RawOrigin::Signed(ALICE).into())); + + assert_eq!(TestLegacyStaking::called_for(), Some(ALICE)); + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 100 * ONE); + assert_eq!(s.gigahdx, 100 * ONE); + assert_eq!(TotalLocked::::get(), 100 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 100 * ONE); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 100 * ONE); + + let found = last_events(5).iter().any(|e| { + matches!( + e, + RuntimeEvent::GigaHdx(Event::MigratedFromLegacy { + who, + hdx_unlocked, + gigahdx_received, + }) if *who == ALICE && *hdx_unlocked == 100 * ONE && *gigahdx_received == 100 * ONE + ) + }); + assert!(found, "expected MigratedFromLegacy event"); + }); +} + +#[test] +fn migrate_should_fail_when_legacy_unstake_refuses() { + ExtBuilder::default().build().execute_with(|| { + TestLegacyStaking::set_err(sp_runtime::DispatchError::Other("no position")); + + assert_err!( + GigaHdx::migrate(RawOrigin::Signed(ALICE).into()), + sp_runtime::DispatchError::Other("no position") + ); + + // No gigahdx position opened. + assert!(Stakes::::get(ALICE).is_none()); + assert_eq!(TotalLocked::::get(), 0); + }); +} + +#[test] +fn migrate_should_fail_when_unlocked_below_min_stake() { + ExtBuilder::default().build().execute_with(|| { + // MinStake = ONE; legacy unlocks half that. + TestLegacyStaking::set_ok(ONE / 2); + + assert_noop!( + GigaHdx::migrate(RawOrigin::Signed(ALICE).into()), + Error::::BelowMinStake + ); + assert!(Stakes::::get(ALICE).is_none()); + }); +} + +#[test] +fn migrate_should_fail_when_external_claim_present_after_unstake() { + ExtBuilder::default().build().execute_with(|| { + TestLegacyStaking::set_ok(100 * ONE); + // Simulate another lock surviving force_unstake (e.g. vesting). + TestExternalClaims::set(10 * ONE); + + assert_noop!( + GigaHdx::migrate(RawOrigin::Signed(ALICE).into()), + Error::::BlockedByExternalLock + ); + assert!(Stakes::::get(ALICE).is_none()); + }); +} + +#[test] +fn migrate_should_fail_when_unlocked_exceeds_stakeable_free_balance() { + ExtBuilder::default().build().execute_with(|| { + // ALICE only has 1_000 ONE endowed; pretend legacy reports 2_000. + TestLegacyStaking::set_ok(2_000 * ONE); + + assert_noop!( + GigaHdx::migrate(RawOrigin::Signed(ALICE).into()), + Error::::InsufficientFreeBalance + ); + }); +} + +#[test] +fn migrate_should_top_up_existing_gigahdx_position() { + ExtBuilder::default().build().execute_with(|| { + // User already has a gigahdx position. + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + let before = Stakes::::get(ALICE).unwrap(); + + TestLegacyStaking::set_ok(30 * ONE); + assert_ok!(GigaHdx::migrate(RawOrigin::Signed(ALICE).into())); + + let after = Stakes::::get(ALICE).unwrap(); + assert_eq!(after.hdx, before.hdx + 30 * ONE); + assert_eq!(after.gigahdx, before.gigahdx + 30 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 80 * ONE); + }); +} diff --git a/pallets/gigahdx/src/tests/mock.rs b/pallets/gigahdx/src/tests/mock.rs new file mode 100644 index 0000000000..359c79cc3c --- /dev/null +++ b/pallets/gigahdx/src/tests/mock.rs @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(test)] + +use crate as pallet_gigahdx; + +use frame_support::sp_runtime::{ + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, DispatchError, +}; +use frame_support::{ + construct_runtime, parameter_types, + traits::{ConstU32, ConstU64, Everything, LockIdentifier}, + PalletId, +}; +use frame_system::EnsureRoot; +use hydradx_traits::gigahdx::MoneyMarketOperations; +use orml_traits::parameter_type_with_key; +use primitives::{AssetId, Balance}; +use sp_core::H256; +use std::cell::RefCell; +use std::collections::HashMap; + +pub type AccountId = u64; +type Block = frame_system::mocking::MockBlock; + +#[allow(dead_code)] +pub const HDX: AssetId = 0; +pub const ST_HDX: AssetId = 670; +pub const ONE: Balance = 1_000_000_000_000; + +pub const ALICE: AccountId = 1; +pub const BOB: AccountId = 2; +pub const TREASURY: AccountId = 99; + +pub const GIGAHDX_LOCK_ID: LockIdentifier = *b"ghdxlock"; + +construct_runtime!( + pub enum Test { + System: frame_system, + Balances: pallet_balances, + Tokens: orml_tokens, + GigaHdx: pallet_gigahdx, + } +); + +impl frame_system::Config for Test { + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type RuntimeTask = RuntimeTask; + type Nonce = u64; + type Block = Block; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = ConstU64<250>; + type DbWeight = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; + type SingleBlockMigrations = (); + type MultiBlockMigrator = (); + type PreInherents = (); + type PostInherents = (); + type PostTransactions = (); + type ExtensionsWeightInfo = (); +} + +parameter_types! { + pub const ExistentialDeposit: Balance = 1; + pub const MaxLocks: u32 = 20; +} + +impl pallet_balances::Config for Test { + type Balance = Balance; + type DustRemoval = (); + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); + type MaxLocks = MaxLocks; + type MaxReserves = ConstU32<50>; + type ReserveIdentifier = [u8; 8]; + type FreezeIdentifier = (); + type MaxFreezes = (); + type RuntimeHoldReason = (); + type RuntimeFreezeReason = (); + type DoneSlashHandler = (); +} + +parameter_type_with_key! { + pub StHdxExistentialDeposits: |_currency_id: AssetId| -> Balance { + 0 + }; +} + +impl orml_tokens::Config for Test { + type Balance = Balance; + type Amount = i128; + type CurrencyId = AssetId; + type WeightInfo = (); + type ExistentialDeposits = StHdxExistentialDeposits; + type CurrencyHooks = (); + type MaxLocks = ConstU32<10>; + type MaxReserves = (); + type ReserveIdentifier = (); + type DustRemovalWhitelist = Everything; +} + +// ---------- TestMoneyMarket ---------- + +thread_local! { + pub static MM_BALANCES: RefCell> = RefCell::new(HashMap::new()); + /// When set, `supply` returns `input * num / den` (rounding test). 1/1 = identity. + pub static MM_SUPPLY_ROUND_NUM: RefCell = const { RefCell::new(1) }; + pub static MM_SUPPLY_ROUND_DEN: RefCell = const { RefCell::new(1) }; + /// When true, `supply` errors. + pub static MM_SUPPLY_FAILS: RefCell = const { RefCell::new(false) }; + pub static MM_WITHDRAW_FAILS: RefCell = const { RefCell::new(false) }; +} + +pub struct TestMoneyMarket; + +impl TestMoneyMarket { + pub fn reset() { + MM_BALANCES.with(|m| m.borrow_mut().clear()); + MM_SUPPLY_ROUND_NUM.with(|v| *v.borrow_mut() = 1); + MM_SUPPLY_ROUND_DEN.with(|v| *v.borrow_mut() = 1); + MM_SUPPLY_FAILS.with(|v| *v.borrow_mut() = false); + MM_WITHDRAW_FAILS.with(|v| *v.borrow_mut() = false); + } + pub fn set_supply_rounding(num: u128, den: u128) { + MM_SUPPLY_ROUND_NUM.with(|v| *v.borrow_mut() = num); + MM_SUPPLY_ROUND_DEN.with(|v| *v.borrow_mut() = den); + } + pub fn fail_supply() { + MM_SUPPLY_FAILS.with(|v| *v.borrow_mut() = true); + } + pub fn fail_withdraw() { + MM_WITHDRAW_FAILS.with(|v| *v.borrow_mut() = true); + } +} + +impl MoneyMarketOperations for TestMoneyMarket { + fn supply(who: &AccountId, _asset: AssetId, amount: Balance) -> Result { + if MM_SUPPLY_FAILS.with(|v| *v.borrow()) { + return Err(DispatchError::Other("MM supply failed")); + } + let num = MM_SUPPLY_ROUND_NUM.with(|v| *v.borrow()); + let den = MM_SUPPLY_ROUND_DEN.with(|v| *v.borrow()); + let actual = amount.saturating_mul(num) / den; + MM_BALANCES.with(|m| *m.borrow_mut().entry(*who).or_default() += actual); + Ok(actual) + } + + fn withdraw(who: &AccountId, _asset: AssetId, amount: Balance) -> Result { + if MM_WITHDRAW_FAILS.with(|v| *v.borrow()) { + return Err(DispatchError::Other("MM withdraw failed")); + } + MM_BALANCES.with(|m| { + let mut map = m.borrow_mut(); + let bal = map.entry(*who).or_default(); + *bal = bal.saturating_sub(amount); + }); + Ok(amount) + } + + fn balance_of(who: &AccountId) -> Balance { + MM_BALANCES.with(|m| *m.borrow().get(who).unwrap_or(&0)) + } +} + +// ---------- TestExternalClaims ---------- + +thread_local! { + pub static EXTERNAL_CLAIMS: RefCell = const { RefCell::new(0) }; +} + +pub struct TestExternalClaims; + +impl TestExternalClaims { + pub fn set(value: Balance) { + EXTERNAL_CLAIMS.with(|v| *v.borrow_mut() = value); + } +} + +impl pallet_gigahdx::traits::ExternalClaims for TestExternalClaims { + fn on(_who: &AccountId) -> Balance { + EXTERNAL_CLAIMS.with(|v| *v.borrow()) + } +} + +// ---------- TestLegacyStaking ---------- + +thread_local! { + pub static LEGACY_STAKE_RESULT: RefCell>> = const { RefCell::new(None) }; + pub static LEGACY_STAKE_CALLED_FOR: RefCell> = const { RefCell::new(None) }; +} + +pub struct TestLegacyStaking; + +impl TestLegacyStaking { + #[allow(dead_code)] + pub fn set_ok(unlocked: Balance) { + LEGACY_STAKE_RESULT.with(|v| *v.borrow_mut() = Some(Ok(unlocked))); + } + #[allow(dead_code)] + pub fn set_err(err: sp_runtime::DispatchError) { + LEGACY_STAKE_RESULT.with(|v| *v.borrow_mut() = Some(Err(err))); + } + #[allow(dead_code)] + pub fn reset() { + LEGACY_STAKE_RESULT.with(|v| *v.borrow_mut() = None); + LEGACY_STAKE_CALLED_FOR.with(|v| *v.borrow_mut() = None); + } + #[allow(dead_code)] + pub fn called_for() -> Option { + LEGACY_STAKE_CALLED_FOR.with(|v| *v.borrow()) + } +} + +impl pallet_gigahdx::traits::LegacyStakeMigrator for TestLegacyStaking { + fn force_unstake(who: &AccountId) -> Result { + LEGACY_STAKE_CALLED_FOR.with(|v| *v.borrow_mut() = Some(*who)); + LEGACY_STAKE_RESULT.with(|v| { + v.borrow_mut() + .take() + .unwrap_or(Err(sp_runtime::DispatchError::Other("legacy stake result not seeded"))) + }) + } +} + +// ---------- pallet-gigahdx config ---------- + +parameter_types! { + pub const StHdxAssetIdConst: AssetId = ST_HDX; + pub const GigaHdxPalletId: PalletId = PalletId(*b"gigahdx!"); + pub const GigaHdxLockId: LockIdentifier = GIGAHDX_LOCK_ID; + pub const GigaHdxMinStake: Balance = ONE; // 1 HDX + pub const GigaHdxCooldownPeriod: u64 = 100; // 100 blocks + pub const GigaHdxMaxPendingUnstakes: u32 = 10; +} + +impl pallet_gigahdx::Config for Test { + type NativeCurrency = Balances; + type MultiCurrency = Tokens; + type StHdxAssetId = StHdxAssetIdConst; + type MoneyMarket = TestMoneyMarket; + type AuthorityOrigin = EnsureRoot; + type PalletId = GigaHdxPalletId; + type LockId = GigaHdxLockId; + type MinStake = GigaHdxMinStake; + type CooldownPeriod = GigaHdxCooldownPeriod; + type MaxPendingUnstakes = GigaHdxMaxPendingUnstakes; + type ExternalClaims = TestExternalClaims; + type LegacyStaking = TestLegacyStaking; + type WeightInfo = (); + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = (); +} + +// ---------- Test helpers ---------- + +/// Flattened view of a single pending-unstake entry, used by tests that +/// assume exactly one position exists. +#[allow(dead_code)] +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct PendingView { + pub id: u64, + pub amount: Balance, + pub expires_at: u64, +} + +/// Return the only pending-unstake entry for `who`. Panics if zero or more +/// than one. Multi-position tests should iterate the storage directly. +#[allow(dead_code)] +pub fn only_pending(who: AccountId) -> PendingView { + let mut iter = pallet_gigahdx::PendingUnstakes::::iter_prefix(who); + let (id, p) = iter.next().expect("expected one pending position, got none"); + assert!(iter.next().is_none(), "expected exactly one pending position"); + PendingView { + id, + amount: p.amount, + expires_at: id + GigaHdxCooldownPeriod::get(), + } +} + +#[allow(dead_code)] +pub fn pending_count(who: AccountId) -> u16 { + pallet_gigahdx::Stakes::::get(who) + .map(|s| s.unstaking_count) + .unwrap_or(0) +} + +// ---------- Test ext builder ---------- + +pub struct ExtBuilder { + endowed_accounts: Vec<(AccountId, Balance)>, + pot_balance: Balance, +} + +impl Default for ExtBuilder { + fn default() -> Self { + Self { + endowed_accounts: vec![(ALICE, 1_000 * ONE), (BOB, 1_000 * ONE), (TREASURY, 1_000 * ONE)], + pot_balance: 0, + } + } +} + +impl ExtBuilder { + pub fn with_pot_balance(mut self, balance: Balance) -> Self { + self.pot_balance = balance; + self + } + + pub fn build(self) -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + + let mut balances = self.endowed_accounts.clone(); + if self.pot_balance > 0 { + use frame_support::sp_runtime::traits::AccountIdConversion; + let pot: AccountId = GigaHdxPalletId::get().into_account_truncating(); + balances.push((pot, self.pot_balance)); + } + pallet_balances::GenesisConfig:: { + balances, + dev_accounts: None, + } + .assimilate_storage(&mut t) + .unwrap(); + + let mut ext: sp_io::TestExternalities = t.into(); + ext.execute_with(|| { + TestMoneyMarket::reset(); + TestExternalClaims::set(0); + TestLegacyStaking::reset(); + System::set_block_number(1); + }); + ext + } +} diff --git a/pallets/gigahdx/src/tests/mod.rs b/pallets/gigahdx/src/tests/mod.rs new file mode 100644 index 0000000000..3fb92c51f9 --- /dev/null +++ b/pallets/gigahdx/src/tests/mod.rs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 + +mod cancel_unstake; +mod do_stake; +mod freeze; +mod invariants; +mod migrate; +mod mock; +mod multi_positions; +mod realize_yield; +mod set_pool_contract; +mod stake; +mod unlock; +mod unstake; diff --git a/pallets/gigahdx/src/tests/multi_positions.rs b/pallets/gigahdx/src/tests/multi_positions.rs new file mode 100644 index 0000000000..2a462a26cf --- /dev/null +++ b/pallets/gigahdx/src/tests/multi_positions.rs @@ -0,0 +1,539 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Error, PendingUnstakes, Stakes, TotalLocked}; +use frame_support::sp_runtime::traits::AccountIdConversion; +use frame_support::{assert_noop, assert_ok}; +use frame_system::RawOrigin; +use primitives::Balance; + +fn pot() -> AccountId { + GigaHdxPalletId::get().into_account_truncating() +} + +fn locked_under_ghdx(account: AccountId) -> Balance { + pallet_balances::Locks::::get(account) + .iter() + .find(|l| l.id == GIGAHDX_LOCK_ID) + .map(|l| l.amount) + .unwrap_or(0) +} + +fn ids_of(who: AccountId) -> Vec { + let mut v: Vec = PendingUnstakes::::iter_prefix(who).map(|(id, _)| id).collect(); + v.sort(); + v +} + +fn pending_sum(who: AccountId) -> Balance { + PendingUnstakes::::iter_prefix(who).map(|(_, p)| p.amount).sum() +} + +fn next_block() { + System::set_block_number(System::block_number() + 1); +} + +// --------------------------------------------------------------------------- +// Same-block compounding +// --------------------------------------------------------------------------- + +#[test] +fn giga_unstake_should_compound_into_one_position_when_called_twice_in_same_block() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let block = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 20 * ONE)); + + assert_eq!(ids_of(ALICE), vec![block]); + assert_eq!(pending_count(ALICE), 1); + assert_eq!(PendingUnstakes::::get(ALICE, block).unwrap().amount, 50 * ONE); + assert_eq!(Stakes::::get(ALICE).unwrap().unstaking, 50 * ONE); + }); +} + +#[test] +fn giga_unstake_should_compound_correctly_when_rate_changes_between_same_block_calls() { + // pot=50, stake=100 → rate=1.5. First unstake 40g → payout 60 (principal). + // Second unstake 60g → payout 90 with 50 yield (active drained). + ExtBuilder::default() + .with_pot_balance(50 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let pot_before = Balances::free_balance(pot()); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 60 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 0); + assert_eq!(s.gigahdx, 0); + assert_eq!(s.unstaking, 150 * ONE); + assert_eq!(s.unstaking_count, 1); + assert_eq!(Balances::free_balance(pot()), pot_before - 50 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 150 * ONE); + }); +} + +#[test] +fn same_block_compounding_should_not_bump_unstaking_count() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + for _ in 0..5 { + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 5 * ONE)); + } + assert_eq!(Stakes::::get(ALICE).unwrap().unstaking_count, 1); + }); +} + +#[test] +fn cancel_should_handle_compounded_position_with_yield_correctly() { + ExtBuilder::default() + .with_pot_balance(50 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let block = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 60 * ONE)); + assert_eq!(PendingUnstakes::::get(ALICE, block).unwrap().amount, 150 * ONE); + assert_eq!(Balances::free_balance(pot()), 0); + + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), block)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 150 * ONE); + assert_eq!(s.gigahdx, 150 * ONE); + assert_eq!(s.unstaking, 0); + assert_eq!(s.unstaking_count, 0); + assert_eq!(TotalLocked::::get(), 150 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 150 * ONE); + }); +} + +#[test] +fn cancel_compounded_position_should_preserve_system_total() { + ExtBuilder::default() + .with_pot_balance(50 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let baseline = system_total(); + let block = System::block_number(); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 60 * ONE)); + assert_eq!(system_total(), baseline); + + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), block)); + assert_eq!(system_total(), baseline); + }); +} + +#[test] +fn cancel_compounded_should_decrement_count_by_one() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 200 * ONE)); + let b0 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 10 * ONE)); + next_block(); + let b1 = System::block_number(); + for _ in 0..3 { + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 10 * ONE)); + } + assert_eq!(Stakes::::get(ALICE).unwrap().unstaking_count, 2); + + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), b1)); + + assert_eq!(Stakes::::get(ALICE).unwrap().unstaking_count, 1); + assert_eq!(ids_of(ALICE), vec![b0]); + }); +} + +#[test] +fn unlock_compounded_should_release_full_compounded_amount() { + ExtBuilder::default() + .with_pot_balance(50 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let block = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 60 * ONE)); + let pre_free = Balances::free_balance(ALICE); + assert_eq!(locked_under_ghdx(ALICE), 150 * ONE); + + System::set_block_number(block + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), block)); + + assert_eq!(pending_count(ALICE), 0); + assert_eq!(Balances::free_balance(ALICE), pre_free); + assert_eq!(locked_under_ghdx(ALICE), 0); + }); +} + +#[test] +fn same_block_compounding_should_not_trigger_admission_cap() { + ExtBuilder::default().build().execute_with(|| { + let max = GigaHdxMaxPendingUnstakes::get(); + assert_ok!(GigaHdx::giga_stake( + RawOrigin::Signed(ALICE).into(), + (max as Balance) * 10 * ONE, + )); + for _ in 0..max + 2 { + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), ONE)); + } + assert_eq!(pending_count(ALICE), 1); + }); +} + +// --------------------------------------------------------------------------- +// Admission cap across distinct blocks +// --------------------------------------------------------------------------- + +#[test] +fn giga_unstake_should_create_distinct_positions_across_blocks() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 300 * ONE)); + let mut expected_ids = vec![]; + for _ in 0..3 { + expected_ids.push(System::block_number()); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + next_block(); + } + assert_eq!(ids_of(ALICE), expected_ids); + assert_eq!(pending_count(ALICE), 3); + }); +} + +#[test] +fn giga_unstake_should_fail_when_max_pending_positions_reached_across_blocks() { + ExtBuilder::default().build().execute_with(|| { + let max = GigaHdxMaxPendingUnstakes::get(); + assert_ok!(GigaHdx::giga_stake( + RawOrigin::Signed(ALICE).into(), + (max as Balance) * 10 * ONE, + )); + for _ in 0..max { + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 5 * ONE)); + next_block(); + } + assert_noop!( + GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 5 * ONE), + Error::::TooManyPendingUnstakes, + ); + }); +} + +#[test] +fn giga_unstake_should_succeed_after_unlock_freed_a_slot() { + ExtBuilder::default().build().execute_with(|| { + let max = GigaHdxMaxPendingUnstakes::get(); + assert_ok!(GigaHdx::giga_stake( + RawOrigin::Signed(ALICE).into(), + (max as Balance) * 10 * ONE, + )); + let first_id = System::block_number(); + for _ in 0..max { + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 5 * ONE)); + next_block(); + } + System::set_block_number(first_id + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), first_id)); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 5 * ONE)); + assert_eq!(pending_count(ALICE) as u32, max); + }); +} + +// --------------------------------------------------------------------------- +// unlock(position_id) — block-keyed +// --------------------------------------------------------------------------- + +#[test] +fn unlock_should_release_only_targeted_position_when_called() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 300 * ONE)); + let b0 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + next_block(); + let b1 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + next_block(); + let b2 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + + System::set_block_number(b1 + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), b1)); + + assert_eq!(ids_of(ALICE), vec![b0, b2]); + assert_eq!(locked_under_ghdx(ALICE), 260 * ONE); + }); +} + +#[test] +fn unlock_should_fail_when_position_id_not_found() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + System::set_block_number(1 + GigaHdxCooldownPeriod::get()); + assert_noop!( + GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), 999_999), + Error::::PendingUnstakeNotFound, + ); + }); +} + +#[test] +fn unlock_should_fail_when_target_cooldown_not_elapsed() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 200 * ONE)); + let b0 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + System::set_block_number(b0 + GigaHdxCooldownPeriod::get() / 2); + let b1 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + System::set_block_number(b0 + GigaHdxCooldownPeriod::get()); + + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), b0)); + assert_noop!( + GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), b1), + Error::::CooldownNotElapsed, + ); + }); +} + +#[test] +fn unlock_should_clean_up_stake_record_when_all_zero() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let b0 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + System::set_block_number(b0 + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), b0)); + + assert!(Stakes::::get(ALICE).is_none()); + assert_eq!(pending_count(ALICE), 0); + assert_eq!(locked_under_ghdx(ALICE), 0); + }); +} + +#[test] +fn unlock_should_keep_stake_record_when_other_positions_remain() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let b0 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + next_block(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 60 * ONE)); + System::set_block_number(b0 + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), b0)); + + assert!(Stakes::::get(ALICE).is_some()); + assert_eq!(pending_count(ALICE), 1); + }); +} + +// --------------------------------------------------------------------------- +// cancel_unstake — block-keyed +// --------------------------------------------------------------------------- + +#[test] +fn cancel_unstake_should_leave_other_positions_intact() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 300 * ONE)); + let b0 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + next_block(); + let b1 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + next_block(); + let b2 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), b1)); + + assert_eq!(ids_of(ALICE), vec![b0, b2]); + assert_eq!(PendingUnstakes::::get(ALICE, b0).unwrap().amount, 30 * ONE); + assert_eq!(PendingUnstakes::::get(ALICE, b2).unwrap().amount, 50 * ONE); + }); +} + +#[test] +fn cancel_unstake_should_fail_when_position_id_not_found() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + assert_noop!( + GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), 999_999), + Error::::PendingUnstakeNotFound, + ); + }); +} + +// --------------------------------------------------------------------------- +// Cached accounting invariants +// --------------------------------------------------------------------------- + +#[test] +fn cached_unstaking_should_match_sum_of_positions() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 300 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + next_block(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 75 * ONE)); + next_block(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 25 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.unstaking, pending_sum(ALICE)); + assert_eq!(s.unstaking, 150 * ONE); + assert_eq!(s.unstaking_count, 3); + }); +} + +#[test] +fn cached_unstaking_should_decrease_when_position_unlocked() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 200 * ONE)); + let b0 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + next_block(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + System::set_block_number(b0 + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), b0)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.unstaking, 30 * ONE); + assert_eq!(s.unstaking_count, 1); + assert_eq!(s.unstaking, pending_sum(ALICE)); + }); +} + +#[test] +fn cached_unstaking_should_decrease_when_position_cancelled() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 200 * ONE)); + let b0 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + next_block(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), b0)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.unstaking, 30 * ONE); + assert_eq!(s.unstaking_count, 1); + assert_eq!(s.unstaking, pending_sum(ALICE)); + }); +} + +// --------------------------------------------------------------------------- +// Lock invariants +// --------------------------------------------------------------------------- + +#[test] +fn lock_should_equal_active_plus_sum_of_pending_when_multiple_positions() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 300 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + next_block(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 75 * ONE)); + next_block(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 25 * ONE)); + + let active = Stakes::::get(ALICE).unwrap().hdx; + assert_eq!(locked_under_ghdx(ALICE), active + pending_sum(ALICE)); + assert_eq!(active, 150 * ONE); + assert_eq!(pending_sum(ALICE), 150 * ONE); + }); +} + +#[test] +fn lock_should_decrease_by_unlocked_amount_when_one_unlocks() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 200 * ONE)); + let b0 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + next_block(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + let pre_lock = locked_under_ghdx(ALICE); + + System::set_block_number(b0 + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), b0)); + + assert_eq!(locked_under_ghdx(ALICE), pre_lock - 50 * ONE); + }); +} + +#[test] +fn lock_should_remain_when_one_position_is_cancelled() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 200 * ONE)); + let b0 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + next_block(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + let pre_lock = locked_under_ghdx(ALICE); + + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), b0)); + + assert_eq!(locked_under_ghdx(ALICE), pre_lock); + }); +} + +fn system_total() -> Balance { + let pending_sum: Balance = PendingUnstakes::::iter().map(|(_, _, p)| p.amount).sum(); + TotalLocked::::get() + pending_sum + Balances::free_balance(pot()) +} + +#[test] +fn multiple_pending_positions_should_conserve_system_total() { + ExtBuilder::default() + .with_pot_balance(60 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let baseline = system_total(); + + let b0 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_eq!(system_total(), baseline); + + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), b0)); + assert_eq!(system_total(), baseline); + + next_block(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + next_block(); + let b_for_30 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + assert_eq!(system_total(), baseline); + + System::set_block_number(b_for_30 + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), b_for_30)); + assert_eq!(system_total(), baseline - 30 * ONE); + }); +} + +#[test] +fn frozen_should_remain_invariant_across_multi_position_operations() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 300 * ONE)); + GigaHdx::freeze(&ALICE, 50 * ONE); + + let b0 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + next_block(); + let b1 = System::block_number(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_ok!(GigaHdx::cancel_unstake(RawOrigin::Signed(ALICE).into(), b0)); + System::set_block_number(b1 + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), b1)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.frozen, 50 * ONE); + assert!(s.frozen <= s.hdx); + }); +} diff --git a/pallets/gigahdx/src/tests/realize_yield.rs b/pallets/gigahdx/src/tests/realize_yield.rs new file mode 100644 index 0000000000..e4f3acbba6 --- /dev/null +++ b/pallets/gigahdx/src/tests/realize_yield.rs @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Event, Stakes, TotalLocked}; +use frame_support::assert_ok; +use frame_system::RawOrigin; +use primitives::Balance; + +fn locked_under_ghdx(account: AccountId) -> Balance { + pallet_balances::Locks::::get(account) + .iter() + .find(|l| l.id == GIGAHDX_LOCK_ID) + .map(|l| l.amount) + .unwrap_or(0) +} + +fn gigapot_balance() -> Balance { + Balances::free_balance(GigaHdx::gigapot_account_id()) +} + +fn yield_realized_amount(who: AccountId) -> Option { + System::events().into_iter().rev().find_map(|r| match r.event { + RuntimeEvent::GigaHdx(Event::YieldRealized { who: w, amount }) if w == who => Some(amount), + _ => None, + }) +} + +#[test] +fn realize_yield_should_move_accrued_into_principal_when_rate_increased() { + // Pot seeded so post-stake rate = (100 + 100) / 100 = 2. + ExtBuilder::default() + .with_pot_balance(100 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let free_before = Balances::free_balance(ALICE); + + assert_ok!(GigaHdx::realize_yield(RawOrigin::Signed(ALICE).into())); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 200 * ONE); + assert_eq!(s.gigahdx, 100 * ONE); + assert_eq!(TotalLocked::::get(), 200 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 200 * ONE); + assert_eq!(gigapot_balance(), 0); + assert_eq!(Balances::free_balance(ALICE), free_before + 100 * ONE); + assert_eq!(yield_realized_amount(ALICE), Some(100 * ONE)); + }); +} + +#[test] +fn realize_yield_should_not_change_gigahdx_balance_when_called() { + ExtBuilder::default() + .with_pot_balance(100 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let gigahdx_before = Stakes::::get(ALICE).unwrap().gigahdx; + let supply_before = GigaHdx::total_gigahdx_supply(); + + assert_ok!(GigaHdx::realize_yield(RawOrigin::Signed(ALICE).into())); + + assert_eq!(Stakes::::get(ALICE).unwrap().gigahdx, gigahdx_before); + assert_eq!(GigaHdx::total_gigahdx_supply(), supply_before); + }); +} + +#[test] +fn realize_yield_should_preserve_exchange_rate_when_called() { + ExtBuilder::default() + .with_pot_balance(150 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let rate_before = GigaHdx::exchange_rate(); + + assert_ok!(GigaHdx::realize_yield(RawOrigin::Signed(ALICE).into())); + + assert_eq!(GigaHdx::exchange_rate(), rate_before); + }); +} + +#[test] +fn realize_yield_should_increase_ghdxlock_by_accrued_amount() { + ExtBuilder::default() + .with_pot_balance(100 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let lock_before = locked_under_ghdx(ALICE); + + assert_ok!(GigaHdx::realize_yield(RawOrigin::Signed(ALICE).into())); + + assert_eq!(locked_under_ghdx(ALICE), lock_before + 100 * ONE); + }); +} + +#[test] +fn realize_yield_should_be_noop_when_no_accrued_yield() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + + assert_ok!(GigaHdx::realize_yield(RawOrigin::Signed(ALICE).into())); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 100 * ONE); + assert_eq!(s.gigahdx, 100 * ONE); + assert_eq!(TotalLocked::::get(), 100 * ONE); + assert_eq!(gigapot_balance(), 0); + assert_eq!(yield_realized_amount(ALICE), None); + }); +} + +#[test] +fn realize_yield_should_be_noop_when_no_stake_record() { + ExtBuilder::default() + .with_pot_balance(100 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::realize_yield(RawOrigin::Signed(ALICE).into())); + + assert!(Stakes::::get(ALICE).is_none()); + assert_eq!(TotalLocked::::get(), 0); + assert_eq!(yield_realized_amount(ALICE), None); + }); +} + +// A *gross* gigapot shortfall (forced via a 2:1 MM mint, far beyond any +// rounding dust) must trip the defensive tripwire. In debug/fuzz builds the +// `debug_assert` panics so it is surfaced loudly; release builds compile the +// assert out and return `GigapotInsufficient` gracefully. The two variants +// pin both halves of that contract (CI runs `test --release`). +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "exceeds rounding tolerance")] +fn realize_yield_should_panic_in_debug_when_gigapot_grossly_insufficient() { + ExtBuilder::default().build().execute_with(|| { + TestMoneyMarket::set_supply_rounding(2, 1); + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + // Shortfall == 100 HDX ≫ MAX_GIGAPOT_ROUNDING_SHORTFALL → debug_assert panics. + let _ = GigaHdx::realize_yield(RawOrigin::Signed(ALICE).into()); + }); +} + +#[cfg(not(debug_assertions))] +#[test] +fn realize_yield_should_return_error_in_release_when_gigapot_grossly_insufficient() { + ExtBuilder::default().build().execute_with(|| { + TestMoneyMarket::set_supply_rounding(2, 1); + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 100 * ONE); + assert_eq!(s.gigahdx, 200 * ONE); + assert_eq!(gigapot_balance(), 0); + + frame_support::assert_noop!( + GigaHdx::realize_yield(RawOrigin::Signed(ALICE).into()), + crate::Error::::GigapotInsufficient + ); + + // State fully rolled back. + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 100 * ONE); + assert_eq!(TotalLocked::::get(), 100 * ONE); + }); +} + +#[test] +fn realize_yield_should_work_when_pending_unstakes_exist() { + // Stake at rate 1, then a yield-paying partial unstake leaves an active + // remainder plus a pending position; realize the remainder's yield. + ExtBuilder::default() + .with_pot_balance(200 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 0); + assert_eq!(s.gigahdx, 50 * ONE); + assert_eq!(s.unstaking, 150 * ONE); + assert_eq!(s.unstaking_count, 1); + + assert_ok!(GigaHdx::realize_yield(RawOrigin::Signed(ALICE).into())); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.gigahdx, 50 * ONE); // unchanged + assert_eq!(s.hdx, 150 * ONE); // accrued folded in + assert_eq!(s.unstaking, 150 * ONE); // untouched + assert_eq!(s.unstaking_count, 1); + assert_eq!(TotalLocked::::get(), 150 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 300 * ONE); // hdx + unstaking + assert_eq!(gigapot_balance(), 0); + assert_eq!(yield_realized_amount(ALICE), Some(150 * ONE)); + }); +} + +#[test] +fn realize_yield_should_reconcile_pure_gigahdx_when_no_pending_unstakes() { + // Reach the "GIGAHDX with zero underlying hdx AND no pending unstakes" + // state: over-active partial unstake drains principal to 0, then `unlock` + // after cooldown clears the pending position (record survives because + // gigahdx > 0). realize_yield must still fold the full current value in. + ExtBuilder::default() + .with_pot_balance(200 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + + let p = only_pending(ALICE); + System::set_block_number(p.expires_at); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), p.id)); + + // Pure GIGAHDX: no principal, no pending, record still alive. + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 0); + assert_eq!(s.gigahdx, 50 * ONE); + assert_eq!(s.unstaking, 0); + assert_eq!(s.unstaking_count, 0); + assert_eq!(locked_under_ghdx(ALICE), 0); + + assert_ok!(GigaHdx::realize_yield(RawOrigin::Signed(ALICE).into())); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.gigahdx, 50 * ONE); // unchanged + assert_eq!(s.hdx, 150 * ONE); // full current value folded in + assert_eq!(s.unstaking, 0); + assert_eq!(s.unstaking_count, 0); + assert_eq!(TotalLocked::::get(), 150 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 150 * ONE); + assert_eq!(gigapot_balance(), 0); + assert_eq!(yield_realized_amount(ALICE), Some(150 * ONE)); + }); +} diff --git a/pallets/gigahdx/src/tests/set_pool_contract.rs b/pallets/gigahdx/src/tests/set_pool_contract.rs new file mode 100644 index 0000000000..3611584a9e --- /dev/null +++ b/pallets/gigahdx/src/tests/set_pool_contract.rs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Error, GigaHdxPoolContract}; +use frame_support::{assert_noop, assert_ok}; +use frame_system::RawOrigin; +use primitives::EvmAddress; + +#[test] +fn set_pool_contract_should_succeed_when_no_supply() { + ExtBuilder::default().build().execute_with(|| { + let new_pool = EvmAddress::from([0xCCu8; 20]); + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), new_pool)); + assert_eq!(GigaHdxPoolContract::::get(), Some(new_pool)); + }); +} + +#[test] +fn set_pool_contract_should_fail_when_active_stake_exists() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_noop!( + GigaHdx::set_pool_contract(RawOrigin::Root.into(), EvmAddress::from([0xCCu8; 20])), + Error::::OutstandingStake + ); + }); +} + +#[test] +fn set_pool_contract_should_fail_when_only_residual_gigahdx_exists() { + // Regression: when an unstake payout exceeds active stake, `Stakes.hdx` + // can land at 0 (so `TotalLocked == 0`) while `Stakes.gigahdx > 0` — + // those atokens are still bound to the current pool. Switching pools + // then would orphan them. + ExtBuilder::default() + .with_pot_balance(200 * ONE) + .build() + .execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + // rate = 3.0; unstake 90 → active drained, gigahdx residue 10. + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 90 * ONE)); + + assert_eq!(crate::TotalLocked::::get(), 0); + assert!(GigaHdx::total_gigahdx_supply() > 0); + + assert_noop!( + GigaHdx::set_pool_contract(RawOrigin::Root.into(), EvmAddress::from([0xCCu8; 20])), + Error::::OutstandingStake + ); + }); +} + +#[test] +fn set_pool_contract_should_fail_when_called_by_non_authority() { + ExtBuilder::default().build().execute_with(|| { + assert!(GigaHdx::set_pool_contract(RawOrigin::Signed(ALICE).into(), EvmAddress::from([0xCCu8; 20]),).is_err()); + }); +} diff --git a/pallets/gigahdx/src/tests/stake.rs b/pallets/gigahdx/src/tests/stake.rs new file mode 100644 index 0000000000..c2ef30c380 --- /dev/null +++ b/pallets/gigahdx/src/tests/stake.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Error, Stakes, TotalLocked}; +use frame_support::traits::fungibles::Inspect as FungiblesInspect; +use frame_support::{assert_noop, assert_ok}; +use frame_system::RawOrigin; +use hydradx_traits::gigahdx::MoneyMarketOperations; +use primitives::Balance; + +fn locked_under_ghdx(account: AccountId) -> Balance { + pallet_balances::Locks::::get(account) + .iter() + .find(|l| l.id == GIGAHDX_LOCK_ID) + .map(|l| l.amount) + .unwrap_or(0) +} + +#[test] +fn giga_stake_should_record_correct_state_when_called() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 100 * ONE); + assert_eq!(s.gigahdx, 100 * ONE); + assert_eq!(TotalLocked::::get(), 100 * ONE); + assert_eq!(GigaHdx::total_gigahdx_supply(), 100 * ONE); + + assert_eq!(locked_under_ghdx(ALICE), 100 * ONE); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 100 * ONE); + }); +} + +#[test] +fn giga_stake_should_fail_when_amount_below_min() { + ExtBuilder::default().build().execute_with(|| { + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), ONE / 2), + Error::::BelowMinStake + ); + }); +} + +#[test] +fn giga_stake_should_fail_when_amount_above_free_balance() { + ExtBuilder::default().build().execute_with(|| { + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 10_000 * ONE), + Error::::InsufficientFreeBalance + ); + }); +} + +#[test] +fn giga_stake_should_increase_lock_when_already_staked() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 150 * ONE); + assert_eq!(s.gigahdx, 150 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 150 * ONE); + assert_eq!(TotalLocked::::get(), 150 * ONE); + assert_eq!(GigaHdx::total_gigahdx_supply(), 150 * ONE); + }); +} + +#[test] +fn giga_stake_should_use_one_to_one_rate_when_supply_is_zero() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_eq!(Stakes::::get(ALICE).unwrap().gigahdx, 100 * ONE); + }); +} + +#[test] +fn giga_stake_should_use_correct_rate_when_pot_funded() { + ExtBuilder::default() + .with_pot_balance(30 * ONE) + .build() + .execute_with(|| { + // Pot exists but no stHDX yet → bootstrap 1:1. + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_eq!(Stakes::::get(ALICE).unwrap().gigahdx, 100 * ONE); + + // S=100, T = TotalLocked(100) + pot(30) = 130 → Bob: floor(100e12 * 100e12 / 130e12). + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(BOB).into(), 100 * ONE)); + let bob_st = Stakes::::get(BOB).unwrap().gigahdx; + assert_eq!(bob_st, 76_923_076_923_076); + }); +} + +#[test] +fn giga_stake_should_store_returned_atoken_when_mm_rounds() { + ExtBuilder::default().build().execute_with(|| { + // MM returns 90% of input; `Stakes.gigahdx` must reflect the + // returned amount, not the input. stHDX issuance reflects the input. + TestMoneyMarket::set_supply_rounding(9, 10); + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 100 * ONE); + assert_eq!(s.gigahdx, 90 * ONE); + assert_eq!(GigaHdx::total_gigahdx_supply(), 100 * ONE); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 90 * ONE); + }); +} + +#[test] +fn giga_stake_should_fail_when_funds_locked_under_cooldown() { + // Free balance is still 1000 after stake+unstake (locks don't subtract), + // but staking another 1 must be rejected — it would draw from cooldown HDX. + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 1_000 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 1_000 * ONE)); + assert_eq!(Balances::free_balance(ALICE), 1_000 * ONE); + + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), ONE), + Error::::InsufficientFreeBalance + ); + }); +} + +#[test] +fn giga_stake_should_fail_when_extending_lock_past_balance() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 1_000 * ONE)); + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), ONE), + Error::::InsufficientFreeBalance + ); + }); +} + +#[test] +fn giga_stake_should_succeed_when_called_after_unlock() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 1_000 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 1_000 * ONE)); + + System::set_block_number(1 + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), 1)); + + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 500 * ONE)); + }); +} + +#[test] +fn giga_stake_should_use_unlocked_balance_when_cooldown_active() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + + // 1000 - cooldown(100) - prev_stake(0) = 900 stakeable. + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 900 * ONE)); + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), ONE), + Error::::InsufficientFreeBalance + ); + }); +} + +#[test] +fn giga_stake_should_revert_storage_when_mm_supply_fails() { + ExtBuilder::default().build().execute_with(|| { + TestMoneyMarket::fail_supply(); + let pre_free = Balances::free_balance(ALICE); + let pre_sthdx = Tokens::balance(ST_HDX, &ALICE); + + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE), + Error::::MoneyMarketSupplyFailed + ); + + assert!(Stakes::::get(ALICE).is_none()); + assert_eq!(TotalLocked::::get(), 0); + assert_eq!(GigaHdx::total_gigahdx_supply(), 0); + assert_eq!(locked_under_ghdx(ALICE), 0); + // stHDX mint rolled back by with_transaction. + assert_eq!(Tokens::balance(ST_HDX, &ALICE), pre_sthdx); + assert_eq!(Balances::free_balance(ALICE), pre_free); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 0); + }); +} + +#[test] +fn giga_stake_should_subtract_own_existing_stake() { + // Alice has 1000 ONE total. Two 500 stakes max out her balance; a third + // stake of 1 must fail because own_claim now equals her whole balance. + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 500 * ONE)); + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 500 * ONE)); + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), ONE), + Error::::InsufficientFreeBalance, + ); + }); +} + +#[test] +fn giga_stake_should_fail_when_external_claims_nonzero() { + // Strict policy: any non-zero external claim blocks admission, + // regardless of how much free balance the caller has. + ExtBuilder::default().build().execute_with(|| { + TestExternalClaims::set(ONE); + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE), + Error::::BlockedByExternalLock, + ); + }); +} + +#[test] +fn giga_stake_should_fail_when_external_claim_appears_after_stake() { + // Existing staker who later acquires another lock (e.g. legacy + // staking) can't grow their gigahdx position. + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 400 * ONE)); + TestExternalClaims::set(ONE); + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), ONE), + Error::::BlockedByExternalLock, + ); + }); +} + +#[test] +fn giga_stake_should_treat_unstaking_as_own_claim() { + // After a full unstake, stake.hdx → 0 and stake.unstaking holds the pending + // amount. A fresh stake must still see that pending portion as committed. + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 600 * ONE)); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 600 * ONE)); + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 0); + assert_eq!(s.unstaking, 600 * ONE); + + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 500 * ONE), + Error::::InsufficientFreeBalance, + ); + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 400 * ONE)); + }); +} diff --git a/pallets/gigahdx/src/tests/unlock.rs b/pallets/gigahdx/src/tests/unlock.rs new file mode 100644 index 0000000000..da660a8869 --- /dev/null +++ b/pallets/gigahdx/src/tests/unlock.rs @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Error, Stakes}; +use frame_support::sp_runtime::traits::AccountIdConversion; +use frame_support::traits::tokens::{Fortitude, Preservation}; +use frame_support::traits::{fungible::Inspect, LockIdentifier}; +use frame_support::{assert_noop, assert_ok}; +use frame_system::RawOrigin; +use primitives::Balance; + +fn pot_account() -> AccountId { + GigaHdxPalletId::get().into_account_truncating() +} + +fn lock_amount(account: AccountId, id: LockIdentifier) -> Balance { + pallet_balances::Locks::::get(account) + .iter() + .find(|l| l.id == id) + .map(|l| l.amount) + .unwrap_or(0) +} + +fn reducible(account: AccountId) -> Balance { + >::reducible_balance(&account, Preservation::Expendable, Fortitude::Polite) +} + +fn stake_alice_100() { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); +} + +#[test] +fn giga_unstake_should_create_pending_position_when_called() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + + let entry = only_pending(ALICE); + assert_eq!(entry.id, 1); + assert_eq!(entry.amount, 40 * ONE); + assert_eq!(entry.expires_at, 1 + GigaHdxCooldownPeriod::get()); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 60 * ONE); + assert_eq!(s.gigahdx, 60 * ONE); + + // Single combined lock covers active + pending; spendable must be zero. + assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 100 * ONE); + assert_eq!(reducible(ALICE), Balances::free_balance(ALICE) - 100 * ONE); + }); +} + +#[test] +fn giga_unstake_should_drain_active_only_when_pot_empty() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 0); + assert_eq!(s.gigahdx, 0); + assert_eq!(only_pending(ALICE).amount, 100 * ONE); + assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 100 * ONE); + }); +} + +#[test] +fn giga_unstake_should_skip_yield_transfer_when_payout_le_active() { + // pot 200 → rate 3.0; unstake 10 → payout 30 ≤ active 100, no yield needed. + ExtBuilder::default() + .with_pot_balance(200 * ONE) + .build() + .execute_with(|| { + stake_alice_100(); + let alice_balance_before = Balances::free_balance(ALICE); + let pot_before = Balances::free_balance(pot_account()); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 10 * ONE)); + + assert_eq!(Stakes::::get(ALICE).unwrap().hdx, 70 * ONE); + assert_eq!(only_pending(ALICE).amount, 30 * ONE); + assert_eq!(Balances::free_balance(ALICE), alice_balance_before); + assert_eq!(Balances::free_balance(pot_account()), pot_before); + assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 100 * ONE); + }); +} + +#[test] +fn giga_unstake_should_extend_lock_when_payout_exceeds_active() { + // pot 200 → rate 3.0; unstake 90 → payout 270 > active 100: + // active drained, yield 170 from pot, lock extends to 270. + ExtBuilder::default() + .with_pot_balance(200 * ONE) + .build() + .execute_with(|| { + stake_alice_100(); + let alice_balance_before = Balances::free_balance(ALICE); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 90 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 0); + assert_eq!(s.gigahdx, 10 * ONE); + assert_eq!(only_pending(ALICE).amount, 270 * ONE); + + assert_eq!(Balances::free_balance(ALICE), alice_balance_before + 170 * ONE); + assert_eq!(Balances::free_balance(pot_account()), 30 * ONE); + assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 270 * ONE); + // Yield is locked under cooldown — spendable must stay zero. + assert_eq!(reducible(ALICE), Balances::free_balance(ALICE) - 270 * ONE); + }); +} + +#[test] +fn unlock_should_fail_when_cooldown_not_elapsed() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + + System::set_block_number(GigaHdxCooldownPeriod::get()); + assert_noop!( + GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), 1), + Error::::CooldownNotElapsed + ); + }); +} + +#[test] +fn unlock_should_release_lock_when_cooldown_elapsed() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + + System::set_block_number(1 + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), 1)); + + assert_eq!(pending_count(ALICE), 0); + // Stakes was {0, 0} after full unstake → cleaned up by unlock. + assert!(Stakes::::get(ALICE).is_none()); + assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 0); + }); +} + +#[test] +fn unlock_should_keep_active_lock_when_partial_unstake() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + System::set_block_number(1 + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), 1)); + + assert_eq!(pending_count(ALICE), 0); + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 60 * ONE); + assert_eq!(s.gigahdx, 60 * ONE); + // Active stake keeps its share of the lock; 40 HDX freed. + assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 60 * ONE); + }); +} + +#[test] +fn unlock_should_fail_when_no_pending_position() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_noop!( + GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), 1), + Error::::PendingUnstakeNotFound + ); + }); +} + +#[test] +fn giga_unstake_should_succeed_when_called_after_unlock() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + let unlock_block = 1 + GigaHdxCooldownPeriod::get(); + System::set_block_number(unlock_block); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), 1)); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 20 * ONE)); + // Second unstake's id = current block at time of unstake. + let entry = only_pending(ALICE); + assert_eq!(entry.id, unlock_block); + assert_eq!(entry.amount, 20 * ONE); + }); +} + +#[test] +fn giga_unstake_should_handle_remaining_atokens_when_active_drained_by_yield() { + // pot 200 → rate 3.0. First unstake 90 zeroes active and leaves 10 stHDX + // with zero cost basis; the remaining 10 still unstakes — payout comes + // entirely from the pot as yield against an empty active stake. + ExtBuilder::default() + .with_pot_balance(200 * ONE) + .build() + .execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 90 * ONE)); + let unlock_block = 1 + GigaHdxCooldownPeriod::get(); + System::set_block_number(unlock_block); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into(), 1)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 0); + assert_eq!(s.gigahdx, 10 * ONE); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 10 * ONE)); + let entry = only_pending(ALICE); + assert_eq!(entry.id, unlock_block); + assert_eq!(entry.amount, 30 * ONE); + assert_eq!(Balances::free_balance(pot_account()), 0); + }); +} diff --git a/pallets/gigahdx/src/tests/unstake.rs b/pallets/gigahdx/src/tests/unstake.rs new file mode 100644 index 0000000000..160e5da024 --- /dev/null +++ b/pallets/gigahdx/src/tests/unstake.rs @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Error, Stakes, TotalLocked}; +use frame_support::sp_runtime::traits::AccountIdConversion; +use frame_support::traits::fungibles::Inspect as FungiblesInspect; +use frame_support::{assert_noop, assert_ok}; +use frame_system::RawOrigin; +use hydradx_traits::gigahdx::MoneyMarketOperations; +use primitives::Balance; + +fn locked_under_ghdx(account: AccountId) -> Balance { + pallet_balances::Locks::::get(account) + .iter() + .find(|l| l.id == GIGAHDX_LOCK_ID) + .map(|l| l.amount) + .unwrap_or(0) +} + +fn stake_alice_100() { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); +} + +#[test] +fn giga_unstake_should_move_active_to_position_when_pot_empty() { + // payout ≤ active → active drained, position = payout, no yield. + ExtBuilder::default().build().execute_with(|| { + let pre_free = Balances::free_balance(ALICE); + stake_alice_100(); + assert_eq!(Balances::free_balance(ALICE), pre_free); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 0); + assert_eq!(s.gigahdx, 0); + assert_eq!(TotalLocked::::get(), 0); + assert_eq!(GigaHdx::total_gigahdx_supply(), 0); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 0); + + assert_eq!(only_pending(ALICE).amount, 100 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 100 * ONE); + assert_eq!(Balances::free_balance(ALICE), pre_free); + }); +} + +#[test] +fn giga_unstake_should_pull_yield_from_pot_when_payout_exceeds_active() { + // payout 130 > active 100 → active drained, yield 30 from pot. + ExtBuilder::default() + .with_pot_balance(30 * ONE) + .build() + .execute_with(|| { + let pre_free = Balances::free_balance(ALICE); + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + + assert_eq!(Balances::free_balance(ALICE), pre_free + 30 * ONE); + let pot: AccountId = GigaHdxPalletId::get().into_account_truncating(); + assert_eq!(Balances::free_balance(pot), 0); + + // Stakes record persists (zeroed) until `unlock` cleans it up. + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 0); + assert_eq!(s.gigahdx, 0); + + assert_eq!(only_pending(ALICE).amount, 130 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 130 * ONE); + }); +} + +#[test] +fn giga_unstake_should_split_state_when_partial() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx, 60 * ONE); + assert_eq!(s.gigahdx, 60 * ONE); + // Combined lock = active(60) + position(40). + assert_eq!(locked_under_ghdx(ALICE), 100 * ONE); + assert_eq!(TotalLocked::::get(), 60 * ONE); + assert_eq!(GigaHdx::total_gigahdx_supply(), 60 * ONE); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 60 * ONE); + assert_eq!(only_pending(ALICE).amount, 40 * ONE); + }); +} + +#[test] +fn giga_unstake_should_fail_when_amount_zero() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_noop!( + GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 0), + Error::::ZeroAmount + ); + }); +} + +#[test] +fn giga_unstake_should_fail_when_amount_exceeds_stake() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_noop!( + GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 101 * ONE), + Error::::InsufficientStake + ); + }); +} + +#[test] +fn giga_unstake_should_fail_when_no_stake_exists() { + ExtBuilder::default().build().execute_with(|| { + assert_noop!( + GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE), + Error::::NoStake + ); + }); +} + +#[test] +fn giga_unstake_should_revert_storage_when_mm_withdraw_fails() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + let pre_stake = Stakes::::get(ALICE).unwrap(); + let pre_total_locked = TotalLocked::::get(); + let pre_total_sthdx = GigaHdx::total_gigahdx_supply(); + let pre_lock = locked_under_ghdx(ALICE); + let pre_mm_balance = TestMoneyMarket::balance_of(&ALICE); + let pre_sthdx_balance = Tokens::balance(ST_HDX, &ALICE); + + TestMoneyMarket::fail_withdraw(); + assert_noop!( + GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 50 * ONE), + Error::::MoneyMarketWithdrawFailed + ); + + // Pre-decrement of `gigahdx` rolled back by `with_transaction`. + let post_stake = Stakes::::get(ALICE).unwrap(); + assert_eq!(post_stake.gigahdx, pre_stake.gigahdx, "gigahdx must be restored"); + assert_eq!(post_stake.hdx, pre_stake.hdx); + assert_eq!(TotalLocked::::get(), pre_total_locked); + assert_eq!(GigaHdx::total_gigahdx_supply(), pre_total_sthdx); + assert_eq!(locked_under_ghdx(ALICE), pre_lock); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), pre_mm_balance); + assert_eq!(Tokens::balance(ST_HDX, &ALICE), pre_sthdx_balance); + assert_eq!(pending_count(ALICE), 0); + }); +} + +#[test] +fn giga_unstake_should_pre_decrement_gigahdx_before_mm_withdraw() { + // `LockableAToken.burn` relies on lock-manager reading the + // already-decremented `Stakes[who].gigahdx`. We can't observe mid-call + // state, but the post-state proves the pre-decrement happened before + // MM.withdraw — otherwise the burn would have reverted in production. + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + assert_eq!(Stakes::::get(ALICE).unwrap().gigahdx, 70 * ONE); + }); +} diff --git a/pallets/gigahdx/src/traits.rs b/pallets/gigahdx/src/traits.rs new file mode 100644 index 0000000000..8478bcb19e --- /dev/null +++ b/pallets/gigahdx/src/traits.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Hooks injected by the runtime to customize gigahdx admission logic. + +use primitives::Balance; +use sp_runtime::DispatchError; + +/// Sum of HDX claimed by other pallets on `who`. `giga_stake` subtracts this +/// from the caller's free balance to ensure the new stake doesn't overlap +/// with HDX already pledged elsewhere. The runtime decides which lock ids +/// count as claims (legacy staking, vesting, …) and which are allowed to +/// overlap with a gigahdx stake (e.g. conviction voting). +pub trait ExternalClaims { + fn on(who: &AccountId) -> Balance; +} + +impl ExternalClaims for () { + fn on(_who: &AccountId) -> Balance { + 0 + } +} + +/// Migration source for users moving from the legacy NFT-based staking pallet +/// into gigahdx. The runtime adapts this to `pallet_staking::force_unstake`. +/// Returning `Ok(unlocked)` means the caller's legacy position has been +/// destroyed and `unlocked` HDX is now free of the legacy lock and any +/// withheld rewards are paid out. Wrapped in `#[transactional]` by the +/// implementor so any failure downstream rolls the unstake back atomically. +pub trait LegacyStakeMigrator { + fn force_unstake(who: &AccountId) -> Result; +} + +impl LegacyStakeMigrator for () { + fn force_unstake(_who: &AccountId) -> Result { + Err(DispatchError::Other("no legacy staking source configured")) + } +} diff --git a/pallets/gigahdx/src/weights.rs b/pallets/gigahdx/src/weights.rs new file mode 100644 index 0000000000..0c898530f4 --- /dev/null +++ b/pallets/gigahdx/src/weights.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 + +use frame_support::weights::Weight; + +pub trait WeightInfo { + fn giga_stake() -> Weight; + fn giga_unstake() -> Weight; + fn unlock() -> Weight; + fn set_pool_contract() -> Weight; + fn cancel_unstake() -> Weight; + fn migrate() -> Weight; + fn realize_yield() -> Weight; +} + +impl WeightInfo for () { + fn giga_stake() -> Weight { + Weight::zero() + } + fn giga_unstake() -> Weight { + Weight::zero() + } + fn unlock() -> Weight { + Weight::zero() + } + fn set_pool_contract() -> Weight { + Weight::zero() + } + fn cancel_unstake() -> Weight { + Weight::zero() + } + fn migrate() -> Weight { + Weight::zero() + } + fn realize_yield() -> Weight { + Weight::zero() + } +} diff --git a/pallets/staking/src/lib.rs b/pallets/staking/src/lib.rs index 03e953e889..4510b4473e 100644 --- a/pallets/staking/src/lib.rs +++ b/pallets/staking/src/lib.rs @@ -18,7 +18,7 @@ #![cfg_attr(not(feature = "std"), no_std)] #![allow(clippy::manual_inspect)] -use crate::traits::{ActionData, GetReferendumState, PayablePercentage, VestingDetails}; +use crate::traits::{ActionData, ExternalClaims, GetReferendumState, PayablePercentage, VestingDetails}; use crate::types::{Action, Balance, Period, Point, Position, StakingData}; use frame_support::ensure; use frame_support::{ @@ -176,6 +176,11 @@ pub mod pallet { /// Provides information about amount of vested tokens. type Vesting: VestingDetails; + /// Sum of HDX claimed by other pallets that must not back a legacy + /// stake (e.g. `ghdxlock`). The runtime decides what counts; `()` + /// disables the check. + type ExternalClaims: ExternalClaims; + #[cfg(feature = "runtime-benchmarks")] /// Max mumber of locks per account. It's used in on_vote_worst_case benchmarks. type MaxLocks: Get; @@ -298,6 +303,16 @@ pub mod pallet { accumulated_rps: FixedU128, total_stake: Balance, }, + + /// Position was force-unstaked through the GIGAHDX migration helper. + /// Distinct from `Unstaked` because no rewards were forfeited. + ForceUnstaked { + who: T::AccountId, + position_id: T::PositionItemId, + stake: Balance, + locked_rewards: Balance, + paid_rewards: Balance, + }, } #[pallet::error] @@ -338,6 +353,16 @@ pub mod pallet { /// Position contains processed votes. Removed these votes first before increasing stake or claiming. ExistingProcessedVotes, + /// User still has an active conviction-vote on an ongoing referendum. + /// Must remove the vote (`pallet_conviction_voting::remove_vote`) before + /// migrating to GIGAHDX via `force_unstake`. + ActiveVotesOngoing, + + /// Caller's HDX is claimed by another pallet (e.g. `ghdxlock`). + /// Strict policy: legacy staking refuses any HDX backing that overlaps + /// with a non-whitelisted lock elsewhere. + BlockedByExternalLock, + /// Action cannot be completed because unexpected error has occurred. This should be reported /// to protocol maintainers. InconsistentState(InconsistentStateError), @@ -807,6 +832,11 @@ impl Pallet { stake: Balance, position: Option<&Position>>, ) -> Result<(), DispatchError> { + // Strict policy: any HDX claimed by a non-whitelisted lock elsewhere + // blocks legacy staking outright. Prevents the same HDX from backing + // both a legacy position and another pallet's claim (e.g. `ghdxlock`). + ensure!(T::ExternalClaims::on(who) == 0, Error::::BlockedByExternalLock); + let free_balance = T::Currency::free_balance(T::NativeAssetId::get(), who); let staked = position .map(|p| p.stake.saturating_add(p.accumulated_locked_rewards)) @@ -839,6 +869,101 @@ impl Pallet { Ok(position_id) } + /// Force-unstakes `who`'s legacy position for the GIGAHDX migration. + /// + /// Unlike the regular `unstake`, this helper pays out 100% of accumulated + /// rewards — no sigmoid `PayablePercentage` slash, no `UnclaimablePeriods` + /// early-exit penalty. It exists solely so users can migrate to + /// `pallet-gigahdx` without losing rewards they earned in the legacy + /// staking system. + /// + /// Fails (and rolls back atomically) if `who` still has a conviction-vote + /// on a referendum that has not finished — the legacy stake is the source + /// of that vote's voting power, so removing it mid-vote would leak state. + /// + /// Returns the total amount unlocked for `who`: + /// `position.stake + accumulated_locked_rewards + paid_rewards`, + /// where `paid_rewards = new_rewards + accumulated_unpaid_rewards`. + /// + /// This is a pallet-internal helper: no origin check, no weight. The + /// orchestrating migrate extrinsic owns both. + #[frame_support::transactional] + pub fn force_unstake(who: &T::AccountId) -> Result { + ensure!(Self::is_initialized(), Error::::NotInitialized); + + let position_id = + Self::get_user_position_id(who)?.ok_or::>(InconsistentStateError::PositionNotFound.into())?; + + let voting = Votes::::get(position_id); + for (ref_index, _) in voting.votes.iter() { + ensure!( + T::ReferendumInfo::is_referendum_finished(*ref_index), + Error::::ActiveVotesOngoing + ); + } + + Staking::::try_mutate(|staking| -> Result { + Self::update_rewards(staking)?; + + let position = Positions::::take(position_id) + .defensive_ok_or::>(InconsistentStateError::PositionNotFound.into())?; + + let new_rewards = math::calculate_rewards( + staking.accumulated_reward_per_stake, + position.reward_per_stake, + position.stake, + ) + .ok_or(Error::::Arithmetic)?; + + let paid_rewards = new_rewards + .checked_add(position.accumulated_unpaid_rewards) + .ok_or(Error::::Arithmetic)?; + + if !paid_rewards.is_zero() { + T::Currency::transfer( + T::NativeAssetId::get(), + &Self::pot_account_id(), + who, + paid_rewards, + ExistenceRequirement::AllowDeath, + )?; + } + + staking.total_stake = staking + .total_stake + .checked_sub(position.stake) + .defensive_ok_or::>(InconsistentStateError::Arithmetic.into())?; + + staking.pot_reserved_balance = staking + .pot_reserved_balance + .checked_sub(paid_rewards) + .defensive_ok_or::>(InconsistentStateError::Arithmetic.into())?; + + T::NFTHandler::burn(&T::NFTCollectionId::get(), &position_id, Some(who))?; + T::Currency::remove_lock(STAKING_LOCK_ID, T::NativeAssetId::get(), who)?; + + Votes::::remove(position_id); + // `drain_prefix` returns an iterator — it must be consumed to actually drain. + let _ = VotesRewarded::::drain_prefix(who).count(); + + Self::deposit_event(Event::ForceUnstaked { + who: who.clone(), + position_id, + stake: position.stake, + locked_rewards: position.accumulated_locked_rewards, + paid_rewards, + }); + + let unlocked = position + .stake + .checked_add(position.accumulated_locked_rewards) + .and_then(|x| x.checked_add(paid_rewards)) + .ok_or(Error::::Arithmetic)?; + + Ok(unlocked) + }) + } + fn is_owner(who: &T::AccountId, id: T::PositionItemId) -> bool { if let Some(owner) = ::NFTHandler::owner(&::NFTCollectionId::get(), &id) diff --git a/pallets/staking/src/tests/force_unstake.rs b/pallets/staking/src/tests/force_unstake.rs new file mode 100644 index 0000000000..e4560cafcd --- /dev/null +++ b/pallets/staking/src/tests/force_unstake.rs @@ -0,0 +1,389 @@ +use super::*; +use crate::types::{Conviction, Vote}; +use frame_support::StorageDoubleMap; +use mock::Staking; +use pretty_assertions::assert_eq; + +// In the unstake tests with the BOB position staked at block 1_452_987 and an +// unstake at 1_470_000 (within `UnclaimablePeriods`), the regular `unstake` +// pays 0 and slashes the full `10_671_709_925_655_406` back to the pot. For +// the same scenario `force_unstake` must pay that exact total to the user. +const HAPPY_PATH_TOTAL_REWARDS: u128 = 10_671_709_925_655_406_u128; +// For the same scenario at block 1_700_000 (past `UnclaimablePeriods`), the +// sigmoid `PayablePercentage` is only ~3.14% so a regular `unstake` would pay +// `334_912_244_857_841` and slash `10_336_797_680_797_565`. The two sum to +// the same total `force_unstake` must pay in full. +const SIGMOID_PATH_PAID_REWARDS: u128 = 334_912_244_857_841_u128; +const SIGMOID_PATH_SLASHED_REWARDS: u128 = 10_336_797_680_797_565_u128; + +fn default_accounts() -> Vec<(u64, u32, Balance)> { + vec![ + (ALICE, HDX, 150_000 * ONE), + (BOB, HDX, 250_000 * ONE), + (CHARLIE, HDX, 10_000 * ONE), + (DAVE, HDX, 100_000 * ONE), + ] +} + +fn default_stakes() -> Vec<(u64, Balance, u64, Balance)> { + vec![ + (ALICE, 100_000 * ONE, 1_452_987, 200_000 * ONE), + (BOB, 120_000 * ONE, 1_452_987, 0), + (CHARLIE, 10_000 * ONE, 1_455_000, 10_000 * ONE), + (DAVE, 10 * ONE, 1_465_000, 1), + ] +} + +#[test] +fn force_unstake_should_pay_full_rewards_when_within_unclaimable_period() { + ExtBuilder::default() + .with_endowed_accounts(default_accounts()) + .with_initialized_staking() + .start_at_block(1_452_987) + .with_stakes(default_stakes()) + .build() + .execute_with(|| { + //Arrange + set_pending_rewards(10_000 * ONE); + set_block_number(1_470_000); + let bob_position_id = Staking::get_user_position_id(&BOB).unwrap().unwrap(); + + //Act + let unlocked = Staking::force_unstake(&BOB).unwrap(); + + //Assert + assert_eq!(unlocked, 120_000 * ONE + HAPPY_PATH_TOTAL_REWARDS); + assert_eq!( + Tokens::free_balance(HDX, &BOB), + 250_000 * ONE + HAPPY_PATH_TOTAL_REWARDS + ); + assert_hdx_lock!(BOB, 0, STAKING_LOCK); + assert_eq!(Staking::positions(bob_position_id), None); + assert_eq!(Staking::get_user_position_id(&BOB).unwrap(), None); + }); +} + +#[test] +fn force_unstake_should_pay_full_rewards_when_sigmoid_would_slash() { + ExtBuilder::default() + .with_endowed_accounts(default_accounts()) + .with_initialized_staking() + .start_at_block(1_452_987) + .with_stakes(default_stakes()) + .build() + .execute_with(|| { + //Arrange + set_pending_rewards(10_000 * ONE); + set_block_number(1_700_000); + let bob_position_id = Staking::get_user_position_id(&BOB).unwrap().unwrap(); + let expected_total = SIGMOID_PATH_PAID_REWARDS + SIGMOID_PATH_SLASHED_REWARDS; + + //Act + let unlocked = Staking::force_unstake(&BOB).unwrap(); + + //Assert: migration path ignores the sigmoid and pays the full total. + assert_eq!(unlocked, 120_000 * ONE + expected_total); + assert_eq!(Tokens::free_balance(HDX, &BOB), 250_000 * ONE + expected_total); + assert_hdx_lock!(BOB, 0, STAKING_LOCK); + assert_eq!(Staking::positions(bob_position_id), None); + }); +} + +#[test] +fn force_unstake_should_update_total_stake_and_pot_reserved() { + ExtBuilder::default() + .with_endowed_accounts(default_accounts()) + .with_initialized_staking() + .start_at_block(1_452_987) + .with_stakes(default_stakes()) + .build() + .execute_with(|| { + //Arrange + set_pending_rewards(10_000 * ONE); + set_block_number(1_470_000); + let total_stake_before = Staking::staking().total_stake; + + //Act + let unlocked = Staking::force_unstake(&BOB).unwrap(); + + //Assert + let staking = Staking::staking(); + assert_eq!(staking.total_stake, total_stake_before - 120_000 * ONE); + + // The user took all rewards out of the pot — the excess of pot balance + // over the reserved balance must therefore be exactly zero. + let pot_balance = Tokens::free_balance(HDX, &Staking::pot_account_id()); + assert_eq!(pot_balance, staking.pot_reserved_balance); + + // The returned unlocked total still matches stake + paid_rewards + // (no locked_rewards for a fresh position). + assert_eq!(unlocked, 120_000 * ONE + HAPPY_PATH_TOTAL_REWARDS); + }); +} + +#[test] +fn force_unstake_should_emit_force_unstaked_event() { + ExtBuilder::default() + .with_endowed_accounts(default_accounts()) + .with_initialized_staking() + .start_at_block(1_452_987) + .with_stakes(default_stakes()) + .build() + .execute_with(|| { + //Arrange + set_pending_rewards(10_000 * ONE); + set_block_number(1_470_000); + let bob_position_id = Staking::get_user_position_id(&BOB).unwrap().unwrap(); + + //Act + assert_ok!(Staking::force_unstake(&BOB)); + + //Assert + assert_last_event!(Event::::ForceUnstaked { + who: BOB, + position_id: bob_position_id, + stake: 120_000 * ONE, + locked_rewards: 0, + paid_rewards: HAPPY_PATH_TOTAL_REWARDS, + } + .into()); + }); +} + +#[test] +fn force_unstake_should_clear_votes_and_votes_rewarded() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 150_000 * ONE)]) + .with_initialized_staking() + .start_at_block(1_452_987) + .with_stakes(vec![(ALICE, 100_000 * ONE, 1_452_987, 200_000 * ONE)]) + .with_votings(vec![( + 0, + // DummyReferendumStatus: even indices are finished. + vec![ + ( + 2_u32, + Vote { + amount: 10_000 * ONE, + conviction: Conviction::Locked4x, + }, + ), + ( + 4_u32, + Vote { + amount: 5_000 * ONE, + conviction: Conviction::Locked2x, + }, + ), + ], + )]) + .build() + .execute_with(|| { + //Arrange + set_block_number(1_470_000); + let alice_position_id = 0; + VotesRewarded::::insert( + ALICE, + 100_u32, + Vote { + amount: 1_000 * ONE, + conviction: Conviction::Locked1x, + }, + ); + VotesRewarded::::insert( + ALICE, + 200_u32, + Vote { + amount: 2_000 * ONE, + conviction: Conviction::Locked2x, + }, + ); + assert!(crate::Votes::::contains_key(alice_position_id)); + assert!(VotesRewarded::::contains_prefix(ALICE)); + + //Act + assert_ok!(Staking::force_unstake(&ALICE)); + + //Assert + assert!(Votes::::get(alice_position_id).votes.is_empty()); + assert!(!VotesRewarded::::contains_prefix(ALICE)); + }); +} + +#[test] +fn force_unstake_should_fail_when_active_ongoing_vote_present() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 150_000 * ONE)]) + .with_initialized_staking() + .start_at_block(1_452_987) + .with_stakes(vec![(ALICE, 100_000 * ONE, 1_452_987, 200_000 * ONE)]) + .with_votings(vec![( + 0, + // Odd index = ongoing per DummyReferendumStatus. + vec![( + 1_u32, + Vote { + amount: 10_000 * ONE, + conviction: Conviction::Locked4x, + }, + )], + )]) + .build() + .execute_with(|| { + //Arrange + set_block_number(1_470_000); + let alice_position_id = 0; + let total_stake_before = Staking::staking().total_stake; + + //Act & assert + assert_noop!(Staking::force_unstake(&ALICE), Error::::ActiveVotesOngoing); + + // Storage must be untouched — atomic rollback. + assert!(Staking::positions(alice_position_id).is_some()); + assert!(Staking::get_user_position_id(&ALICE).unwrap().is_some()); + assert_eq!(Staking::staking().total_stake, total_stake_before); + }); +} + +#[test] +fn force_unstake_should_fail_when_mixed_ongoing_and_finished_votes() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 150_000 * ONE)]) + .with_initialized_staking() + .start_at_block(1_452_987) + .with_stakes(vec![(ALICE, 100_000 * ONE, 1_452_987, 200_000 * ONE)]) + .with_votings(vec![( + 0, + vec![ + ( + 2_u32, + Vote { + amount: 10_000 * ONE, + conviction: Conviction::Locked4x, + }, + ), + // At least one ongoing entry blocks the whole call. + ( + 3_u32, + Vote { + amount: 5_000 * ONE, + conviction: Conviction::Locked1x, + }, + ), + ], + )]) + .build() + .execute_with(|| { + //Arrange + set_block_number(1_470_000); + let alice_position_id = 0; + + //Act & assert + assert_noop!(Staking::force_unstake(&ALICE), Error::::ActiveVotesOngoing); + assert!(Staking::positions(alice_position_id).is_some()); + }); +} + +#[test] +fn force_unstake_should_succeed_when_only_finished_votes_present() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 150_000 * ONE)]) + .with_initialized_staking() + .start_at_block(1_452_987) + .with_stakes(vec![(ALICE, 100_000 * ONE, 1_452_987, 200_000 * ONE)]) + .with_votings(vec![( + 0, + vec![( + 2_u32, + Vote { + amount: 10_000 * ONE, + conviction: Conviction::Locked4x, + }, + )], + )]) + .build() + .execute_with(|| { + //Arrange + set_block_number(1_470_000); + let alice_position_id = 0; + + //Act + assert_ok!(Staking::force_unstake(&ALICE)); + + //Assert + assert!(Staking::positions(alice_position_id).is_none()); + assert!(Votes::::get(alice_position_id).votes.is_empty()); + }); +} + +#[test] +fn force_unstake_should_fail_when_no_position() { + ExtBuilder::default() + .with_endowed_accounts(default_accounts()) + .with_initialized_staking() + .start_at_block(1_452_987) + .build() + .execute_with(|| { + //Arrange + set_block_number(1_470_000); + + //Act & assert + assert_noop!( + Staking::force_unstake(&BOB), + Error::::InconsistentState(InconsistentStateError::PositionNotFound) + ); + }); +} + +#[test] +fn force_unstake_should_fail_when_staking_not_initialized() { + ExtBuilder::default() + .with_endowed_accounts(default_accounts()) + .start_at_block(1_452_987) + .build() + .execute_with(|| { + //Arrange + set_block_number(1_470_000); + + //Act & assert + assert_noop!(Staking::force_unstake(&BOB), Error::::NotInitialized); + }); +} + +#[test] +fn force_unstake_should_return_paid_unpaid_rewards_when_only_locked_after_increase_stake() { + ExtBuilder::default() + .with_endowed_accounts(default_accounts()) + .with_initialized_staking() + .start_at_block(1_452_987) + .with_stakes(default_stakes()) + .build() + .execute_with(|| { + //Arrange — push BOB past unclaimable, then immediately increase_stake to + // "fold" accumulated rewards into the position. After that, `new_rewards` + // is zero and any payout must come from `accumulated_unpaid_rewards`. + set_pending_rewards(10_000 * ONE); + set_block_number(1_700_000); + let bob_position_id = Staking::get_user_position_id(&BOB).unwrap().unwrap(); + assert_ok!(Staking::increase_stake( + RuntimeOrigin::signed(BOB), + bob_position_id, + 10 * ONE + )); + + let position = Staking::positions(bob_position_id).unwrap(); + let unpaid_before = position.accumulated_unpaid_rewards; + let locked_before = position.accumulated_locked_rewards; + let bob_balance_before = Tokens::free_balance(HDX, &BOB); + + //Act + let unlocked = Staking::force_unstake(&BOB).unwrap(); + + //Assert: no new pending rewards introduced since the increase_stake, so the + // only payable amount is whatever was carried in `accumulated_unpaid_rewards`. + assert_eq!(Tokens::free_balance(HDX, &BOB) - bob_balance_before, unpaid_before); + assert_eq!(unlocked, position.stake + locked_before + unpaid_before); + assert!(Staking::positions(bob_position_id).is_none()); + assert_hdx_lock!(BOB, 0, STAKING_LOCK); + }); +} diff --git a/pallets/staking/src/tests/mock.rs b/pallets/staking/src/tests/mock.rs index e1ee86d8c7..7c103afec7 100644 --- a/pallets/staking/src/tests/mock.rs +++ b/pallets/staking/src/tests/mock.rs @@ -13,9 +13,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::traits::{Freeze, VestingDetails}; +use crate::traits::{ExternalClaims, Freeze, VestingDetails}; use crate::types::{Vote, Voting}; use crate::*; +use std::cell::RefCell; use frame_support::traits::Everything; use frame_support::{assert_ok, PalletId}; @@ -227,6 +228,7 @@ impl pallet_staking::Config for Test { type MaxPointsPerAction = DummyMaxPointsPerAction; type ReferendumInfo = DummyReferendumStatus; type Vesting = DummyVesting; + type ExternalClaims = TestExternalClaims; type Collections = FreezableUniques; type AuthorityOrigin = EnsureRoot; type MinSlash = DummyMinSlash; @@ -275,6 +277,29 @@ impl VestingDetails for DummyVesting { } } +thread_local! { + pub static EXTERNAL_CLAIMS: RefCell = const { RefCell::new(0) }; +} + +pub struct TestExternalClaims; + +impl TestExternalClaims { + #[allow(dead_code)] + pub fn set(value: Balance) { + EXTERNAL_CLAIMS.with(|v| *v.borrow_mut() = value); + } + #[allow(dead_code)] + pub fn reset() { + EXTERNAL_CLAIMS.with(|v| *v.borrow_mut() = 0); + } +} + +impl ExternalClaims for TestExternalClaims { + fn on(_who: &AccountId) -> Balance { + EXTERNAL_CLAIMS.with(|v| *v.borrow()) + } +} + pub struct FreezableUniques; impl Freeze for FreezableUniques { @@ -341,6 +366,7 @@ impl ExtBuilder { let mut r: sp_io::TestExternalities = t.into(); r.execute_with(|| { + TestExternalClaims::reset(); pallet_staking::SixSecBlocksSince::::put(1_000_000_000); if self.initial_block_number.is_zero() { diff --git a/pallets/staking/src/tests/mod.rs b/pallets/staking/src/tests/mod.rs index 8be110bb0c..f60bf142ca 100644 --- a/pallets/staking/src/tests/mod.rs +++ b/pallets/staking/src/tests/mod.rs @@ -6,6 +6,7 @@ use frame_support::{assert_noop, assert_ok}; use orml_tokens::BalanceLock; mod claim; +mod force_unstake; mod increase_stake; pub(crate) mod mock; mod stake; diff --git a/pallets/staking/src/tests/stake.rs b/pallets/staking/src/tests/stake.rs index 78e26dcd33..b867ada1c2 100644 --- a/pallets/staking/src/tests/stake.rs +++ b/pallets/staking/src/tests/stake.rs @@ -273,3 +273,44 @@ fn stake_should_not_work_when_tokens_are_vestred() { ); }); } + +#[test] +fn stake_should_fail_when_any_external_claim_present() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 100 * ONE)]) + .with_initialized_staking() + .start_at_block(1_452_987) + .build() + .execute_with(|| { + // Even a tiny external claim refuses the stake outright. + TestExternalClaims::set(1); + + assert_noop!( + Staking::stake(RuntimeOrigin::signed(ALICE), 50 * ONE), + Error::::BlockedByExternalLock + ); + + // Clearing the claim lets the same stake succeed. + TestExternalClaims::reset(); + assert_ok!(Staking::stake(RuntimeOrigin::signed(ALICE), 50 * ONE)); + }); +} + +#[test] +fn increase_stake_should_fail_when_any_external_claim_present() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 200 * ONE)]) + .with_initialized_staking() + .start_at_block(1_452_987) + .with_stakes(vec![(ALICE, 50 * ONE, 1_452_987, 0)]) + .build() + .execute_with(|| { + TestExternalClaims::set(1); + let position_id = Staking::get_user_position_id(&ALICE).unwrap().unwrap(); + + assert_noop!( + Staking::increase_stake(RuntimeOrigin::signed(ALICE), position_id, 10 * ONE), + Error::::BlockedByExternalLock + ); + }); +} diff --git a/pallets/staking/src/traits.rs b/pallets/staking/src/traits.rs index 004cabc655..9e1ad2d055 100644 --- a/pallets/staking/src/traits.rs +++ b/pallets/staking/src/traits.rs @@ -25,3 +25,18 @@ pub trait VestingDetails { /// Returns vested amount for who. fn locked(who: AccountId) -> Balance; } + +/// Sum of HDX claimed by other pallets that must not back a legacy stake. +/// The runtime decides which lock ids count (e.g. `ghdxlock`) and which are +/// allowed to overlap (e.g. `pyconvot`). Returning > 0 reduces `stakeable` +/// by that amount, so the user cannot legacy-stake HDX that is already +/// pledged elsewhere. +pub trait ExternalClaims { + fn on(who: &AccountId) -> Balance; +} + +impl ExternalClaims for () { + fn on(_who: &AccountId) -> Balance { + 0 + } +} diff --git a/precompiles/lock-manager/Cargo.toml b/precompiles/lock-manager/Cargo.toml new file mode 100644 index 0000000000..df2ee816ef --- /dev/null +++ b/precompiles/lock-manager/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "pallet-evm-precompile-lock-manager" +description = "Precompile that reports the locked GIGAHDX balance for an account, consumed by `LockableAToken.sol`'s `freeBalance` check." +edition = "2021" +version = "1.0.0" +authors = ["GalacticCouncil"] +repository = "https://github.com/galacticcouncil/hydration-node" + +[dependencies] +log = { workspace = true } +num_enum = { workspace = true } +precompile-utils = { workspace = true } + +# Substrate +frame-support = { workspace = true } +frame-system = { workspace = true } +pallet-gigahdx = { workspace = true } +codec = { workspace = true, features = [ "max-encoded-len" ] } +sp-core = { workspace = true } +sp-io = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } + +# Frontier +fp-evm = { workspace = true } +pallet-evm = { workspace = true } + +[dev-dependencies] + +[features] +default = [ "std" ] +std = [ + "fp-evm/std", + "frame-support/std", + "frame-system/std", + "pallet-evm/std", + "pallet-gigahdx/std", + "codec/std", + "precompile-utils/std", + "sp-core/std", + "sp-io/std", + "sp-std/std", +] diff --git a/precompiles/lock-manager/src/lib.rs b/precompiles/lock-manager/src/lib.rs new file mode 100644 index 0000000000..6f7bb1641a --- /dev/null +++ b/precompiles/lock-manager/src/lib.rs @@ -0,0 +1,79 @@ +// : $$\ $$\ $$\ $$$$$$$\ $$\ $$\ +// !YJJ^ $$ | $$ | $$ | $$ __$$\ $$ | $$ | +// 7B5. ~B5^ $$ | $$ |$$\ $$\ $$$$$$$ | $$$$$$\ $$$$$$\ $$ | $$ |\$$\ $$ | +// .?B@G ~@@P~ $$$$$$$$ |$$ | $$ |$$ __$$ |$$ __$$\ \____$$\ $$ | $$ | \$$$$ / +// :?#@@@Y .&@@@P!. $$ __$$ |$$ | $$ |$$ / $$ |$$ | \__|$$$$$$$ |$$ | $$ | $$ $$< +// ^?J^7P&@@! .5@@#Y~!J!. $$ | $$ |$$ | $$ |$$ | $$ |$$ | $$ __$$ |$$ | $$ |$$ /\$$\ +// ^JJ!. :!J5^ ?5?^ ^?Y7. $$ | $$ |\$$$$$$$ |\$$$$$$$ |$$ | \$$$$$$$ |$$$$$$$ |$$ / $$ | +// ~PP: 7#B5!. :?P#G: 7G?. \__| \__| \____$$ | \_______|\__| \_______|\_______/ \__| \__| +// .!P@G 7@@@#Y^ .!P@@@#. ~@&J: $$\ $$ | +// !&@@J :&@@@@P. !&@@@@5 #@@P. \$$$$$$ | +// :J##: Y@@&P! :JB@@&~ ?@G! \______/ +// .?P!.?GY7: .. . ^?PP^:JP~ +// .7Y7. .!YGP^ ?BP?^ ^JJ^ This file is part of https://github.com/galacticcouncil/HydraDX-node +// .!Y7Y#@@#: ?@@@G?JJ^ Built with <3 for decentralisation. +// !G@@@Y .&@@&J: +// ^5@#. 7@#?. Copyright (C) 2021-2025 Intergalactic, Limited (GIB). +// :5P^.?G7. SPDX-License-Identifier: Apache-2.0 +// :?Y! Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// http://www.apache.org/licenses/LICENSE-2.0 + +#![cfg_attr(not(feature = "std"), no_std)] + +use core::marker::PhantomData; +use frame_support::traits::Get; +use pallet_evm::GasWeightMapping; +use precompile_utils::prelude::*; +use sp_core::{H160, U256}; + +/// Precompile at address 0x0806. +/// +/// Reports a per-account "locked GIGAHDX" amount derived from +/// `pallet_gigahdx::Stakes[who].gigahdx`. This is consumed by the +/// `LockableAToken.sol` contract's `freeBalance` check +/// (`free = balanceOf - locked`) to: +/// +/// 1. Block user-initiated transfers of GIGAHDX (since `gigahdx` equals +/// the user's aToken balance, `free = 0`). +/// 2. Allow legitimate `Pool.withdraw → aToken.burn` paths during +/// `pallet-gigahdx::giga_unstake`, which pre-decrements `gigahdx` by +/// the amount being unstaked before invoking the MM. +/// +/// `ExpectedToken` pins the EVM address of the GIGAHDX aToken contract +/// the precompile is willing to answer for. Calls from any other token +/// address return zero — defense against an unrelated aToken pointing +/// its `freeBalance` check at `0x0806` and accidentally over-locking +/// holders based on their gigahdx-stake state. +pub struct LockManagerPrecompile(PhantomData<(Runtime, ExpectedToken)>); + +#[precompile_utils::precompile] +impl LockManagerPrecompile +where + Runtime: pallet_gigahdx::Config + pallet_evm::Config, + Runtime::AddressMapping: pallet_evm::AddressMapping<::AccountId>, + ExpectedToken: Get, +{ + /// Returns the locked GIGAHDX balance for `account`. Returns zero when + /// `token` is not the configured GIGAHDX aToken address. + #[precompile::public("getLockedBalance(address,address)")] + #[precompile::view] + fn get_locked_balance(handle: &mut impl PrecompileHandle, token: Address, account: Address) -> EvmResult { + if H160::from(token) != ExpectedToken::get() { + return Ok(U256::zero()); + } + + // Charge for the `Stakes` StorageMap read via DbWeight (proof-size + // aware) — more accurate than `record_db_read`'s byte heuristic. + let read_weight = ::DbWeight::get().reads(1); + let read_gas = ::GasWeightMapping::weight_to_gas(read_weight); + handle.record_cost(read_gas)?; + + let account_id = ::AccountId, + >>::into_account_id(account.into()); + let locked = pallet_gigahdx::Pallet::::locked_gigahdx(&account_id); + + Ok(U256::from(locked)) + } +} diff --git a/primitives/Cargo.toml b/primitives/Cargo.toml index b315a59df3..3cbd796b03 100644 --- a/primitives/Cargo.toml +++ b/primitives/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "primitives" -version = "6.3.0" +version = "6.4.0" authors = ["GalacticCouncil"] edition = "2021" repository = "https://github.com/galacticcouncil/HydraDX-node" diff --git a/primitives/src/constants.rs b/primitives/src/constants.rs index 585bc07ee1..6fea78d802 100644 --- a/primitives/src/constants.rs +++ b/primitives/src/constants.rs @@ -83,6 +83,7 @@ pub mod chain { pub const OMNIPOOL_SOURCE: [u8; 8] = *b"omnipool"; pub const STABLESWAP_SOURCE: [u8; 8] = *b"stablesw"; pub const XYK_SOURCE: [u8; 8] = *b"hydraxyk"; + pub const GIGAHDX_SOURCE: [u8; 8] = *b"gigahdxs"; pub const DEFAULT_RELAY_PARENT_OFFSET: u32 = 1; /// Maximum number of blocks simultaneously accepted by the Runtime, not yet included into the diff --git a/runtime/hydradx/Cargo.toml b/runtime/hydradx/Cargo.toml index e1719d558d..80088a8044 100644 --- a/runtime/hydradx/Cargo.toml +++ b/runtime/hydradx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hydradx-runtime" -version = "420.0.0" +version = "422.0.0" authors = ["GalacticCouncil"] edition = "2021" license = "Apache 2.0" @@ -178,6 +178,9 @@ pallet-evm-precompile-bn128 = { workspace = true } pallet-evm-precompile-blake2 = { workspace = true } pallet-evm-precompile-call-permit = { workspace = true } pallet-evm-precompile-flash-loan = { workspace = true } +pallet-evm-precompile-lock-manager = { workspace = true } +pallet-gigahdx = { workspace = true } +pallet-gigahdx-rewards = { workspace = true } precompile-utils = { workspace = true } module-evm-utility-macro = { workspace = true } ethabi = { workspace = true } @@ -255,6 +258,8 @@ runtime-benchmarks = [ "pallet-xcm-benchmarks/runtime-benchmarks", "pallet-signet/runtime-benchmarks", "pallet-dispenser/runtime-benchmarks", + "pallet-gigahdx/runtime-benchmarks", + "pallet-gigahdx-rewards/runtime-benchmarks", "cumulus-pallet-weight-reclaim/runtime-benchmarks", ] std = [ @@ -369,6 +374,9 @@ std = [ "pallet-evm-precompile-blake2/std", "pallet-evm-precompile-call-permit/std", "pallet-evm-precompile-flash-loan/std", + "pallet-evm-precompile-lock-manager/std", + "pallet-gigahdx/std", + "pallet-gigahdx-rewards/std", "pallet-xyk/std", "pallet-referrals/std", "pallet-evm-accounts/std", diff --git a/runtime/hydradx/src/assets.rs b/runtime/hydradx/src/assets.rs index 0d110a7ff3..09e9d05fb8 100644 --- a/runtime/hydradx/src/assets.rs +++ b/runtime/hydradx/src/assets.rs @@ -689,6 +689,9 @@ impl Get> for ExtendedDustRemovalWhitelist { BondsPalletId::get().into_account_truncating(), pallet_route_executor::Pallet::::router_account(), EVMAccounts::account_id(crate::evm::HOLDING_ADDRESS), + GigaHdxPalletId::get().into_account_truncating(), + pallet_gigahdx_rewards::Pallet::::reward_accumulator_pot(), + pallet_gigahdx_rewards::Pallet::::allocated_rewards_pot(), ]; if let Some((flash_minter, loan_receiver)) = pallet_hsm::GetFlashMinterSupport::::get() { @@ -1653,6 +1656,7 @@ impl pallet_staking::Config for Runtime { type ReferendumInfo = pallet_staking::integrations::conviction_voting::DirectReferendumStatus; type MaxPointsPerAction = PointsPerAction; type Vesting = VestingInfo; + type ExternalClaims = crate::gigahdx::LegacyStakingExternalClaims; type WeightInfo = weights::pallet_staking::HydraWeight; type MinSlash = StakingMinSlash; @@ -1877,6 +1881,96 @@ impl pallet_dispenser::Config for Runtime { type BenchmarkHelper = DispenserBenchmarkHelper; } +parameter_types! { + pub const GigaHdxLockId: frame_support::traits::LockIdentifier = *b"ghdxlock"; + pub const GigaHdxPalletId: frame_support::PalletId = frame_support::PalletId(*b"gigahdx!"); + // stHDX invariants — verify on any runtime / AAVE configuration change: + // (1) Mint/burn exclusive to `pallet-gigahdx` — rate denominator reads + // global `total_issuance` directly; any external mint/burn dilutes stakers. + // (2) Non-borrowable on AAVE (zero borrow cap / IRM returning 0). If + // `liquidityIndex` drifts above 1 RAY the `aToken : stHDX = 1 : 1` + // invariant breaks, leaking unlocked aTokens past the lock-manager. + pub const StHdxAssetId: AssetId = 670; + pub const GigaHdxAssetIdConst: AssetId = 67; + pub const GigaHdxMinStake: Balance = UNITS; + pub const GigaHdxCooldownPeriod: BlockNumber = 30 * DAYS; + pub const GigaHdxMaxPendingUnstakes: u32 = 10; +} + +impl pallet_gigahdx::Config for Runtime { + type NativeCurrency = Balances; + type MultiCurrency = FungibleCurrencies; + type StHdxAssetId = StHdxAssetId; + #[cfg(not(feature = "runtime-benchmarks"))] + type MoneyMarket = crate::gigahdx::AaveMoneyMarket; + #[cfg(feature = "runtime-benchmarks")] + type MoneyMarket = crate::gigahdx::BenchmarkMoneyMarket; + type AuthorityOrigin = EitherOf, TechCommitteeMajority>; + type PalletId = GigaHdxPalletId; + type LockId = GigaHdxLockId; + type MinStake = GigaHdxMinStake; + type CooldownPeriod = GigaHdxCooldownPeriod; + type MaxPendingUnstakes = GigaHdxMaxPendingUnstakes; + type ExternalClaims = crate::gigahdx::HdxExternalClaims; + type LegacyStaking = crate::gigahdx::LegacyStakingMigrator; + type WeightInfo = weights::pallet_gigahdx::HydraWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = GigaHdxBenchmarkHelper; +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct GigaHdxBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl pallet_gigahdx::BenchmarkHelper for GigaHdxBenchmarkHelper { + fn register_assets() -> DispatchResult { + if ::exists(StHdxAssetId::get()) { + return Ok(()); + } + let name: BoundedVec = b"stHDX" + .to_vec() + .try_into() + .map_err(|_| DispatchError::Other("BoundedConversionFailed"))?; + with_transaction(|| { + TransactionOutcome::Commit(AssetRegistry::register_sufficient_asset( + Some(StHdxAssetId::get()), + Some(name), + AssetKind::Token, + 1u128, + None, + None, + None, + None, + )) + })?; + Ok(()) + } + + fn setup_legacy_staking_position(who: &AccountId, amount: Balance) -> DispatchResult { + use frame_support::traits::Currency; + let _ = Balances::deposit_creating(who, amount.saturating_mul(10)); + let staking_pot = pallet_staking::Pallet::::pot_account_id(); + let _ = Balances::deposit_creating(&staking_pot, amount.saturating_mul(10)); + + // Idempotent — second-and-later calls hit `AlreadyInitialized`. + let _ = pallet_staking::Pallet::::initialize_staking(frame_system::RawOrigin::Root.into()); + + pallet_staking::Pallet::::stake(frame_system::RawOrigin::Signed(who.clone()).into(), amount) + } +} + +parameter_types! { + pub const GigaRewardPotPalletId: frame_support::PalletId = frame_support::PalletId(*b"gigarwd!"); +} + +impl pallet_gigahdx_rewards::Config for Runtime { + type TrackId = u16; + type Referenda = crate::gigahdx::RuntimeReferenda; + type TrackRewardConfig = crate::gigahdx::TrackRewardConfig; + type RewardPotPalletId = GigaRewardPotPalletId; + type WeightInfo = weights::pallet_gigahdx_rewards::HydraWeight; +} + #[cfg(feature = "runtime-benchmarks")] pub struct DispenserBenchmarkHelper; diff --git a/runtime/hydradx/src/evm/precompiles/chainlink_adapter.rs b/runtime/hydradx/src/evm/precompiles/chainlink_adapter.rs index 4ca3820ea7..23dc728610 100644 --- a/runtime/hydradx/src/evm/precompiles/chainlink_adapter.rs +++ b/runtime/hydradx/src/evm/precompiles/chainlink_adapter.rs @@ -23,7 +23,10 @@ use num_enum::{IntoPrimitive, TryFromPrimitive}; use pallet_ema_oracle::Price; use pallet_evm::{ExitRevert, Precompile, PrecompileFailure, PrecompileHandle, PrecompileResult}; use primitive_types::{H160, U128, U256}; -use primitives::{constants::chain::OMNIPOOL_SOURCE, AssetId}; +use primitives::{ + constants::chain::{GIGAHDX_SOURCE, OMNIPOOL_SOURCE}, + AssetId, +}; use sp_runtime::{traits::Dispatchable, RuntimeDebug}; use sp_std::{cmp::Ordering, marker::PhantomData}; @@ -49,7 +52,8 @@ where + pallet_evm::Config + pallet_asset_registry::Config + pallet_ema_oracle::Config - + pallet_route_executor::Config, + + pallet_route_executor::Config + + pallet_gigahdx::Config, EmaOracle: AggregatedPriceOracle, Price>, Router: RouteProvider, AssetId: EncodeLike<::AssetId>, @@ -88,7 +92,8 @@ where + pallet_evm::Config + pallet_asset_registry::Config + pallet_ema_oracle::Config - + pallet_route_executor::Config, + + pallet_route_executor::Config + + pallet_gigahdx::Config, EmaOracle: AggregatedPriceOracle, Price>, Router: RouteProvider, AssetId: EncodeLike<::AssetId>, @@ -153,6 +158,11 @@ where let rat_as_u128 = round_to_rational((nominator, denominator), Rounding::Nearest); Price::from(rat_as_u128) + } + // pallet-gigahdx floors the rate at 1.0 so AAVE never sees a sub-1 reading. + else if source == GIGAHDX_SOURCE { + let rate = pallet_gigahdx::Pallet::::exchange_rate(); + Price { n: rate.n, d: rate.d } } else { let (price, _block_number) = >::get_price( asset_id_a, asset_id_b, period, source, @@ -355,3 +365,14 @@ fn decode_oracle_address_should_work() { Some((4, 5, OraclePeriod::TenMinutes, OMNIPOOL_SOURCE)) ); } + +#[test] +fn encode_gigahdx_oracle_address_should_work() { + let addr = encode_oracle_address(670, 0, OraclePeriod::TenMinutes, GIGAHDX_SOURCE); + let (a, b, period, source) = decode_oracle_address(addr).unwrap(); + assert_eq!(a, 670); + assert_eq!(b, 0); + assert_eq!(period, OraclePeriod::TenMinutes); + assert_eq!(source, GIGAHDX_SOURCE); + assert_eq!(addr, H160::from(hex!("0000010267696761686478730000029e00000000"))); +} diff --git a/runtime/hydradx/src/evm/precompiles/mod.rs b/runtime/hydradx/src/evm/precompiles/mod.rs index 0a21a61b22..a36136ae43 100644 --- a/runtime/hydradx/src/evm/precompiles/mod.rs +++ b/runtime/hydradx/src/evm/precompiles/mod.rs @@ -101,6 +101,8 @@ pub const BN_PAIRING: H160 = H160(hex!("0000000000000000000000000000000000000008 pub const BLAKE2F: H160 = H160(hex!("0000000000000000000000000000000000000009")); pub const CALLPERMIT: H160 = H160(hex!("000000000000000000000000000000000000080a")); pub const FLASH_LOAN_RECEIVER: H160 = H160(hex!("000000000000000000000000000000000000090a")); +/// Lock-manager precompile address consumed by `LockableAToken.sol`. +pub const LOCK_MANAGER: H160 = H160(hex!("0000000000000000000000000000000000000806")); pub const ETH_PRECOMPILE_END: H160 = BLAKE2F; @@ -120,6 +122,19 @@ impl Get> for AllowedFlashLoanCallers { } } +/// EVM address of the GIGAHDX aToken contract — the only token allowed to +/// query the lock-manager precompile. Resolved through the asset registry +/// at call time so it tracks any asset-id remapping. +pub struct GigaHdxATokenAddress; + +impl Get for GigaHdxATokenAddress { + fn get() -> H160 { + >::asset_address(crate::assets::GigaHdxAssetIdConst::get()) + } +} + impl PrecompileSet for HydraDXPrecompiles where R: pallet_evm::Config @@ -127,7 +142,8 @@ where + pallet_evm_accounts::Config + pallet_stableswap::Config + pallet_liquidation::Config - + pallet_hsm::Config, + + pallet_hsm::Config + + pallet_gigahdx::Config, ::RuntimeCall: Dispatchable + GetDispatchInfo + Decode, <::RuntimeCall as Dispatchable>::RuntimeOrigin: From>>, MultiCurrencyPrecompile: Precompile, @@ -176,6 +192,11 @@ where R, AllowedFlashLoanCallers, >::execute(handle)) + } else if address == LOCK_MANAGER { + Some(pallet_evm_precompile_lock_manager::LockManagerPrecompile::< + R, + crate::evm::precompiles::GigaHdxATokenAddress, + >::execute(handle)) } else if address == DISPATCH_ADDR { let caller_account = R::AddressMapping::into_account_id(handle.context().caller); let original_nonce = frame_system::Pallet::::account_nonce(caller_account.clone()); @@ -210,7 +231,7 @@ where } pub fn is_precompile(address: H160) -> bool { - address == DISPATCH_ADDR || is_asset_address(address) || is_standard_precompile(address) + address == DISPATCH_ADDR || address == LOCK_MANAGER || is_asset_address(address) || is_standard_precompile(address) } // This is a reimplementation of the upstream u64->H160 conversion diff --git a/runtime/hydradx/src/gigahdx.rs b/runtime/hydradx/src/gigahdx.rs new file mode 100644 index 0000000000..df271e1c5f --- /dev/null +++ b/runtime/hydradx/src/gigahdx.rs @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Runtime wiring for the gigahdx stack: +// - `AaveMoneyMarket` — `MoneyMarketOperations` adapter that bridges +// `pallet-gigahdx` to the EVM-side AAVE V3 fork. `supply` mints aToken +// (GIGAHDX) on behalf of the user from their stHDX; `withdraw` burns +// aToken and returns stHDX. The pool address is read from +// `pallet_gigahdx::GigaHdxPoolContract` (settable via `set_pool_contract`). +// - `TrackRewardConfig` / `RuntimeReferenda` — the two adapters that wire +// `pallet-gigahdx-rewards` into the runtime (per-track reward table +// and a `ReferendumInfoFor`-backed track lookup). + +use crate::evm::aave_trade_executor::Function as AaveFunction; +use crate::evm::evm_error_decoder::EvmErrorDecoder; +use crate::evm::precompiles::erc20_mapping::HydraErc20Mapping; +use crate::evm::precompiles::handle::EvmDataWriter; +use crate::evm::Erc20Currency; +use crate::evm::Executor; +use crate::Runtime; +use evm::ExitReason::Succeed; +use frame_support::sp_runtime::traits::Convert; +use frame_support::sp_runtime::DispatchError; +use frame_support::traits::LockIdentifier; +use frame_support::weights::Weight; +use hydradx_traits::evm::{CallContext, CallResult, Erc20Mapping, InspectEvmAccounts, ERC20, EVM}; +use hydradx_traits::gigahdx::MoneyMarketOperations; +use pallet_evm::GasWeightMapping; +use pallet_gigahdx_rewards::traits::{ReferendaTrackInspect, TrackRewardTable}; +use pallet_gigahdx_rewards::types::ReferendumIndex; +use pallet_referenda::ReferendumInfo; +use primitive_types::U256; +use primitives::{AccountId, AssetId, Balance, EvmAddress}; +use sp_runtime::Permill; + +const GAS_LIMIT: u64 = 500_000; + +fn handle(result: CallResult) -> Result<(), DispatchError> { + match &result.exit_reason { + Succeed(_) => Ok(()), + _ => { + log::error!( + target: "gigahdx::adapter", + "AAVE EVM call failed: exit_reason={:?}, data=0x{}", + result.exit_reason, + hex::encode(&result.value), + ); + Err(EvmErrorDecoder::convert(result)) + } + } +} + +pub struct AaveMoneyMarket; + +impl AaveMoneyMarket { + fn pool() -> Result { + pallet_gigahdx::GigaHdxPoolContract::::get() + .ok_or(DispatchError::Other("gigahdx: pool contract not set")) + } +} + +impl MoneyMarketOperations for AaveMoneyMarket { + fn supply(who: &AccountId, underlying_asset: AssetId, amount: Balance) -> Result { + let asset_evm = HydraErc20Mapping::asset_address(underlying_asset); + let who_evm = pallet_evm_accounts::Pallet::::evm_address(who); + let pool = Self::pool()?; + + let approve_ctx = CallContext::new_call(asset_evm, who_evm); + as ERC20>::approve(approve_ctx, pool, amount)?; + + // `Pool.supply` rounds scaled balance down, so the actual aToken + // minted may be < `amount`. We return the balance delta so the pallet + // preserves `Stakes.gigahdx == aToken.balanceOf`, which + // `LockableAToken.burn`'s `freeBalance` check relies on. + let balance_before = Self::balance_of(who); + + let supply_ctx = CallContext::new_call(pool, who_evm); + let referral_code = 0_u16; + let data = EvmDataWriter::new_with_selector(AaveFunction::Supply) + .write(asset_evm) + .write(amount) + .write(who_evm) + .write(referral_code) + .build(); + handle(Executor::::call(supply_ctx, data, U256::zero(), GAS_LIMIT))?; + + let balance_after = Self::balance_of(who); + Ok(balance_after.saturating_sub(balance_before)) + } + + fn withdraw(who: &AccountId, underlying_asset: AssetId, amount: Balance) -> Result { + let asset_evm = HydraErc20Mapping::asset_address(underlying_asset); + let who_evm = pallet_evm_accounts::Pallet::::evm_address(who); + let pool = Self::pool()?; + + // Symmetric with `supply`: return actual underlying delta so callers + // reconcile against AAVE's scaledBalance rounding. + let balance_before = Self::balance_of(who); + + let withdraw_ctx = CallContext::new_call(pool, who_evm); + let data = EvmDataWriter::new_with_selector(AaveFunction::Withdraw) + .write(asset_evm) + .write(amount) + .write(who_evm) + .build(); + handle(Executor::::call(withdraw_ctx, data, U256::zero(), GAS_LIMIT))?; + + let balance_after = Self::balance_of(who); + Ok(balance_before.saturating_sub(balance_after)) + } + + fn balance_of(who: &AccountId) -> Balance { + let atoken_addr = HydraErc20Mapping::asset_address(crate::assets::GigaHdxAssetIdConst::get()); + let who_evm = pallet_evm_accounts::Pallet::::evm_address(who); + as ERC20>::balance_of(CallContext::new_view(atoken_addr), who_evm) + } + + fn supply_weight() -> Weight { + ::GasWeightMapping::gas_to_weight(GAS_LIMIT, true) + } + + fn withdraw_weight() -> Weight { + ::GasWeightMapping::gas_to_weight(GAS_LIMIT, true) + } +} + +/// No-op `MoneyMarketOperations` used during benchmarks. Returns 1:1 for +/// `supply` / `withdraw` so the pallet's `actual_minted` accounting stays +/// well-defined without invoking the EVM. The runtime swaps this in for +/// `AaveMoneyMarket` under `runtime-benchmarks` (see `assets.rs`). +#[cfg(feature = "runtime-benchmarks")] +pub struct BenchmarkMoneyMarket; + +#[cfg(feature = "runtime-benchmarks")] +impl MoneyMarketOperations for BenchmarkMoneyMarket { + fn supply(_who: &AccountId, _underlying_asset: AssetId, amount: Balance) -> Result { + Ok(amount) + } + + fn withdraw(_who: &AccountId, _underlying_asset: AssetId, amount: Balance) -> Result { + Ok(amount) + } + + fn balance_of(_who: &AccountId) -> Balance { + 0 + } +} + +// --------------------------------------------------------------------------- +// pallet-gigahdx-rewards wiring +// --------------------------------------------------------------------------- + +/// Per-track reward percentage table. Tracks are defined in +/// `governance/tracks.rs`: +/// - `0` (root) → 10% +/// - `1` (whitelisted_caller) → 8% +/// - `5` (treasurer) → 5% +/// - `9` (economic_parameters) → 5% +/// - any other track → 2% (default) +pub struct TrackRewardConfig; + +impl TrackRewardTable for TrackRewardConfig { + fn reward_percentage(track_id: u16) -> Permill { + match track_id { + 0 => Permill::from_percent(10), + 1 => Permill::from_percent(8), + 5 | 9 => Permill::from_percent(5), + _ => Permill::from_percent(2), + } + } +} + +/// Track-id inspector backed by `pallet_referenda::ReferendumInfoFor`. +/// +/// Only `ReferendumInfo::Ongoing(status)` exposes the track id directly on +/// this `polkadot-sdk` version. For all completed variants the track is not +/// preserved on the info entry; the rewards pallet keeps its own +/// `ReferendumTracks` cache populated during `on_before_vote` and falls back +/// to that when `track_of` returns `None`. +pub struct RuntimeReferenda; + +impl ReferendaTrackInspect for RuntimeReferenda { + fn track_of(ref_index: ReferendumIndex) -> Option { + match pallet_referenda::ReferendumInfoFor::::get(ref_index)? { + ReferendumInfo::Ongoing(status) => Some(status.track), + // Completed variants do not carry the track id on this SDK version. + ReferendumInfo::Approved(..) + | ReferendumInfo::Rejected(..) + | ReferendumInfo::Cancelled(..) + | ReferendumInfo::TimedOut(..) + | ReferendumInfo::Killed(_) => None, + } + } +} + +/// `ExternalClaims` impl: sum of HDX claimed by other pallets that should NOT +/// overlap with a gigahdx stake. `ghdxlock` is excluded because the pallet +/// accounts for it from its own ledger; `pyconvot` is excluded because a +/// conviction vote is intentionally permitted to share HDX with a stake. +pub struct HdxExternalClaims; + +impl pallet_gigahdx::traits::ExternalClaims for HdxExternalClaims { + fn on(who: &AccountId) -> Balance { + const ALLOWED_OVERLAP: &[LockIdentifier] = &[*b"ghdxlock", *b"pyconvot"]; + pallet_balances::Locks::::get(who) + .iter() + .filter(|l| !ALLOWED_OVERLAP.contains(&l.id)) + .map(|l| l.amount) + .fold(0, Balance::saturating_add) + } +} + +/// Adapter wiring `pallet_gigahdx::migrate` to the legacy NFT staking pallet. +pub struct LegacyStakingMigrator; + +impl pallet_gigahdx::traits::LegacyStakeMigrator for LegacyStakingMigrator { + fn force_unstake(who: &AccountId) -> Result { + pallet_staking::Pallet::::force_unstake(who) + } +} + +/// `ExternalClaims` impl for the legacy staking pallet. Mirrors +/// `HdxExternalClaims` but with the legacy pallet's exclusions: +/// `stk_stks` (its own lock — already deducted via the position), +/// `ormlvest` (vesting — already deducted via the `Vesting` config), +/// `pyconvot` (governance overlap allowed). Everything else — `ghdxlock` +/// in particular — counts and blocks legacy staking from re-pledging +/// HDX already claimed elsewhere. +pub struct LegacyStakingExternalClaims; + +impl pallet_staking::traits::ExternalClaims for LegacyStakingExternalClaims { + fn on(who: &AccountId) -> Balance { + const ALLOWED_OVERLAP: &[LockIdentifier] = &[*b"stk_stks", *b"ormlvest", *b"pyconvot"]; + pallet_balances::Locks::::get(who) + .iter() + .filter(|l| !ALLOWED_OVERLAP.contains(&l.id)) + .map(|l| l.amount) + .fold(0, Balance::saturating_add) + } +} diff --git a/runtime/hydradx/src/governance/mod.rs b/runtime/hydradx/src/governance/mod.rs index 26f49926b2..1d0829cadb 100644 --- a/runtime/hydradx/src/governance/mod.rs +++ b/runtime/hydradx/src/governance/mod.rs @@ -48,10 +48,13 @@ use frame_support::{ use frame_system::{EnsureRoot, EnsureRootWithSuccess}; use hydradx_traits::evm::MaybeEvmCall; use pallet_collective::EnsureProportionAtLeast; +use pallet_conviction_voting::{AccountVote, Status, VotingHooks}; +use pallet_referenda::ReferendumIndex; use primitives::constants::{currency::DOLLARS, time::DAYS}; use sp_arithmetic::Perbill; use sp_core::ConstU32; use sp_runtime::traits::IdentityLookup; +use sp_std::marker::PhantomData; pub type TechCommitteeMajority = EnsureProportionAtLeast; pub type TechCommitteeSuperMajority = EnsureProportionAtLeast; @@ -184,6 +187,56 @@ parameter_types! { pub const MaxVotes: u32 = 25; } +/// Tuple adapter for `pallet_conviction_voting::VotingHooks` so the runtime +/// can wire two consumers (staking + gigahdx rewards) into the single hook +/// slot. The upstream trait does not provide a tuple impl. +pub struct CombinedVotingHooks(PhantomData<(A, B)>); + +impl VotingHooks for CombinedVotingHooks +where + A: VotingHooks, + B: VotingHooks, + Balance: sp_std::cmp::PartialOrd + Clone, +{ + fn on_before_vote( + who: &AccountId, + idx: ReferendumIndex, + vote: AccountVote, + ) -> frame_support::dispatch::DispatchResult { + A::on_before_vote(who, idx, vote.clone())?; + B::on_before_vote(who, idx, vote) + } + + fn on_remove_vote(who: &AccountId, idx: ReferendumIndex, status: Status) { + A::on_remove_vote(who, idx, status); + B::on_remove_vote(who, idx, status); + } + + fn lock_balance_on_unsuccessful_vote(who: &AccountId, idx: ReferendumIndex) -> Option { + // Combine conservatively: max if both ask for a lock, otherwise + // pass through whichever side answered. + match ( + A::lock_balance_on_unsuccessful_vote(who, idx), + B::lock_balance_on_unsuccessful_vote(who, idx), + ) { + (Some(a), Some(b)) => Some(if a > b { a } else { b }), + (a, b) => a.or(b), + } + } + + #[cfg(feature = "runtime-benchmarks")] + fn on_vote_worst_case(who: &AccountId) { + A::on_vote_worst_case(who); + B::on_vote_worst_case(who); + } + + #[cfg(feature = "runtime-benchmarks")] + fn on_remove_vote_worst_case(who: &AccountId) { + A::on_remove_vote_worst_case(who); + B::on_remove_vote_worst_case(who); + } +} + impl pallet_conviction_voting::Config for Runtime { type WeightInfo = weights::pallet_conviction_voting::HydraWeight; type RuntimeEvent = RuntimeEvent; @@ -192,7 +245,10 @@ impl pallet_conviction_voting::Config for Runtime { type MaxVotes = MaxVotes; type MaxTurnout = frame_support::traits::tokens::currency::ActiveIssuanceOf; type Polls = Referenda; - type VotingHooks = pallet_staking::integrations::conviction_voting::StakingConvictionVoting; + type VotingHooks = CombinedVotingHooks< + pallet_staking::integrations::conviction_voting::StakingConvictionVoting, + pallet_gigahdx_rewards::voting_hooks::VotingHooksImpl, + >; // Any single technical committee member may remove a vote. type VoteRemovalOrigin = frame_system::EnsureSignedBy; type BlockNumberProvider = System; diff --git a/runtime/hydradx/src/lib.rs b/runtime/hydradx/src/lib.rs index 2ceae8a11f..caaa7e33e0 100644 --- a/runtime/hydradx/src/lib.rs +++ b/runtime/hydradx/src/lib.rs @@ -35,6 +35,7 @@ pub mod weights; mod assets; pub mod circuit_breaker; pub mod evm; +pub mod gigahdx; pub mod governance; mod helpers; mod system; @@ -128,7 +129,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: Cow::Borrowed("hydradx"), impl_name: Cow::Borrowed("hydradx"), authoring_version: 1, - spec_version: 420, + spec_version: 422, impl_version: 0, apis: RUNTIME_API_VERSIONS, transaction_version: 1, @@ -201,6 +202,8 @@ construct_runtime!( Parameters: pallet_parameters = 83, Signet: pallet_signet = 84, EthDispenser: pallet_dispenser = 85, + GigaHdx: pallet_gigahdx = 86, + GigaHdxRewards: pallet_gigahdx_rewards = 87, // ORML related modules Tokens: orml_tokens = 77, @@ -359,6 +362,8 @@ mod benches { [pallet_dynamic_fees, DynamicFees] [pallet_signet, Signet] [pallet_dispenser, EthDispenser] + [pallet_gigahdx, GigaHdx] + [pallet_gigahdx_rewards, GigaHdxRewards] //[ismp_parachain, IsmpParachain] //[pallet_token_gateway, TokenGateway] [frame_system_extensions, frame_system_benchmarking::extensions::Pallet::] diff --git a/runtime/hydradx/src/weights/mod.rs b/runtime/hydradx/src/weights/mod.rs index 6ba164e867..8c7a229549 100644 --- a/runtime/hydradx/src/weights/mod.rs +++ b/runtime/hydradx/src/weights/mod.rs @@ -24,6 +24,8 @@ pub mod pallet_dynamic_evm_fee; pub mod pallet_dynamic_fees; pub mod pallet_ema_oracle; pub mod pallet_evm_accounts; +pub mod pallet_gigahdx; +pub mod pallet_gigahdx_rewards; pub mod pallet_hsm; pub mod pallet_identity; pub mod pallet_lbp; diff --git a/runtime/hydradx/src/weights/pallet_gigahdx.rs b/runtime/hydradx/src/weights/pallet_gigahdx.rs new file mode 100644 index 0000000000..ca3512f5eb --- /dev/null +++ b/runtime/hydradx/src/weights/pallet_gigahdx.rs @@ -0,0 +1,180 @@ +// This file is part of HydraDX. + +// Copyright (C) 2020-2024 Intergalactic, Limited (GIB). +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + + +//! Autogenerated weights for `pallet_gigahdx` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 +//! DATE: 2026-05-07, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `bench-bot`, CPU: `Intel(R) Core(TM) i7-7700K CPU @ 4.20GHz` +//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` + +// Executed Command: +// ./bin/hydradx +// benchmark +// pallet +// --wasm-execution=compiled +// --pallet +// pallet_gigahdx +// --extrinsic +// * +// --heap-pages +// 4096 +// --steps +// 50 +// --repeat +// 20 +// --template +// scripts/pallet-weight-template.hbs +// --output +// runtime/hydradx/src/weights/pallet_gigahdx.rs +// --quiet + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; +use crate::*; + +/// Weights for `pallet_gigahdx`. +pub struct WeightInfo(PhantomData); + +/// Weights for `pallet_gigahdx` using the HydraDX node and recommended hardware. +pub struct HydraWeight(PhantomData); +impl pallet_gigahdx::WeightInfo for HydraWeight { + /// Storage: `AssetRegistry::Assets` (r:1 w:0) + /// Proof: `AssetRegistry::Assets` (`max_values`: None, `max_size`: Some(125), added: 2600, mode: `MaxEncodedLen`) + /// Storage: `Tokens::TotalIssuance` (r:1 w:1) + /// Proof: `Tokens::TotalIssuance` (`max_values`: None, `max_size`: Some(28), added: 2503, mode: `MaxEncodedLen`) + /// Storage: `Tokens::Accounts` (r:1 w:1) + /// Proof: `Tokens::Accounts` (`max_values`: None, `max_size`: Some(108), added: 2583, mode: `MaxEncodedLen`) + /// Storage: `AssetRegistry::BannedAssets` (r:1 w:0) + /// Proof: `AssetRegistry::BannedAssets` (`max_values`: None, `max_size`: Some(20), added: 2495, mode: `MaxEncodedLen`) + /// Storage: `MultiTransactionPayment::AccountCurrencyMap` (r:1 w:0) + /// Proof: `MultiTransactionPayment::AccountCurrencyMap` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `MultiTransactionPayment::AcceptedCurrencies` (r:1 w:0) + /// Proof: `MultiTransactionPayment::AcceptedCurrencies` (`max_values`: None, `max_size`: Some(28), added: 2503, mode: `MaxEncodedLen`) + /// Storage: `CircuitBreaker::EgressAccounts` (r:1 w:0) + /// Proof: `CircuitBreaker::EgressAccounts` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `CircuitBreaker::GlobalAssetOverrides` (r:1 w:0) + /// Proof: `CircuitBreaker::GlobalAssetOverrides` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// Storage: `GigaHdx::Stakes` (r:1 w:1) + /// Proof: `GigaHdx::Stakes` (`max_values`: None, `max_size`: Some(80), added: 2555, mode: `MaxEncodedLen`) + /// Storage: `GigaHdx::TotalLocked` (r:1 w:1) + /// Proof: `GigaHdx::TotalLocked` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `GigaHdx::PendingUnstakes` (r:1 w:0) + /// Proof: `GigaHdx::PendingUnstakes` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `Balances::Locks` (r:1 w:1) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + fn giga_stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `1040` + // Estimated: `4764` + // Minimum execution time: 121_429_000 picoseconds. + Weight::from_parts(122_689_000, 4764) + .saturating_add(T::DbWeight::get().reads(13_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: `GigaHdx::PendingUnstakes` (r:1 w:1) + /// Proof: `GigaHdx::PendingUnstakes` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `GigaHdx::Stakes` (r:1 w:1) + /// Proof: `GigaHdx::Stakes` (`max_values`: None, `max_size`: Some(80), added: 2555, mode: `MaxEncodedLen`) + /// Storage: `AssetRegistry::Assets` (r:1 w:0) + /// Proof: `AssetRegistry::Assets` (`max_values`: None, `max_size`: Some(125), added: 2600, mode: `MaxEncodedLen`) + /// Storage: `Tokens::TotalIssuance` (r:1 w:1) + /// Proof: `Tokens::TotalIssuance` (`max_values`: None, `max_size`: Some(28), added: 2503, mode: `MaxEncodedLen`) + /// Storage: `GigaHdx::TotalLocked` (r:1 w:1) + /// Proof: `GigaHdx::TotalLocked` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Tokens::Accounts` (r:1 w:1) + /// Proof: `Tokens::Accounts` (`max_values`: None, `max_size`: Some(108), added: 2583, mode: `MaxEncodedLen`) + /// Storage: `MultiTransactionPayment::AccountCurrencyMap` (r:1 w:0) + /// Proof: `MultiTransactionPayment::AccountCurrencyMap` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `CircuitBreaker::GlobalAssetOverrides` (r:1 w:0) + /// Proof: `CircuitBreaker::GlobalAssetOverrides` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// Storage: `Balances::Locks` (r:1 w:1) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + fn giga_unstake() -> Weight { + // Proof Size summary in bytes: + // Measured: `1352` + // Estimated: `4764` + // Minimum execution time: 176_803_000 picoseconds. + Weight::from_parts(177_684_000, 4764) + .saturating_add(T::DbWeight::get().reads(11_u64)) + .saturating_add(T::DbWeight::get().writes(7_u64)) + } + /// Storage: `GigaHdx::PendingUnstakes` (r:1 w:1) + /// Proof: `GigaHdx::PendingUnstakes` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `GigaHdx::Stakes` (r:1 w:1) + /// Proof: `GigaHdx::Stakes` (`max_values`: None, `max_size`: Some(80), added: 2555, mode: `MaxEncodedLen`) + /// Storage: `Balances::Locks` (r:1 w:1) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + fn unlock() -> Weight { + // Proof Size summary in bytes: + // Measured: `336` + // Estimated: `4764` + // Minimum execution time: 57_170_000 picoseconds. + Weight::from_parts(57_868_000, 4764) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: `GigaHdx::TotalLocked` (r:1 w:0) + /// Proof: `GigaHdx::TotalLocked` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `GigaHdx::GigaHdxPoolContract` (r:0 w:1) + /// Proof: `GigaHdx::GigaHdxPoolContract` (`max_values`: Some(1), `max_size`: Some(20), added: 515, mode: `MaxEncodedLen`) + fn set_pool_contract() -> Weight { + // Proof Size summary in bytes: + // Measured: `4` + // Estimated: `1501` + // Minimum execution time: 10_831_000 picoseconds. + Weight::from_parts(11_293_000, 1501) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + // Placeholder: cancel_unstake takes a pending entry then runs the full + // `do_stake` path. Conservatively reuse `giga_stake` storage cost plus the + // `PendingUnstakes` write. Re-benchmark before mainnet upgrade. + fn cancel_unstake() -> Weight { + Weight::from_parts(122_689_000, 4764) + .saturating_add(T::DbWeight::get().reads(14_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) + } + // Placeholder — re-benchmark before mainnet upgrade. + fn migrate() -> Weight { + Weight::from_parts(180_000_000, 4764) + .saturating_add(T::DbWeight::get().reads(20_u64)) + .saturating_add(T::DbWeight::get().writes(12_u64)) + } + // Placeholder: rate read + gigapot→user transfer + Stakes/TotalLocked/lock + // updates. No money-market call. Re-benchmark before mainnet upgrade. + fn realize_yield() -> Weight { + Weight::from_parts(70_000_000, 4764) + .saturating_add(T::DbWeight::get().reads(8_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } +} \ No newline at end of file diff --git a/runtime/hydradx/src/weights/pallet_gigahdx_rewards.rs b/runtime/hydradx/src/weights/pallet_gigahdx_rewards.rs new file mode 100644 index 0000000000..769c15b89e --- /dev/null +++ b/runtime/hydradx/src/weights/pallet_gigahdx_rewards.rs @@ -0,0 +1,59 @@ +// This file is part of HydraDX. + +// Copyright (C) 2020-2026 Intergalactic, Limited (GIB). +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + + +//! Placeholder weights for `pallet_gigahdx_rewards`. +//! +//! THIS FILE IS A HAND-WRITTEN STUB. It will be replaced by the output of +//! `./bin/hydradx benchmark pallet --pallet pallet_gigahdx_rewards ...` once +//! the benchmark is run on reference hardware. Numbers track +//! `pallet_gigahdx::giga_stake` (the compound path inside `claim_rewards` is +//! essentially `do_stake` plus one `PendingRewards` write and one +//! HDX pot → user transfer). + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; +use crate::*; + +/// Weights for `pallet_gigahdx_rewards` using the HydraDX node and recommended hardware. +pub struct HydraWeight(PhantomData); +impl pallet_gigahdx_rewards::WeightInfo for HydraWeight { + /// Storage: `GigaHdxRewards::PendingRewards` (r:1 w:1) + /// Storage: `System::Account` (r:1 w:1) + /// Storage: `AssetRegistry::Assets` (r:1 w:0) + /// Storage: `Tokens::TotalIssuance` (r:1 w:1) + /// Storage: `Tokens::Accounts` (r:1 w:1) + /// Storage: `GigaHdx::Stakes` (r:1 w:1) + /// Storage: `GigaHdx::TotalLocked` (r:1 w:1) + /// Storage: `GigaHdx::PendingUnstakes` (r:1 w:0) + /// Storage: `Balances::Locks` (r:1 w:1) + /// Storage: `Balances::Freezes` (r:1 w:0) + fn claim_rewards() -> Weight { + // Proof Size summary in bytes: + // Measured: `1200` + // Estimated: `4764` + // Minimum execution time: 140_000_000 picoseconds (placeholder, tracks `giga_stake`). + Weight::from_parts(140_000_000, 4764) + .saturating_add(T::DbWeight::get().reads(15_u64)) + .saturating_add(T::DbWeight::get().writes(7_u64)) + } +} diff --git a/traits/Cargo.toml b/traits/Cargo.toml index 2ee587bcf9..e26922cf16 100644 --- a/traits/Cargo.toml +++ b/traits/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hydradx-traits" -version = "4.7.1" +version = "4.8.0" description = "Shared traits" authors = ["GalacticCouncil"] edition = "2021" diff --git a/traits/src/gigahdx.rs b/traits/src/gigahdx.rs new file mode 100644 index 0000000000..f365e9ca44 --- /dev/null +++ b/traits/src/gigahdx.rs @@ -0,0 +1,76 @@ +// This file is part of hydradx-traits. + +// Copyright (C) 2020-2025 Intergalactic, Limited (GIB). +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use frame_support::sp_runtime::traits::Zero; +use frame_support::sp_runtime::DispatchError; +use frame_support::weights::Weight; + +/// Bridges `pallet-gigahdx` to a money market (e.g. an Aave V3 fork on EVM). +/// +/// Behaviour contract: +/// - `supply` is called by `pallet-gigahdx` after stHDX is minted to the +/// pallet account. The adapter consumes that stHDX and mints aToken +/// (GIGAHDX) to `who`. Returns the amount of aToken received. +/// - `withdraw` is called during unstake. The adapter burns the aToken from +/// `who` and returns the underlying stHDX. Returns the amount returned. +/// - `balance_of` returns the user's current aToken balance — used for +/// defensive checks before initiating a withdraw. +/// +/// The `who` is the substrate `AccountId`. EVM-backed implementations are +/// responsible for resolving the user's H160. +pub trait MoneyMarketOperations { + /// Supply `amount` of `underlying_asset` on behalf of `who`. Returns the + /// amount of aToken received. + fn supply(who: &AccountId, underlying_asset: AssetId, amount: Balance) -> Result; + + /// Burn aToken from `who` and return `amount` of `underlying_asset`. + /// Returns the amount of underlying received. + fn withdraw(who: &AccountId, underlying_asset: AssetId, amount: Balance) -> Result; + + /// User's current aToken (GIGAHDX) balance in the money market. + fn balance_of(who: &AccountId) -> Balance; + + /// Weight overhead this implementation contributes on top of the + /// pallet's substrate-side `giga_stake` weight. EVM-backed impls should + /// return the gas-equivalent weight of the underlying call so block + /// weight tracks the real cost. Defaults to zero for tests / no-op impls. + fn supply_weight() -> Weight { + Weight::zero() + } + + /// Symmetric for `withdraw`. + fn withdraw_weight() -> Weight { + Weight::zero() + } +} + +/// No-op implementation. Useful in tests and on chains that have not yet +/// deployed the money market. `supply`/`withdraw` are identity, `balance_of` +/// is always zero. +impl MoneyMarketOperations for () { + fn supply(_who: &AccountId, _underlying_asset: AssetId, amount: Balance) -> Result { + Ok(amount) + } + + fn withdraw(_who: &AccountId, _underlying_asset: AssetId, amount: Balance) -> Result { + Ok(amount) + } + + fn balance_of(_who: &AccountId) -> Balance { + Balance::zero() + } +} diff --git a/traits/src/lib.rs b/traits/src/lib.rs index 2014162592..6e6140794c 100644 --- a/traits/src/lib.rs +++ b/traits/src/lib.rs @@ -21,6 +21,7 @@ pub mod circuit_breaker; pub mod evm; pub mod fee; +pub mod gigahdx; pub mod liquidity_mining; pub mod nft; pub mod offchain;