diff --git a/Cargo.lock b/Cargo.lock index c61dcd152..0c08a6eb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9729,7 +9729,7 @@ dependencies = [ [[package]] name = "pallet-gigahdx" -version = "1.1.0" +version = "1.2.0" dependencies = [ "frame-benchmarking", "frame-support", diff --git a/integration-tests/src/dca.rs b/integration-tests/src/dca.rs index 85e85faf3..f6344936b 100644 --- a/integration-tests/src/dca.rs +++ b/integration-tests/src/dca.rs @@ -6138,3 +6138,407 @@ fn rolling_buy_dca_completes_prematurely_when_price_increases() { ); }); } + +// Interaction between a `ghdxlock` balance lock and a named reserve. The Router +// (fungible API) treats the spendable floor as `frozen - reserved`, whereas +// `reserve`/locks treat them independently — so reserved HDX lets the Router +// move otherwise-locked HDX. +mod gigahdx_lock_reserve { + use super::*; + use frame_support::traits::tokens::fungible::Inspect as FungibleInspect; + use frame_support::traits::tokens::{Fortitude, Preservation}; + use frame_support::traits::{LockIdentifier, LockableCurrency, WithdrawReasons}; + use sp_runtime::TokenError; + + // Same id a gigahdx stake installs via `set_lock`. + const GHDX_LOCK: LockIdentifier = *b"ghdxlock"; + + fn omnipool_route() -> BoundedVec, ConstU32<{ MAX_NUMBER_OF_TRADES }>> { + vec![Trade { + pool: PoolType::Omnipool, + asset_in: HDX, + asset_out: DAI, + }] + .try_into() + .unwrap() + } + + fn reducible_hdx(who: &AccountId) -> Balance { + >::reducible_balance(who, Preservation::Expendable, Fortitude::Polite) + } + + // With a 600 reserve, the fungible floor drops to `frozen - reserved = 0`, so + // the Router can sell HDX otherwise held by the 400 lock. + #[test] + fn router_sell_should_drain_ghdxlock_locked_hdx_when_account_holds_a_reserve() { + TestNet::reset(); + Hydra::execute_with(|| { + init_omnipool_with_oracle_for_block_10(); + let alice: AccountId = ALICE.into(); + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 1000 * UNITS + )); + // Reserve under the DCA id, then lock the remaining free (free == frozen == 400). + assert_ok!(Currencies::reserve_named( + &NamedReserveId::get(), + HDX, + &alice, + 600 * UNITS + )); + Balances::set_lock(GHDX_LOCK, &alice, 400 * UNITS, WithdrawReasons::all()); + + // Reducible is the whole free balance, not `free - frozen`. + assert_balance!(alice.clone(), HDX, 400 * UNITS); + assert_reserved_balance!(alice.clone(), HDX, 600 * UNITS); + assert_eq!(reducible_hdx(&alice), 400 * UNITS); + + assert_ok!(Router::sell( + RuntimeOrigin::signed(alice.clone()), + HDX, + DAI, + 300 * UNITS, + 0, + omnipool_route(), + )); + + // Free HDX drops below the 400 lock; the reserve is untouched. + assert_balance!(alice.clone(), HDX, 100 * UNITS); + assert_reserved_balance!(alice.clone(), HDX, 600 * UNITS); + assert!( + Currencies::free_balance(HDX, &alice) < 400 * UNITS, + "free HDX dropped below the lock" + ); + }); + } + + // Same lock, no reserve: reducible is 0 and the Router sell is refused. + #[test] + fn router_sell_should_fail_to_touch_ghdxlock_when_no_reserve() { + TestNet::reset(); + Hydra::execute_with(|| { + init_omnipool_with_oracle_for_block_10(); + let alice: AccountId = ALICE.into(); + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 400 * UNITS + )); + Balances::set_lock(GHDX_LOCK, &alice, 400 * UNITS, WithdrawReasons::all()); + + assert_eq!(reducible_hdx(&alice), 0); + + assert_noop!( + Router::sell( + RuntimeOrigin::signed(alice.clone()), + HDX, + DAI, + 300 * UNITS, + 0, + omnipool_route(), + ), + TokenError::FundsUnavailable + ); + + assert_balance!(alice.clone(), HDX, 400 * UNITS); + }); + } + + // A real `DCA::schedule` parks the reserve; the Router then sells locked HDX + // while the DCA budget stays intact. + #[test] + fn dca_reserve_should_enable_selling_ghdxlock_locked_hdx() { + TestNet::reset(); + Hydra::execute_with(|| { + init_omnipool_with_oracle_for_block_10(); + go_to_block(11); + let alice: AccountId = ALICE.into(); + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 2000 * UNITS + )); + + // Non-rolling sell schedule reserves its full 1200 HDX budget under + // NamedReserveId (dca/src/lib.rs: `reserve_amount = total_amount`). + let schedule = + schedule_fake_with_sell_order(ALICE, PoolType::Omnipool, 1200 * UNITS, HDX, DAI, 100 * UNITS); + assert_ok!(DCA::schedule(RuntimeOrigin::signed(alice.clone()), schedule, None)); + assert_reserved_balance!(alice.clone(), HDX, 1200 * UNITS); + assert_balance!(alice.clone(), HDX, 800 * UNITS); + + // Lock the remaining 800 free HDX (free == frozen == 800). + Balances::set_lock(GHDX_LOCK, &alice, 800 * UNITS, WithdrawReasons::all()); + assert_eq!(reducible_hdx(&alice), 800 * UNITS); + + assert_ok!(Router::sell( + RuntimeOrigin::signed(alice.clone()), + HDX, + DAI, + 600 * UNITS, + 0, + omnipool_route(), + )); + + assert_balance!(alice.clone(), HDX, 200 * UNITS); + assert!( + Currencies::free_balance(HDX, &alice) < 800 * UNITS, + "free HDX dropped below the lock" + ); + // DCA budget untouched. + assert_reserved_balance!(alice.clone(), HDX, 1200 * UNITS); + }); + } + + // Free can be pushed down only to `frozen - reserved`, so with reserve 200 < + // lock 600 exactly 200 of locked HDX is sellable and the next unit is refused. + #[test] + fn locked_hdx_sellable_is_capped_at_the_reserved_amount() { + TestNet::reset(); + Hydra::execute_with(|| { + init_omnipool_with_oracle_for_block_10(); + let alice: AccountId = ALICE.into(); + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 1000 * UNITS + )); + // Stake/lock 600, then park a 200 reserve (< stake). + Balances::set_lock(GHDX_LOCK, &alice, 600 * UNITS, WithdrawReasons::all()); + assert_ok!(Currencies::reserve_named( + &NamedReserveId::get(), + HDX, + &alice, + 200 * UNITS + )); + + // free 800, frozen 600, reserved 200 -> reducible = 800 - (600 - 200) = 400. + assert_balance!(alice.clone(), HDX, 800 * UNITS); + assert_eq!(reducible_hdx(&alice), 400 * UNITS); + + // Sell the full reducible: 200 above the lock + 200 below it. + assert_ok!(Router::sell( + RuntimeOrigin::signed(alice.clone()), + HDX, + DAI, + 400 * UNITS, + 0, + omnipool_route(), + )); + + // free == `frozen - reserved`; the floor is reached, one more unit is refused. + assert_balance!(alice.clone(), HDX, 400 * UNITS); + assert_eq!(reducible_hdx(&alice), 0); + assert_noop!( + Router::sell( + RuntimeOrigin::signed(alice.clone()), + HDX, + DAI, + 1 * UNITS, + 0, + omnipool_route(), + ), + TokenError::FundsUnavailable + ); + }); + } + + // Reserve >= lock zeroes the floor, so the whole free balance is sellable: + // free drains to 0 while the full lock remains. + #[test] + fn whole_stake_becomes_sellable_when_reserve_at_least_matches_stake() { + TestNet::reset(); + Hydra::execute_with(|| { + init_omnipool_with_oracle_for_block_10(); + let alice: AccountId = ALICE.into(); + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 1000 * UNITS + )); + // Stake/lock 400, then park a 500 reserve (>= stake). + Balances::set_lock(GHDX_LOCK, &alice, 400 * UNITS, WithdrawReasons::all()); + assert_ok!(Currencies::reserve_named( + &NamedReserveId::get(), + HDX, + &alice, + 500 * UNITS + )); + + // free 500, frozen 400, reserved 500 -> floor 0, whole free is reducible. + assert_balance!(alice.clone(), HDX, 500 * UNITS); + assert_eq!(reducible_hdx(&alice), 500 * UNITS); + + assert_ok!(Router::sell( + RuntimeOrigin::signed(alice.clone()), + HDX, + DAI, + 500 * UNITS, + 0, + omnipool_route(), + )); + + // Free fully drained; the 400 lock remains, reserve untouched. + assert_balance!(alice.clone(), HDX, 0); + assert_reserved_balance!(alice.clone(), HDX, 500 * UNITS); + }); + } + // End to end with a reserve == lock. The reserve needs `free - budget >= + // frozen`, so it comes from unlocked HDX (account carries 2X); once it matches + // the lock the floor is 0 and a plain `transfer_allow_death` moves the locked X. + #[test] + fn user_scenario_stake_then_reserve_via_dca_then_transfer_out_the_whole_stake() { + TestNet::reset(); + Hydra::execute_with(|| { + init_omnipool_with_oracle_for_block_10(); + go_to_block(11); + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + + // X = 1000 (>= DCA `MinBudgetInNativeCurrency`). Alice needs 2X. + let x = 1000 * UNITS; + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 2 * x + )); + let bob_before = Currencies::free_balance(HDX, &bob); + + // Lock X (stake). + Balances::set_lock(GHDX_LOCK, &alice, x, WithdrawReasons::all()); + assert_balance!(alice.clone(), HDX, 2 * x); + + // Schedule a DCA to sell X: reserves X under `dcaorder`. + let schedule = schedule_fake_with_sell_order(ALICE, PoolType::Omnipool, x, HDX, DAI, 100 * UNITS); + assert_ok!(DCA::schedule(RuntimeOrigin::signed(alice.clone()), schedule, None)); + assert_reserved_balance!(alice.clone(), HDX, x); + assert_balance!(alice.clone(), HDX, x); + assert_eq!(reducible_hdx(&alice), x); + + // A plain transfer moves the whole locked X to Bob. + assert_ok!(Balances::transfer_allow_death( + RuntimeOrigin::signed(alice.clone()), + bob.clone(), + x + )); + + // Free HDX 0 behind the live X lock; DCA budget untouched; Bob got X. + assert_balance!(alice.clone(), HDX, 0); + assert_reserved_balance!(alice.clone(), HDX, x); + assert_eq!(Currencies::free_balance(HDX, &bob), bob_before + x); + }); + } + + // With free 0, reserved X, frozen X, each unreserved unit is immediately + // re-frozen (reducible 0), so the DCA's own trade leg fails and the schedule + // terminates without selling. + #[test] + fn dca_cannot_trade_after_the_backing_is_transferred_out() { + TestNet::reset(); + Hydra::execute_with(|| { + init_omnipool_with_oracle_for_block_10(); + go_to_block(11); + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + let x = 1000 * UNITS; + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 2 * x + )); + + // Reserve X via a real DCA schedule, lock X, then move X out. + let schedule = schedule_fake_with_sell_order(ALICE, PoolType::Omnipool, x, HDX, DAI, 100 * UNITS); + assert_ok!(DCA::schedule(RuntimeOrigin::signed(alice.clone()), schedule, None)); + Balances::set_lock(GHDX_LOCK, &alice, x, WithdrawReasons::all()); + assert_ok!(Balances::transfer_allow_death( + RuntimeOrigin::signed(alice.clone()), + bob.clone(), + x + )); + assert_balance!(alice.clone(), HDX, 0); + assert_reserved_balance!(alice.clone(), HDX, x); + + // The DCA's internal trade leg — a Router::sell — now fails outright. + assert_noop!( + Router::sell( + RuntimeOrigin::signed(alice.clone()), + HDX, + DAI, + 100 * UNITS, + 0, + omnipool_route(), + ), + sp_runtime::TokenError::FundsUnavailable + ); + + // Drive the scheduler: the DCA fails its trade and terminates, selling nothing. + let dai_before = Currencies::free_balance(DAI, &alice); + run_to_block(12, 20); + assert!(DCA::schedules(0).is_none(), "DCA terminated"); + assert_balance!(alice.clone(), DAI, dai_before); + + // The refunded budget is re-frozen by the live lock (reducible 0). + assert_balance!(alice.clone(), HDX, x); + assert_reserved_balance!(alice.clone(), HDX, 0); + assert_eq!(reducible_hdx(&alice), 0); + }); + } + + // `Router::sell_all` sizes to `reducible_balance(.., Polite)`, which the reserve + // inflates to the full free balance — so one call sells all the locked HDX. The + // reserve stays trapped behind the lock afterwards. + #[test] + fn sell_all_drains_the_entire_locked_stake_in_one_call() { + TestNet::reset(); + Hydra::execute_with(|| { + init_omnipool_with_oracle_for_block_10(); + go_to_block(11); + let alice: AccountId = ALICE.into(); + let x = 1000 * UNITS; + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 2 * x + )); + + // Park the reserve via a real DCA schedule, then lock X. + let schedule = schedule_fake_with_sell_order(ALICE, PoolType::Omnipool, x, HDX, DAI, 100 * UNITS); + assert_ok!(DCA::schedule(RuntimeOrigin::signed(alice.clone()), schedule, None)); + Balances::set_lock(GHDX_LOCK, &alice, x, WithdrawReasons::all()); + assert_balance!(alice.clone(), HDX, x); + assert_reserved_balance!(alice.clone(), HDX, x); + assert_eq!(reducible_hdx(&alice), x); + + let dai_before = Currencies::free_balance(DAI, &alice); + + // One call, no amount: sizes to the full free balance. + assert_ok!(Router::sell_all( + RuntimeOrigin::signed(alice.clone()), + HDX, + DAI, + 0, + omnipool_route(), + )); + + // All the locked HDX was sold; the DCA budget is untouched. + assert_balance!(alice.clone(), HDX, 0); + assert_eq!(Currencies::free_balance(DAI, &alice) - dai_before, 712_143_506_239_384); + assert_reserved_balance!(alice.clone(), HDX, x); + + // Unreserving the budget re-freezes it behind the lock. + assert_eq!(Currencies::unreserve_named(&NamedReserveId::get(), HDX, &alice, x), 0); + assert_balance!(alice.clone(), HDX, x); + assert_reserved_balance!(alice.clone(), HDX, 0); + assert_eq!(reducible_hdx(&alice), 0); + }); + } +} diff --git a/integration-tests/src/gigahdx.rs b/integration-tests/src/gigahdx.rs index e1080ad44..c31360424 100644 --- a/integration-tests/src/gigahdx.rs +++ b/integration-tests/src/gigahdx.rs @@ -3980,6 +3980,84 @@ fn gigahdx_liquidation_e2e_should_seize_when_normal_staker() { }); } +/// Full flow when the borrower's stake backing sits in `reserved` (free 0): the +/// seize draws from reserved via `slash_reserved`, so the liquidation lands and +/// the recipient is credited from the borrower's reserved balance. +#[test] +fn gigahdx_liquidation_e2e_should_seize_from_reserved_backing() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + use crate::liquidation::{borrow, get_user_account_data}; + use orml_traits::{MultiReservableCurrency, NamedMultiReservableCurrency}; + + reset_giga_state_for_fixture(); + let (alice, bob, alice_evm, pool, oracle, hollar_addr) = liquidation_test_setup(); + let st_hdx_evm = HydraErc20Mapping::asset_address(ST_HDX); + let liq_account = hydradx_runtime::TreasuryAccount::get(); + let main_mm = Liquidation::borrowing_contract(); + + let stake_amount = 10_000 * UNITS; + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), stake_amount)); + assert_rate_eq(GigaHdx::exchange_rate(), 1, 1); + + let borrow_amount: Balance = 5 * HOLLAR_DECIMALS_18; + borrow(pool, alice_evm, hollar_addr, borrow_amount); + let liq_evm = e2e_provision_liq_account(&liq_account, main_mm); + + // Move the stake backing into `reserved`, leaving free at 0 behind the lock. + assert_ok!(Currencies::reserve_named( + &hydradx_runtime::NamedReserveId::get(), + HDX, + &alice, + stake_amount + )); + assert_ok!(Balances::force_set_balance(RawOrigin::Root.into(), alice.clone(), 0)); + assert_eq!(Balances::free_balance(&alice), 0); + assert_eq!(Currencies::reserved_balance(HDX, &alice), stake_amount); + + crash_st_hdx_price(oracle, st_hdx_evm); + + let pre = get_user_account_data(pool, alice_evm).unwrap(); + assert!(pre.health_factor < U256::from(1_000_000_000_000_000_000u128)); + let debt_before = pre.total_debt_base; + let alice_gigahdx_before = Currencies::free_balance(GIGAHDX, &alice); + let alice_reserved_before = Currencies::reserved_balance(HDX, &alice); + + set_liquidation_protocol_fee(pool, st_hdx_evm, 0); + let collector_before = Currencies::free_balance(GIGAHDX, &gigahdx_atoken_collector()); + assert_ok!(Liquidation::liquidate( + RuntimeOrigin::signed(bob), + GIGAHDX, + HOLLAR_ASSET_ID, + alice_evm, + borrow_amount / 2, + hydradx_traits::router::Route::default(), + )); + + assert!(Currencies::free_balance(GIGAHDX, &alice) < alice_gigahdx_before); + e2e_assert_consolidated( + &alice, + alice_evm, + pool, + &liq_account, + liq_evm, + main_mm, + debt_before, + stake_amount, + collector_before, + ); + + // The seized HDX came out of reserved, not free. + let seized_hdx = pallet_gigahdx::Stakes::::get(&liq_account).unwrap().hdx; + assert_eq!(Balances::free_balance(&alice), 0, "free stays 0"); + assert_eq!( + Currencies::reserved_balance(HDX, &alice), + alice_reserved_before - seized_hdx, + "reserved dropped by the seized amount" + ); + }); +} + /// Borrower has an active full-stake conviction vote. After the seize the /// stake backing it is gone, so the extrinsic must remove the vote (clearing /// the gigahdx vote record) and the referendum tally must drop — the protocol @@ -4705,3 +4783,275 @@ fn full_unstake_should_reap_record_when_rate_causes_rounding() { ); }); } + +// Reserve-vs-lock interaction against a real `giga_stake` (not a synthetic +// `set_lock`). +mod reserve_leak { + use super::*; + use hydradx_runtime::NamedReserveId; + use orml_traits::NamedMultiReservableCurrency; + + // With a reserve of X parked, a plain `transfer_allow_death` moves the whole + // staked X out; the stake record and aToken stay in place. + #[test] + fn reserve_lets_the_backing_hdx_be_moved_out_from_under_a_real_gigahdx_stake() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + let x = 100 * UNITS; + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 2 * x + )); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); + let bob_before = Balances::free_balance(&bob); + + // Stake X: HDX stays in the account but locked; X aToken minted. + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), x)); + let atoken = Currencies::free_balance(GIGAHDX, &alice); + assert!(atoken > 0, "Alice holds the aToken after staking"); + assert_eq!(locked_under_ghdx(&alice), x); + assert_eq!( + Balances::free_balance(&alice), + 2 * x, + "HDX stays in the account (lock model)" + ); + let total_locked_before = pallet_gigahdx::TotalLocked::::get(); + + // Park a reserve of X (as DCA::schedule does). + assert_ok!(Currencies::reserve_named(&NamedReserveId::get(), HDX, &alice, x)); + assert_eq!(Balances::free_balance(&alice), x); + + assert_ok!(Balances::transfer_allow_death( + RuntimeOrigin::signed(alice.clone()), + bob.clone(), + x + )); + + // Free HDX moved out; the stake record and aToken are unchanged. + assert_eq!(Balances::free_balance(&alice), 0, "free HDX moved out"); + assert_eq!(Balances::free_balance(&bob), bob_before + x, "Bob received it"); + assert_eq!( + Currencies::free_balance(GIGAHDX, &alice), + atoken, + "aToken still held by Alice" + ); + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("stake persists"); + assert_eq!(stake.hdx, x, "stake record unchanged"); + assert_eq!( + pallet_gigahdx::TotalLocked::::get(), + total_locked_before, + "TotalLocked unchanged" + ); + assert_eq!(locked_under_ghdx(&alice), x, "lock still X with 0 free behind it"); + }); + } + + // `ensure_stakeable` reads raw `free - own_claim`, and the reserve only reduces + // raw free, so a second stake on top of a parked reserve is refused. + #[test] + fn reserve_does_not_let_a_single_hdx_back_two_gigahdx_stakes() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + let x = 100 * UNITS; + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 2 * x + )); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), x)); + let atoken_after_first = Currencies::free_balance(GIGAHDX, &alice); + assert_ok!(Currencies::reserve_named(&NamedReserveId::get(), HDX, &alice, x)); + + // Raw free is now X, but the existing stake already claims X. + assert_noop!( + GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), x), + pallet_gigahdx::Error::::InsufficientFreeBalance + ); + assert_eq!( + Currencies::free_balance(GIGAHDX, &alice), + atoken_after_first, + "no extra aToken minted" + ); + }); + } + + // With free 0, reserved X, frozen X, `giga_unstake` still succeeds. It does + // not double-recover: the principal already left, so free HDX stays 0. + #[test] + fn unstake_still_succeeds_after_the_backing_hdx_is_moved_out() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + let x = 100 * UNITS; + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 2 * x + )); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); + + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), x)); + let atoken = Currencies::free_balance(GIGAHDX, &alice); + assert_ok!(Currencies::reserve_named(&NamedReserveId::get(), HDX, &alice, x)); + assert_ok!(Balances::transfer_allow_death( + RuntimeOrigin::signed(alice.clone()), + bob.clone(), + x + )); + assert_eq!(Balances::free_balance(&alice), 0); + + assert_ok!(GigaHdx::giga_unstake(RuntimeOrigin::signed(alice.clone()), atoken)); + + let stake = pallet_gigahdx::Stakes::::get(&alice).expect("record persists until unlock"); + assert_eq!(stake.hdx, 0); + assert_eq!(stake.gigahdx, 0); + assert_eq!(pending_count(&alice), 1); + assert_eq!(only_pending_position(&alice).amount, x); + assert_eq!(Currencies::free_balance(GIGAHDX, &alice), 0, "aToken burned"); + assert_eq!(locked_under_ghdx(&alice), x, "lock now backs the pending unstake"); + // Not paid twice: the principal already left. + assert_eq!(Balances::free_balance(&alice), 0); + }); + } +} + +// Trace every balance field per step to show that unreserving the budget just +// re-freezes it behind the still-live lock (reducible stays 0). +mod reserve_recovery_trace { + use super::*; + use frame_support::traits::tokens::fungible::Inspect as FungibleInspect; + use frame_support::traits::tokens::{Fortitude, Preservation}; + use hydradx_runtime::NamedReserveId; + use orml_traits::NamedMultiReservableCurrency; + + fn snap(who: &AccountId) -> (Balance, Balance, Balance, Balance, Balance) { + let d = frame_system::Account::::get(who).data; + let ghdx = locked_under_ghdx(who); + let reducible = >::reducible_balance( + who, + Preservation::Expendable, + Fortitude::Polite, + ); + (d.free, d.reserved, d.frozen, ghdx, reducible) + } + + #[test] + fn reserve_cannot_be_recovered_as_spendable_because_the_ghdxlock_re_traps_it() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + let x = 100 * UNITS; + + assert_ok!(Balances::force_set_balance(RawOrigin::Root.into(), alice.clone(), 2 * x)); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); + assert_ok!(Balances::force_set_balance(RawOrigin::Root.into(), bob.clone(), 0)); + + let dump = |label: &str, a: (Balance, Balance, Balance, Balance, Balance), b: (Balance, Balance, Balance, Balance, Balance)| { + let u = UNITS; + eprintln!( + "\n[{label}]\n ALICE free={} reserved={} frozen={} ghdxlock={} spendable(reducible)={}\n BOB free={} reserved={} frozen={} ghdxlock={} spendable(reducible)={}", + a.0 / u, a.1 / u, a.2 / u, a.3 / u, a.4 / u, + b.0 / u, b.1 / u, b.2 / u, b.3 / u, b.4 / u, + ); + }; + + // Step 1: real giga_stake(X). Locks X in place; mints X aToken. + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), x)); + let a1 = snap(&alice); + dump("step 1: giga_stake(100)", a1, snap(&bob)); + assert_eq!(a1, (2 * x, 0, x, x, x)); // free 200, reserved 0, frozen 100, lock 100, spendable 100 + + // Step 2: reserve X (exactly what DCA::schedule does). + assert_ok!(Currencies::reserve_named(&NamedReserveId::get(), HDX, &alice, x)); + let a2 = snap(&alice); + dump("step 2: reserve 100 (DCA budget)", a2, snap(&bob)); + assert_eq!(a2, (x, x, x, x, x)); // free 100, reserved 100, frozen 100, lock 100, spendable 100 (!) + + // Step 3: move the entire (locked) stake HDX out to Bob. + assert_ok!(Balances::transfer_allow_death(RuntimeOrigin::signed(alice.clone()), bob.clone(), x)); + let a3 = snap(&alice); + let b3 = snap(&bob); + dump("step 3: transfer 100 -> Bob", a3, b3); + assert_eq!(a3, (0, x, x, x, 0)); // free 0, reserved 100, frozen 100, lock 100, spendable 0 + assert_eq!(b3.0, x); // Bob got the 100 backing, fully spendable + + // Step 4: unreserve -> back to free, but the lock (still 100) re-freezes it. + let leftover = Currencies::unreserve_named(&NamedReserveId::get(), HDX, &alice, x); + assert_eq!(leftover, 0, "whole reserve returned to free"); + let a4 = snap(&alice); + dump("step 4: unreserve 100", a4, snap(&bob)); + assert_eq!(a4, (x, 0, x, x, 0)); // free 100, frozen 100, reserved 0 -> spendable 0 + + // With the reserve gone, the lock is the sole constraint -> `Frozen`. + assert_noop!( + Balances::transfer_allow_death(RuntimeOrigin::signed(alice.clone()), bob.clone(), UNITS), + sp_runtime::TokenError::Frozen + ); + }); + } +} + +// Seize (`Seize::on_seize`) must reach stake backing held in `reserved`, not +// just `free`. +mod liquidation_seize { + use super::*; + use crate::assert_reserved_balance; + use hydradx_runtime::NamedReserveId; + use hydradx_traits::gigahdx::Seize; + use orml_traits::{MultiReservableCurrency, NamedMultiReservableCurrency}; + + #[test] + fn liquidation_seize_should_reach_stake_backing_held_in_reserved() { + TestNet::reset(); + hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| { + let alice: AccountId = ALICE.into(); + let bob: AccountId = BOB.into(); + // Stand-in for the liquidation account that should receive the seized HDX. + let recipient: AccountId = CHARLIE.into(); + let x = 100 * UNITS; + + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + alice.clone(), + 2 * x + )); + let _ = EVMAccounts::bind_evm_address(RuntimeOrigin::signed(alice.clone())); + + // Stake X, reserve X, then move the free (locked) X out, so the backing + // ends up entirely in `reserved`. + assert_ok!(GigaHdx::giga_stake(RuntimeOrigin::signed(alice.clone()), x)); + let atoken = Currencies::free_balance(GIGAHDX, &alice); + assert_ok!(Currencies::reserve_named(&NamedReserveId::get(), HDX, &alice, x)); + assert_ok!(Balances::transfer_allow_death( + RuntimeOrigin::signed(alice.clone()), + bob.clone(), + x + )); + assert_eq!(Balances::free_balance(&alice), 0); + assert_reserved_balance!(alice.clone(), HDX, x); + let recipient_before = Balances::free_balance(&recipient); + + assert_ok!(>::on_seize( + &alice, &recipient, x, atoken, atoken + )); + + assert_reserved_balance!(alice.clone(), HDX, 0); + assert_eq!( + Balances::free_balance(&recipient), + recipient_before + x, + "recipient receives the seized backing" + ); + }); + } +} diff --git a/pallets/gigahdx/Cargo.toml b/pallets/gigahdx/Cargo.toml index 454da7018..6d6a8250d 100644 --- a/pallets/gigahdx/Cargo.toml +++ b/pallets/gigahdx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-gigahdx" -version = "1.1.0" +version = "1.2.0" description = "Liquid-staking primitive on top of an EVM money market." authors = ["GalacticCouncil"] edition = "2021" diff --git a/pallets/gigahdx/src/lib.rs b/pallets/gigahdx/src/lib.rs index 79a7d6752..5beea1b95 100644 --- a/pallets/gigahdx/src/lib.rs +++ b/pallets/gigahdx/src/lib.rs @@ -85,7 +85,8 @@ pub mod pallet { use frame_support::traits::fungibles::Mutate as FungiblesMutate; use frame_support::traits::tokens::{Fortitude, Precision, Preservation}; use frame_support::traits::{ - fungibles, Currency, ExistenceRequirement, LockIdentifier, LockableCurrency, WithdrawReasons, + fungibles, Currency, ExistenceRequirement, LockIdentifier, LockableCurrency, ReservableCurrency, + WithdrawReasons, }; use frame_support::{transactional, PalletId}; use frame_system::pallet_prelude::*; @@ -148,7 +149,8 @@ pub mod pallet { #[pallet::config] pub trait Config: frame_system::Config>> { - type NativeCurrency: LockableCurrency>; + type NativeCurrency: LockableCurrency> + + ReservableCurrency; /// Multi-asset register that holds stHDX (and any other registered /// fungible). Only this pallet mints / burns stHDX through it. @@ -982,39 +984,31 @@ pub mod pallet { Self::refresh_lock(borrower)?; if !seize_hdx.is_zero() { - // Prefer a clean transfer. If the borrower's remaining locks - // (e.g. uncleared `pyconvot`, vesting, or any unmanaged lock) - // still block the move, fall back to `slash` + `resolve_creating` - // — liquidation is top priority and must always land. - let new_balance = T::NativeCurrency::free_balance(borrower) - .checked_sub(seize_hdx) - .ok_or(Error::::SeizeFailed)?; - let can_transfer = - T::NativeCurrency::ensure_can_withdraw(borrower, seize_hdx, WithdrawReasons::TRANSFER, new_balance) - .is_ok(); + // Prefer a clean transfer; fall back to slash when a lock blocks it. + let free = T::NativeCurrency::free_balance(borrower); + let can_transfer = seize_hdx <= free + && T::NativeCurrency::ensure_can_withdraw( + borrower, + seize_hdx, + WithdrawReasons::TRANSFER, + free.saturating_sub(seize_hdx), + ) + .is_ok(); if can_transfer { T::NativeCurrency::transfer(borrower, recipient, seize_hdx, ExistenceRequirement::AllowDeath)?; } else { - // Intentional policy: liquidation outranks every lock. `slash` takes - // the HDX regardless of `ormlvest` vesting, `pyconvot`, or any other - // foreign lock; the lock owner bears any later `balance < lock` - // shortfall. gigahdx's own ledger stays consistent regardless: - // `seize_hdx <= active hdx` (snapshot reads only `s.hdx`) and the - // lock invariant `balance >= hdx + unstaking` together guarantee - // `balance_new >= hdx_new + unstaking`, so `unstaking` / - // `PendingUnstakes` are never stranded by the slash. - // `slash` ignores locks (unlike `transfer`), but - // `pallet_balances` refuses to push a non-reapable - // account below ED. Tolerate that ≤ED dust — Aave has - // already moved the collateral aToken by this point, so - // the seize must land. Larger shortfalls keep the - // fail-loud tripwire for a genuinely broken stake/lock - // ledger (the `free >= seize_hdx` staking invariant - // bounds the shortfall to exactly the ED). - let (imbalance, remaining) = T::NativeCurrency::slash(borrower, seize_hdx); + // Liquidation outranks locks and reserves. `slash` ignores locks but + // reaches only `free`; take any remainder from `reserved`. The + // `free + reserved >= frozen >= seize_hdx` invariant covers the seize; + // tolerate the ≤ED dust `pallet_balances` leaves on a non-reapable account. let ed = T::NativeCurrency::minimum_balance(); - ensure!(remaining <= ed, Error::::SeizeFailed); - T::NativeCurrency::resolve_creating(recipient, imbalance); + let (free_imbalance, remaining) = T::NativeCurrency::slash(borrower, seize_hdx); + T::NativeCurrency::resolve_creating(recipient, free_imbalance); + if remaining > ed { + let (reserved_imbalance, unpaid) = T::NativeCurrency::slash_reserved(borrower, remaining); + ensure!(unpaid <= ed, Error::::SeizeFailed); + T::NativeCurrency::resolve_creating(recipient, reserved_imbalance); + } } } diff --git a/pallets/gigahdx/src/tests/seize.rs b/pallets/gigahdx/src/tests/seize.rs index 7026411f8..d44ee56b6 100644 --- a/pallets/gigahdx/src/tests/seize.rs +++ b/pallets/gigahdx/src/tests/seize.rs @@ -332,3 +332,183 @@ fn on_seize_should_clamp_in_release_when_seize_gigahdx_exceeds_snapshot() { assert_eq!(recipient.gigahdx, orig_gigahdx + 50 * ONE); }); } + +/// With the backing held in `reserved` and `free = 0`, the seize falls back to +/// `slash_reserved` and credits the recipient in full. +#[test] +fn finalise_seize_should_reach_backing_held_in_reserved_when_free_is_zero() { + use frame_support::traits::ReservableCurrency; + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let orig_g = >::on_pre_seize(&ALICE).unwrap(); + + // Move the 100 of backing into `reserved`, leaving `free` at 0 behind the lock. + assert_ok!(pallet_balances::Pallet::::force_set_balance( + RawOrigin::Root.into(), + ALICE, + 200 * ONE + )); + assert_ok!( as ReservableCurrency<_>>::reserve( + &ALICE, + 100 * ONE + )); + assert_ok!(pallet_balances::Pallet::::force_set_balance( + RawOrigin::Root.into(), + ALICE, + 0 + )); + assert_eq!(pallet_balances::Pallet::::free_balance(ALICE), 0); + assert_eq!( + as ReservableCurrency<_>>::reserved_balance(&ALICE), + 100 * ONE + ); + + let treasury_before = pallet_balances::Pallet::::free_balance(TREASURY); + + // Nothing in free, so the seize is drawn from reserved. + assert_ok!(>::on_seize( + &ALICE, + &TREASURY, + 100 * ONE, + orig_g, + orig_g, + )); + + assert_eq!( + pallet_balances::Pallet::::free_balance(TREASURY) - treasury_before, + 100 * ONE, + "recipient credited in full" + ); + assert_eq!( + as ReservableCurrency<_>>::reserved_balance(&ALICE), + 0, + "borrower's reserved fully seized" + ); + assert_eq!(Stakes::::get(ALICE).unwrap().hdx, 0); + assert_eq!(Stakes::::get(TREASURY).unwrap().hdx, 100 * ONE); + }); +} + +/// Backing split across free and reserved: the seize takes what free can cover, +/// then the remainder from reserved, and credits the recipient in full. +#[test] +fn finalise_seize_should_split_between_free_and_reserved() { + use frame_support::traits::ReservableCurrency; + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let orig_g = >::on_pre_seize(&ALICE).unwrap(); + + // 40 in free, 60 in reserved. + assert_ok!(pallet_balances::Pallet::::force_set_balance( + RawOrigin::Root.into(), + ALICE, + 200 * ONE + )); + assert_ok!( as ReservableCurrency<_>>::reserve( + &ALICE, + 60 * ONE + )); + assert_ok!(pallet_balances::Pallet::::force_set_balance( + RawOrigin::Root.into(), + ALICE, + 40 * ONE + )); + + let treasury_before = pallet_balances::Pallet::::free_balance(TREASURY); + assert_ok!(>::on_seize( + &ALICE, + &TREASURY, + 100 * ONE, + orig_g, + orig_g, + )); + + assert_eq!( + pallet_balances::Pallet::::free_balance(TREASURY) - treasury_before, + 100 * ONE + ); + assert_eq!(pallet_balances::Pallet::::free_balance(ALICE), 0); + assert_eq!( + as ReservableCurrency<_>>::reserved_balance(&ALICE), + 0 + ); + }); +} + +/// Partial seize (close factor < 100%) still reaches reserved backing. +#[test] +fn finalise_seize_should_reach_reserved_on_partial_seize() { + use frame_support::traits::ReservableCurrency; + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let orig_g = >::on_pre_seize(&ALICE).unwrap(); + + assert_ok!(pallet_balances::Pallet::::force_set_balance( + RawOrigin::Root.into(), + ALICE, + 200 * ONE + )); + assert_ok!( as ReservableCurrency<_>>::reserve( + &ALICE, + 100 * ONE + )); + assert_ok!(pallet_balances::Pallet::::force_set_balance( + RawOrigin::Root.into(), + ALICE, + 0 + )); + + let treasury_before = pallet_balances::Pallet::::free_balance(TREASURY); + assert_ok!(>::on_seize( + &ALICE, + &TREASURY, + 50 * ONE, + 50 * ONE, + orig_g, + )); + + assert_eq!( + pallet_balances::Pallet::::free_balance(TREASURY) - treasury_before, + 50 * ONE + ); + assert_eq!( + as ReservableCurrency<_>>::reserved_balance(&ALICE), + 50 * ONE + ); + let alice = Stakes::::get(ALICE).unwrap(); + assert_eq!(alice.hdx, 50 * ONE); + assert_eq!(alice.gigahdx, orig_g - 50 * ONE); + }); +} + +/// When free + reserved fall short by more than ED, the seize fails loud even +/// with a nonzero reserved balance. +#[test] +fn finalise_seize_should_fail_when_free_plus_reserved_short_by_more_than_ed() { + use frame_support::traits::ReservableCurrency; + ExtBuilder::default().build().execute_with(|| { + assert_ok!(GigaHdx::giga_stake(RawOrigin::Signed(ALICE).into(), 100 * ONE)); + let orig_g = >::on_pre_seize(&ALICE).unwrap(); + + // free 0, reserved 30 -> total 30, well below the 100 seize. + assert_ok!(pallet_balances::Pallet::::force_set_balance( + RawOrigin::Root.into(), + ALICE, + 200 * ONE + )); + assert_ok!( as ReservableCurrency<_>>::reserve( + &ALICE, + 30 * ONE + )); + assert_ok!(pallet_balances::Pallet::::force_set_balance( + RawOrigin::Root.into(), + ALICE, + 0 + )); + + assert_noop!( + >::on_seize(&ALICE, &TREASURY, 100 * ONE, orig_g, orig_g), + Error::::SeizeFailed + ); + }); +}