From 290e91d548442f8f6af5c21d24df6ee0a619325f Mon Sep 17 00:00:00 2001 From: Martin Hloska Date: Tue, 5 May 2026 14:49:32 +0200 Subject: [PATCH 01/29] gigahdx --- Cargo.lock | 43 ++ Cargo.toml | 6 +- integration-tests/Cargo.toml | 2 + integration-tests/src/evm.rs | 113 ++++- integration-tests/src/gigahdx.rs | 310 ++++++++++++ integration-tests/src/lib.rs | 1 + pallets/gigahdx/Cargo.toml | 60 +++ pallets/gigahdx/src/lib.rs | 475 ++++++++++++++++++ pallets/gigahdx/src/math.rs | 89 ++++ pallets/gigahdx/src/tests/mock.rs | 252 ++++++++++ pallets/gigahdx/src/tests/mod.rs | 6 + pallets/gigahdx/src/tests/stake.rs | 200 ++++++++ pallets/gigahdx/src/tests/unlock.rs | 235 +++++++++ pallets/gigahdx/src/tests/unstake.rs | 172 +++++++ pallets/gigahdx/src/weights.rs | 21 + precompiles/lock-manager/Cargo.toml | 43 ++ precompiles/lock-manager/src/lib.rs | 65 +++ primitives/src/constants.rs | 2 + runtime/hydradx/Cargo.toml | 4 + runtime/hydradx/src/assets.rs | 22 + .../src/evm/precompiles/chainlink_adapter.rs | 35 +- runtime/hydradx/src/evm/precompiles/mod.rs | 11 +- runtime/hydradx/src/gigahdx.rs | 105 ++++ runtime/hydradx/src/lib.rs | 2 + traits/src/gigahdx.rs | 62 +++ traits/src/lib.rs | 1 + 26 files changed, 2329 insertions(+), 8 deletions(-) create mode 100644 integration-tests/src/gigahdx.rs create mode 100644 pallets/gigahdx/Cargo.toml create mode 100644 pallets/gigahdx/src/lib.rs create mode 100644 pallets/gigahdx/src/math.rs create mode 100644 pallets/gigahdx/src/tests/mock.rs create mode 100644 pallets/gigahdx/src/tests/mod.rs create mode 100644 pallets/gigahdx/src/tests/stake.rs create mode 100644 pallets/gigahdx/src/tests/unlock.rs create mode 100644 pallets/gigahdx/src/tests/unstake.rs create mode 100644 pallets/gigahdx/src/weights.rs create mode 100644 precompiles/lock-manager/Cargo.toml create mode 100644 precompiles/lock-manager/src/lib.rs create mode 100644 runtime/hydradx/src/gigahdx.rs create mode 100644 traits/src/gigahdx.rs diff --git a/Cargo.lock b/Cargo.lock index 8b61715264..af296bab3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6464,9 +6464,11 @@ 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-hsm", "pallet-hyperbridge", "pallet-identity", @@ -10650,6 +10652,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" @@ -10705,6 +10726,27 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-gigahdx" +version = "0.1.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "hydradx-traits", + "log", + "orml-tokens", + "orml-traits", + "pallet-balances", + "parity-scale-codec", + "primitives", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-glutton" version = "27.0.0" @@ -15729,6 +15771,7 @@ dependencies = [ "pallet-evm", "pallet-evm-accounts", "pallet-evm-precompile-call-permit", + "pallet-gigahdx", "pallet-hsm", "pallet-im-online", "pallet-ismp", diff --git a/Cargo.toml b/Cargo.toml index aeea40df4b..c93e9a7a9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,12 +46,14 @@ 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", ] resolver = "2" @@ -165,6 +167,7 @@ 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 } hydra-dx-build-script-utils = { path = "utils/build-script-utils", default-features = false } scraper = { path = "scraper", default-features = false } @@ -174,6 +177,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 d786a1345f..11486496ad 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -47,6 +47,7 @@ pallet-evm-accounts = { workspace = true } pallet-xyk-liquidity-mining = { workspace = true } pallet-transaction-pause = { workspace = true } pallet-liquidation = { workspace = true } +pallet-gigahdx = { workspace = true } liquidation-worker-support = { workspace = true } pallet-broadcast = { workspace = true } pallet-duster = { workspace = true } @@ -233,6 +234,7 @@ std = [ "precompile-utils/std", "pallet-transaction-pause/std", "pallet-liquidation/std", + "pallet-gigahdx/std", "pallet-broadcast/std", "pallet-dispatcher/std", "pallet-hsm/std", diff --git a/integration-tests/src/evm.rs b/integration-tests/src/evm.rs index fbc6968cfc..d1c100482d 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,117 @@ 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) { + pallet_gigahdx::TotalStHdx::::put(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(), + FixedU128::from_rational(11, 10) + ); + + 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 = 0; precompile must clamp to 1.0. + seed_gigapot_and_supply(0, 100 * UNITS); + + pretty_assertions::assert_eq!( + pallet_gigahdx::Pallet::::exchange_rate(), + FixedU128::from(0u128) + ); + + 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 = 0.5; precompile must clamp to 1.0 (not 50_000_000). + seed_gigapot_and_supply(50 * UNITS, 100 * UNITS); + + pretty_assertions::assert_eq!( + pallet_gigahdx::Pallet::::exchange_rate(), + FixedU128::from_rational(1, 2) + ); + + 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..691bdd10d6 --- /dev/null +++ b/integration-tests/src/gigahdx.rs @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Integration tests for `pallet-gigahdx` against a snapshot of mainnet +// state with the AAVE V3 fork already 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, UNITS}; +use frame_support::assert_ok; +use frame_system::RawOrigin; +use hex_literal::hex; +use hydradx_runtime::{ + Balances, ConvictionVoting, Currencies, EVMAccounts, GigaHdx, Referenda, Runtime, RuntimeOrigin, System, +}; +use hydradx_traits::evm::InspectEvmAccounts; +use orml_traits::MultiCurrency; +use pallet_conviction_voting::{AccountVote, Conviction, Vote}; +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; + +/// Aave V3 Pool deployed in the gigahdx snapshot. +pub fn pool_contract() -> EvmAddress { + H160(hex!("820df200b69031a84bb8e608b0016f688e43051c")) +} + +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) +} + +/// Set up the gigaHDX system: configure pool contract, fund Alice with HDX. +fn init_gigahdx() { + // Set the deployed AAVE Pool address so the adapter knows where to call. + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract(),)); + + // Give Alice plenty of HDX. + let alice: AccountId = ALICE.into(); + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 1_000 * UNITS, + )); + + // Bind Alice's EVM address (idempotent — adapter does this too). + 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) +} + +#[test] +fn giga_stake_locks_hdx_in_user_account_and_mints_atoken() { + 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)); + + // HDX stays in Alice's account (lock model — not pool model). + // `free_balance` reports total free; locks don't subtract from it. + assert_eq!( + Balances::free_balance(&alice), + alice_hdx_before, + "HDX must remain in Alice's account (lock model)" + ); + + // A `ghdxlock` lock of 100 HDX exists on Alice. + assert_eq!(locked_under_ghdx(&alice), 100 * UNITS); + + // `Stakes[Alice]` populated. + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake should exist"); + assert_eq!(stake.hdx_locked, 100 * UNITS); + assert_eq!(stake.st_minted, 100 * UNITS); // bootstrap 1:1 + + // Alice received GIGAHDX (aToken) on the EVM side. + 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_releases_lock_and_burns_atoken() { + 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); + + // Full unstake — active stake drops to zero, position holds the payout. + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); + + // `Stakes[Alice]` is now zero-active (cleaned up only by `unlock`). + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake remains until unlock"); + assert_eq!(stake.hdx_locked, 0); + assert_eq!(stake.st_minted, 0); + + // Combined lock now equals the position amount. + let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).expect("position created"); + assert_eq!(locked_under_ghdx(&alice), entry.amount); + + // Alice's GIGAHDX balance dropped (aToken burned). + 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_partial_keeps_proportional_state() { + 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"); + // st_minted always drops by exactly the unstaked amount. + assert_eq!(stake.st_minted, 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 + // (case 2). With a near-bootstrap rate the active stake just shrinks + // (case 1). Either way the combined lock equals active + position. + let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).expect("position created"); + assert_eq!(locked_under_ghdx(&alice), stake.hdx_locked + entry.amount); + assert!(entry.amount >= 40 * UNITS, "payout covers at least the principal share"); + }); +} + +#[test] +fn lock_manager_precompile_reports_st_minted() { + 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)); + + // Call lock-manager precompile at 0x0806. ABI: + // getLockedBalance(address token, address account) returns (uint256) + // The `token` arg is unused; we pass any address. + let lock_manager: EvmAddress = H160(hex!("0000000000000000000000000000000000000806")); + 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(EvmAddress::zero()).as_bytes()); // token (unused) + data.extend_from_slice(H256::from(alice_evm).as_bytes()); // account + + use hydradx_runtime::evm::Executor; + use hydradx_traits::evm::{CallContext, EVM}; + 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 st_minted"); + }); +} + +#[test] +fn giga_unstake_creates_pending_position_and_combined_lock() { + 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 = pallet_gigahdx::PendingUnstakes::::get(&alice).expect("entry exists"); + // Mainnet snapshot's gigapot may already hold yield → payout ≥ principal. + assert!(entry.amount >= 40 * UNITS, "position covers at least principal"); + + // Single combined lock: active stake (Stakes.hdx_locked) + position.amount. + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake remains"); + assert_eq!(lock_amount(&alice, GIGAHDX_LOCK_ID), stake.hdx_locked + entry.amount); + }); +} + +#[test] +fn unlock_after_cooldown_releases_lock() { + 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 = pallet_gigahdx::PendingUnstakes::::get(&alice).unwrap(); + System::set_block_number(entry.expires_at); + + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()))); + + assert!(pallet_gigahdx::PendingUnstakes::::get(&alice).is_none()); + // 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_with_locked_hdx_works_via_max_lock_semantics() { + // Proves the lock model: HDX locked under `ghdxlock` is ALSO usable + // for conviction voting via `LockableCurrency::max` semantics. We + // submit a referendum, place its decision deposit, fast-forward into + // the deciding period, and vote with the staked HDX. + 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)); + + // Submit a trivial Root-track referendum (Alice pays the small submission deposit). + 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), + )); + + // Bob covers the (large) decision deposit. + assert_ok!(Referenda::place_decision_deposit(RuntimeOrigin::signed(bob), ref_index,)); + + // Vote with 50 HDX of conviction-locked balance — strictly less than + // the gigaHDX-locked 100, so we're voting "into" the locked HDX. + // `LockableCurrency::max` semantics allow conviction-voting to layer + // its own lock on the same 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` (100) and conviction-voting's lock (50). + 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" + ); + }); +} diff --git a/integration-tests/src/lib.rs b/integration-tests/src/lib.rs index 089926089a..ad3c946ad9 100644 --- a/integration-tests/src/lib.rs +++ b/integration-tests/src/lib.rs @@ -20,6 +20,7 @@ mod evm; mod evm_permit; mod exchange_asset; mod fee_calculation; +mod gigahdx; mod global_withdraw_limit; mod hsm; //mod hyperbridge; diff --git a/pallets/gigahdx/Cargo.toml b/pallets/gigahdx/Cargo.toml new file mode 100644 index 0000000000..69219597d4 --- /dev/null +++ b/pallets/gigahdx/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "pallet-gigahdx" +version = "0.1.0" +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 +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"] } + +[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", + "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/src/lib.rs b/pallets/gigahdx/src/lib.rs new file mode 100644 index 0000000000..c10b585ff2 --- /dev/null +++ b/pallets/gigahdx/src/lib.rs @@ -0,0 +1,475 @@ +// 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; see `specs/07-gigahdx-implementation-spec.md` +//! and `specs/09-gigahdx-money-market-adapter.md`. + +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +#[cfg(test)] +mod tests; + +pub mod math; +pub mod weights; + +#[frame_support::pallet] +pub mod pallet { + pub use crate::weights::WeightInfo; + use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; + use frame_support::pallet_prelude::*; + use frame_support::sp_runtime::traits::{AccountIdConversion, CheckedAdd}; + use frame_support::sp_runtime::{FixedPointNumber, FixedU128}; + use frame_support::storage::{with_transaction, TransactionOutcome}; + use frame_support::traits::fungibles::Mutate as FungiblesMutate; + use frame_support::traits::tokens::{Fortitude, Precision, Preservation}; + use frame_support::traits::{ + fungible, fungibles, Currency, ExistenceRequirement, LockIdentifier, LockableCurrency, WithdrawReasons, + }; + use frame_support::PalletId; + use frame_system::pallet_prelude::*; + 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_locked: 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 st_minted: Balance, + } + + /// Pending-unstake record. At most one per account at a time. + #[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug)] + pub struct PendingUnstake { + /// HDX value to release on `unlock`. Equals the unstake payout + /// (principal share consumed + yield received from gigapot). + pub amount: Balance, + /// Block at which `unlock` becomes callable. + pub expires_at: BlockNumber, + } + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config>> { + /// HDX lockable currency. The `fungible::Inspect` bound is required so + /// `giga_stake` can use `reducible_balance` (free balance minus + /// transfer-blocking locks) instead of raw `free_balance`. + type Currency: LockableCurrency> + + fungible::Inspect; + + /// stHDX is a multi-asset-registry fungible token. Only this pallet + /// mints / burns it. + type StHdx: fungibles::Mutate + + fungibles::Inspect; + + /// stHDX asset id. + #[pallet::constant] + type StHdxAssetId: Get; + + /// Money-market adapter. + type MoneyMarket: MoneyMarketOperations; + + /// Origin allowed to set the pool contract address. + type GovernanceOrigin: 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>; + + type WeightInfo: WeightInfo; + } + + /// 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_locked`. + #[pallet::storage] + pub type TotalLocked = StorageValue<_, Balance, ValueQuery>; + + /// Total stHDX issued. + #[pallet::storage] + pub type TotalStHdx = StorageValue<_, Balance, ValueQuery>; + + /// Aave V3 Pool contract address. Settable by `GovernanceOrigin`. + #[pallet::storage] + pub type GigaHdxPoolContract = StorageValue<_, EvmAddress, ValueQuery>; + + /// At most one pending unstake per account. A second `giga_unstake` + /// while this slot is full is rejected — caller must wait for the + /// cooldown and `unlock` first. + #[pallet::storage] + pub type PendingUnstakes = + StorageMap<_, Blake2_128Concat, T::AccountId, PendingUnstake>, OptionQuery>; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + Staked { + who: T::AccountId, + amount: Balance, + st_minted: Balance, + }, + Unstaked { + who: T::AccountId, + st_amount: Balance, + payout: Balance, + yield_share: Balance, + expires_at: BlockNumberFor, + }, + Unlocked { + who: T::AccountId, + amount: Balance, + }, + PoolContractUpdated { + contract: EvmAddress, + }, + } + + #[pallet::error] + pub enum Error { + BelowMinStake, + InsufficientFreeBalance, + InsufficientStake, + NoStake, + ZeroAmount, + MoneyMarketSupplyFailed, + MoneyMarketWithdrawFailed, + Overflow, + /// The cooldown period has not yet elapsed for the pending unstake. + CooldownNotElapsed, + /// No pending unstake exists for the caller. + NoPendingUnstake, + /// Caller already has a pending unstake; must `unlock` it first. + PendingUnstakeAlreadyExists, + } + + #[pallet::call] + impl Pallet { + /// Lock `amount` HDX in the caller's account, mint stHDX to the caller, + /// and supply it to the money market. The MM mints GIGAHDX (aToken) + /// to the caller's EVM-mapped address. + /// + /// `Stakes[caller].st_minted` records the **actual** aToken amount + /// returned by the MM (may differ from input by rounding). + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::giga_stake())] + pub fn giga_stake(origin: OriginFor, amount: Balance) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!(amount >= T::MinStake::get(), Error::::BelowMinStake); + + // Use `reducible_balance` so the check respects every transfer-blocking + // lock — including this pallet's own combined `LockId` lock (active + // stake + pending unstake) and any unrelated conviction/vesting locks. + let usable = >::reducible_balance( + &who, + Preservation::Expendable, + Fortitude::Polite, + ); + ensure!(usable >= amount, Error::::InsufficientFreeBalance); + + // Compute stHDX to mint based on current rate. + let s = TotalStHdx::::get(); + let t = Self::total_hdx(); + let st_input = crate::math::st_input_for_stake(amount, s, t).map_err(|_| Error::::Overflow)?; + + // Mint stHDX to caller, then supply to MM. Wrapped in `with_transaction` + // so that if MM supply fails, the freshly-minted stHDX rolls back — + // no orphaned stHDX on the user. + let actual_minted = with_transaction(|| -> TransactionOutcome> { + if let Err(e) = T::StHdx::mint_into(T::StHdxAssetId::get(), &who, st_input) { + return TransactionOutcome::Rollback(Err(e)); + } + match T::MoneyMarket::supply(&who, T::StHdxAssetId::get(), st_input) { + Ok(actual) => TransactionOutcome::Commit(Ok(actual)), + Err(e) => TransactionOutcome::Rollback(Err(e)), + } + }) + .map_err(|_| Error::::MoneyMarketSupplyFailed)?; + + let prev = Stakes::::get(&who).unwrap_or_default(); + let new_locked = prev.hdx_locked.checked_add(amount).ok_or(Error::::Overflow)?; + let new_minted = prev.st_minted.checked_add(actual_minted).ok_or(Error::::Overflow)?; + Stakes::::insert( + &who, + StakeRecord { + hdx_locked: new_locked, + st_minted: new_minted, + }, + ); + TotalLocked::::mutate(|x| *x = x.saturating_add(amount)); + TotalStHdx::::mutate(|x| *x = x.saturating_add(actual_minted)); + Self::refresh_lock(&who); + + Self::deposit_event(Event::Staked { + who, + amount, + st_minted: actual_minted, + }); + Ok(()) + } + + /// Set the AAVE V3 Pool contract H160 used by the money-market adapter. + /// Gated by `GovernanceOrigin`. + #[pallet::call_index(2)] + #[pallet::weight(Weight::from_parts(10_000, 0))] + pub fn set_pool_contract(origin: OriginFor, contract: EvmAddress) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + GigaHdxPoolContract::::put(contract); + Self::deposit_event(Event::PoolContractUpdated { contract }); + Ok(()) + } + + /// Unstake `st_amount` of the caller's GIGAHDX. The MM burns the + /// aToken and returns stHDX to the caller, which the pallet then burns. + /// The HDX value (current rate × st_amount) is moved into a single + /// pending-unstake position; any portion that exceeds the user's + /// active stake is paid as yield from the gigapot. + /// + /// At most one pending position per account — caller must `unlock` an + /// existing position before calling again. + /// + /// Implementation detail (must match `LockableAToken.sol`): + /// the lock-manager precompile (`0x0806`) reads `Stakes[who].st_minted` + /// and treats it as the user's locked GIGAHDX. The aToken contract + /// rejects burns where `amount > balance - locked`, so we + /// **pre-decrement `st_minted` by `st_amount` before the MM call**. + /// The whole body is wrapped in `with_transaction` for atomic rollback. + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::giga_unstake())] + pub fn giga_unstake(origin: OriginFor, st_amount: Balance) -> DispatchResult { + let who = ensure_signed(origin)?; + with_transaction::<(), DispatchError, _>(|| { + let outcome = Self::do_giga_unstake(&who, st_amount); + match outcome { + Ok(()) => TransactionOutcome::Commit(Ok(())), + Err(e) => TransactionOutcome::Rollback(Err(e)), + } + }) + } + + /// Release the pending-unstake position once + /// [`Config::CooldownPeriod`] has elapsed. Reduces `LockId` by the + /// stored amount; the caller's HDX becomes spendable. + #[pallet::call_index(3)] + #[pallet::weight(T::WeightInfo::unlock())] + pub fn unlock(origin: OriginFor) -> DispatchResult { + let who = ensure_signed(origin)?; + let entry = PendingUnstakes::::get(&who).ok_or(Error::::NoPendingUnstake)?; + ensure!( + frame_system::Pallet::::block_number() >= entry.expires_at, + Error::::CooldownNotElapsed + ); + + PendingUnstakes::::remove(&who); + Self::refresh_lock(&who); + // `Stakes` may have been emptied by the unstake that opened this + // position; once the position closes, drop the empty record too. + if let Some(s) = Stakes::::get(&who) { + if s.hdx_locked == 0 && s.st_minted == 0 { + Stakes::::remove(&who); + } + } + + Self::deposit_event(Event::Unlocked { + who, + amount: entry.amount, + }); + Ok(()) + } + } + + impl Pallet { + /// Internal helper for `giga_unstake`. Uses `?` freely; the caller + /// wraps it in `with_transaction` for atomic rollback. + fn do_giga_unstake(who: &T::AccountId, st_amount: Balance) -> DispatchResult { + ensure!( + PendingUnstakes::::get(who).is_none(), + Error::::PendingUnstakeAlreadyExists + ); + + let stake = Stakes::::get(who).ok_or(Error::::NoStake)?; + ensure!(st_amount > 0, Error::::ZeroAmount); + ensure!(st_amount <= stake.st_minted, Error::::InsufficientStake); + + // Compute payout from PRE-unstake totals. + let s_pre = TotalStHdx::::get(); + let t_pre = Self::total_hdx(); + let payout = crate::math::total_payout(st_amount, t_pre, s_pre).map_err(|_| Error::::Overflow)?; + + // Pre-decrement `st_minted` so `LockableAToken.burn`'s `freeBalance` + // check (via the lock-manager precompile) lets the burn through. + let new_st_minted = stake.st_minted.checked_sub(st_amount).ok_or(Error::::Overflow)?; + Stakes::::insert( + who, + StakeRecord { + hdx_locked: stake.hdx_locked, + st_minted: new_st_minted, + }, + ); + + // MM withdraw: returns stHDX to `who`, burns aToken from `who`. + T::MoneyMarket::withdraw(who, T::StHdxAssetId::get(), st_amount) + .map_err(|_| Error::::MoneyMarketWithdrawFailed)?; + + // Burn the returned stHDX from the user. + T::StHdx::burn_from( + T::StHdxAssetId::get(), + who, + st_amount, + Preservation::Expendable, + Precision::Exact, + Fortitude::Force, + )?; + + // Split `payout` between the user's active stake and the gigapot. + // • payout ≤ active stake → consume from active only + // • payout > active stake → drain active, pull remainder from pot + let (new_hdx_locked, yield_share) = if payout <= stake.hdx_locked { + (stake.hdx_locked - payout, 0) + } else { + let yield_amount = payout - stake.hdx_locked; + T::Currency::transfer( + &Self::gigapot_account_id(), + who, + yield_amount, + ExistenceRequirement::AllowDeath, + )?; + (0, yield_amount) + }; + let principal_consumed = stake.hdx_locked.saturating_sub(new_hdx_locked); + + Stakes::::insert( + who, + StakeRecord { + hdx_locked: new_hdx_locked, + st_minted: new_st_minted, + }, + ); + TotalLocked::::mutate(|x| *x = x.saturating_sub(principal_consumed)); + TotalStHdx::::mutate(|x| *x = x.saturating_sub(st_amount)); + + let expires_at = frame_system::Pallet::::block_number() + .checked_add(&T::CooldownPeriod::get()) + .ok_or(Error::::Overflow)?; + PendingUnstakes::::insert( + who, + PendingUnstake { + amount: payout, + expires_at, + }, + ); + Self::refresh_lock(who); + + Self::deposit_event(Event::Unstaked { + who: who.clone(), + st_amount, + payout, + yield_share, + expires_at, + }); + Ok(()) + } + } + + impl Pallet { + /// Recompute the single combined balance lock for `who`: + /// `lock_amount = Stakes[who].hdx_locked + PendingUnstakes[who].amount`. + /// Uses `set_lock` (not `extend_lock`) so the lock can shrink on unstake + /// or unlock. Removes the lock entirely when both components are zero. + fn refresh_lock(who: &T::AccountId) { + let stake_amount = Stakes::::get(who).map(|s| s.hdx_locked).unwrap_or(0); + let pending = PendingUnstakes::::get(who).map(|p| p.amount).unwrap_or(0); + let total = stake_amount.saturating_add(pending); + if total == 0 { + T::Currency::remove_lock(T::LockId::get(), who); + } else { + T::Currency::set_lock(T::LockId::get(), who, total, WithdrawReasons::all()); + } + } + + /// Account id of the gigapot (yield holder), derived from + /// `Config::PalletId`. + pub fn gigapot_account_id() -> T::AccountId { + T::PalletId::get().into_account_truncating() + } + + /// Total HDX backing all stHDX: + /// `TotalLocked + free_balance(gigapot_account_id)`. + pub fn total_hdx() -> Balance { + TotalLocked::::get().saturating_add(T::Currency::free_balance(&Self::gigapot_account_id())) + } + + /// Total stHDX issued. + pub fn total_st_hdx_supply() -> Balance { + TotalStHdx::::get() + } + + /// stHDX → HDX exchange rate as `FixedU128 = total_hdx / total_st_hdx_supply`. + /// + /// Returns `1.0` when no stHDX has been issued yet (bootstrap). + pub fn exchange_rate() -> FixedU128 { + let s = Self::total_st_hdx_supply(); + if s == 0 { + FixedU128::from(1) + } else { + FixedU128::checked_from_rational(Self::total_hdx(), s).unwrap_or(FixedU128::from(1)) + } + } + } +} diff --git a/pallets/gigahdx/src/math.rs b/pallets/gigahdx/src/math.rs new file mode 100644 index 0000000000..8787d8df0b --- /dev/null +++ b/pallets/gigahdx/src/math.rs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Arithmetic helpers for `pallet-gigahdx`. +//! +//! All operations use `u128` for inputs/outputs and lift to `U256` for any +//! intermediate product that can exceed `u128::MAX`. Division is floor. +//! Overflow returns `ArithmeticError::Overflow`; never panics. + +use primitives::Balance; +use sp_core::U256; +use sp_runtime::ArithmeticError; + +/// stHDX to mint for a given HDX `amount`, given current totals +/// `s = TotalStHdx`, `t = TotalLocked + gigapot_balance`. +/// +/// Bootstrap (`s == 0` or `t == 0`) returns `amount` unchanged (1:1 rate). +/// Otherwise returns `floor(amount * s / t)` via U256 to avoid overflow. +pub fn st_input_for_stake(amount: Balance, s: Balance, t: Balance) -> Result { + if s == 0 || t == 0 { + return Ok(amount); + } + let num = U256::from(amount) + .checked_mul(U256::from(s)) + .ok_or(ArithmeticError::Overflow)?; + let q = num.checked_div(U256::from(t)).ok_or(ArithmeticError::DivisionByZero)?; + q.try_into().map_err(|_| ArithmeticError::Overflow) +} + +/// Total HDX paid out for unstaking `st_amount`, given totals +/// `t = TotalLocked + gigapot_balance` and `s = TotalStHdx` BEFORE the +/// unstake. +/// +/// Returns `floor(st_amount * t / s)`. +pub fn total_payout(st_amount: Balance, t: Balance, s: Balance) -> Result { + if s == 0 { + return Err(ArithmeticError::DivisionByZero); + } + let num = U256::from(st_amount) + .checked_mul(U256::from(t)) + .ok_or(ArithmeticError::Overflow)?; + let q = num.checked_div(U256::from(s)).ok_or(ArithmeticError::DivisionByZero)?; + q.try_into().map_err(|_| ArithmeticError::Overflow) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn st_input_bootstrap_is_one_to_one() { + assert_eq!(st_input_for_stake(100, 0, 0).unwrap(), 100); + assert_eq!(st_input_for_stake(100, 0, 50).unwrap(), 100); + assert_eq!(st_input_for_stake(100, 50, 0).unwrap(), 100); + } + + #[test] + fn st_input_with_pot_returns_fewer_st() { + // 100 staked, 30 in pot, 100 stHDX issued -> rate = 130/100 + // new stake of 60 HDX -> 60 * 100 / 130 = 46 + assert_eq!(st_input_for_stake(60, 100, 130).unwrap(), 46); + } + + #[test] + fn st_input_uses_u256_for_large_inputs() { + // amount * s would overflow u128 but not u256 + let big = u128::MAX / 2; + let r = st_input_for_stake(big, big, big).unwrap(); + assert_eq!(r, big); // s == t -> rate is 1 + } + + #[test] + fn total_payout_with_pot_pays_yield() { + // t = 130, s = 100, st_amount = 100 -> 130 (100 principal + 30 yield) + assert_eq!(total_payout(100, 130, 100).unwrap(), 130); + } + + #[test] + fn total_payout_round_trip_no_pot() { + // Bootstrap-like state: t = s = 100 -> payout for 100 stHDX is 100. + let st = st_input_for_stake(100, 0, 0).unwrap(); + assert_eq!(st, 100); + assert_eq!(total_payout(st, 100, st).unwrap(), 100); + } + + #[test] + fn divisor_zero_errors() { + assert!(matches!(total_payout(10, 5, 0), Err(ArithmeticError::DivisionByZero))); + } +} diff --git a/pallets/gigahdx/src/tests/mock.rs b/pallets/gigahdx/src/tests/mock.rs new file mode 100644 index 0000000000..9e3a9c430f --- /dev/null +++ b/pallets/gigahdx/src/tests/mock.rs @@ -0,0 +1,252 @@ +// 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 (per doc 09 §5) ---------- + +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)) + } +} + +// ---------- 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 +} + +impl pallet_gigahdx::Config for Test { + type Currency = Balances; + type StHdx = Tokens; + type StHdxAssetId = StHdxAssetIdConst; + type MoneyMarket = TestMoneyMarket; + type GovernanceOrigin = EnsureRoot; + type PalletId = GigaHdxPalletId; + type LockId = GigaHdxLockId; + type MinStake = GigaHdxMinStake; + type CooldownPeriod = GigaHdxCooldownPeriod; + type WeightInfo = (); +} + +// ---------- 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(); + 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..ffcbf94f88 --- /dev/null +++ b/pallets/gigahdx/src/tests/mod.rs @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 + +mod mock; +mod stake; +mod unlock; +mod unstake; diff --git a/pallets/gigahdx/src/tests/stake.rs b/pallets/gigahdx/src/tests/stake.rs new file mode 100644 index 0000000000..1c8e655ec9 --- /dev/null +++ b/pallets/gigahdx/src/tests/stake.rs @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Error, Stakes, TotalLocked, TotalStHdx}; +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 stake_locks_correct_amount_and_records_actual_minted() { + 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_locked, 100 * ONE); + assert_eq!(s.st_minted, 100 * ONE); // bootstrap 1:1, no rounding + assert_eq!(TotalLocked::::get(), 100 * ONE); + assert_eq!(TotalStHdx::::get(), 100 * ONE); + + assert_eq!(locked_under_ghdx(ALICE), 100 * ONE); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 100 * ONE); + }); +} + +#[test] +fn stake_below_min_fails() { + ExtBuilder::default().build().execute_with(|| { + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), ONE / 2), + Error::::BelowMinStake + ); + }); +} + +#[test] +fn stake_above_free_balance_fails() { + ExtBuilder::default().build().execute_with(|| { + // Alice has 1_000 * ONE + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 10_000 * ONE), + Error::::InsufficientFreeBalance + ); + }); +} + +#[test] +fn stake_increases_lock_not_replaces() { + 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_locked, 150 * ONE); + assert_eq!(s.st_minted, 150 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 150 * ONE); + assert_eq!(TotalLocked::::get(), 150 * ONE); + assert_eq!(TotalStHdx::::get(), 150 * ONE); + }); +} + +#[test] +fn stake_zero_total_uses_one_to_one_rate() { + ExtBuilder::default().build().execute_with(|| { + // Empty pot, no prior stakers -> 1:1 + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + assert_eq!(Stakes::::get(ALICE).unwrap().st_minted, 100 * ONE); + }); +} + +#[test] +fn stake_with_pot_uses_correct_rate() { + // Pre-fund pot with 30 HDX, Alice already staked 100, then Bob stakes 100. + ExtBuilder::default() + .with_pot_balance(30 * ONE) + .build() + .execute_with(|| { + // Alice's stake at bootstrap (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().st_minted, 100 * ONE); + + // Now S=100, T = TotalLocked(100) + pot(30) = 130. Bob's 100 HDX -> 100*100/130 = 76. + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(BOB).into(), 100 * ONE)); + let bob_st = Stakes::::get(BOB).unwrap().st_minted; + // floor(100e12 * 100e12 / 130e12) = 76923076923076 (~76.92 stHDX) + assert_eq!(bob_st, 76_923_076_923_076); + }); +} + +#[test] +fn stake_stores_returned_atoken_not_input() { + ExtBuilder::default().build().execute_with(|| { + // Configure MM to round: returns 90% of 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_locked, 100 * ONE); // input + assert_eq!(s.st_minted, 90 * ONE); // returned by MM, not input + assert_eq!(TotalStHdx::::get(), 90 * ONE); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 90 * ONE); + }); +} + +#[test] +fn stake_cannot_use_funds_already_locked_under_cooldown() { + // Alice has 1000 HDX. She stakes 1000 and then unstakes 1000 → cooldown lock = 1000. + // The free balance is still 1000 (locks don't subtract), but staking 1 more + // must be rejected because that 1 would have to be drawn 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 stake_cannot_extend_lock_past_actual_balance() { + // Alice has 1000 HDX. After staking 1000, an existing-lock-aware check must + // reject another 1 — there is no unlocked HDX left to back it. + 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 stake_after_unlock_succeeds() { + // After the cooldown elapses and the user unlocks, the lock is gone + // and their balance is fully available for a new stake. + 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())); + + // Lock is gone, no active stake — fresh 500 HDX stake works. + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 500 * ONE)); + }); +} + +#[test] +fn stake_partial_remaining_balance_works_with_active_cooldown() { + // Alice stakes 100, unstakes 100 (cooldown = 100). She still has 900 free for staking. + 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)); + // One more HDX is impossible. + assert_noop!( + GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), ONE), + Error::::InsufficientFreeBalance + ); + }); +} + +#[test] +fn stake_mm_supply_failure_reverts_no_storage_mutation() { + 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 + ); + + // No pallet-gigahdx state mutation. + assert!(Stakes::::get(ALICE).is_none()); + assert_eq!(TotalLocked::::get(), 0); + assert_eq!(TotalStHdx::::get(), 0); + assert_eq!(locked_under_ghdx(ALICE), 0); + // stHDX rolled back by with_transaction. + assert_eq!(Tokens::balance(ST_HDX, &ALICE), pre_sthdx); + assert_eq!(Balances::free_balance(ALICE), pre_free); + // MM was never credited (it errored). + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 0); + }); +} diff --git a/pallets/gigahdx/src/tests/unlock.rs b/pallets/gigahdx/src/tests/unlock.rs new file mode 100644 index 0000000000..9d1b8043d8 --- /dev/null +++ b/pallets/gigahdx/src/tests/unlock.rs @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Error, PendingUnstakes, 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 unstake_creates_single_pending_position_and_combined_lock() { + // Empty pot, stake 100, partial unstake 40. + // payout = 40, active drops 100→60, position = 40, combined lock = 60+40 = 100. + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); + + let entry = PendingUnstakes::::get(ALICE).expect("entry exists"); + assert_eq!(entry.amount, 40 * ONE); + assert_eq!(entry.expires_at, 1 + GigaHdxCooldownPeriod::get()); + + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx_locked, 60 * ONE); + assert_eq!(s.st_minted, 60 * ONE); + + // Single combined lock under GIGAHDX_LOCK_ID covers active + pending. + assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 100 * ONE); + // Spendable strictly zero — no leakage. + assert_eq!(reducible(ALICE), Balances::free_balance(ALICE) - 100 * ONE); + }); +} + +#[test] +fn unstake_full_drains_active_only_when_pot_empty() { + // Empty pot, stake 100, unstake 100. payout = 100. active drops to 0, + // no yield transferred. Position = 100. + 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_locked, 0); + assert_eq!(s.st_minted, 0); + assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 100 * ONE); + assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 100 * ONE); + }); +} + +#[test] +fn unstake_with_pot_partial_payout_le_active_no_yield_transfer() { + // Pot 200 → rate 3.0. Stake 100, unstake 10 stHDX → payout 30 ≤ active 100. + // Active drops 100→70, no pot transfer. Position = 30. + 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_locked, 70 * ONE); + assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 30 * ONE); + // Alice's free balance unchanged — no yield transfer (payout came from active). + assert_eq!(Balances::free_balance(ALICE), alice_balance_before); + // Pot unchanged. + assert_eq!(Balances::free_balance(pot_account()), pot_before); + // Combined lock = 70 + 30 = 100. + assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 100 * ONE); + }); +} + +#[test] +fn unstake_with_pot_payout_gt_active_transfers_yield_and_extends_lock() { + // Pot 200 → rate 3.0. Stake 100, unstake 90 stHDX → payout 270 > active 100. + // Active drops to 0, yield = 170 transferred 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_locked, 0); + assert_eq!(s.st_minted, 10 * ONE); + assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 270 * ONE); + + // Alice received 170 HDX yield directly into her balance. + assert_eq!(Balances::free_balance(ALICE), alice_balance_before + 170 * ONE); + // Pot reduced by 170. + assert_eq!(Balances::free_balance(pot_account()), 30 * ONE); + // Combined lock = 0 + 270 = 270 (covers all of Alice's HDX in account). + assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 270 * ONE); + // Spendable strictly zero — yield is locked under cooldown. + assert_eq!(reducible(ALICE), Balances::free_balance(ALICE) - 270 * ONE); + }); +} + +#[test] +fn unstake_with_existing_pending_position_fails() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + assert_noop!( + GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 10 * ONE), + Error::::PendingUnstakeAlreadyExists + ); + }); +} + +#[test] +fn unlock_before_cooldown_fails() { + 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()); // 1 block early + assert_noop!( + GigaHdx::unlock(RawOrigin::Signed(ALICE).into()), + Error::::CooldownNotElapsed + ); + }); +} + +#[test] +fn unlock_after_cooldown_releases_lock_and_clears_position() { + 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())); + + assert!(PendingUnstakes::::get(ALICE).is_none()); + // Stakes was {0, 0} after full unstake — should now be cleaned up. + assert!(Stakes::::get(ALICE).is_none()); + // Lock fully removed. + assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 0); + }); +} + +#[test] +fn unlock_partial_unstake_keeps_active_lock() { + // Stake 100, unstake 40, unlock. Active stake (60) keeps its lock. + 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())); + + assert!(PendingUnstakes::::get(ALICE).is_none()); + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx_locked, 60 * ONE); + assert_eq!(s.st_minted, 60 * ONE); + // Lock is now just the active stake (40 HDX freed). + assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 60 * ONE); + }); +} + +#[test] +fn unlock_with_no_position_fails() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_noop!( + GigaHdx::unlock(RawOrigin::Signed(ALICE).into()), + Error::::NoPendingUnstake + ); + }); +} + +#[test] +fn unstake_after_unlock_succeeds() { + // Slot frees up after unlock — caller can unstake again. + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); + System::set_block_number(1 + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into())); + + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 20 * ONE)); + assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 20 * ONE); + }); +} + +#[test] +fn full_unstake_with_yield_leaves_zero_active_with_st_minted_and_resolves_correctly() { + // Pot 200 → rate 3.0. Stake 100. Unstake 90 → active = 0, st_minted = 10. + // Then unstake remaining 10 — case 2 again (active = 0), full payout 30 from pot. + ExtBuilder::default() + .with_pot_balance(200 * ONE) + .build() + .execute_with(|| { + stake_alice_100(); + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 90 * ONE)); + System::set_block_number(1 + GigaHdxCooldownPeriod::get()); + assert_ok!(GigaHdx::unlock(RawOrigin::Signed(ALICE).into())); + + // Active stake is gone, but Alice still owns 10 stHDX with zero cost basis. + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx_locked, 0); + assert_eq!(s.st_minted, 10 * ONE); + + // Unstake the remainder. + assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 10 * ONE)); + assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 30 * ONE); + // Pot drained completely. + 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..8fd4e4dada --- /dev/null +++ b/pallets/gigahdx/src/tests/unstake.rs @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::mock::*; +use crate::{Error, PendingUnstakes, Stakes, TotalLocked, TotalStHdx}; +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 unstake_full_no_pot_consumes_active_into_position() { + // Empty pot, stake 100, unstake 100. payout = 100, case 1 (= active). + // Active drops to 0; position = 100; combined lock = 100; 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_locked, 0); + assert_eq!(s.st_minted, 0); + assert_eq!(TotalLocked::::get(), 0); + assert_eq!(TotalStHdx::::get(), 0); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 0); + + // Position holds 100; lock covers it; no yield to free_balance. + assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 100 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 100 * ONE); + assert_eq!(Balances::free_balance(ALICE), pre_free); + }); +} + +#[test] +fn unstake_full_with_pot_drains_active_and_pulls_yield_from_pot() { + // Pot 30, stake 100. Unstake 100 stHDX → payout 130 (case 2). + // active 100 → 0, yield 30 transferred from pot, position = 130. + 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)); + + // Yield 30 received into Alice's free balance. + assert_eq!(Balances::free_balance(ALICE), pre_free + 30 * ONE); + // Pot drained. + let pot: AccountId = GigaHdxPalletId::get().into_account_truncating(); + assert_eq!(Balances::free_balance(pot), 0); + + // Stakes record still present (zeroed) until `unlock` cleans it up. + let s = Stakes::::get(ALICE).unwrap(); + assert_eq!(s.hdx_locked, 0); + assert_eq!(s.st_minted, 0); + + // Position = full payout; lock covers everything. + assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 130 * ONE); + assert_eq!(locked_under_ghdx(ALICE), 130 * ONE); + }); +} + +#[test] +fn unstake_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_locked, 60 * ONE); + assert_eq!(s.st_minted, 60 * ONE); + // Combined lock = active(60) + position(40) = 100. + assert_eq!(locked_under_ghdx(ALICE), 100 * ONE); + assert_eq!(TotalLocked::::get(), 60 * ONE); + assert_eq!(TotalStHdx::::get(), 60 * ONE); + assert_eq!(TestMoneyMarket::balance_of(&ALICE), 60 * ONE); + assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 40 * ONE); + }); +} + +#[test] +fn unstake_zero_fails() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_noop!( + GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 0), + Error::::ZeroAmount + ); + }); +} + +#[test] +fn unstake_above_stake_fails() { + ExtBuilder::default().build().execute_with(|| { + stake_alice_100(); + assert_noop!( + GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 101 * ONE), + Error::::InsufficientStake + ); + }); +} + +#[test] +fn unstake_no_stake_fails() { + ExtBuilder::default().build().execute_with(|| { + assert_noop!( + GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE), + Error::::NoStake + ); + }); +} + +#[test] +fn unstake_mm_failure_reverts_no_storage_mutation() { + 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 = TotalStHdx::::get(); + 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 `st_minted` was rolled back by `with_transaction`. + let post_stake = Stakes::::get(ALICE).unwrap(); + assert_eq!(post_stake.st_minted, pre_stake.st_minted, "st_minted must be restored"); + assert_eq!(post_stake.hdx_locked, pre_stake.hdx_locked); + assert_eq!(TotalLocked::::get(), pre_total_locked); + assert_eq!(TotalStHdx::::get(), 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!( + PendingUnstakes::::get(ALICE).is_none(), + "no position created on failure" + ); + }); +} + +#[test] +fn unstake_pre_decrements_st_minted_before_mm_withdraw() { + // LockableAToken.burn relies on the lock-manager precompile reading the + // already-decremented `Stakes[who].st_minted`. We can't observe that + // mid-call here, but the post-state proves the pre-decrement happened + // before MM.withdraw (otherwise the burn would have failed 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().st_minted, 70 * ONE); + }); +} diff --git a/pallets/gigahdx/src/weights.rs b/pallets/gigahdx/src/weights.rs new file mode 100644 index 0000000000..678cac0d89 --- /dev/null +++ b/pallets/gigahdx/src/weights.rs @@ -0,0 +1,21 @@ +// 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; +} + +impl WeightInfo for () { + fn giga_stake() -> Weight { + Weight::zero() + } + fn giga_unstake() -> Weight { + Weight::zero() + } + fn unlock() -> Weight { + Weight::zero() + } +} 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..011fdf9ad0 --- /dev/null +++ b/precompiles/lock-manager/src/lib.rs @@ -0,0 +1,65 @@ +// : $$\ $$\ $$\ $$$$$$$\ $$\ $$\ +// !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 precompile_utils::prelude::*; +use sp_core::U256; + +/// Precompile at address 0x0806. +/// +/// Reports a per-account "locked GIGAHDX" amount derived from +/// `pallet_gigahdx::Stakes[who].st_minted`. This is consumed by the +/// `LockableAToken.sol` contract's `freeBalance` check +/// (`free = balanceOf - locked`) to: +/// +/// 1. Block user-initiated transfers of GIGAHDX (since `st_minted` equals +/// the user's aToken balance, `free = 0`). +/// 2. Allow legitimate `Pool.withdraw → aToken.burn` paths during +/// `pallet-gigahdx::giga_unstake`, which pre-decrements `st_minted` by +/// the amount being unstaked before invoking the MM. +pub struct LockManagerPrecompile(PhantomData); + +#[precompile_utils::precompile] +impl LockManagerPrecompile +where + Runtime: pallet_gigahdx::Config + pallet_evm::Config, + Runtime::AddressMapping: pallet_evm::AddressMapping<::AccountId>, +{ + /// Returns the locked GIGAHDX balance for a given account. + /// The `token` parameter is accepted for forward-compatibility but currently unused. + #[precompile::public("getLockedBalance(address,address)")] + #[precompile::view] + fn get_locked_balance(handle: &mut impl PrecompileHandle, _token: Address, account: Address) -> EvmResult { + // Blake2_128Concat key prefix (16) + AccountId (32) + StakeRecord (2 × u128 = 32) = 80 bytes + handle.record_db_read::(80)?; + + let account_id = ::AccountId, + >>::into_account_id(account.into()); + let locked = pallet_gigahdx::Stakes::::get(&account_id) + .map(|s| s.st_minted) + .unwrap_or(0); + + Ok(U256::from(locked)) + } +} diff --git a/primitives/src/constants.rs b/primitives/src/constants.rs index 585bc07ee1..bb9697ecb8 100644 --- a/primitives/src/constants.rs +++ b/primitives/src/constants.rs @@ -83,6 +83,8 @@ 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"; + /// Trailing 's' is load-bearing — `Source` is exactly 8 bytes and `gigahdx` is only 7. + 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 455c12e0c0..63cda7eeba 100644 --- a/runtime/hydradx/Cargo.toml +++ b/runtime/hydradx/Cargo.toml @@ -179,6 +179,8 @@ 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 } precompile-utils = { workspace = true } module-evm-utility-macro = { workspace = true } ethabi = { workspace = true } @@ -382,6 +384,8 @@ 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-xyk/std", "pallet-referrals/std", "pallet-evm-accounts/std", diff --git a/runtime/hydradx/src/assets.rs b/runtime/hydradx/src/assets.rs index 1697ed391a..7b1de41880 100644 --- a/runtime/hydradx/src/assets.rs +++ b/runtime/hydradx/src/assets.rs @@ -1868,6 +1868,28 @@ 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!"); + pub const StHdxAssetId: AssetId = 670; + pub const GigaHdxAssetIdConst: AssetId = 67; + pub const GigaHdxMinStake: Balance = UNITS; // 1 HDX + pub const GigaHdxCooldownPeriod: BlockNumber = 30 * DAYS; +} + +impl pallet_gigahdx::Config for Runtime { + type Currency = Balances; + type StHdx = FungibleCurrencies; + type StHdxAssetId = StHdxAssetId; + type MoneyMarket = crate::gigahdx::AaveMoneyMarket; + type GovernanceOrigin = EnsureRoot; + type PalletId = GigaHdxPalletId; + type LockId = GigaHdxLockId; + type MinStake = GigaHdxMinStake; + type CooldownPeriod = GigaHdxCooldownPeriod; + type WeightInfo = (); +} + #[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..08b525ddb1 100644 --- a/runtime/hydradx/src/evm/precompiles/chainlink_adapter.rs +++ b/runtime/hydradx/src/evm/precompiles/chainlink_adapter.rs @@ -23,8 +23,11 @@ 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 sp_runtime::{traits::Dispatchable, RuntimeDebug}; +use primitives::{ + constants::chain::{GIGAHDX_SOURCE, OMNIPOOL_SOURCE}, + AssetId, +}; +use sp_runtime::{traits::Dispatchable, FixedU128, RuntimeDebug}; use sp_std::{cmp::Ordering, marker::PhantomData}; const EMPTY_SOURCE: Source = [0u8; 8]; @@ -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,17 @@ where let rat_as_u128 = round_to_rational((nominator, denominator), Rounding::Nearest); Price::from(rat_as_u128) + } + // stHDX/HDX exchange rate from pallet-gigahdx (spot value, period is ignored). + // Floor at 1.0: stHDX accrues HDX value monotonically under user flows; a sub-1 + // reading is only reachable via privileged ops or migration bugs and would + // spuriously liquidate stHDX collateral on AAVE. + else if source == GIGAHDX_SOURCE { + let rate = pallet_gigahdx::Pallet::::exchange_rate().max(FixedU128::from(1u128)); + Price { + n: rate.into_inner(), + d: 1_000_000_000_000_000_000u128, + } } else { let (price, _block_number) = >::get_price( asset_id_a, asset_id_b, period, source, @@ -355,3 +371,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..e946a61e15 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; @@ -127,7 +129,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 +179,10 @@ where R, AllowedFlashLoanCallers, >::execute(handle)) + } else if address == LOCK_MANAGER { + Some(pallet_evm_precompile_lock_manager::LockManagerPrecompile::::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 +217,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..9e04606946 --- /dev/null +++ b/runtime/hydradx/src/gigahdx.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// `MoneyMarketOperations` adapter that bridges `pallet-gigahdx` to the +// EVM-side AAVE V3 fork. See `specs/09-gigahdx-money-market-adapter.md`. +// +// `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`). + +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::Erc20Currency; +use crate::evm::Executor; +use crate::Runtime; +use crate::RuntimeOrigin; +use ethabi::ethereum_types::BigEndianHash; +use evm::ExitReason::Succeed; +use frame_support::sp_runtime::traits::Convert; +use frame_support::sp_runtime::DispatchError; +use hydradx_traits::evm::{CallContext, CallResult, Erc20Mapping, InspectEvmAccounts, ERC20, EVM}; +use hydradx_traits::gigahdx::MoneyMarketOperations; +use primitive_types::U256; +use primitives::{AccountId, AssetId, Balance, EvmAddress}; +use sp_core::H256; + +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 { + let pool = pallet_gigahdx::GigaHdxPoolContract::::get(); + if pool == EvmAddress::zero() { + return Err(DispatchError::Other("gigahdx: pool contract not set")); + } + Ok(pool) + } +} + +impl MoneyMarketOperations for AaveMoneyMarket { + fn supply(who: &AccountId, underlying_asset: AssetId, amount: Balance) -> Result { + // Idempotent — binds an EVM address for `who` if not already bound. + let _ = pallet_evm_accounts::Pallet::::bind_evm_address(RuntimeOrigin::signed(who.clone())); + + let asset_evm = HydraErc20Mapping::asset_address(underlying_asset); + let who_evm = pallet_evm_accounts::Pallet::::evm_address(who); + let pool = Self::pool()?; + + // Approve the pool to pull `amount` of underlying. + let approve_ctx = CallContext::new_call(asset_evm, who_evm); + as ERC20>::approve(approve_ctx, pool, amount)?; + + // Pool.supply(asset, amount, onBehalfOf=user, referralCode=0) + let supply_ctx = CallContext::new_call(pool, who_evm); + let mut data = Into::::into(AaveFunction::Supply).to_be_bytes().to_vec(); + data.extend_from_slice(H256::from(asset_evm).as_bytes()); + data.extend_from_slice(H256::from_uint(&U256::from(amount)).as_bytes()); + data.extend_from_slice(H256::from(who_evm).as_bytes()); + data.extend_from_slice(H256::from_uint(&U256::zero()).as_bytes()); // referralCode = 0 + handle(Executor::::call(supply_ctx, data, U256::zero(), GAS_LIMIT))?; + + Ok(amount) + } + + fn withdraw(who: &AccountId, underlying_asset: AssetId, amount: Balance) -> Result { + let _ = pallet_evm_accounts::Pallet::::bind_evm_address(RuntimeOrigin::signed(who.clone())); + + let asset_evm = HydraErc20Mapping::asset_address(underlying_asset); + let who_evm = pallet_evm_accounts::Pallet::::evm_address(who); + let pool = Self::pool()?; + + // Pool.withdraw(asset, amount, to=user) + let withdraw_ctx = CallContext::new_call(pool, who_evm); + let mut data = Into::::into(AaveFunction::Withdraw).to_be_bytes().to_vec(); + data.extend_from_slice(H256::from(asset_evm).as_bytes()); + data.extend_from_slice(H256::from_uint(&U256::from(amount)).as_bytes()); + data.extend_from_slice(H256::from(who_evm).as_bytes()); + handle(Executor::::call(withdraw_ctx, data, U256::zero(), GAS_LIMIT))?; + + Ok(amount) + } + + fn balance_of(who: &AccountId) -> Balance { + // Read aToken (GIGAHDX) balance via ERC20.balanceOf. + 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) + } +} diff --git a/runtime/hydradx/src/lib.rs b/runtime/hydradx/src/lib.rs index 622b7dd081..abc78f7be1 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 hyperbridge; @@ -202,6 +203,7 @@ construct_runtime!( Parameters: pallet_parameters = 83, Signet: pallet_signet = 84, EthDispenser: pallet_dispenser = 85, + GigaHdx: pallet_gigahdx = 86, // ORML related modules Tokens: orml_tokens = 77, diff --git a/traits/src/gigahdx.rs b/traits/src/gigahdx.rs new file mode 100644 index 0000000000..43288800a6 --- /dev/null +++ b/traits/src/gigahdx.rs @@ -0,0 +1,62 @@ +// 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; + +/// 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; +} + +/// 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; From 9f2f3433a34ecd1f1e458848b046a5051be943c2 Mon Sep 17 00:00:00 2001 From: Martin Hloska Date: Tue, 5 May 2026 15:21:26 +0200 Subject: [PATCH 02/29] additional integration tests --- integration-tests/src/gigahdx.rs | 605 ++++++++++++++++++++++++++++++- 1 file changed, 601 insertions(+), 4 deletions(-) diff --git a/integration-tests/src/gigahdx.rs b/integration-tests/src/gigahdx.rs index 691bdd10d6..8e025050f7 100644 --- a/integration-tests/src/gigahdx.rs +++ b/integration-tests/src/gigahdx.rs @@ -5,16 +5,24 @@ // reserve, `LockableAToken` consuming the lock-manager precompile at // 0x0806). -use crate::polkadot_test_net::{hydra_live_ext, TestNet, ALICE, BOB, UNITS}; -use frame_support::assert_ok; +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 hydradx_runtime::evm::{ + aave_trade_executor::Function as AaveFunction, precompiles::erc20_mapping::HydraErc20Mapping, + precompiles::handle::EvmDataWriter, Executor, +}; use hydradx_runtime::{ - Balances, ConvictionVoting, Currencies, EVMAccounts, GigaHdx, Referenda, Runtime, RuntimeOrigin, System, + Balances, ConvictionVoting, Currencies, Democracy, EVMAccounts, GigaHdx, Preimage, Referenda, Runtime, + RuntimeOrigin, Scheduler, System, }; -use hydradx_traits::evm::InspectEvmAccounts; +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; @@ -308,3 +316,592 @@ fn vote_with_locked_hdx_works_via_max_lock_semantics() { ); }); } + +// --------------------------------------------------------------------------- +// Snapshot-based scenario tests (ported from old gigahdx test suite). +// +// These run against the gigahdx snapshot with the AAVE fork live, exercising +// the lock-cooldown design end-to-end against the real money market. +// --------------------------------------------------------------------------- + +/// Reset the gigapot balance and stHDX issuance so test math runs from a +/// clean baseline. The snapshot may carry pre-existing yield in the gigapot; +/// rate-sensitive scenarios need a known starting state. +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, + )); +} + +#[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 +} + +// ---------- Wave 1: snapshot integration tests ---------- + +#[test] +fn giga_stake_should_mint_gigahdx_on_mainnet_snapshot() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + 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 hdx_before = Currencies::free_balance(HDX, &alice); + let total_hdx_before = GigaHdx::total_hdx(); + let total_st_hdx_before = GigaHdx::total_st_hdx_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); + + // GIGAHDX (aToken) minted to Alice via the real AAVE supply. + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), stake_amount); + + // Totals incremented; bootstrap rate = 1. + assert_eq!(GigaHdx::total_hdx(), total_hdx_before + stake_amount); + assert_eq!(GigaHdx::total_st_hdx_supply(), total_st_hdx_before + stake_amount); + assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_u32(1)); + }); +} + +#[test] +fn giga_unstake_should_succeed_when_full_exit() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + + 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); + // Position created, st_minted zeroed, lock now equals position amount. + let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).expect("position exists"); + assert!(entry.amount > 0); + }); +} + +#[test] +fn giga_unstake_should_fail_when_amount_exceeds_balance() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + + 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!(pallet_gigahdx::PendingUnstakes::::get(&alice).is_none()); + }); +} + +#[test] +fn giga_stake_should_succeed_above_min_and_fail_below() { + // Pallet gate: amounts strictly below MinStake are rejected by the pallet + // regardless of AAVE state. The "succeeds above min" half uses 10 UNITS, + // safely clear of any AAVE-internal minimum supply rounding. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + + 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 restake_should_succeed_after_full_exit() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + 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_st_hdx_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_st_hdx_supply(), 0); + assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_u32(1)); + + // Bob can stake fresh — Alice's cooldown is hers, Bob is unaffected. + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(bob.clone()), 100 * UNITS)); + assert_eq!(Currencies::free_balance(GIGAHDX, &bob), 100 * UNITS); + assert_eq!(GigaHdx::total_st_hdx_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(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + 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(); + + // Pot starts with 1 UNIT yield. + fund(&gigapot, UNITS); + fund(&alice, 1_000_000 * UNITS); + fund(&bob, 1_000_000 * UNITS); + + // Alice stakes 100 → rate becomes (100 + 1) / 100 = 1.01. + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice), 100 * UNITS)); + assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_rational(101, 100)); + + // Bob donates 1000 HDX directly to the gigapot → rate inflates. + assert_ok!(Currencies::transfer( + RuntimeOrigin::signed(bob.clone()), + gigapot, + HDX, + 1_000 * UNITS, + )); + assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_rational(1101, 100)); + + // New stake at the inflated rate gets fewer GIGAHDX. + 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" + ); + + // A fresh staker can still participate. + fund(&charlie, 1_000 * UNITS); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(charlie), 100 * UNITS)); + }); +} + +#[test] +fn unstake_payout_should_succeed_after_donation_on_real_aave() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + 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); + + // Bob grief-donates HDX to inflate the rate. + assert_ok!(Currencies::transfer( + RuntimeOrigin::signed(bob), + gigapot, + HDX, + 500 * UNITS, + )); + assert!(GigaHdx::exchange_rate() > sp_runtime::FixedU128::from(1)); + + // Alice fully unstakes — the donation is a bonus to her, not a DoS. + assert_ok!(GigaHdx::giga_unstake( + RuntimeOrigin::signed(alice.clone()), + gigahdx_minted, + )); + + let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).expect("position created"); + assert!( + entry.amount > 100 * UNITS, + "payout reflects inflated rate: got {} (staked 100 UNITS)", + entry.amount, + ); + }); +} + +#[test] +fn giga_unstake_should_succeed_at_extreme_exchange_rate() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + 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); + + // Inflate the gigapot to an extreme value. + fund(&gigapot, 1_000_000_000_000_000 * UNITS); + + // Full unstake at extreme rate: case 2 — active drained, all yield from pot. + assert_ok!(GigaHdx::giga_unstake( + RuntimeOrigin::signed(alice.clone()), + 100 * UNITS, + )); + + let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).expect("position created"); + // payout = 100 * UNITS * (10^15 * UNITS + 100 * UNITS) / (100 * UNITS) + // = 10^15 * UNITS + 100 * UNITS (≈ 10^15 UNITS) + 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-level Pool.withdraw must be rejected by the lock-manager + // precompile while the user still has an active stake — `st_minted` + // equals atoken balance, so `LockableAToken.burn`'s freeBalance check + // gives 0 and the burn reverts. This protects the cooldown semantics: + // without it, users could bypass `giga_unstake` entirely. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + + 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 = pallet_gigahdx::GigaHdxPoolContract::::get(); + 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, + ); + + // Nothing moved. + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), gigahdx_balance); + assert_eq!(Currencies::free_balance(ST_HDX, &alice), sthdx_before); + // st_minted unchanged. + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake remains"); + assert_eq!(stake.st_minted, stake_amount); + }); +} + +#[test] +fn atoken_evm_transfer_should_fail_while_staked() { + // ERC20 `transfer` of GIGAHDX must revert while the user has an active + // stake — atokens are 100% locked-balance per the lock-manager precompile. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + + 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); + assert_ok!(EVMAccounts::bind_evm_address(RuntimeOrigin::signed(bob.clone()))); + 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); + }); +} + +// ---------- Wave 2: cooldown × voting-lock co-existence ---------- + +#[test] +fn partial_unstake_should_not_leak_via_max_aggregated_lock_ids() { + // Regression test for the per-unstake-lock-id design where pallet-balances' + // max-of-locks semantics let `min(active_stake, cooldown)` HDX leak out + // during cooldown. Under the new single-combined-lock model the lock + // equals `active + position`, so partial unstake never frees any HDX. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + 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); + + // Partial unstake — half. With pot empty, payout = principal (case 1). + assert_ok!(GigaHdx::giga_unstake( + RuntimeOrigin::signed(alice.clone()), + 500 * UNITS, + )); + + let stake = pallet_gigahdx::Stakes::::get(&alice).unwrap(); + let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).unwrap(); + assert_eq!(stake.hdx_locked, 500 * UNITS); + assert_eq!(entry.amount, 500 * UNITS); + + // Combined lock = active(500) + position(500) = 1000. Old buggy + // design produced max(500, 500) = 500, leaking 500 HDX out of + // cooldown immediately after partial unstake. + 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 unstake_during_active_vote_keeps_lock_layers_consistent() { + // Stake → vote with conviction on a balance larger than the stake → partial + // unstake. The gigahdx lock (active + position) and the conviction lock + // must coexist; spendable balance is `balance − max(both)`. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + reset_giga_state_for_fixture(); + fund_bob_for_decision_deposit(); + + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000 * UNITS); + + assert_ok!(GigaHdx::giga_stake( + RuntimeOrigin::signed(alice.clone()), + 500 * UNITS, + )); + + // Vote with 800 HDX conviction — exceeds the stake amount, layers + // over both staked and free HDX. + let ref_index = begin_referendum_by_bob(); + assert_ok!(ConvictionVoting::vote( + RuntimeOrigin::signed(alice.clone()), + ref_index, + aye_with_conviction(800 * UNITS, Conviction::Locked1x), + )); + + // Partial unstake — 100 stHDX. Pot empty → payout = principal = 100. + assert_ok!(GigaHdx::giga_unstake( + RuntimeOrigin::signed(alice.clone()), + 100 * UNITS, + )); + + // Combined gigahdx lock = active(400) + position(100) = 500. + assert_eq!(locked_under_ghdx(&alice), 500 * UNITS); + // Conviction lock unchanged at 800. + 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 = balance(1000) − max(ghdx=500, 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 second_unstake_is_rejected_while_position_pending() { + // One pending position per account — no concurrent unstakes. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + + let alice: AccountId = ALICE.into(); + fund(&alice, 1_000 * UNITS); + assert_ok!(GigaHdx::giga_stake( + RuntimeOrigin::signed(alice.clone()), + 1_000 * UNITS, + )); + assert_ok!(GigaHdx::giga_unstake( + RuntimeOrigin::signed(alice.clone()), + 300 * UNITS, + )); + + assert_noop!( + GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS), + pallet_gigahdx::Error::::PendingUnstakeAlreadyExists, + ); + }); +} From 08fa9c4be8a93b1fcc28bbd7a58de5b967a669ea Mon Sep 17 00:00:00 2001 From: Martin Hloska Date: Wed, 6 May 2026 17:00:11 +0200 Subject: [PATCH 03/29] gigahdx impl --- integration-tests/src/evm.rs | 4 +- integration-tests/src/gigahdx.rs | 340 ++++++++++++++++++--------- pallets/gigahdx/src/lib.rs | 60 +++-- pallets/gigahdx/src/math.rs | 16 +- pallets/gigahdx/src/tests/stake.rs | 48 ++-- pallets/gigahdx/src/tests/unlock.rs | 34 +-- pallets/gigahdx/src/tests/unstake.rs | 40 ++-- precompiles/lock-manager/src/lib.rs | 10 +- primitives/src/constants.rs | 1 - runtime/hydradx/src/gigahdx.rs | 13 +- 10 files changed, 343 insertions(+), 223 deletions(-) diff --git a/integration-tests/src/evm.rs b/integration-tests/src/evm.rs index d1c100482d..4c8564f4f8 100644 --- a/integration-tests/src/evm.rs +++ b/integration-tests/src/evm.rs @@ -2715,7 +2715,9 @@ mod chainlink_precompile { } fn seed_gigapot_and_supply(gigapot_hdx: Balance, st_hdx_supply: Balance) { - pallet_gigahdx::TotalStHdx::::put(st_hdx_supply); + // `total_st_hdx_supply` reads orml-tokens issuance directly, so seed + // the stHDX issuance there rather than via a pallet-side counter. + 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,)); } diff --git a/integration-tests/src/gigahdx.rs b/integration-tests/src/gigahdx.rs index 8e025050f7..c0a8184b60 100644 --- a/integration-tests/src/gigahdx.rs +++ b/integration-tests/src/gigahdx.rs @@ -33,9 +33,13 @@ pub const PATH_TO_SNAPSHOT: &str = "snapshots/gigahdx/gigahdx"; pub const ST_HDX: AssetId = 670; pub const GIGAHDX: AssetId = 67; -/// Aave V3 Pool deployed in the gigahdx snapshot. -pub fn pool_contract() -> EvmAddress { - H160(hex!("820df200b69031a84bb8e608b0016f688e43051c")) +/// Reads the AAVE pool contract address from the gigahdx pallet storage. +/// **Tests must not set this themselves** — the snapshot is expected to +/// carry the correct address; failing to do so means the snapshot is +/// misconfigured and the tests should fail loud rather than silently +/// patching it with a hardcoded fallback. +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"; @@ -48,11 +52,8 @@ fn lock_amount(account: &AccountId, id: frame_support::traits::LockIdentifier) - .unwrap_or(0) } -/// Set up the gigaHDX system: configure pool contract, fund Alice with HDX. +/// Fund Alice with HDX. Snapshot already configures the AAVE pool contract. fn init_gigahdx() { - // Set the deployed AAVE Pool address so the adapter knows where to call. - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract(),)); - // Give Alice plenty of HDX. let alice: AccountId = ALICE.into(); assert_ok!(Balances::force_set_balance( @@ -84,7 +85,7 @@ fn locked_under_ghdx(account: &AccountId) -> Balance { } #[test] -fn giga_stake_locks_hdx_in_user_account_and_mints_atoken() { +fn giga_stake_should_lock_hdx_in_user_account_when_called() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { init_gigahdx(); @@ -109,7 +110,7 @@ fn giga_stake_locks_hdx_in_user_account_and_mints_atoken() { // `Stakes[Alice]` populated. let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake should exist"); assert_eq!(stake.hdx_locked, 100 * UNITS); - assert_eq!(stake.st_minted, 100 * UNITS); // bootstrap 1:1 + assert_eq!(stake.gigahdx, 100 * UNITS); // bootstrap 1:1 // Alice received GIGAHDX (aToken) on the EVM side. let alice_atoken_after = Currencies::free_balance(GIGAHDX, &alice); @@ -121,7 +122,7 @@ fn giga_stake_locks_hdx_in_user_account_and_mints_atoken() { } #[test] -fn giga_unstake_releases_lock_and_burns_atoken() { +fn giga_unstake_should_burn_atoken_when_full_exit() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { init_gigahdx(); @@ -136,7 +137,7 @@ fn giga_unstake_releases_lock_and_burns_atoken() { // `Stakes[Alice]` is now zero-active (cleaned up only by `unlock`). let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake remains until unlock"); assert_eq!(stake.hdx_locked, 0); - assert_eq!(stake.st_minted, 0); + assert_eq!(stake.gigahdx, 0); // Combined lock now equals the position amount. let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).expect("position created"); @@ -152,7 +153,7 @@ fn giga_unstake_releases_lock_and_burns_atoken() { } #[test] -fn giga_unstake_partial_keeps_proportional_state() { +fn giga_unstake_should_keep_proportional_state_when_partial() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { init_gigahdx(); @@ -162,8 +163,8 @@ fn giga_unstake_partial_keeps_proportional_state() { assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 40 * UNITS)); let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake should exist"); - // st_minted always drops by exactly the unstaked amount. - assert_eq!(stake.st_minted, 60 * UNITS); + // 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 @@ -176,7 +177,7 @@ fn giga_unstake_partial_keeps_proportional_state() { } #[test] -fn lock_manager_precompile_reports_st_minted() { +fn lock_manager_precompile_should_report_gigahdx_when_account_has_stake() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { init_gigahdx(); @@ -206,12 +207,12 @@ fn lock_manager_precompile_reports_st_minted() { result.exit_reason ); let reported = U256::from_big_endian(&result.value); - assert_eq!(reported, U256::from(100 * UNITS), "lock-manager must report st_minted"); + assert_eq!(reported, U256::from(100 * UNITS), "lock-manager must report gigahdx"); }); } #[test] -fn giga_unstake_creates_pending_position_and_combined_lock() { +fn giga_unstake_should_create_pending_position_when_called() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { init_gigahdx(); @@ -231,7 +232,7 @@ fn giga_unstake_creates_pending_position_and_combined_lock() { } #[test] -fn unlock_after_cooldown_releases_lock() { +fn unlock_should_release_lock_when_cooldown_elapsed() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { init_gigahdx(); @@ -253,7 +254,7 @@ fn unlock_after_cooldown_releases_lock() { } #[test] -fn vote_with_locked_hdx_works_via_max_lock_semantics() { +fn vote_should_succeed_with_locked_hdx_when_max_lock_semantics() { // Proves the lock model: HDX locked under `ghdxlock` is ALSO usable // for conviction voting via `LockableCurrency::max` semantics. We // submit a referendum, place its decision deposit, fast-forward into @@ -342,6 +343,10 @@ fn fund(account: &AccountId, amount: Balance) { account.clone(), amount, )); + // Idempotent: binds the EVM address for `account` if not already bound. + // Production users are expected to be bound before calling stake/unstake; + // this mirrors that pre-condition for snapshot integration tests. + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(account.clone())); } #[allow(dead_code)] @@ -431,15 +436,17 @@ fn build_erc20_transfer_calldata(to: H160, amount: Balance) -> Vec { // ---------- Wave 1: snapshot integration tests ---------- #[test] -fn giga_stake_should_mint_gigahdx_on_mainnet_snapshot() { +fn giga_stake_should_mint_gigahdx_when_called_on_mainnet_snapshot() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); reset_giga_state_for_fixture(); let alice: AccountId = ALICE.into(); let stake_amount = 1_000 * UNITS; assert_ok!(>::deposit(HDX, &alice, 10_000 * UNITS)); + // Mirror production pre-condition: user has bound their EVM address + // before any AAVE-touching extrinsic. + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); let hdx_before = Currencies::free_balance(HDX, &alice); let total_hdx_before = GigaHdx::total_hdx(); @@ -468,8 +475,6 @@ fn giga_stake_should_mint_gigahdx_on_mainnet_snapshot() { fn giga_unstake_should_succeed_when_full_exit() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); - let alice: AccountId = ALICE.into(); fund(&alice, 1_000_000 * UNITS); assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 10 * UNITS)); @@ -483,7 +488,7 @@ fn giga_unstake_should_succeed_when_full_exit() { )); assert_eq!(Currencies::free_balance(GIGAHDX, &alice), 0); - // Position created, st_minted zeroed, lock now equals position amount. + // Position created, gigahdx zeroed, lock now equals position amount. let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).expect("position exists"); assert!(entry.amount > 0); }); @@ -493,8 +498,6 @@ fn giga_unstake_should_succeed_when_full_exit() { fn giga_unstake_should_fail_when_amount_exceeds_balance() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); - let alice: AccountId = ALICE.into(); fund(&alice, 1_000_000 * UNITS); assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); @@ -511,14 +514,12 @@ fn giga_unstake_should_fail_when_amount_exceeds_balance() { } #[test] -fn giga_stake_should_succeed_above_min_and_fail_below() { +fn giga_stake_should_fail_when_amount_below_min_on_snapshot() { // Pallet gate: amounts strictly below MinStake are rejected by the pallet // regardless of AAVE state. The "succeeds above min" half uses 10 UNITS, // safely clear of any AAVE-internal minimum supply rounding. TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); - let alice: AccountId = ALICE.into(); fund(&alice, 1_000_000 * UNITS); @@ -534,10 +535,9 @@ fn giga_stake_should_succeed_above_min_and_fail_below() { } #[test] -fn restake_should_succeed_after_full_exit() { +fn giga_stake_should_succeed_when_supply_zeroed_after_full_exit() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); reset_giga_state_for_fixture(); let alice: AccountId = ALICE.into(); @@ -548,10 +548,7 @@ fn restake_should_succeed_after_full_exit() { assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); assert_eq!(GigaHdx::total_st_hdx_supply(), 100 * UNITS); - assert_ok!(GigaHdx::giga_unstake( - RuntimeOrigin::signed(alice.clone()), - 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_st_hdx_supply(), 0); @@ -568,7 +565,6 @@ fn restake_should_succeed_after_full_exit() { fn exchange_rate_should_inflate_when_hdx_transferred_directly_to_gigapot() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); reset_giga_state_for_fixture(); let alice: AccountId = ALICE.into(); @@ -592,7 +588,10 @@ fn exchange_rate_should_inflate_when_hdx_transferred_directly_to_gigapot() { HDX, 1_000 * UNITS, )); - assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_rational(1101, 100)); + assert_eq!( + GigaHdx::exchange_rate(), + sp_runtime::FixedU128::from_rational(1101, 100) + ); // New stake at the inflated rate gets fewer GIGAHDX. assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(bob.clone()), 100 * UNITS)); @@ -608,10 +607,9 @@ fn exchange_rate_should_inflate_when_hdx_transferred_directly_to_gigapot() { } #[test] -fn unstake_payout_should_succeed_after_donation_on_real_aave() { +fn giga_unstake_should_succeed_with_inflated_payout_when_pot_donated() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); reset_giga_state_for_fixture(); let alice: AccountId = ALICE.into(); @@ -650,10 +648,9 @@ fn unstake_payout_should_succeed_after_donation_on_real_aave() { } #[test] -fn giga_unstake_should_succeed_at_extreme_exchange_rate() { +fn giga_unstake_should_succeed_when_exchange_rate_extreme() { TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); reset_giga_state_for_fixture(); let alice: AccountId = ALICE.into(); @@ -667,10 +664,7 @@ fn giga_unstake_should_succeed_at_extreme_exchange_rate() { fund(&gigapot, 1_000_000_000_000_000 * UNITS); // Full unstake at extreme rate: case 2 — active drained, all yield from pot. - assert_ok!(GigaHdx::giga_unstake( - RuntimeOrigin::signed(alice.clone()), - 100 * UNITS, - )); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS,)); let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).expect("position created"); // payout = 100 * UNITS * (10^15 * UNITS + 100 * UNITS) / (100 * UNITS) @@ -682,37 +676,27 @@ fn giga_unstake_should_succeed_at_extreme_exchange_rate() { #[test] fn aave_withdraw_should_revert_when_atokens_are_locked_by_active_stake() { // Direct EVM-level Pool.withdraw must be rejected by the lock-manager - // precompile while the user still has an active stake — `st_minted` + // precompile while the user still has an active stake — `gigahdx` // equals atoken balance, so `LockableAToken.burn`'s freeBalance check // gives 0 and the burn reverts. This protects the cooldown semantics: // without it, users could bypass `giga_unstake` entirely. TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); - 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, - )); + 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 = pallet_gigahdx::GigaHdxPoolContract::::get(); + 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, - ); + let result = Executor::::call(CallContext::new_call(pool, alice_evm), data, U256::zero(), 500_000); assert!( matches!(result.exit_reason, fp_evm::ExitReason::Revert(_)), @@ -723,31 +707,25 @@ fn aave_withdraw_should_revert_when_atokens_are_locked_by_active_stake() { // Nothing moved. assert_eq!(Currencies::free_balance(GIGAHDX, &alice), gigahdx_balance); assert_eq!(Currencies::free_balance(ST_HDX, &alice), sthdx_before); - // st_minted unchanged. + // gigahdx unchanged. let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake remains"); - assert_eq!(stake.st_minted, stake_amount); + assert_eq!(stake.gigahdx, stake_amount); }); } #[test] -fn atoken_evm_transfer_should_fail_while_staked() { +fn atoken_evm_transfer_should_fail_when_staked() { // ERC20 `transfer` of GIGAHDX must revert while the user has an active // stake — atokens are 100% locked-balance per the lock-manager precompile. TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); - 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, - )); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), stake_amount,)); - fund(&bob, UNITS); - assert_ok!(EVMAccounts::bind_evm_address(RuntimeOrigin::signed(bob.clone()))); + fund(&bob, UNITS); // also binds Bob's EVM address let bob_evm = EVMAccounts::evm_address(&bob); let bob_gigahdx_before = Currencies::free_balance(GIGAHDX, &bob); @@ -776,29 +754,22 @@ fn atoken_evm_transfer_should_fail_while_staked() { // ---------- Wave 2: cooldown × voting-lock co-existence ---------- #[test] -fn partial_unstake_should_not_leak_via_max_aggregated_lock_ids() { +fn partial_unstake_should_not_leak_when_locks_aggregated_via_max() { // Regression test for the per-unstake-lock-id design where pallet-balances' // max-of-locks semantics let `min(active_stake, cooldown)` HDX leak out // during cooldown. Under the new single-combined-lock model the lock // equals `active + position`, so partial unstake never frees any HDX. TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); 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_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 1_000 * UNITS,)); assert_eq!(locked_under_ghdx(&alice), 1_000 * UNITS); // Partial unstake — half. With pot empty, payout = principal (case 1). - assert_ok!(GigaHdx::giga_unstake( - RuntimeOrigin::signed(alice.clone()), - 500 * UNITS, - )); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 500 * UNITS,)); let stake = pallet_gigahdx::Stakes::::get(&alice).unwrap(); let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).unwrap(); @@ -816,33 +787,26 @@ fn partial_unstake_should_not_leak_via_max_aggregated_lock_ids() { use frame_support::traits::fungible::Inspect; use frame_support::traits::tokens::{Fortitude, Preservation}; - let spendable = >::reducible_balance( - &alice, - Preservation::Expendable, - Fortitude::Polite, - ); + let spendable = + >::reducible_balance(&alice, Preservation::Expendable, Fortitude::Polite); assert_eq!(spendable, 0, "no HDX may leak out of the gigahdx system"); }); } #[test] -fn unstake_during_active_vote_keeps_lock_layers_consistent() { +fn giga_unstake_should_keep_lock_layers_consistent_when_vote_active() { // Stake → vote with conviction on a balance larger than the stake → partial // unstake. The gigahdx lock (active + position) and the conviction lock // must coexist; spendable balance is `balance − max(both)`. TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); reset_giga_state_for_fixture(); fund_bob_for_decision_deposit(); let alice: AccountId = ALICE.into(); fund(&alice, 1_000 * UNITS); - assert_ok!(GigaHdx::giga_stake( - RuntimeOrigin::signed(alice.clone()), - 500 * UNITS, - )); + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 500 * UNITS,)); // Vote with 800 HDX conviction — exceeds the stake amount, layers // over both staked and free HDX. @@ -854,10 +818,7 @@ fn unstake_during_active_vote_keeps_lock_layers_consistent() { )); // Partial unstake — 100 stHDX. Pot empty → payout = principal = 100. - assert_ok!(GigaHdx::giga_unstake( - RuntimeOrigin::signed(alice.clone()), - 100 * UNITS, - )); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS,)); // Combined gigahdx lock = active(400) + position(100) = 500. assert_eq!(locked_under_ghdx(&alice), 500 * UNITS); @@ -872,32 +833,191 @@ fn unstake_during_active_vote_keeps_lock_layers_consistent() { // Spendable = balance(1000) − max(ghdx=500, 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, - ); + let spendable = + >::reducible_balance(&alice, Preservation::Expendable, Fortitude::Polite); assert_eq!(spendable, 200 * UNITS); }); } #[test] -fn second_unstake_is_rejected_while_position_pending() { - // One pending position per account — no concurrent unstakes. +fn partial_unstake_should_drain_active_when_payout_exceeds_active() { + // Case 2 with PARTIAL unstake: rate is high enough that the payout for a + // fraction of the atokens already exceeds the user's active stake. Active + // drops to zero, the rest of the payout comes from the gigapot, and the + // user is left with `Stakes = { hdx_locked: 0, gigahdx > 0 }` — + // remaining atokens with zero cost basis. They can be unstaked later + // (each subsequent unstake hits case 2 against an empty active stake). TestNet::reset(); hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { - assert_ok!(GigaHdx::set_pool_contract(RawOrigin::Root.into(), pool_contract())); + reset_giga_state_for_fixture(); let alice: AccountId = ALICE.into(); - fund(&alice, 1_000 * UNITS); - assert_ok!(GigaHdx::giga_stake( - RuntimeOrigin::signed(alice.clone()), + let gigapot = GigaHdx::gigapot_account_id(); + fund(&alice, 1_000_000 * UNITS); + + // Stake 100 first (bootstrap rate 1.0), THEN inflate the pot to 200. + // Resulting rate = (100 + 200) / 100 = 3.0. + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS,)); + fund(&gigapot, 200 * UNITS); + assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_u32(3)); + + let alice_balance_before = Balances::free_balance(&alice); + + // Unstake HALF the atokens. payout = 50 × 3 = 150 > active 100. + // → drain active to 0, transfer 50 yield from pot, position = 150. + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 50 * UNITS,)); + + // Active stake fully consumed; atokens still partially held. + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("record persists"); + assert_eq!(stake.hdx_locked, 0, "active stake drained by case-2 payout"); + assert_eq!(stake.gigahdx, 50 * UNITS, "remaining atokens have zero cost basis now"); + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), 50 * UNITS); + + // Position covers the full payout (principal share consumed + yield share). + let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).expect("position created"); + assert_eq!(entry.amount, 150 * UNITS); + + // Yield (50) was transferred from gigapot to Alice. + assert_eq!(Balances::free_balance(&alice), alice_balance_before + 50 * UNITS,); + assert_eq!(Balances::free_balance(&gigapot), 150 * UNITS); + + // Combined lock = active(0) + position(150) = 150 — only the cooldown + // portion is locked; Alice's pre-stake balance (the part outside any + // gigahdx commitment) is fully spendable. + assert_eq!(locked_under_ghdx(&alice), 150 * UNITS); + + // Sanity: rate stays at 3.0 (TotalLocked=0, pot=150, supply=50). + assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_u32(3)); + }); +} + +#[test] +fn full_lifecycle_should_conserve_value_when_rate_inflated() { + // End-to-end conservation against the real AAVE snapshot: + // stake 100 @ rate 1.0 → pot inflates rate to 3.0 → drain across two + // case-2 unstakes (split by the cooldown) → assert total receipts + // equal original_stake × rate, gigapot fully drained, every ledger zeroed. + 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); + + // 1. Stake 100 at bootstrap rate. Then inflate pot → rate 3.0. + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS,)); + fund(&gigapot, 200 * UNITS); + assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_u32(3)); + + // 2. First unstake: 50 stHDX → payout 150, active drained to 0. + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 50 * UNITS,)); + let entry1 = pallet_gigahdx::PendingUnstakes::::get(&alice).unwrap(); + assert_eq!(entry1.amount, 150 * UNITS); + + // 3. Wait out cooldown #1, unlock. + System::set_block_number(entry1.expires_at); + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()))); + assert!(pallet_gigahdx::PendingUnstakes::::get(&alice).is_none()); + + // Stakes record persists (gigahdx still 50 with zero cost basis). + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("atokens remain"); + assert_eq!(stake.hdx_locked, 0); + assert_eq!(stake.gigahdx, 50 * UNITS); + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), 50 * UNITS); + + // 4. Second unstake: remaining 50 stHDX. Active still 0 → full payout + // from pot. Pot was 200 - 50 = 150, supply 50, rate stays 3.0, + // payout = 50 × 3 = 150. + assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_u32(3)); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 50 * UNITS,)); + let entry2 = pallet_gigahdx::PendingUnstakes::::get(&alice).unwrap(); + assert_eq!(entry2.amount, 150 * UNITS); + + // 5. Wait + unlock #2. + System::set_block_number(entry2.expires_at); + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()))); + + // 6. Conservation checks. + // Alice's net receipt = (yield transfers from pot) — her HDX never left her account. + // Original deposit 100 was always in her account (locked, then unlocked). + // Yield transferred = 50 (first unstake) + 150 (second unstake) = 200. + // Final balance = starting(1_000_000) + yield(200) = 1_000_200. + assert_eq!(Balances::free_balance(&alice), starting_balance + 200 * UNITS); + + // All gigahdx state cleared. + assert!(pallet_gigahdx::Stakes::::get(&alice).is_none()); + assert!(pallet_gigahdx::PendingUnstakes::::get(&alice).is_none()); + 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_st_hdx_supply(), 0); + + // Pot fully drained (200 yield → 0). + assert_eq!(Balances::free_balance(&gigapot), 0); + + // Total HDX received from gigahdx system = 100 (original stake, never moved) + // + 200 (full pot yield) = 300 = 100 × rate(3.0). Conservation holds. + let alice_total = Balances::free_balance(&alice); + assert_eq!(alice_total, starting_balance + 200 * UNITS); + // Equivalently: net gain = original_stake × (rate − 1) = 100 × 2 = 200. + }); +} + +#[test] +fn giga_stake_should_fail_when_evm_address_unbound() { + // Production users are expected to bind their EVM address before + // staking. If they don't, AAVE rejects the `Pool.supply` call (the + // truncated `onBehalfOf` doesn't satisfy AAVE's preconditions), the + // adapter surfaces `MoneyMarketSupplyFailed`, and `with_transaction` + // rolls the stHDX mint back. This test pins that loud-failure + // behaviour — without it, atokens could silently land on a phantom + // account derived from the truncated EVM address. + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + // Raw force_set_balance — deliberately bypasses `fund()` which + // would bind Alice's EVM address. + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), 1_000 * UNITS, )); - assert_ok!(GigaHdx::giga_unstake( - RuntimeOrigin::signed(alice.clone()), - 300 * UNITS, - )); + + // Precondition: Alice is unbound. + 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, + ); + + // No atokens credited. + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), atoken_before); + // stHDX mint rolled back by `with_transaction`. + assert_eq!(>::total_issuance(ST_HDX), sthdx_before); + // No pallet-side state mutation. + assert!(pallet_gigahdx::Stakes::::get(&alice).is_none()); + }); +} + +#[test] +fn giga_unstake_should_fail_when_position_pending() { + // One pending position per account — no concurrent unstakes. + 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()), 1_000 * UNITS,)); + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 300 * UNITS,)); assert_noop!( GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS), diff --git a/pallets/gigahdx/src/lib.rs b/pallets/gigahdx/src/lib.rs index c10b585ff2..c31f038e96 100644 --- a/pallets/gigahdx/src/lib.rs +++ b/pallets/gigahdx/src/lib.rs @@ -72,7 +72,7 @@ pub mod pallet { /// 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 st_minted: Balance, + pub gigahdx: Balance, } /// Pending-unstake record. At most one per account at a time. @@ -142,13 +142,11 @@ pub mod pallet { #[pallet::storage] pub type TotalLocked = StorageValue<_, Balance, ValueQuery>; - /// Total stHDX issued. + /// Aave V3 Pool contract address. Must be set explicitly by + /// `GovernanceOrigin` before any stake/unstake — the pallet refuses to + /// silently default to the zero address. #[pallet::storage] - pub type TotalStHdx = StorageValue<_, Balance, ValueQuery>; - - /// Aave V3 Pool contract address. Settable by `GovernanceOrigin`. - #[pallet::storage] - pub type GigaHdxPoolContract = StorageValue<_, EvmAddress, ValueQuery>; + pub type GigaHdxPoolContract = StorageValue<_, EvmAddress, OptionQuery>; /// At most one pending unstake per account. A second `giga_unstake` /// while this slot is full is rejected — caller must wait for the @@ -163,7 +161,7 @@ pub mod pallet { Staked { who: T::AccountId, amount: Balance, - st_minted: Balance, + gigahdx: Balance, }, Unstaked { who: T::AccountId, @@ -205,7 +203,7 @@ pub mod pallet { /// and supply it to the money market. The MM mints GIGAHDX (aToken) /// to the caller's EVM-mapped address. /// - /// `Stakes[caller].st_minted` records the **actual** aToken amount + /// `Stakes[caller].gigahdx` records the **actual** aToken amount /// returned by the MM (may differ from input by rounding). #[pallet::call_index(0)] #[pallet::weight(T::WeightInfo::giga_stake())] @@ -224,7 +222,7 @@ pub mod pallet { ensure!(usable >= amount, Error::::InsufficientFreeBalance); // Compute stHDX to mint based on current rate. - let s = TotalStHdx::::get(); + let s = Self::total_st_hdx_supply(); let t = Self::total_hdx(); let st_input = crate::math::st_input_for_stake(amount, s, t).map_err(|_| Error::::Overflow)?; @@ -244,22 +242,21 @@ pub mod pallet { let prev = Stakes::::get(&who).unwrap_or_default(); let new_locked = prev.hdx_locked.checked_add(amount).ok_or(Error::::Overflow)?; - let new_minted = prev.st_minted.checked_add(actual_minted).ok_or(Error::::Overflow)?; + let new_minted = prev.gigahdx.checked_add(actual_minted).ok_or(Error::::Overflow)?; Stakes::::insert( &who, StakeRecord { hdx_locked: new_locked, - st_minted: new_minted, + gigahdx: new_minted, }, ); TotalLocked::::mutate(|x| *x = x.saturating_add(amount)); - TotalStHdx::::mutate(|x| *x = x.saturating_add(actual_minted)); Self::refresh_lock(&who); Self::deposit_event(Event::Staked { who, amount, - st_minted: actual_minted, + gigahdx: actual_minted, }); Ok(()) } @@ -285,10 +282,10 @@ pub mod pallet { /// existing position before calling again. /// /// Implementation detail (must match `LockableAToken.sol`): - /// the lock-manager precompile (`0x0806`) reads `Stakes[who].st_minted` + /// 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 we - /// **pre-decrement `st_minted` by `st_amount` before the MM call**. + /// **pre-decrement `gigahdx` by `st_amount` before the MM call**. /// The whole body is wrapped in `with_transaction` for atomic rollback. #[pallet::call_index(1)] #[pallet::weight(T::WeightInfo::giga_unstake())] @@ -321,7 +318,7 @@ pub mod pallet { // `Stakes` may have been emptied by the unstake that opened this // position; once the position closes, drop the empty record too. if let Some(s) = Stakes::::get(&who) { - if s.hdx_locked == 0 && s.st_minted == 0 { + if s.hdx_locked == 0 && s.gigahdx == 0 { Stakes::::remove(&who); } } @@ -345,21 +342,21 @@ pub mod pallet { let stake = Stakes::::get(who).ok_or(Error::::NoStake)?; ensure!(st_amount > 0, Error::::ZeroAmount); - ensure!(st_amount <= stake.st_minted, Error::::InsufficientStake); + ensure!(st_amount <= stake.gigahdx, Error::::InsufficientStake); // Compute payout from PRE-unstake totals. - let s_pre = TotalStHdx::::get(); + let s_pre = Self::total_st_hdx_supply(); let t_pre = Self::total_hdx(); let payout = crate::math::total_payout(st_amount, t_pre, s_pre).map_err(|_| Error::::Overflow)?; - // Pre-decrement `st_minted` so `LockableAToken.burn`'s `freeBalance` + // Pre-decrement `gigahdx` so `LockableAToken.burn`'s `freeBalance` // check (via the lock-manager precompile) lets the burn through. - let new_st_minted = stake.st_minted.checked_sub(st_amount).ok_or(Error::::Overflow)?; + let new_gigahdx = stake.gigahdx.checked_sub(st_amount).ok_or(Error::::Overflow)?; Stakes::::insert( who, StakeRecord { hdx_locked: stake.hdx_locked, - st_minted: new_st_minted, + gigahdx: new_gigahdx, }, ); @@ -398,11 +395,10 @@ pub mod pallet { who, StakeRecord { hdx_locked: new_hdx_locked, - st_minted: new_st_minted, + gigahdx: new_gigahdx, }, ); TotalLocked::::mutate(|x| *x = x.saturating_sub(principal_consumed)); - TotalStHdx::::mutate(|x| *x = x.saturating_sub(st_amount)); let expires_at = frame_system::Pallet::::block_number() .checked_add(&T::CooldownPeriod::get()) @@ -449,15 +445,27 @@ pub mod pallet { 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_hdx() -> Balance { TotalLocked::::get().saturating_add(T::Currency::free_balance(&Self::gigapot_account_id())) } - /// Total stHDX issued. + /// Total stHDX issued, read live from the asset registry — no pallet-side + /// counter to keep in sync. pub fn total_st_hdx_supply() -> Balance { - TotalStHdx::::get() + >::total_issuance(T::StHdxAssetId::get()) } /// stHDX → HDX exchange rate as `FixedU128 = total_hdx / total_st_hdx_supply`. diff --git a/pallets/gigahdx/src/math.rs b/pallets/gigahdx/src/math.rs index 8787d8df0b..51397794df 100644 --- a/pallets/gigahdx/src/math.rs +++ b/pallets/gigahdx/src/math.rs @@ -11,7 +11,7 @@ use sp_core::U256; use sp_runtime::ArithmeticError; /// stHDX to mint for a given HDX `amount`, given current totals -/// `s = TotalStHdx`, `t = TotalLocked + gigapot_balance`. +/// `s = total_st_hdx_supply`, `t = TotalLocked + gigapot_balance`. /// /// Bootstrap (`s == 0` or `t == 0`) returns `amount` unchanged (1:1 rate). /// Otherwise returns `floor(amount * s / t)` via U256 to avoid overflow. @@ -27,7 +27,7 @@ pub fn st_input_for_stake(amount: Balance, s: Balance, t: Balance) -> Result rate = 130/100 // new stake of 60 HDX -> 60 * 100 / 130 = 46 assert_eq!(st_input_for_stake(60, 100, 130).unwrap(), 46); } #[test] - fn st_input_uses_u256_for_large_inputs() { + fn st_input_for_stake_should_use_u256_when_inputs_are_large() { // amount * s would overflow u128 but not u256 let big = u128::MAX / 2; let r = st_input_for_stake(big, big, big).unwrap(); @@ -69,13 +69,13 @@ mod tests { } #[test] - fn total_payout_with_pot_pays_yield() { + fn total_payout_should_include_yield_when_pot_funded() { // t = 130, s = 100, st_amount = 100 -> 130 (100 principal + 30 yield) assert_eq!(total_payout(100, 130, 100).unwrap(), 130); } #[test] - fn total_payout_round_trip_no_pot() { + fn total_payout_should_equal_input_when_round_trip_no_pot() { // Bootstrap-like state: t = s = 100 -> payout for 100 stHDX is 100. let st = st_input_for_stake(100, 0, 0).unwrap(); assert_eq!(st, 100); @@ -83,7 +83,7 @@ mod tests { } #[test] - fn divisor_zero_errors() { + fn total_payout_should_error_when_supply_is_zero() { assert!(matches!(total_payout(10, 5, 0), Err(ArithmeticError::DivisionByZero))); } } diff --git a/pallets/gigahdx/src/tests/stake.rs b/pallets/gigahdx/src/tests/stake.rs index 1c8e655ec9..1d00d4e943 100644 --- a/pallets/gigahdx/src/tests/stake.rs +++ b/pallets/gigahdx/src/tests/stake.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::mock::*; -use crate::{Error, Stakes, TotalLocked, TotalStHdx}; +use crate::{Error, Stakes, TotalLocked}; use frame_support::traits::fungibles::Inspect as FungiblesInspect; use frame_support::{assert_noop, assert_ok}; use frame_system::RawOrigin; @@ -17,15 +17,15 @@ fn locked_under_ghdx(account: AccountId) -> Balance { } #[test] -fn stake_locks_correct_amount_and_records_actual_minted() { +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_locked, 100 * ONE); - assert_eq!(s.st_minted, 100 * ONE); // bootstrap 1:1, no rounding + assert_eq!(s.gigahdx, 100 * ONE); // bootstrap 1:1, no rounding assert_eq!(TotalLocked::::get(), 100 * ONE); - assert_eq!(TotalStHdx::::get(), 100 * ONE); + assert_eq!(GigaHdx::total_st_hdx_supply(), 100 * ONE); assert_eq!(locked_under_ghdx(ALICE), 100 * ONE); assert_eq!(TestMoneyMarket::balance_of(&ALICE), 100 * ONE); @@ -33,7 +33,7 @@ fn stake_locks_correct_amount_and_records_actual_minted() { } #[test] -fn stake_below_min_fails() { +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), @@ -43,7 +43,7 @@ fn stake_below_min_fails() { } #[test] -fn stake_above_free_balance_fails() { +fn giga_stake_should_fail_when_amount_above_free_balance() { ExtBuilder::default().build().execute_with(|| { // Alice has 1_000 * ONE assert_noop!( @@ -54,31 +54,31 @@ fn stake_above_free_balance_fails() { } #[test] -fn stake_increases_lock_not_replaces() { +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_locked, 150 * ONE); - assert_eq!(s.st_minted, 150 * ONE); + assert_eq!(s.gigahdx, 150 * ONE); assert_eq!(locked_under_ghdx(ALICE), 150 * ONE); assert_eq!(TotalLocked::::get(), 150 * ONE); - assert_eq!(TotalStHdx::::get(), 150 * ONE); + assert_eq!(GigaHdx::total_st_hdx_supply(), 150 * ONE); }); } #[test] -fn stake_zero_total_uses_one_to_one_rate() { +fn giga_stake_should_use_one_to_one_rate_when_supply_is_zero() { ExtBuilder::default().build().execute_with(|| { // Empty pot, no prior stakers -> 1:1 assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); - assert_eq!(Stakes::::get(ALICE).unwrap().st_minted, 100 * ONE); + assert_eq!(Stakes::::get(ALICE).unwrap().gigahdx, 100 * ONE); }); } #[test] -fn stake_with_pot_uses_correct_rate() { +fn giga_stake_should_use_correct_rate_when_pot_funded() { // Pre-fund pot with 30 HDX, Alice already staked 100, then Bob stakes 100. ExtBuilder::default() .with_pot_balance(30 * ONE) @@ -86,18 +86,18 @@ fn stake_with_pot_uses_correct_rate() { .execute_with(|| { // Alice's stake at bootstrap (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().st_minted, 100 * ONE); + assert_eq!(Stakes::::get(ALICE).unwrap().gigahdx, 100 * ONE); // Now S=100, T = TotalLocked(100) + pot(30) = 130. Bob's 100 HDX -> 100*100/130 = 76. assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(BOB).into(), 100 * ONE)); - let bob_st = Stakes::::get(BOB).unwrap().st_minted; + let bob_st = Stakes::::get(BOB).unwrap().gigahdx; // floor(100e12 * 100e12 / 130e12) = 76923076923076 (~76.92 stHDX) assert_eq!(bob_st, 76_923_076_923_076); }); } #[test] -fn stake_stores_returned_atoken_not_input() { +fn giga_stake_should_store_returned_atoken_when_mm_rounds() { ExtBuilder::default().build().execute_with(|| { // Configure MM to round: returns 90% of input. TestMoneyMarket::set_supply_rounding(9, 10); @@ -105,14 +105,16 @@ fn stake_stores_returned_atoken_not_input() { let s = Stakes::::get(ALICE).unwrap(); assert_eq!(s.hdx_locked, 100 * ONE); // input - assert_eq!(s.st_minted, 90 * ONE); // returned by MM, not input - assert_eq!(TotalStHdx::::get(), 90 * ONE); + assert_eq!(s.gigahdx, 90 * ONE); // returned by MM, not input + // stHDX issuance reflects what was minted into the user (input); + // MM rounding only affects the aToken count stored in `Stakes.gigahdx`. + assert_eq!(GigaHdx::total_st_hdx_supply(), 100 * ONE); assert_eq!(TestMoneyMarket::balance_of(&ALICE), 90 * ONE); }); } #[test] -fn stake_cannot_use_funds_already_locked_under_cooldown() { +fn giga_stake_should_fail_when_funds_locked_under_cooldown() { // Alice has 1000 HDX. She stakes 1000 and then unstakes 1000 → cooldown lock = 1000. // The free balance is still 1000 (locks don't subtract), but staking 1 more // must be rejected because that 1 would have to be drawn from cooldown HDX. @@ -129,7 +131,7 @@ fn stake_cannot_use_funds_already_locked_under_cooldown() { } #[test] -fn stake_cannot_extend_lock_past_actual_balance() { +fn giga_stake_should_fail_when_extending_lock_past_balance() { // Alice has 1000 HDX. After staking 1000, an existing-lock-aware check must // reject another 1 — there is no unlocked HDX left to back it. ExtBuilder::default().build().execute_with(|| { @@ -142,7 +144,7 @@ fn stake_cannot_extend_lock_past_actual_balance() { } #[test] -fn stake_after_unlock_succeeds() { +fn giga_stake_should_succeed_when_called_after_unlock() { // After the cooldown elapses and the user unlocks, the lock is gone // and their balance is fully available for a new stake. ExtBuilder::default().build().execute_with(|| { @@ -158,7 +160,7 @@ fn stake_after_unlock_succeeds() { } #[test] -fn stake_partial_remaining_balance_works_with_active_cooldown() { +fn giga_stake_should_use_unlocked_balance_when_cooldown_active() { // Alice stakes 100, unstakes 100 (cooldown = 100). She still has 900 free for staking. ExtBuilder::default().build().execute_with(|| { assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); @@ -175,7 +177,7 @@ fn stake_partial_remaining_balance_works_with_active_cooldown() { } #[test] -fn stake_mm_supply_failure_reverts_no_storage_mutation() { +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); @@ -189,7 +191,7 @@ fn stake_mm_supply_failure_reverts_no_storage_mutation() { // No pallet-gigahdx state mutation. assert!(Stakes::::get(ALICE).is_none()); assert_eq!(TotalLocked::::get(), 0); - assert_eq!(TotalStHdx::::get(), 0); + assert_eq!(GigaHdx::total_st_hdx_supply(), 0); assert_eq!(locked_under_ghdx(ALICE), 0); // stHDX rolled back by with_transaction. assert_eq!(Tokens::balance(ST_HDX, &ALICE), pre_sthdx); diff --git a/pallets/gigahdx/src/tests/unlock.rs b/pallets/gigahdx/src/tests/unlock.rs index 9d1b8043d8..788d73fa8f 100644 --- a/pallets/gigahdx/src/tests/unlock.rs +++ b/pallets/gigahdx/src/tests/unlock.rs @@ -30,7 +30,7 @@ fn stake_alice_100() { } #[test] -fn unstake_creates_single_pending_position_and_combined_lock() { +fn giga_unstake_should_create_pending_position_when_called() { // Empty pot, stake 100, partial unstake 40. // payout = 40, active drops 100→60, position = 40, combined lock = 60+40 = 100. ExtBuilder::default().build().execute_with(|| { @@ -43,7 +43,7 @@ fn unstake_creates_single_pending_position_and_combined_lock() { let s = Stakes::::get(ALICE).unwrap(); assert_eq!(s.hdx_locked, 60 * ONE); - assert_eq!(s.st_minted, 60 * ONE); + assert_eq!(s.gigahdx, 60 * ONE); // Single combined lock under GIGAHDX_LOCK_ID covers active + pending. assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 100 * ONE); @@ -53,7 +53,7 @@ fn unstake_creates_single_pending_position_and_combined_lock() { } #[test] -fn unstake_full_drains_active_only_when_pot_empty() { +fn giga_unstake_should_drain_active_only_when_pot_empty() { // Empty pot, stake 100, unstake 100. payout = 100. active drops to 0, // no yield transferred. Position = 100. ExtBuilder::default().build().execute_with(|| { @@ -62,14 +62,14 @@ fn unstake_full_drains_active_only_when_pot_empty() { let s = Stakes::::get(ALICE).unwrap(); assert_eq!(s.hdx_locked, 0); - assert_eq!(s.st_minted, 0); + assert_eq!(s.gigahdx, 0); assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 100 * ONE); assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 100 * ONE); }); } #[test] -fn unstake_with_pot_partial_payout_le_active_no_yield_transfer() { +fn giga_unstake_should_skip_yield_transfer_when_payout_le_active() { // Pot 200 → rate 3.0. Stake 100, unstake 10 stHDX → payout 30 ≤ active 100. // Active drops 100→70, no pot transfer. Position = 30. ExtBuilder::default() @@ -94,7 +94,7 @@ fn unstake_with_pot_partial_payout_le_active_no_yield_transfer() { } #[test] -fn unstake_with_pot_payout_gt_active_transfers_yield_and_extends_lock() { +fn giga_unstake_should_extend_lock_when_payout_exceeds_active() { // Pot 200 → rate 3.0. Stake 100, unstake 90 stHDX → payout 270 > active 100. // Active drops to 0, yield = 170 transferred from pot, lock extends to 270. ExtBuilder::default() @@ -108,7 +108,7 @@ fn unstake_with_pot_payout_gt_active_transfers_yield_and_extends_lock() { let s = Stakes::::get(ALICE).unwrap(); assert_eq!(s.hdx_locked, 0); - assert_eq!(s.st_minted, 10 * ONE); + assert_eq!(s.gigahdx, 10 * ONE); assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 270 * ONE); // Alice received 170 HDX yield directly into her balance. @@ -123,7 +123,7 @@ fn unstake_with_pot_payout_gt_active_transfers_yield_and_extends_lock() { } #[test] -fn unstake_with_existing_pending_position_fails() { +fn giga_unstake_should_fail_when_pending_position_exists() { ExtBuilder::default().build().execute_with(|| { stake_alice_100(); assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 30 * ONE)); @@ -135,7 +135,7 @@ fn unstake_with_existing_pending_position_fails() { } #[test] -fn unlock_before_cooldown_fails() { +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)); @@ -149,7 +149,7 @@ fn unlock_before_cooldown_fails() { } #[test] -fn unlock_after_cooldown_releases_lock_and_clears_position() { +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)); @@ -166,7 +166,7 @@ fn unlock_after_cooldown_releases_lock_and_clears_position() { } #[test] -fn unlock_partial_unstake_keeps_active_lock() { +fn unlock_should_keep_active_lock_when_partial_unstake() { // Stake 100, unstake 40, unlock. Active stake (60) keeps its lock. ExtBuilder::default().build().execute_with(|| { stake_alice_100(); @@ -177,14 +177,14 @@ fn unlock_partial_unstake_keeps_active_lock() { assert!(PendingUnstakes::::get(ALICE).is_none()); let s = Stakes::::get(ALICE).unwrap(); assert_eq!(s.hdx_locked, 60 * ONE); - assert_eq!(s.st_minted, 60 * ONE); + assert_eq!(s.gigahdx, 60 * ONE); // Lock is now just the active stake (40 HDX freed). assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 60 * ONE); }); } #[test] -fn unlock_with_no_position_fails() { +fn unlock_should_fail_when_no_pending_position() { ExtBuilder::default().build().execute_with(|| { stake_alice_100(); assert_noop!( @@ -195,7 +195,7 @@ fn unlock_with_no_position_fails() { } #[test] -fn unstake_after_unlock_succeeds() { +fn giga_unstake_should_succeed_when_called_after_unlock() { // Slot frees up after unlock — caller can unstake again. ExtBuilder::default().build().execute_with(|| { stake_alice_100(); @@ -209,8 +209,8 @@ fn unstake_after_unlock_succeeds() { } #[test] -fn full_unstake_with_yield_leaves_zero_active_with_st_minted_and_resolves_correctly() { - // Pot 200 → rate 3.0. Stake 100. Unstake 90 → active = 0, st_minted = 10. +fn giga_unstake_should_handle_remaining_atokens_when_active_drained_by_yield() { + // Pot 200 → rate 3.0. Stake 100. Unstake 90 → active = 0, gigahdx = 10. // Then unstake remaining 10 — case 2 again (active = 0), full payout 30 from pot. ExtBuilder::default() .with_pot_balance(200 * ONE) @@ -224,7 +224,7 @@ fn full_unstake_with_yield_leaves_zero_active_with_st_minted_and_resolves_correc // Active stake is gone, but Alice still owns 10 stHDX with zero cost basis. let s = Stakes::::get(ALICE).unwrap(); assert_eq!(s.hdx_locked, 0); - assert_eq!(s.st_minted, 10 * ONE); + assert_eq!(s.gigahdx, 10 * ONE); // Unstake the remainder. assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 10 * ONE)); diff --git a/pallets/gigahdx/src/tests/unstake.rs b/pallets/gigahdx/src/tests/unstake.rs index 8fd4e4dada..84fe862ac8 100644 --- a/pallets/gigahdx/src/tests/unstake.rs +++ b/pallets/gigahdx/src/tests/unstake.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::mock::*; -use crate::{Error, PendingUnstakes, Stakes, TotalLocked, TotalStHdx}; +use crate::{Error, PendingUnstakes, Stakes, TotalLocked}; use frame_support::sp_runtime::traits::AccountIdConversion; use frame_support::traits::fungibles::Inspect as FungiblesInspect; use frame_support::{assert_noop, assert_ok}; @@ -22,7 +22,7 @@ fn stake_alice_100() { } #[test] -fn unstake_full_no_pot_consumes_active_into_position() { +fn giga_unstake_should_move_active_to_position_when_pot_empty() { // Empty pot, stake 100, unstake 100. payout = 100, case 1 (= active). // Active drops to 0; position = 100; combined lock = 100; no yield. ExtBuilder::default().build().execute_with(|| { @@ -34,9 +34,9 @@ fn unstake_full_no_pot_consumes_active_into_position() { let s = Stakes::::get(ALICE).unwrap(); assert_eq!(s.hdx_locked, 0); - assert_eq!(s.st_minted, 0); + assert_eq!(s.gigahdx, 0); assert_eq!(TotalLocked::::get(), 0); - assert_eq!(TotalStHdx::::get(), 0); + assert_eq!(GigaHdx::total_st_hdx_supply(), 0); assert_eq!(TestMoneyMarket::balance_of(&ALICE), 0); // Position holds 100; lock covers it; no yield to free_balance. @@ -47,7 +47,7 @@ fn unstake_full_no_pot_consumes_active_into_position() { } #[test] -fn unstake_full_with_pot_drains_active_and_pulls_yield_from_pot() { +fn giga_unstake_should_pull_yield_from_pot_when_payout_exceeds_active() { // Pot 30, stake 100. Unstake 100 stHDX → payout 130 (case 2). // active 100 → 0, yield 30 transferred from pot, position = 130. ExtBuilder::default() @@ -67,7 +67,7 @@ fn unstake_full_with_pot_drains_active_and_pulls_yield_from_pot() { // Stakes record still present (zeroed) until `unlock` cleans it up. let s = Stakes::::get(ALICE).unwrap(); assert_eq!(s.hdx_locked, 0); - assert_eq!(s.st_minted, 0); + assert_eq!(s.gigahdx, 0); // Position = full payout; lock covers everything. assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 130 * ONE); @@ -76,25 +76,25 @@ fn unstake_full_with_pot_drains_active_and_pulls_yield_from_pot() { } #[test] -fn unstake_partial() { +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_locked, 60 * ONE); - assert_eq!(s.st_minted, 60 * ONE); + assert_eq!(s.gigahdx, 60 * ONE); // Combined lock = active(60) + position(40) = 100. assert_eq!(locked_under_ghdx(ALICE), 100 * ONE); assert_eq!(TotalLocked::::get(), 60 * ONE); - assert_eq!(TotalStHdx::::get(), 60 * ONE); + assert_eq!(GigaHdx::total_st_hdx_supply(), 60 * ONE); assert_eq!(TestMoneyMarket::balance_of(&ALICE), 60 * ONE); assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 40 * ONE); }); } #[test] -fn unstake_zero_fails() { +fn giga_unstake_should_fail_when_amount_zero() { ExtBuilder::default().build().execute_with(|| { stake_alice_100(); assert_noop!( @@ -105,7 +105,7 @@ fn unstake_zero_fails() { } #[test] -fn unstake_above_stake_fails() { +fn giga_unstake_should_fail_when_amount_exceeds_stake() { ExtBuilder::default().build().execute_with(|| { stake_alice_100(); assert_noop!( @@ -116,7 +116,7 @@ fn unstake_above_stake_fails() { } #[test] -fn unstake_no_stake_fails() { +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), @@ -126,12 +126,12 @@ fn unstake_no_stake_fails() { } #[test] -fn unstake_mm_failure_reverts_no_storage_mutation() { +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 = TotalStHdx::::get(); + let pre_total_sthdx = GigaHdx::total_st_hdx_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); @@ -142,12 +142,12 @@ fn unstake_mm_failure_reverts_no_storage_mutation() { Error::::MoneyMarketWithdrawFailed ); - // Pre-decrement of `st_minted` was rolled back by `with_transaction`. + // Pre-decrement of `gigahdx` was rolled back by `with_transaction`. let post_stake = Stakes::::get(ALICE).unwrap(); - assert_eq!(post_stake.st_minted, pre_stake.st_minted, "st_minted must be restored"); + assert_eq!(post_stake.gigahdx, pre_stake.gigahdx, "gigahdx must be restored"); assert_eq!(post_stake.hdx_locked, pre_stake.hdx_locked); assert_eq!(TotalLocked::::get(), pre_total_locked); - assert_eq!(TotalStHdx::::get(), pre_total_sthdx); + assert_eq!(GigaHdx::total_st_hdx_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); @@ -159,14 +159,14 @@ fn unstake_mm_failure_reverts_no_storage_mutation() { } #[test] -fn unstake_pre_decrements_st_minted_before_mm_withdraw() { +fn giga_unstake_should_pre_decrement_gigahdx_before_mm_withdraw() { // LockableAToken.burn relies on the lock-manager precompile reading the - // already-decremented `Stakes[who].st_minted`. We can't observe that + // already-decremented `Stakes[who].gigahdx`. We can't observe that // mid-call here, but the post-state proves the pre-decrement happened // before MM.withdraw (otherwise the burn would have failed 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().st_minted, 70 * ONE); + assert_eq!(Stakes::::get(ALICE).unwrap().gigahdx, 70 * ONE); }); } diff --git a/precompiles/lock-manager/src/lib.rs b/precompiles/lock-manager/src/lib.rs index 011fdf9ad0..100be8df29 100644 --- a/precompiles/lock-manager/src/lib.rs +++ b/precompiles/lock-manager/src/lib.rs @@ -28,14 +28,14 @@ use sp_core::U256; /// Precompile at address 0x0806. /// /// Reports a per-account "locked GIGAHDX" amount derived from -/// `pallet_gigahdx::Stakes[who].st_minted`. This is consumed by the +/// `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 `st_minted` equals +/// 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 `st_minted` by +/// `pallet-gigahdx::giga_unstake`, which pre-decrements `gigahdx` by /// the amount being unstaked before invoking the MM. pub struct LockManagerPrecompile(PhantomData); @@ -56,9 +56,7 @@ where let account_id = ::AccountId, >>::into_account_id(account.into()); - let locked = pallet_gigahdx::Stakes::::get(&account_id) - .map(|s| s.st_minted) - .unwrap_or(0); + let locked = pallet_gigahdx::Pallet::::locked_gigahdx(&account_id); Ok(U256::from(locked)) } diff --git a/primitives/src/constants.rs b/primitives/src/constants.rs index bb9697ecb8..6fea78d802 100644 --- a/primitives/src/constants.rs +++ b/primitives/src/constants.rs @@ -83,7 +83,6 @@ 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"; - /// Trailing 's' is load-bearing — `Source` is exactly 8 bytes and `gigahdx` is only 7. pub const GIGAHDX_SOURCE: [u8; 8] = *b"gigahdxs"; pub const DEFAULT_RELAY_PARENT_OFFSET: u32 = 1; diff --git a/runtime/hydradx/src/gigahdx.rs b/runtime/hydradx/src/gigahdx.rs index 9e04606946..20e24df534 100644 --- a/runtime/hydradx/src/gigahdx.rs +++ b/runtime/hydradx/src/gigahdx.rs @@ -13,7 +13,6 @@ use crate::evm::precompiles::erc20_mapping::HydraErc20Mapping; use crate::evm::Erc20Currency; use crate::evm::Executor; use crate::Runtime; -use crate::RuntimeOrigin; use ethabi::ethereum_types::BigEndianHash; use evm::ExitReason::Succeed; use frame_support::sp_runtime::traits::Convert; @@ -45,19 +44,13 @@ pub struct AaveMoneyMarket; impl AaveMoneyMarket { fn pool() -> Result { - let pool = pallet_gigahdx::GigaHdxPoolContract::::get(); - if pool == EvmAddress::zero() { - return Err(DispatchError::Other("gigahdx: pool contract not set")); - } - Ok(pool) + 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 { - // Idempotent — binds an EVM address for `who` if not already bound. - let _ = pallet_evm_accounts::Pallet::::bind_evm_address(RuntimeOrigin::signed(who.clone())); - let asset_evm = HydraErc20Mapping::asset_address(underlying_asset); let who_evm = pallet_evm_accounts::Pallet::::evm_address(who); let pool = Self::pool()?; @@ -79,8 +72,6 @@ impl MoneyMarketOperations for AaveMoneyMarket { } fn withdraw(who: &AccountId, underlying_asset: AssetId, amount: Balance) -> Result { - let _ = pallet_evm_accounts::Pallet::::bind_evm_address(RuntimeOrigin::signed(who.clone())); - let asset_evm = HydraErc20Mapping::asset_address(underlying_asset); let who_evm = pallet_evm_accounts::Pallet::::evm_address(who); let pool = Self::pool()?; From 442ba8f5b6b5bfa83b6b01f31e54c6034aa34381 Mon Sep 17 00:00:00 2001 From: Martin Hloska Date: Wed, 6 May 2026 19:22:40 +0200 Subject: [PATCH 04/29] rename config params --- Cargo.lock | 1 + integration-tests/src/evm.rs | 16 +- integration-tests/src/gigahdx.rs | 69 +++--- pallets/gigahdx/Cargo.toml | 2 + pallets/gigahdx/src/lib.rs | 228 ++++++++++-------- pallets/gigahdx/src/math.rs | 89 ------- pallets/gigahdx/src/tests/mock.rs | 6 +- pallets/gigahdx/src/tests/stake.rs | 14 +- pallets/gigahdx/src/tests/unlock.rs | 12 +- pallets/gigahdx/src/tests/unstake.rs | 16 +- pallets/gigahdx/src/weights.rs | 4 + runtime/hydradx/src/assets.rs | 6 +- .../src/evm/precompiles/chainlink_adapter.rs | 16 +- 13 files changed, 214 insertions(+), 265 deletions(-) delete mode 100644 pallets/gigahdx/src/math.rs diff --git a/Cargo.lock b/Cargo.lock index da6952f8f5..261bb177ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10733,6 +10733,7 @@ dependencies = [ "frame-benchmarking", "frame-support", "frame-system", + "hydra-dx-math", "hydradx-traits", "log", "orml-tokens", diff --git a/integration-tests/src/evm.rs b/integration-tests/src/evm.rs index 4c8564f4f8..c5ac2e09f2 100644 --- a/integration-tests/src/evm.rs +++ b/integration-tests/src/evm.rs @@ -2715,7 +2715,7 @@ mod chainlink_precompile { } fn seed_gigapot_and_supply(gigapot_hdx: Balance, st_hdx_supply: Balance) { - // `total_st_hdx_supply` reads orml-tokens issuance directly, so seed + // `total_gigahdx_supply` reads orml-tokens issuance directly, so seed // the stHDX issuance there rather than via a pallet-side counter. orml_tokens::TotalIssuance::::set(STHDX, st_hdx_supply); let gigapot = pallet_gigahdx::Pallet::::gigapot_account_id(); @@ -2750,8 +2750,8 @@ mod chainlink_precompile { seed_gigapot_and_supply(110 * UNITS, 100 * UNITS); pretty_assertions::assert_eq!( - pallet_gigahdx::Pallet::::exchange_rate(), - FixedU128::from_rational(11, 10) + 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); @@ -2775,9 +2775,10 @@ mod chainlink_precompile { // Full drain: native rate = 0; precompile must clamp to 1.0. seed_gigapot_and_supply(0, 100 * UNITS); + // pallet floors the rate at 1.0; the raw value (0) never escapes. pretty_assertions::assert_eq!( - pallet_gigahdx::Pallet::::exchange_rate(), - FixedU128::from(0u128) + 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); @@ -2795,9 +2796,10 @@ mod chainlink_precompile { // Partial drain: native rate = 0.5; precompile must clamp to 1.0 (not 50_000_000). seed_gigapot_and_supply(50 * UNITS, 100 * UNITS); + // pallet floors the rate at 1.0; the raw value (0.5) never escapes. pretty_assertions::assert_eq!( - pallet_gigahdx::Pallet::::exchange_rate(), - FixedU128::from_rational(1, 2) + 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); diff --git a/integration-tests/src/gigahdx.rs b/integration-tests/src/gigahdx.rs index c0a8184b60..e348447bb2 100644 --- a/integration-tests/src/gigahdx.rs +++ b/integration-tests/src/gigahdx.rs @@ -11,6 +11,7 @@ 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, @@ -33,6 +34,19 @@ pub const PATH_TO_SNAPSHOT: &str = "snapshots/gigahdx/gigahdx"; 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, + ); +} + /// Reads the AAVE pool contract address from the gigahdx pallet storage. /// **Tests must not set this themselves** — the snapshot is expected to /// carry the correct address; failing to do so means the snapshot is @@ -109,7 +123,7 @@ fn giga_stake_should_lock_hdx_in_user_account_when_called() { // `Stakes[Alice]` populated. let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake should exist"); - assert_eq!(stake.hdx_locked, 100 * UNITS); + assert_eq!(stake.hdx, 100 * UNITS); assert_eq!(stake.gigahdx, 100 * UNITS); // bootstrap 1:1 // Alice received GIGAHDX (aToken) on the EVM side. @@ -136,7 +150,7 @@ fn giga_unstake_should_burn_atoken_when_full_exit() { // `Stakes[Alice]` is now zero-active (cleaned up only by `unlock`). let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake remains until unlock"); - assert_eq!(stake.hdx_locked, 0); + assert_eq!(stake.hdx, 0); assert_eq!(stake.gigahdx, 0); // Combined lock now equals the position amount. @@ -171,7 +185,7 @@ fn giga_unstake_should_keep_proportional_state_when_partial() { // (case 2). With a near-bootstrap rate the active stake just shrinks // (case 1). Either way the combined lock equals active + position. let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).expect("position created"); - assert_eq!(locked_under_ghdx(&alice), stake.hdx_locked + entry.amount); + assert_eq!(locked_under_ghdx(&alice), stake.hdx + entry.amount); assert!(entry.amount >= 40 * UNITS, "payout covers at least the principal share"); }); } @@ -225,9 +239,9 @@ fn giga_unstake_should_create_pending_position_when_called() { // Mainnet snapshot's gigapot may already hold yield → payout ≥ principal. assert!(entry.amount >= 40 * UNITS, "position covers at least principal"); - // Single combined lock: active stake (Stakes.hdx_locked) + position.amount. + // Single combined lock: active stake (Stakes.hdx) + position.amount. let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake remains"); - assert_eq!(lock_amount(&alice, GIGAHDX_LOCK_ID), stake.hdx_locked + entry.amount); + assert_eq!(lock_amount(&alice, GIGAHDX_LOCK_ID), stake.hdx + entry.amount); }); } @@ -449,8 +463,8 @@ fn giga_stake_should_mint_gigahdx_when_called_on_mainnet_snapshot() { let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); let hdx_before = Currencies::free_balance(HDX, &alice); - let total_hdx_before = GigaHdx::total_hdx(); - let total_st_hdx_before = GigaHdx::total_st_hdx_supply(); + 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)); @@ -465,9 +479,9 @@ fn giga_stake_should_mint_gigahdx_when_called_on_mainnet_snapshot() { assert_eq!(Currencies::free_balance(GIGAHDX, &alice), stake_amount); // Totals incremented; bootstrap rate = 1. - assert_eq!(GigaHdx::total_hdx(), total_hdx_before + stake_amount); - assert_eq!(GigaHdx::total_st_hdx_supply(), total_st_hdx_before + stake_amount); - assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_u32(1)); + 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); }); } @@ -546,18 +560,18 @@ fn giga_stake_should_succeed_when_supply_zeroed_after_full_exit() { fund(&bob, 1_000_000 * UNITS); assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS)); - assert_eq!(GigaHdx::total_st_hdx_supply(), 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_st_hdx_supply(), 0); - assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_u32(1)); + assert_eq!(GigaHdx::total_gigahdx_supply(), 0); + assert_rate_eq(GigaHdx::exchange_rate(), 1, 1); // Bob can stake fresh — Alice's cooldown is hers, Bob is unaffected. assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(bob.clone()), 100 * UNITS)); assert_eq!(Currencies::free_balance(GIGAHDX, &bob), 100 * UNITS); - assert_eq!(GigaHdx::total_st_hdx_supply(), 100 * UNITS); + assert_eq!(GigaHdx::total_gigahdx_supply(), 100 * UNITS); }); } @@ -579,7 +593,7 @@ fn exchange_rate_should_inflate_when_hdx_transferred_directly_to_gigapot() { // Alice stakes 100 → rate becomes (100 + 1) / 100 = 1.01. assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice), 100 * UNITS)); - assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_rational(101, 100)); + assert_rate_eq(GigaHdx::exchange_rate(), 101, 100); // Bob donates 1000 HDX directly to the gigapot → rate inflates. assert_ok!(Currencies::transfer( @@ -588,10 +602,7 @@ fn exchange_rate_should_inflate_when_hdx_transferred_directly_to_gigapot() { HDX, 1_000 * UNITS, )); - assert_eq!( - GigaHdx::exchange_rate(), - sp_runtime::FixedU128::from_rational(1101, 100) - ); + assert_rate_eq(GigaHdx::exchange_rate(), 1101, 100); // New stake at the inflated rate gets fewer GIGAHDX. assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(bob.clone()), 100 * UNITS)); @@ -630,7 +641,7 @@ fn giga_unstake_should_succeed_with_inflated_payout_when_pot_donated() { HDX, 500 * UNITS, )); - assert!(GigaHdx::exchange_rate() > sp_runtime::FixedU128::from(1)); + assert!(GigaHdx::exchange_rate() > Ratio::one()); // Alice fully unstakes — the donation is a bonus to her, not a DoS. assert_ok!(GigaHdx::giga_unstake( @@ -773,7 +784,7 @@ fn partial_unstake_should_not_leak_when_locks_aggregated_via_max() { let stake = pallet_gigahdx::Stakes::::get(&alice).unwrap(); let entry = pallet_gigahdx::PendingUnstakes::::get(&alice).unwrap(); - assert_eq!(stake.hdx_locked, 500 * UNITS); + assert_eq!(stake.hdx, 500 * UNITS); assert_eq!(entry.amount, 500 * UNITS); // Combined lock = active(500) + position(500) = 1000. Old buggy @@ -844,7 +855,7 @@ fn partial_unstake_should_drain_active_when_payout_exceeds_active() { // Case 2 with PARTIAL unstake: rate is high enough that the payout for a // fraction of the atokens already exceeds the user's active stake. Active // drops to zero, the rest of the payout comes from the gigapot, and the - // user is left with `Stakes = { hdx_locked: 0, gigahdx > 0 }` — + // user is left with `Stakes = { hdx: 0, gigahdx > 0 }` — // remaining atokens with zero cost basis. They can be unstaked later // (each subsequent unstake hits case 2 against an empty active stake). TestNet::reset(); @@ -859,7 +870,7 @@ fn partial_unstake_should_drain_active_when_payout_exceeds_active() { // Resulting rate = (100 + 200) / 100 = 3.0. assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS,)); fund(&gigapot, 200 * UNITS); - assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_u32(3)); + assert_rate_eq(GigaHdx::exchange_rate(), 3, 1); let alice_balance_before = Balances::free_balance(&alice); @@ -869,7 +880,7 @@ fn partial_unstake_should_drain_active_when_payout_exceeds_active() { // Active stake fully consumed; atokens still partially held. let stake = pallet_gigahdx::Stakes::::get(&alice).expect("record persists"); - assert_eq!(stake.hdx_locked, 0, "active stake drained by case-2 payout"); + assert_eq!(stake.hdx, 0, "active stake drained by case-2 payout"); assert_eq!(stake.gigahdx, 50 * UNITS, "remaining atokens have zero cost basis now"); assert_eq!(Currencies::free_balance(GIGAHDX, &alice), 50 * UNITS); @@ -887,7 +898,7 @@ fn partial_unstake_should_drain_active_when_payout_exceeds_active() { assert_eq!(locked_under_ghdx(&alice), 150 * UNITS); // Sanity: rate stays at 3.0 (TotalLocked=0, pot=150, supply=50). - assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_u32(3)); + assert_rate_eq(GigaHdx::exchange_rate(), 3, 1); }); } @@ -909,7 +920,7 @@ fn full_lifecycle_should_conserve_value_when_rate_inflated() { // 1. Stake 100 at bootstrap rate. Then inflate pot → rate 3.0. assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), 100 * UNITS,)); fund(&gigapot, 200 * UNITS); - assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_u32(3)); + assert_rate_eq(GigaHdx::exchange_rate(), 3, 1); // 2. First unstake: 50 stHDX → payout 150, active drained to 0. assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 50 * UNITS,)); @@ -923,14 +934,14 @@ fn full_lifecycle_should_conserve_value_when_rate_inflated() { // Stakes record persists (gigahdx still 50 with zero cost basis). let stake = pallet_gigahdx::Stakes::::get(&alice).expect("atokens remain"); - assert_eq!(stake.hdx_locked, 0); + assert_eq!(stake.hdx, 0); assert_eq!(stake.gigahdx, 50 * UNITS); assert_eq!(Currencies::free_balance(GIGAHDX, &alice), 50 * UNITS); // 4. Second unstake: remaining 50 stHDX. Active still 0 → full payout // from pot. Pot was 200 - 50 = 150, supply 50, rate stays 3.0, // payout = 50 × 3 = 150. - assert_eq!(GigaHdx::exchange_rate(), sp_runtime::FixedU128::from_u32(3)); + assert_rate_eq(GigaHdx::exchange_rate(), 3, 1); assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 50 * UNITS,)); let entry2 = pallet_gigahdx::PendingUnstakes::::get(&alice).unwrap(); assert_eq!(entry2.amount, 150 * UNITS); @@ -952,7 +963,7 @@ fn full_lifecycle_should_conserve_value_when_rate_inflated() { 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_st_hdx_supply(), 0); + assert_eq!(GigaHdx::total_gigahdx_supply(), 0); // Pot fully drained (200 yield → 0). assert_eq!(Balances::free_balance(&gigapot), 0); diff --git a/pallets/gigahdx/Cargo.toml b/pallets/gigahdx/Cargo.toml index 69219597d4..5c3c083bbf 100644 --- a/pallets/gigahdx/Cargo.toml +++ b/pallets/gigahdx/Cargo.toml @@ -24,6 +24,7 @@ sp-core = { workspace = true } sp-io = { workspace = true } # Local +hydra-dx-math = { workspace = true } hydradx-traits = { workspace = true } primitives = { workspace = true } @@ -49,6 +50,7 @@ std = [ "sp-core/std", "sp-io/std", "frame-benchmarking?/std", + "hydra-dx-math/std", "hydradx-traits/std", "primitives/std", ] diff --git a/pallets/gigahdx/src/lib.rs b/pallets/gigahdx/src/lib.rs index c31f038e96..b80cff4b98 100644 --- a/pallets/gigahdx/src/lib.rs +++ b/pallets/gigahdx/src/lib.rs @@ -37,7 +37,6 @@ pub use pallet::*; #[cfg(test)] mod tests; -pub mod math; pub mod weights; #[frame_support::pallet] @@ -45,16 +44,17 @@ pub mod pallet { 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::{FixedPointNumber, FixedU128}; - use frame_support::storage::{with_transaction, TransactionOutcome}; + 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::{ fungible, fungibles, Currency, ExistenceRequirement, LockIdentifier, LockableCurrency, WithdrawReasons, }; - use frame_support::PalletId; + 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; @@ -66,7 +66,7 @@ pub mod pallet { /// 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_locked: Balance, + pub hdx: Balance, /// aToken (GIGAHDX) units this account's stake backs. /// /// Stored as the value returned by [`MoneyMarketOperations::supply`], @@ -90,15 +90,15 @@ pub mod pallet { #[pallet::config] pub trait Config: frame_system::Config>> { - /// HDX lockable currency. The `fungible::Inspect` bound is required so - /// `giga_stake` can use `reducible_balance` (free balance minus - /// transfer-blocking locks) instead of raw `free_balance`. - type Currency: LockableCurrency> + /// Native (HDX) lockable currency. The `fungible::Inspect` bound is + /// required so `giga_stake` can use `reducible_balance` (free balance + /// minus transfer-blocking locks) instead of raw `free_balance`. + type NativeCurrency: LockableCurrency> + fungible::Inspect; - /// stHDX is a multi-asset-registry fungible token. Only this pallet - /// mints / burns it. - type StHdx: fungibles::Mutate + /// 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; /// stHDX asset id. @@ -109,7 +109,7 @@ pub mod pallet { type MoneyMarket: MoneyMarketOperations; /// Origin allowed to set the pool contract address. - type GovernanceOrigin: EnsureOrigin; + type AuthorityOrigin: EnsureOrigin; /// Pallet account id used as the gigapot (yield) account. Derived /// via `PalletId::into_account_truncating`. @@ -138,12 +138,12 @@ pub mod pallet { #[pallet::storage] pub type Stakes = StorageMap<_, Blake2_128Concat, T::AccountId, StakeRecord, OptionQuery>; - /// Sum of all `Stakes[a].hdx_locked`. + /// Sum of all `Stakes[a].hdx`. #[pallet::storage] pub type TotalLocked = StorageValue<_, Balance, ValueQuery>; /// Aave V3 Pool contract address. Must be set explicitly by - /// `GovernanceOrigin` before any stake/unstake — the pallet refuses to + /// `AuthorityOrigin` before any stake/unstake — the pallet refuses to /// silently default to the zero address. #[pallet::storage] pub type GigaHdxPoolContract = StorageValue<_, EvmAddress, OptionQuery>; @@ -165,7 +165,7 @@ pub mod pallet { }, Unstaked { who: T::AccountId, - st_amount: Balance, + gigahdx_amount: Balance, payout: Balance, yield_share: Balance, expires_at: BlockNumberFor, @@ -181,13 +181,25 @@ pub mod pallet { #[pallet::error] pub enum Error { + /// Stake amount is below `Config::MinStake`. BelowMinStake, + /// Caller does not have enough unlocked HDX to back this stake (the + /// admission check uses `reducible_balance`, which subtracts every + /// transfer-blocking lock — including this pallet's own lock). InsufficientFreeBalance, + /// Unstake amount exceeds the caller's `Stakes.gigahdx`. InsufficientStake, + /// Caller has no active stake record. NoStake, + /// Amount must be strictly greater than zero. ZeroAmount, + /// 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 pending unstake. CooldownNotElapsed, @@ -214,44 +226,31 @@ pub mod pallet { // Use `reducible_balance` so the check respects every transfer-blocking // lock — including this pallet's own combined `LockId` lock (active // stake + pending unstake) and any unrelated conviction/vesting locks. - let usable = >::reducible_balance( + let usable = >::reducible_balance( &who, Preservation::Expendable, Fortitude::Polite, ); ensure!(usable >= amount, Error::::InsufficientFreeBalance); - // Compute stHDX to mint based on current rate. - let s = Self::total_st_hdx_supply(); - let t = Self::total_hdx(); - let st_input = crate::math::st_input_for_stake(amount, s, t).map_err(|_| Error::::Overflow)?; - - // Mint stHDX to caller, then supply to MM. Wrapped in `with_transaction` - // so that if MM supply fails, the freshly-minted stHDX rolls back — - // no orphaned stHDX on the user. - let actual_minted = with_transaction(|| -> TransactionOutcome> { - if let Err(e) = T::StHdx::mint_into(T::StHdxAssetId::get(), &who, st_input) { - return TransactionOutcome::Rollback(Err(e)); - } - match T::MoneyMarket::supply(&who, T::StHdxAssetId::get(), st_input) { - Ok(actual) => TransactionOutcome::Commit(Ok(actual)), - Err(e) => TransactionOutcome::Rollback(Err(e)), - } - }) - .map_err(|_| Error::::MoneyMarketSupplyFailed)?; - - let prev = Stakes::::get(&who).unwrap_or_default(); - let new_locked = prev.hdx_locked.checked_add(amount).ok_or(Error::::Overflow)?; - let new_minted = prev.gigahdx.checked_add(actual_minted).ok_or(Error::::Overflow)?; - Stakes::::insert( - &who, - StakeRecord { - hdx_locked: new_locked, - gigahdx: new_minted, - }, - ); + // Compute GIGAHDX to mint at the current rate. + let gigahdx_to_mint = Self::calculate_gigahdx_given_hdx_amount(amount).map_err(|_| Error::::Overflow)?; + + // Mint stHDX to caller, then supply to MM. The dispatchable runs in + // a storage layer so any Err here rolls back the mint atomically. + T::MultiCurrency::mint_into(T::StHdxAssetId::get(), &who, gigahdx_to_mint) + .map_err(|_| Error::::MoneyMarketSupplyFailed)?; + let actual_minted = T::MoneyMarket::supply(&who, T::StHdxAssetId::get(), gigahdx_to_mint) + .map_err(|_| 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::refresh_lock(&who)?; Self::deposit_event(Event::Staked { who, @@ -262,19 +261,19 @@ pub mod pallet { } /// Set the AAVE V3 Pool contract H160 used by the money-market adapter. - /// Gated by `GovernanceOrigin`. + /// Gated by `AuthorityOrigin`. #[pallet::call_index(2)] - #[pallet::weight(Weight::from_parts(10_000, 0))] + #[pallet::weight(T::WeightInfo::set_pool_contract())] pub fn set_pool_contract(origin: OriginFor, contract: EvmAddress) -> DispatchResult { - T::GovernanceOrigin::ensure_origin(origin)?; + T::AuthorityOrigin::ensure_origin(origin)?; GigaHdxPoolContract::::put(contract); Self::deposit_event(Event::PoolContractUpdated { contract }); Ok(()) } - /// Unstake `st_amount` of the caller's GIGAHDX. The MM burns the + /// Unstake `gigahdx_amount` of the caller's GIGAHDX. The MM burns the /// aToken and returns stHDX to the caller, which the pallet then burns. - /// The HDX value (current rate × st_amount) is moved into a single + /// The HDX value (current rate × gigahdx_amount) is moved into a single /// pending-unstake position; any portion that exceeds the user's /// active stake is paid as yield from the gigapot. /// @@ -285,19 +284,14 @@ pub mod pallet { /// 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 we - /// **pre-decrement `gigahdx` by `st_amount` before the MM call**. - /// The whole body is wrapped in `with_transaction` for atomic rollback. + /// **pre-decrement `gigahdx` by `gigahdx_amount` before the MM call**. + /// The dispatchable runs in a storage layer so any `?` failure + /// rolls back the pre-decrement atomically. #[pallet::call_index(1)] #[pallet::weight(T::WeightInfo::giga_unstake())] - pub fn giga_unstake(origin: OriginFor, st_amount: Balance) -> DispatchResult { + pub fn giga_unstake(origin: OriginFor, gigahdx_amount: Balance) -> DispatchResult { let who = ensure_signed(origin)?; - with_transaction::<(), DispatchError, _>(|| { - let outcome = Self::do_giga_unstake(&who, st_amount); - match outcome { - Ok(()) => TransactionOutcome::Commit(Ok(())), - Err(e) => TransactionOutcome::Rollback(Err(e)), - } - }) + Self::do_giga_unstake(&who, gigahdx_amount) } /// Release the pending-unstake position once @@ -314,11 +308,11 @@ pub mod pallet { ); PendingUnstakes::::remove(&who); - Self::refresh_lock(&who); + Self::refresh_lock(&who)?; // `Stakes` may have been emptied by the unstake that opened this // position; once the position closes, drop the empty record too. if let Some(s) = Stakes::::get(&who) { - if s.hdx_locked == 0 && s.gigahdx == 0 { + if s.hdx == 0 && s.gigahdx == 0 { Stakes::::remove(&who); } } @@ -332,43 +326,44 @@ pub mod pallet { } impl Pallet { - /// Internal helper for `giga_unstake`. Uses `?` freely; the caller - /// wraps it in `with_transaction` for atomic rollback. - fn do_giga_unstake(who: &T::AccountId, st_amount: Balance) -> DispatchResult { + /// 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_giga_unstake(who: &T::AccountId, gigahdx_amount: Balance) -> DispatchResult { ensure!( PendingUnstakes::::get(who).is_none(), Error::::PendingUnstakeAlreadyExists ); let stake = Stakes::::get(who).ok_or(Error::::NoStake)?; - ensure!(st_amount > 0, Error::::ZeroAmount); - ensure!(st_amount <= stake.gigahdx, Error::::InsufficientStake); + ensure!(gigahdx_amount > 0, Error::::ZeroAmount); + ensure!(gigahdx_amount <= stake.gigahdx, Error::::InsufficientStake); - // Compute payout from PRE-unstake totals. - let s_pre = Self::total_st_hdx_supply(); - let t_pre = Self::total_hdx(); - let payout = crate::math::total_payout(st_amount, t_pre, s_pre).map_err(|_| Error::::Overflow)?; + // Compute payout from PRE-unstake totals (helper reads live state, so + // it must run BEFORE any mint/burn/transfer below). + let payout = Self::calculate_hdx_amount_given_gigahdx(gigahdx_amount).map_err(|_| Error::::Overflow)?; // 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(st_amount).ok_or(Error::::Overflow)?; + let new_gigahdx = stake.gigahdx.checked_sub(gigahdx_amount).ok_or(Error::::Overflow)?; Stakes::::insert( who, StakeRecord { - hdx_locked: stake.hdx_locked, + hdx: stake.hdx, gigahdx: new_gigahdx, }, ); // MM withdraw: returns stHDX to `who`, burns aToken from `who`. - T::MoneyMarket::withdraw(who, T::StHdxAssetId::get(), st_amount) + T::MoneyMarket::withdraw(who, T::StHdxAssetId::get(), gigahdx_amount) .map_err(|_| Error::::MoneyMarketWithdrawFailed)?; // Burn the returned stHDX from the user. - T::StHdx::burn_from( + T::MultiCurrency::burn_from( T::StHdxAssetId::get(), who, - st_amount, + gigahdx_amount, Preservation::Expendable, Precision::Exact, Fortitude::Force, @@ -377,11 +372,11 @@ pub mod pallet { // Split `payout` between the user's active stake and the gigapot. // • payout ≤ active stake → consume from active only // • payout > active stake → drain active, pull remainder from pot - let (new_hdx_locked, yield_share) = if payout <= stake.hdx_locked { - (stake.hdx_locked - payout, 0) + let (new_hdx, yield_share) = if payout <= stake.hdx { + (stake.hdx - payout, 0) } else { - let yield_amount = payout - stake.hdx_locked; - T::Currency::transfer( + let yield_amount = payout - stake.hdx; + T::NativeCurrency::transfer( &Self::gigapot_account_id(), who, yield_amount, @@ -389,12 +384,12 @@ pub mod pallet { )?; (0, yield_amount) }; - let principal_consumed = stake.hdx_locked.saturating_sub(new_hdx_locked); + let principal_consumed = stake.hdx.saturating_sub(new_hdx); Stakes::::insert( who, StakeRecord { - hdx_locked: new_hdx_locked, + hdx: new_hdx, gigahdx: new_gigahdx, }, ); @@ -410,11 +405,11 @@ pub mod pallet { expires_at, }, ); - Self::refresh_lock(who); + Self::refresh_lock(who)?; Self::deposit_event(Event::Unstaked { who: who.clone(), - st_amount, + gigahdx_amount, payout, yield_share, expires_at, @@ -425,18 +420,20 @@ pub mod pallet { impl Pallet { /// Recompute the single combined balance lock for `who`: - /// `lock_amount = Stakes[who].hdx_locked + PendingUnstakes[who].amount`. + /// `lock_amount = Stakes[who].hdx + PendingUnstakes[who].amount`. /// Uses `set_lock` (not `extend_lock`) so the lock can shrink on unstake /// or unlock. Removes the lock entirely when both components are zero. - fn refresh_lock(who: &T::AccountId) { - let stake_amount = Stakes::::get(who).map(|s| s.hdx_locked).unwrap_or(0); + #[transactional] + fn refresh_lock(who: &T::AccountId) -> DispatchResult { + let stake_amount = Stakes::::get(who).map(|s| s.hdx).unwrap_or(0); let pending = PendingUnstakes::::get(who).map(|p| p.amount).unwrap_or(0); let total = stake_amount.saturating_add(pending); if total == 0 { - T::Currency::remove_lock(T::LockId::get(), who); + T::NativeCurrency::remove_lock(T::LockId::get(), who); } else { - T::Currency::set_lock(T::LockId::get(), who, total, WithdrawReasons::all()); + T::NativeCurrency::set_lock(T::LockId::get(), who, total, WithdrawReasons::all()); } + Ok(()) } /// Account id of the gigapot (yield holder), derived from @@ -458,26 +455,51 @@ pub mod pallet { /// Total HDX backing all stHDX: /// `TotalLocked + free_balance(gigapot_account_id)`. - pub fn total_hdx() -> Balance { - TotalLocked::::get().saturating_add(T::Currency::free_balance(&Self::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_st_hdx_supply() -> Balance { - >::total_issuance(T::StHdxAssetId::get()) + pub fn total_gigahdx_supply() -> Balance { + >::total_issuance(T::StHdxAssetId::get()) } - /// stHDX → HDX exchange rate as `FixedU128 = total_hdx / total_st_hdx_supply`. + /// HDX/GIGAHDX exchange rate as `Ratio { n: total_staked_hdx, d: total_gigahdx_supply }`, + /// floored at 1.0. /// - /// Returns `1.0` when no stHDX has been issued yet (bootstrap). - pub fn exchange_rate() -> FixedU128 { - let s = Self::total_st_hdx_supply(); - if s == 0 { - FixedU128::from(1) - } else { - FixedU128::checked_from_rational(Self::total_hdx(), s).unwrap_or(FixedU128::from(1)) + /// 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/math.rs b/pallets/gigahdx/src/math.rs deleted file mode 100644 index 51397794df..0000000000 --- a/pallets/gigahdx/src/math.rs +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Arithmetic helpers for `pallet-gigahdx`. -//! -//! All operations use `u128` for inputs/outputs and lift to `U256` for any -//! intermediate product that can exceed `u128::MAX`. Division is floor. -//! Overflow returns `ArithmeticError::Overflow`; never panics. - -use primitives::Balance; -use sp_core::U256; -use sp_runtime::ArithmeticError; - -/// stHDX to mint for a given HDX `amount`, given current totals -/// `s = total_st_hdx_supply`, `t = TotalLocked + gigapot_balance`. -/// -/// Bootstrap (`s == 0` or `t == 0`) returns `amount` unchanged (1:1 rate). -/// Otherwise returns `floor(amount * s / t)` via U256 to avoid overflow. -pub fn st_input_for_stake(amount: Balance, s: Balance, t: Balance) -> Result { - if s == 0 || t == 0 { - return Ok(amount); - } - let num = U256::from(amount) - .checked_mul(U256::from(s)) - .ok_or(ArithmeticError::Overflow)?; - let q = num.checked_div(U256::from(t)).ok_or(ArithmeticError::DivisionByZero)?; - q.try_into().map_err(|_| ArithmeticError::Overflow) -} - -/// Total HDX paid out for unstaking `st_amount`, given totals -/// `t = TotalLocked + gigapot_balance` and `s = total_st_hdx_supply` BEFORE the -/// unstake. -/// -/// Returns `floor(st_amount * t / s)`. -pub fn total_payout(st_amount: Balance, t: Balance, s: Balance) -> Result { - if s == 0 { - return Err(ArithmeticError::DivisionByZero); - } - let num = U256::from(st_amount) - .checked_mul(U256::from(t)) - .ok_or(ArithmeticError::Overflow)?; - let q = num.checked_div(U256::from(s)).ok_or(ArithmeticError::DivisionByZero)?; - q.try_into().map_err(|_| ArithmeticError::Overflow) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn st_input_for_stake_should_be_one_to_one_when_bootstrap() { - assert_eq!(st_input_for_stake(100, 0, 0).unwrap(), 100); - assert_eq!(st_input_for_stake(100, 0, 50).unwrap(), 100); - assert_eq!(st_input_for_stake(100, 50, 0).unwrap(), 100); - } - - #[test] - fn st_input_for_stake_should_return_fewer_st_when_pot_funded() { - // 100 staked, 30 in pot, 100 stHDX issued -> rate = 130/100 - // new stake of 60 HDX -> 60 * 100 / 130 = 46 - assert_eq!(st_input_for_stake(60, 100, 130).unwrap(), 46); - } - - #[test] - fn st_input_for_stake_should_use_u256_when_inputs_are_large() { - // amount * s would overflow u128 but not u256 - let big = u128::MAX / 2; - let r = st_input_for_stake(big, big, big).unwrap(); - assert_eq!(r, big); // s == t -> rate is 1 - } - - #[test] - fn total_payout_should_include_yield_when_pot_funded() { - // t = 130, s = 100, st_amount = 100 -> 130 (100 principal + 30 yield) - assert_eq!(total_payout(100, 130, 100).unwrap(), 130); - } - - #[test] - fn total_payout_should_equal_input_when_round_trip_no_pot() { - // Bootstrap-like state: t = s = 100 -> payout for 100 stHDX is 100. - let st = st_input_for_stake(100, 0, 0).unwrap(); - assert_eq!(st, 100); - assert_eq!(total_payout(st, 100, st).unwrap(), 100); - } - - #[test] - fn total_payout_should_error_when_supply_is_zero() { - assert!(matches!(total_payout(10, 5, 0), Err(ArithmeticError::DivisionByZero))); - } -} diff --git a/pallets/gigahdx/src/tests/mock.rs b/pallets/gigahdx/src/tests/mock.rs index 9e3a9c430f..9d9529fe5d 100644 --- a/pallets/gigahdx/src/tests/mock.rs +++ b/pallets/gigahdx/src/tests/mock.rs @@ -192,11 +192,11 @@ parameter_types! { } impl pallet_gigahdx::Config for Test { - type Currency = Balances; - type StHdx = Tokens; + type NativeCurrency = Balances; + type MultiCurrency = Tokens; type StHdxAssetId = StHdxAssetIdConst; type MoneyMarket = TestMoneyMarket; - type GovernanceOrigin = EnsureRoot; + type AuthorityOrigin = EnsureRoot; type PalletId = GigaHdxPalletId; type LockId = GigaHdxLockId; type MinStake = GigaHdxMinStake; diff --git a/pallets/gigahdx/src/tests/stake.rs b/pallets/gigahdx/src/tests/stake.rs index 1d00d4e943..db80ffc44d 100644 --- a/pallets/gigahdx/src/tests/stake.rs +++ b/pallets/gigahdx/src/tests/stake.rs @@ -22,10 +22,10 @@ fn giga_stake_should_record_correct_state_when_called() { assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); let s = Stakes::::get(ALICE).unwrap(); - assert_eq!(s.hdx_locked, 100 * ONE); + assert_eq!(s.hdx, 100 * ONE); assert_eq!(s.gigahdx, 100 * ONE); // bootstrap 1:1, no rounding assert_eq!(TotalLocked::::get(), 100 * ONE); - assert_eq!(GigaHdx::total_st_hdx_supply(), 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); @@ -60,11 +60,11 @@ fn giga_stake_should_increase_lock_when_already_staked() { assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 50 * ONE)); let s = Stakes::::get(ALICE).unwrap(); - assert_eq!(s.hdx_locked, 150 * ONE); + 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_st_hdx_supply(), 150 * ONE); + assert_eq!(GigaHdx::total_gigahdx_supply(), 150 * ONE); }); } @@ -104,11 +104,11 @@ fn giga_stake_should_store_returned_atoken_when_mm_rounds() { assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); let s = Stakes::::get(ALICE).unwrap(); - assert_eq!(s.hdx_locked, 100 * ONE); // input + assert_eq!(s.hdx, 100 * ONE); // input assert_eq!(s.gigahdx, 90 * ONE); // returned by MM, not input // stHDX issuance reflects what was minted into the user (input); // MM rounding only affects the aToken count stored in `Stakes.gigahdx`. - assert_eq!(GigaHdx::total_st_hdx_supply(), 100 * ONE); + assert_eq!(GigaHdx::total_gigahdx_supply(), 100 * ONE); assert_eq!(TestMoneyMarket::balance_of(&ALICE), 90 * ONE); }); } @@ -191,7 +191,7 @@ fn giga_stake_should_revert_storage_when_mm_supply_fails() { // No pallet-gigahdx state mutation. assert!(Stakes::::get(ALICE).is_none()); assert_eq!(TotalLocked::::get(), 0); - assert_eq!(GigaHdx::total_st_hdx_supply(), 0); + assert_eq!(GigaHdx::total_gigahdx_supply(), 0); assert_eq!(locked_under_ghdx(ALICE), 0); // stHDX rolled back by with_transaction. assert_eq!(Tokens::balance(ST_HDX, &ALICE), pre_sthdx); diff --git a/pallets/gigahdx/src/tests/unlock.rs b/pallets/gigahdx/src/tests/unlock.rs index 788d73fa8f..380579c257 100644 --- a/pallets/gigahdx/src/tests/unlock.rs +++ b/pallets/gigahdx/src/tests/unlock.rs @@ -42,7 +42,7 @@ fn giga_unstake_should_create_pending_position_when_called() { assert_eq!(entry.expires_at, 1 + GigaHdxCooldownPeriod::get()); let s = Stakes::::get(ALICE).unwrap(); - assert_eq!(s.hdx_locked, 60 * ONE); + assert_eq!(s.hdx, 60 * ONE); assert_eq!(s.gigahdx, 60 * ONE); // Single combined lock under GIGAHDX_LOCK_ID covers active + pending. @@ -61,7 +61,7 @@ fn giga_unstake_should_drain_active_only_when_pot_empty() { assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); let s = Stakes::::get(ALICE).unwrap(); - assert_eq!(s.hdx_locked, 0); + assert_eq!(s.hdx, 0); assert_eq!(s.gigahdx, 0); assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 100 * ONE); assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 100 * ONE); @@ -82,7 +82,7 @@ fn giga_unstake_should_skip_yield_transfer_when_payout_le_active() { assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 10 * ONE)); - assert_eq!(Stakes::::get(ALICE).unwrap().hdx_locked, 70 * ONE); + assert_eq!(Stakes::::get(ALICE).unwrap().hdx, 70 * ONE); assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 30 * ONE); // Alice's free balance unchanged — no yield transfer (payout came from active). assert_eq!(Balances::free_balance(ALICE), alice_balance_before); @@ -107,7 +107,7 @@ fn giga_unstake_should_extend_lock_when_payout_exceeds_active() { assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 90 * ONE)); let s = Stakes::::get(ALICE).unwrap(); - assert_eq!(s.hdx_locked, 0); + assert_eq!(s.hdx, 0); assert_eq!(s.gigahdx, 10 * ONE); assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 270 * ONE); @@ -176,7 +176,7 @@ fn unlock_should_keep_active_lock_when_partial_unstake() { assert!(PendingUnstakes::::get(ALICE).is_none()); let s = Stakes::::get(ALICE).unwrap(); - assert_eq!(s.hdx_locked, 60 * ONE); + assert_eq!(s.hdx, 60 * ONE); assert_eq!(s.gigahdx, 60 * ONE); // Lock is now just the active stake (40 HDX freed). assert_eq!(lock_amount(ALICE, GIGAHDX_LOCK_ID), 60 * ONE); @@ -223,7 +223,7 @@ fn giga_unstake_should_handle_remaining_atokens_when_active_drained_by_yield() { // Active stake is gone, but Alice still owns 10 stHDX with zero cost basis. let s = Stakes::::get(ALICE).unwrap(); - assert_eq!(s.hdx_locked, 0); + assert_eq!(s.hdx, 0); assert_eq!(s.gigahdx, 10 * ONE); // Unstake the remainder. diff --git a/pallets/gigahdx/src/tests/unstake.rs b/pallets/gigahdx/src/tests/unstake.rs index 84fe862ac8..0325fc914a 100644 --- a/pallets/gigahdx/src/tests/unstake.rs +++ b/pallets/gigahdx/src/tests/unstake.rs @@ -33,10 +33,10 @@ fn giga_unstake_should_move_active_to_position_when_pot_empty() { assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); let s = Stakes::::get(ALICE).unwrap(); - assert_eq!(s.hdx_locked, 0); + assert_eq!(s.hdx, 0); assert_eq!(s.gigahdx, 0); assert_eq!(TotalLocked::::get(), 0); - assert_eq!(GigaHdx::total_st_hdx_supply(), 0); + assert_eq!(GigaHdx::total_gigahdx_supply(), 0); assert_eq!(TestMoneyMarket::balance_of(&ALICE), 0); // Position holds 100; lock covers it; no yield to free_balance. @@ -66,7 +66,7 @@ fn giga_unstake_should_pull_yield_from_pot_when_payout_exceeds_active() { // Stakes record still present (zeroed) until `unlock` cleans it up. let s = Stakes::::get(ALICE).unwrap(); - assert_eq!(s.hdx_locked, 0); + assert_eq!(s.hdx, 0); assert_eq!(s.gigahdx, 0); // Position = full payout; lock covers everything. @@ -82,12 +82,12 @@ fn giga_unstake_should_split_state_when_partial() { assert_ok!(GigaHdx::giga_unstake(RawOrigin::Signed(ALICE).into(), 40 * ONE)); let s = Stakes::::get(ALICE).unwrap(); - assert_eq!(s.hdx_locked, 60 * ONE); + assert_eq!(s.hdx, 60 * ONE); assert_eq!(s.gigahdx, 60 * ONE); // Combined lock = active(60) + position(40) = 100. assert_eq!(locked_under_ghdx(ALICE), 100 * ONE); assert_eq!(TotalLocked::::get(), 60 * ONE); - assert_eq!(GigaHdx::total_st_hdx_supply(), 60 * ONE); + assert_eq!(GigaHdx::total_gigahdx_supply(), 60 * ONE); assert_eq!(TestMoneyMarket::balance_of(&ALICE), 60 * ONE); assert_eq!(PendingUnstakes::::get(ALICE).unwrap().amount, 40 * ONE); }); @@ -131,7 +131,7 @@ fn giga_unstake_should_revert_storage_when_mm_withdraw_fails() { stake_alice_100(); let pre_stake = Stakes::::get(ALICE).unwrap(); let pre_total_locked = TotalLocked::::get(); - let pre_total_sthdx = GigaHdx::total_st_hdx_supply(); + 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); @@ -145,9 +145,9 @@ fn giga_unstake_should_revert_storage_when_mm_withdraw_fails() { // Pre-decrement of `gigahdx` was 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_locked, pre_stake.hdx_locked); + assert_eq!(post_stake.hdx, pre_stake.hdx); assert_eq!(TotalLocked::::get(), pre_total_locked); - assert_eq!(GigaHdx::total_st_hdx_supply(), pre_total_sthdx); + 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); diff --git a/pallets/gigahdx/src/weights.rs b/pallets/gigahdx/src/weights.rs index 678cac0d89..ade506886e 100644 --- a/pallets/gigahdx/src/weights.rs +++ b/pallets/gigahdx/src/weights.rs @@ -6,6 +6,7 @@ pub trait WeightInfo { fn giga_stake() -> Weight; fn giga_unstake() -> Weight; fn unlock() -> Weight; + fn set_pool_contract() -> Weight; } impl WeightInfo for () { @@ -18,4 +19,7 @@ impl WeightInfo for () { fn unlock() -> Weight { Weight::zero() } + fn set_pool_contract() -> Weight { + Weight::zero() + } } diff --git a/runtime/hydradx/src/assets.rs b/runtime/hydradx/src/assets.rs index 7b1de41880..4b3c6858d4 100644 --- a/runtime/hydradx/src/assets.rs +++ b/runtime/hydradx/src/assets.rs @@ -1878,11 +1878,11 @@ parameter_types! { } impl pallet_gigahdx::Config for Runtime { - type Currency = Balances; - type StHdx = FungibleCurrencies; + type NativeCurrency = Balances; + type MultiCurrency = FungibleCurrencies; type StHdxAssetId = StHdxAssetId; type MoneyMarket = crate::gigahdx::AaveMoneyMarket; - type GovernanceOrigin = EnsureRoot; + type AuthorityOrigin = EnsureRoot; type PalletId = GigaHdxPalletId; type LockId = GigaHdxLockId; type MinStake = GigaHdxMinStake; diff --git a/runtime/hydradx/src/evm/precompiles/chainlink_adapter.rs b/runtime/hydradx/src/evm/precompiles/chainlink_adapter.rs index 08b525ddb1..4e561fe1ae 100644 --- a/runtime/hydradx/src/evm/precompiles/chainlink_adapter.rs +++ b/runtime/hydradx/src/evm/precompiles/chainlink_adapter.rs @@ -27,7 +27,7 @@ use primitives::{ constants::chain::{GIGAHDX_SOURCE, OMNIPOOL_SOURCE}, AssetId, }; -use sp_runtime::{traits::Dispatchable, FixedU128, RuntimeDebug}; +use sp_runtime::{traits::Dispatchable, RuntimeDebug}; use sp_std::{cmp::Ordering, marker::PhantomData}; const EMPTY_SOURCE: Source = [0u8; 8]; @@ -159,16 +159,12 @@ where Price::from(rat_as_u128) } - // stHDX/HDX exchange rate from pallet-gigahdx (spot value, period is ignored). - // Floor at 1.0: stHDX accrues HDX value monotonically under user flows; a sub-1 - // reading is only reachable via privileged ops or migration bugs and would - // spuriously liquidate stHDX collateral on AAVE. + // stHDX/HDX exchange rate from pallet-gigahdx (spot value, period is + // ignored). The pallet itself 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().max(FixedU128::from(1u128)); - Price { - n: rate.into_inner(), - d: 1_000_000_000_000_000_000u128, - } + 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, From 6b4ad97e9865cff851a7e632ffcda93ce5208d3f Mon Sep 17 00:00:00 2001 From: Martin Hloska Date: Thu, 7 May 2026 08:11:49 +0200 Subject: [PATCH 05/29] minor issues --- integration-tests/src/gigahdx.rs | 113 ++++++++++++++++++++- pallets/gigahdx/src/lib.rs | 15 ++- precompiles/lock-manager/src/lib.rs | 24 +++-- runtime/hydradx/src/assets.rs | 11 ++ runtime/hydradx/src/evm/precompiles/mod.rs | 20 +++- runtime/hydradx/src/gigahdx.rs | 22 +++- 6 files changed, 186 insertions(+), 19 deletions(-) diff --git a/integration-tests/src/gigahdx.rs b/integration-tests/src/gigahdx.rs index e348447bb2..5239e6dacb 100644 --- a/integration-tests/src/gigahdx.rs +++ b/integration-tests/src/gigahdx.rs @@ -203,17 +203,18 @@ fn lock_manager_precompile_should_report_gigahdx_when_account_has_stake() { // Call lock-manager precompile at 0x0806. ABI: // getLockedBalance(address token, address account) returns (uint256) - // The `token` arg is unused; we pass any address. + // `token` MUST be the GIGAHDX aToken address — the precompile now + // returns 0 for any other caller as a defense against unrelated + // aTokens accidentally consuming 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(EvmAddress::zero()).as_bytes()); // token (unused) - data.extend_from_slice(H256::from(alice_evm).as_bytes()); // account + data.extend_from_slice(H256::from(gigahdx_token).as_bytes()); + data.extend_from_slice(H256::from(alice_evm).as_bytes()); - use hydradx_runtime::evm::Executor; - use hydradx_traits::evm::{CallContext, EVM}; let result = Executor::::view(CallContext::new_view(lock_manager), data, 100_000); assert!( matches!(result.exit_reason, fp_evm::ExitReason::Succeed(_)), @@ -222,6 +223,15 @@ fn lock_manager_precompile_should_report_gigahdx_when_account_has_stake() { ); let reported = U256::from_big_endian(&result.value); assert_eq!(reported, U256::from(100 * UNITS), "lock-manager must report gigahdx"); + + // Wrong token returns zero — no leak of gigahdx-stake state 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()); }); } @@ -1020,6 +1030,99 @@ fn giga_stake_should_fail_when_evm_address_unbound() { }); } +#[test] +fn first_staker_inflation_grief_should_be_self_defeating_against_real_aave() { + // Characterizes the first-staker inflation-grief lead from the audit. + // Attacker leaves a 1-wei stHDX residual, donates HDX to inflate the + // rate, expects new stakers to mint round-to-zero atokens. + // + // Outcome against the real AAVE V3 deployment: + // - Step 1–3 (attack setup) succeeds: 1-wei residual is achievable + // via `Pool.withdraw(99·UNITS, leaving 1 wei)`. + // - Step 4 (donation inflates rate) succeeds. + // - Step 5 (new staker reverts) succeeds: `gigahdx_to_mint` floors to 0 + // and AAVE rejects `Pool.supply(0)` with `MoneyMarketSupplyFailed`. + // - Step 6 (attacker recovers donation) **fails**: AAVE has its own + // min-amount check on `Pool.withdraw`, so withdrawing 1 wei reverts + // with `MoneyMarketWithdrawFailed`. The attacker cannot exit. + // + // Net: the attack is **self-defeating** — to deny new stakers the + // attacker must burn their entire donation into the gigapot with no + // recovery path. AAVE's wei-precision floor acts as a defense in depth + // against the inflation pattern. We pin this so that any future change + // to the AAVE config (lower min-amount, scaled-balance changes) that + // makes the attack profitable will trip this test. + 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(); + + // 1. Alice stakes 100 UNITS at bootstrap rate. + 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); + + // 2. Partial-unstake leaving 1 wei. The first withdraw burns the bulk + // (>= AAVE's min-amount), so this step succeeds. + assert_ok!(GigaHdx::giga_unstake( + RuntimeOrigin::signed(alice.clone()), + alice_gigahdx - 1, + )); + let position1 = pallet_gigahdx::PendingUnstakes::::get(&alice).unwrap(); + System::set_block_number(position1.expires_at); + assert_ok!(GigaHdx::unlock(RuntimeOrigin::signed(alice.clone()))); + assert_eq!(GigaHdx::total_gigahdx_supply(), 1); + + // 3. Donate HDX directly to the gigapot — no permission check. + let gigapot = GigaHdx::gigapot_account_id(); + let donation = 500_000 * UNITS; + assert_ok!(Currencies::transfer( + RuntimeOrigin::signed(alice.clone()), + gigapot.clone(), + HDX, + donation, + )); + // Rate spike: ~donation × 10^12 over 1 wei. + assert!( + GigaHdx::exchange_rate() > Ratio::new(donation, 1), + "rate should be heavily inflated after donation", + ); + + // 4. New staker fails: `gigahdx_to_mint` floors to 0, the pallet's + // `ZeroAmount` guard rejects before the AAVE call (same outcome + // even on AAVE forks that would 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()); + + // 5. THE SELF-DEFEAT: the attacker cannot exit her 1-wei residual. + // `Pool.withdraw(1)` reverts on AAVE's min-amount check, so the + // pallet surfaces `MoneyMarketWithdrawFailed`. The donation is + // permanently locked in the gigapot from the attacker's perspective. + assert_noop!( + GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), 1), + pallet_gigahdx::Error::::MoneyMarketWithdrawFailed, + ); + + // 6. State invariants: pot still holds the donation, supply = 1 wei, + // rate stays inflated. New stakes remain blocked. The attacker + // paid the full donation cost for a temporary denial-of-service — + // not a viable attack. + 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_position_pending() { // One pending position per account — no concurrent unstakes. diff --git a/pallets/gigahdx/src/lib.rs b/pallets/gigahdx/src/lib.rs index b80cff4b98..c7ac7f7767 100644 --- a/pallets/gigahdx/src/lib.rs +++ b/pallets/gigahdx/src/lib.rs @@ -207,6 +207,9 @@ pub mod pallet { NoPendingUnstake, /// Caller already has a pending unstake; must `unlock` it first. PendingUnstakeAlreadyExists, + /// `set_pool_contract` was called while users still hold outstanding + /// stake. The pool is settable only when `TotalLocked == 0`. + OutstandingStake, } #[pallet::call] @@ -235,6 +238,12 @@ pub mod pallet { // Compute GIGAHDX to mint at the current rate. let gigahdx_to_mint = Self::calculate_gigahdx_given_hdx_amount(amount).map_err(|_| Error::::Overflow)?; + // Defense in depth: real AAVE V3 reverts on `Pool.supply(0)`, but a + // fork or test mock that accepted it would leave the user with HDX + // locked under `LockId` and `Stakes.gigahdx == 0` — and `giga_unstake` + // requires `gigahdx_amount > 0`, so they could never exit. Reject + // at the pallet level instead. + ensure!(gigahdx_to_mint > 0, Error::::ZeroAmount); // Mint stHDX to caller, then supply to MM. The dispatchable runs in // a storage layer so any Err here rolls back the mint atomically. @@ -261,11 +270,15 @@ pub mod pallet { } /// Set the AAVE V3 Pool contract H160 used by the money-market adapter. - /// Gated by `AuthorityOrigin`. + /// Gated by `AuthorityOrigin`. Refuses to swap the pool while users + /// hold outstanding stake — a swap mid-flight would route subsequent + /// `giga_unstake` calls to a pool that doesn't hold their atokens, + /// reverting the burn and leaving HDX permanently locked. #[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!(TotalLocked::::get() == 0, Error::::OutstandingStake); GigaHdxPoolContract::::put(contract); Self::deposit_event(Event::PoolContractUpdated { contract }); Ok(()) diff --git a/precompiles/lock-manager/src/lib.rs b/precompiles/lock-manager/src/lib.rs index 100be8df29..6047efa84d 100644 --- a/precompiles/lock-manager/src/lib.rs +++ b/precompiles/lock-manager/src/lib.rs @@ -22,8 +22,9 @@ #![cfg_attr(not(feature = "std"), no_std)] use core::marker::PhantomData; +use frame_support::traits::Get; use precompile_utils::prelude::*; -use sp_core::U256; +use sp_core::{H160, U256}; /// Precompile at address 0x0806. /// @@ -37,22 +38,33 @@ use sp_core::U256; /// 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. -pub struct LockManagerPrecompile(PhantomData); +/// +/// `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 +impl LockManagerPrecompile where Runtime: pallet_gigahdx::Config + pallet_evm::Config, Runtime::AddressMapping: pallet_evm::AddressMapping<::AccountId>, + ExpectedToken: Get, { - /// Returns the locked GIGAHDX balance for a given account. - /// The `token` parameter is accepted for forward-compatibility but currently unused. + /// 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 { + fn get_locked_balance(handle: &mut impl PrecompileHandle, token: Address, account: Address) -> EvmResult { // Blake2_128Concat key prefix (16) + AccountId (32) + StakeRecord (2 × u128 = 32) = 80 bytes handle.record_db_read::(80)?; + if H160::from(token) != ExpectedToken::get() { + return Ok(U256::zero()); + } + let account_id = ::AccountId, >>::into_account_id(account.into()); diff --git a/runtime/hydradx/src/assets.rs b/runtime/hydradx/src/assets.rs index 4b3c6858d4..addf8e1656 100644 --- a/runtime/hydradx/src/assets.rs +++ b/runtime/hydradx/src/assets.rs @@ -688,6 +688,10 @@ impl Get> for ExtendedDustRemovalWhitelist { BondsPalletId::get().into_account_truncating(), pallet_route_executor::Pallet::::router_account(), EVMAccounts::account_id(crate::evm::HOLDING_ADDRESS), + // gigapot must not be dust-reaped — its HDX balance is the rate + // denominator (`total_staked_hdx = TotalLocked + free_balance(gigapot)`) + // and the source of yield payouts during `do_giga_unstake`. + GigaHdxPalletId::get().into_account_truncating(), ]; if let Some((flash_minter, loan_receiver)) = pallet_hsm::GetFlashMinterSupport::::get() { @@ -1871,6 +1875,13 @@ impl pallet_dispenser::Config for Runtime { parameter_types! { pub const GigaHdxLockId: frame_support::traits::LockIdentifier = *b"ghdxlock"; pub const GigaHdxPalletId: frame_support::PalletId = frame_support::PalletId(*b"gigahdx!"); + // stHDX (id 670) MUST only be mintable / burnable by `pallet-gigahdx`. + // `total_gigahdx_supply()` reads global `total_issuance(StHdxAssetId)` + // directly — any external mint/burn shifts the rate denominator and + // silently dilutes existing stakers (or, with the rate floor, locks + // residual `Stakes.hdx` with no exit). Asset 670 should therefore have + // no other minter wired in the runtime; verify this invariant whenever + // asset-registry permissions or new bridges are added. pub const StHdxAssetId: AssetId = 670; pub const GigaHdxAssetIdConst: AssetId = 67; pub const GigaHdxMinStake: Balance = UNITS; // 1 HDX diff --git a/runtime/hydradx/src/evm/precompiles/mod.rs b/runtime/hydradx/src/evm/precompiles/mod.rs index e946a61e15..a36136ae43 100644 --- a/runtime/hydradx/src/evm/precompiles/mod.rs +++ b/runtime/hydradx/src/evm/precompiles/mod.rs @@ -122,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 @@ -180,9 +193,10 @@ where AllowedFlashLoanCallers, >::execute(handle)) } else if address == LOCK_MANAGER { - Some(pallet_evm_precompile_lock_manager::LockManagerPrecompile::::execute( - handle, - )) + 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()); diff --git a/runtime/hydradx/src/gigahdx.rs b/runtime/hydradx/src/gigahdx.rs index 20e24df534..0b6a7bbe52 100644 --- a/runtime/hydradx/src/gigahdx.rs +++ b/runtime/hydradx/src/gigahdx.rs @@ -59,7 +59,14 @@ impl MoneyMarketOperations for AaveMoneyMarket { let approve_ctx = CallContext::new_call(asset_evm, who_evm); as ERC20>::approve(approve_ctx, pool, amount)?; - // Pool.supply(asset, amount, onBehalfOf=user, referralCode=0) + // Pool.supply rounds the scaled balance down, so the actual aToken + // minted may be 1+ wei less than `amount`. Read the user's aToken + // balance before/after and return the delta — the pallet records that + // as `Stakes.gigahdx`, preserving the invariant + // `Stakes.gigahdx == aToken.balanceOf` that `LockableAToken.burn`'s + // `freeBalance = balanceOf - locked` check relies on. + let balance_before = Self::balance_of(who); + let supply_ctx = CallContext::new_call(pool, who_evm); let mut data = Into::::into(AaveFunction::Supply).to_be_bytes().to_vec(); data.extend_from_slice(H256::from(asset_evm).as_bytes()); @@ -68,7 +75,8 @@ impl MoneyMarketOperations for AaveMoneyMarket { data.extend_from_slice(H256::from_uint(&U256::zero()).as_bytes()); // referralCode = 0 handle(Executor::::call(supply_ctx, data, U256::zero(), GAS_LIMIT))?; - Ok(amount) + let balance_after = Self::balance_of(who); + Ok(balance_after.saturating_sub(balance_before)) } fn withdraw(who: &AccountId, underlying_asset: AssetId, amount: Balance) -> Result { @@ -76,7 +84,12 @@ impl MoneyMarketOperations for AaveMoneyMarket { let who_evm = pallet_evm_accounts::Pallet::::evm_address(who); let pool = Self::pool()?; - // Pool.withdraw(asset, amount, to=user) + // Mirror the supply path — return the actual underlying received, + // not the requested amount, so callers can reconcile against AAVE's + // scaledBalance rounding. Symmetry with `supply` keeps round-trip + // accounting consistent across rate drift. + let balance_before = Self::balance_of(who); + let withdraw_ctx = CallContext::new_call(pool, who_evm); let mut data = Into::::into(AaveFunction::Withdraw).to_be_bytes().to_vec(); data.extend_from_slice(H256::from(asset_evm).as_bytes()); @@ -84,7 +97,8 @@ impl MoneyMarketOperations for AaveMoneyMarket { data.extend_from_slice(H256::from(who_evm).as_bytes()); handle(Executor::::call(withdraw_ctx, data, U256::zero(), GAS_LIMIT))?; - Ok(amount) + let balance_after = Self::balance_of(who); + Ok(balance_before.saturating_sub(balance_after)) } fn balance_of(who: &AccountId) -> Balance { From 02eb404f5860aee77a810941318ca24cee1d8603 Mon Sep 17 00:00:00 2001 From: Martin Hloska Date: Thu, 7 May 2026 08:49:34 +0200 Subject: [PATCH 06/29] benchmarks and weights --- pallets/gigahdx/src/benchmarking.rs | 129 ++++++++++++++++++++++++++++ pallets/gigahdx/src/lib.rs | 30 ++++++- runtime/hydradx/Cargo.toml | 1 + runtime/hydradx/src/assets.rs | 35 ++++++++ runtime/hydradx/src/gigahdx.rs | 32 +++++++ runtime/hydradx/src/lib.rs | 1 + traits/src/gigahdx.rs | 14 +++ 7 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 pallets/gigahdx/src/benchmarking.rs diff --git a/pallets/gigahdx/src/benchmarking.rs b/pallets/gigahdx/src/benchmarking.rs new file mode 100644 index 0000000000..abe9cd8018 --- /dev/null +++ b/pallets/gigahdx/src/benchmarking.rs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Benchmarks for `pallet-gigahdx`. The runtime swaps `Config::MoneyMarket` +// to a no-op `BenchmarkMoneyMarket` under `runtime-benchmarks` so the +// measurements capture only the substrate-side bookkeeping cost, not the +// EVM round-trip into AAVE. + +#![cfg(feature = "runtime-benchmarks")] + +use super::*; +use frame_benchmarking::v2::*; +use frame_support::assert_ok; +use frame_support::traits::Currency; +use frame_system::RawOrigin; +use primitives::{Balance, EvmAddress}; + +// 1 HDX in raw units (assumes 12 decimals as in primitives/src/constants.rs). +const ONE: Balance = 1_000_000_000_000; + +#[benchmarks(where T: Config, T::NativeCurrency: Currency)] +mod benches { + use super::*; + + /// Fund `who` with `amount` HDX via the lockable currency directly. + fn fund(who: &T::AccountId, amount: Balance) + where + T::NativeCurrency: Currency, + { + let _ = T::NativeCurrency::deposit_creating(who, amount); + } + + /// Set the AAVE pool address so `MoneyMarket::supply` doesn't bail out + /// on the `pool not set` precondition. The benchmark MoneyMarket is a + /// stub, so the address itself is not used — but the pallet's adapter + /// reads it before delegating, so it must be `Some`. + 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); + + // Stakes record populated; lock active. + 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 stake_amount: Balance = 100 * ONE; + fund::(&caller, stake_amount.saturating_mul(10)); + + // Existing stake to unstake from. + assert_ok!(Pallet::::giga_stake( + RawOrigin::Signed(caller.clone()).into(), + stake_amount, + )); + + // Worst-case path is case 2 (payout > active → yield transferred from + // gigapot). Pre-fund the gigapot so the rate is > 1 and the case-2 + // branch fires when the caller exits. + let gigapot = Pallet::::gigapot_account_id(); + fund::(&gigapot, stake_amount.saturating_mul(2)); + + // Unstake the full position. + let stake = Stakes::::get(&caller).expect("setup invariant"); + let unstake_amount = stake.gigahdx; + + #[extrinsic_call] + giga_unstake(RawOrigin::Signed(caller.clone()), unstake_amount); + + // Pending position created; gigahdx zeroed. + assert!(PendingUnstakes::::get(&caller).is_some()); + assert_eq!(Stakes::::get(&caller).map(|s| s.gigahdx).unwrap_or_default(), 0); + } + + #[benchmark] + fn unlock() { + assert_ok!(T::BenchmarkHelper::register_assets()); + set_dummy_pool::(); + + let caller: T::AccountId = whitelisted_caller(); + let stake_amount: Balance = 100 * ONE; + fund::(&caller, stake_amount.saturating_mul(10)); + + assert_ok!(Pallet::::giga_stake( + RawOrigin::Signed(caller.clone()).into(), + stake_amount, + )); + assert_ok!(Pallet::::giga_unstake( + RawOrigin::Signed(caller.clone()).into(), + stake_amount, + )); + + // Fast-forward to the position's expiry. + let position = PendingUnstakes::::get(&caller).expect("position created"); + frame_system::Pallet::::set_block_number(position.expires_at); + + #[extrinsic_call] + unlock(RawOrigin::Signed(caller.clone())); + + assert!(PendingUnstakes::::get(&caller).is_none()); + } + + #[benchmark] + fn set_pool_contract() { + // Precondition: TotalLocked == 0. Clean state — no setup needed. + let new_pool = EvmAddress::from([0xBBu8; 20]); + + #[extrinsic_call] + set_pool_contract(RawOrigin::Root, new_pool); + + assert_eq!(GigaHdxPoolContract::::get(), Some(new_pool)); + } +} diff --git a/pallets/gigahdx/src/lib.rs b/pallets/gigahdx/src/lib.rs index c7ac7f7767..ede62c355d 100644 --- a/pallets/gigahdx/src/lib.rs +++ b/pallets/gigahdx/src/lib.rs @@ -37,8 +37,28 @@ pub use pallet::*; #[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; +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkHelper for () { + fn register_assets() -> sp_runtime::DispatchResult { + Ok(()) + } +} + #[frame_support::pallet] pub mod pallet { pub use crate::weights::WeightInfo; @@ -131,6 +151,12 @@ pub mod pallet { type CooldownPeriod: Get>; 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 @@ -221,7 +247,7 @@ pub mod pallet { /// `Stakes[caller].gigahdx` records the **actual** aToken amount /// returned by the MM (may differ from input by rounding). #[pallet::call_index(0)] - #[pallet::weight(T::WeightInfo::giga_stake())] + #[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); @@ -301,7 +327,7 @@ pub mod pallet { /// The dispatchable runs in a storage layer so any `?` failure /// rolls back the pre-decrement atomically. #[pallet::call_index(1)] - #[pallet::weight(T::WeightInfo::giga_unstake())] + #[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_giga_unstake(&who, gigahdx_amount) diff --git a/runtime/hydradx/Cargo.toml b/runtime/hydradx/Cargo.toml index 63cda7eeba..80e054f03d 100644 --- a/runtime/hydradx/Cargo.toml +++ b/runtime/hydradx/Cargo.toml @@ -268,6 +268,7 @@ runtime-benchmarks = [ "pallet-xcm-benchmarks/runtime-benchmarks", "pallet-signet/runtime-benchmarks", "pallet-dispenser/runtime-benchmarks", + "pallet-gigahdx/runtime-benchmarks", "ismp-parachain/runtime-benchmarks", "pallet-token-gateway/runtime-benchmarks", "cumulus-pallet-weight-reclaim/runtime-benchmarks", diff --git a/runtime/hydradx/src/assets.rs b/runtime/hydradx/src/assets.rs index addf8e1656..e2591eeb8a 100644 --- a/runtime/hydradx/src/assets.rs +++ b/runtime/hydradx/src/assets.rs @@ -1892,13 +1892,48 @@ 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 = EnsureRoot; type PalletId = GigaHdxPalletId; type LockId = GigaHdxLockId; type MinStake = GigaHdxMinStake; type CooldownPeriod = GigaHdxCooldownPeriod; type WeightInfo = (); + #[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 { + // Idempotent — benchmarks invoke this multiple times across runs. + 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(()) + } } #[cfg(feature = "runtime-benchmarks")] diff --git a/runtime/hydradx/src/gigahdx.rs b/runtime/hydradx/src/gigahdx.rs index 0b6a7bbe52..d019a80473 100644 --- a/runtime/hydradx/src/gigahdx.rs +++ b/runtime/hydradx/src/gigahdx.rs @@ -17,8 +17,10 @@ use ethabi::ethereum_types::BigEndianHash; use evm::ExitReason::Succeed; use frame_support::sp_runtime::traits::Convert; use frame_support::sp_runtime::DispatchError; +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 primitive_types::U256; use primitives::{AccountId, AssetId, Balance, EvmAddress}; use sp_core::H256; @@ -107,4 +109,34 @@ impl MoneyMarketOperations for AaveMoneyMarket { 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 + } } diff --git a/runtime/hydradx/src/lib.rs b/runtime/hydradx/src/lib.rs index abc78f7be1..04fd6efad4 100644 --- a/runtime/hydradx/src/lib.rs +++ b/runtime/hydradx/src/lib.rs @@ -369,6 +369,7 @@ mod benches { [pallet_dynamic_fees, DynamicFees] [pallet_signet, Signet] [pallet_dispenser, EthDispenser] + [pallet_gigahdx, GigaHdx] //[ismp_parachain, IsmpParachain] //[pallet_token_gateway, TokenGateway] [frame_system_extensions, frame_system_benchmarking::extensions::Pallet::] diff --git a/traits/src/gigahdx.rs b/traits/src/gigahdx.rs index 43288800a6..f365e9ca44 100644 --- a/traits/src/gigahdx.rs +++ b/traits/src/gigahdx.rs @@ -17,6 +17,7 @@ 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). /// @@ -42,6 +43,19 @@ pub trait MoneyMarketOperations { /// 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 From 4f966f4384593d8c74c761ae2078aa0c7781dfc5 Mon Sep 17 00:00:00 2001 From: Martin Hloska Date: Thu, 7 May 2026 09:35:13 +0200 Subject: [PATCH 07/29] add snapshot --- integration-tests/snapshots/gigahdx/gigahdx | Bin 0 -> 18492772 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 integration-tests/snapshots/gigahdx/gigahdx diff --git a/integration-tests/snapshots/gigahdx/gigahdx b/integration-tests/snapshots/gigahdx/gigahdx new file mode 100644 index 0000000000000000000000000000000000000000..549118cbf88f47989249b694e440e443fbce7311 GIT binary patch literal 18492772 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