Skip to content

Commit 352fdaf

Browse files
review comments
1 parent f898a5a commit 352fdaf

16 files changed

Lines changed: 1355 additions & 377 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integration-tests/src/gigahdx.rs

Lines changed: 767 additions & 57 deletions
Large diffs are not rendered by default.

pallets/gigahdx/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pallet-gigahdx"
3-
version = "0.1.1"
3+
version = "0.1.3"
44
description = "Liquid-staking primitive on top of an EVM money market."
55
authors = ["GalacticCouncil"]
66
edition = "2021"

pallets/gigahdx/src/lib.rs

Lines changed: 112 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -595,21 +595,33 @@ pub mod pallet {
595595
///
596596
#[pallet::call_index(6)]
597597
#[pallet::weight(T::WeightInfo::realize_yield())]
598-
#[transactional]
599598
pub fn realize_yield(origin: OriginFor<T>) -> DispatchResult {
600599
let who = ensure_signed(origin)?;
600+
Self::do_realize_yield(&who)?;
601+
Ok(())
602+
}
603+
}
601604

602-
let stake = Stakes::<T>::get(&who).unwrap_or_default();
605+
impl<T: Config> Pallet<T> {
606+
/// Realize `who`'s accrued GIGAHDX yield into their locked stake
607+
/// principal: move `rate × gigahdx − Stakes[who].hdx` HDX from the
608+
/// gigapot into `who`, fold it into `Stakes[who].hdx`, refresh the
609+
/// lock. No-op (returns `0`) when there is no accrued yield or no
610+
/// stake. Shared by the `realize_yield` extrinsic and the liquidation
611+
/// seize path. `#[transactional]` so a mid-way Err rolls back.
612+
#[transactional]
613+
fn do_realize_yield(who: &T::AccountId) -> Result<Balance, DispatchError> {
614+
let stake = Stakes::<T>::get(who).unwrap_or_default();
603615
let current_value =
604616
Self::calculate_hdx_amount_given_gigahdx(stake.gigahdx).map_err(|_| Error::<T>::Overflow)?;
605617
let accrued = current_value.saturating_sub(stake.hdx);
606618
if accrued == 0 {
607-
return Ok(());
619+
return Ok(0);
608620
}
609621

610622
if T::NativeCurrency::transfer(
611623
&Self::gigapot_account_id(),
612-
&who,
624+
who,
613625
accrued,
614626
ExistenceRequirement::AllowDeath,
615627
)
@@ -625,20 +637,21 @@ pub mod pallet {
625637
return Err(Error::<T>::GigapotInsufficient.into());
626638
}
627639

628-
Stakes::<T>::try_mutate(&who, |maybe| -> Result<(), Error<T>> {
640+
Stakes::<T>::try_mutate(who, |maybe| -> Result<(), Error<T>> {
629641
let s = maybe.get_or_insert_with(StakeRecord::default);
630642
s.hdx = s.hdx.checked_add(accrued).ok_or(Error::<T>::Overflow)?;
631643
Ok(())
632644
})?;
633645
TotalLocked::<T>::mutate(|x| *x = x.saturating_add(accrued));
634-
Self::refresh_lock(&who)?;
646+
Self::refresh_lock(who)?;
635647

636-
Self::deposit_event(Event::YieldRealized { who, amount: accrued });
637-
Ok(())
648+
Self::deposit_event(Event::YieldRealized {
649+
who: who.clone(),
650+
amount: accrued,
651+
});
652+
Ok(accrued)
638653
}
639-
}
640654

641-
impl<T: Config> Pallet<T> {
642655
/// Internal helper for `giga_unstake`. Uses `?` freely; the
643656
/// `#[transactional]` attribute wraps the body in its own storage
644657
/// layer so any Err here rolls back partial mutations.
@@ -853,91 +866,6 @@ pub mod pallet {
853866
Stakes::<T>::get(who).map(|s| s.gigahdx).unwrap_or(0)
854867
}
855868

856-
/// `Seize::snapshot_stake` impl.
857-
pub fn seize_snapshot(borrower: &T::AccountId) -> Result<(Balance, Balance), DispatchError> {
858-
let s = Stakes::<T>::get(borrower).ok_or(Error::<T>::NoStake)?;
859-
Ok((s.hdx, s.gigahdx))
860-
}
861-
862-
/// `Seize::pre_seize` impl. Zeroes `Stakes[borrower].gigahdx` so the
863-
/// lock-manager precompile reports `locked = 0` and `LockableAToken`
864-
/// accepts Aave's internal aToken transfer. Returns the prior value
865-
/// for the caller to use when computing the actually-seized amount.
866-
pub fn seize_unlock_atoken(borrower: &T::AccountId) -> Result<Balance, DispatchError> {
867-
let orig = Stakes::<T>::try_mutate(borrower, |maybe| -> Result<Balance, DispatchError> {
868-
let s = maybe.as_mut().ok_or(Error::<T>::NoStake)?;
869-
let prev = s.gigahdx;
870-
s.gigahdx = 0;
871-
Ok(prev)
872-
})?;
873-
Ok(orig)
874-
}
875-
876-
/// `Seize::finalise_seize` impl. After Aave has moved aToken to the
877-
/// liquidator's EVM account, move the matching HDX from borrower to
878-
/// `recipient`, restore the borrower's `gigahdx` to the residual
879-
/// (`orig - actually_seized`), credit the recipient's stake, refresh
880-
/// both ghdxlocks. `TotalLocked` is unchanged — same HDX moved
881-
/// between two gigahdx-stakers.
882-
#[transactional]
883-
pub fn seize_finalise(
884-
borrower: &T::AccountId,
885-
recipient: &T::AccountId,
886-
seize_hdx: Balance,
887-
seize_gigahdx: Balance,
888-
residual_borrower_gigahdx: Balance,
889-
) -> DispatchResult {
890-
Stakes::<T>::try_mutate(borrower, |maybe| -> DispatchResult {
891-
let s = maybe.as_mut().ok_or(Error::<T>::NoStake)?;
892-
s.hdx = s.hdx.checked_sub(seize_hdx).ok_or(Error::<T>::Overflow)?;
893-
// Votes stay intact across a seize; clamp `frozen` so the
894-
// `hdx >= frozen` invariant still holds on the residual stake.
895-
s.frozen = s.frozen.min(s.hdx);
896-
s.gigahdx = residual_borrower_gigahdx;
897-
Ok(())
898-
})?;
899-
// Shrink the borrower's lock *before* withdrawing. The stale
900-
// pre-seize ghdxlock (sized to `hdx + unstaking`) would otherwise
901-
// block the transfer with `LiquidityRestrictions` for any
902-
// borrower whose free balance equals their staked amount.
903-
Self::refresh_lock(borrower)?;
904-
905-
if !seize_hdx.is_zero() {
906-
// Prefer a clean transfer. If the borrower's remaining locks
907-
// (e.g. uncleared `pyconvot`, vesting, or any unmanaged lock)
908-
// still block the move, fall back to `slash` + `resolve_creating`
909-
// — liquidation is top priority and must always land.
910-
let new_balance = T::NativeCurrency::free_balance(borrower).saturating_sub(seize_hdx);
911-
let can_transfer =
912-
T::NativeCurrency::ensure_can_withdraw(borrower, seize_hdx, WithdrawReasons::TRANSFER, new_balance)
913-
.is_ok();
914-
if can_transfer {
915-
T::NativeCurrency::transfer(borrower, recipient, seize_hdx, ExistenceRequirement::AllowDeath)?;
916-
} else {
917-
let (imbalance, remaining) = T::NativeCurrency::slash(borrower, seize_hdx);
918-
let slashed = imbalance.peek();
919-
T::NativeCurrency::resolve_creating(recipient, imbalance);
920-
// Liquidation must move EXACTLY `seize_hdx`. `slash` is
921-
// bounded above by request (it never takes more than asked)
922-
// but can take less when ED leaves an unslashable remainder;
923-
// both `remaining == 0` and `slashed == seize_hdx` are
924-
// equivalent expressions of "took exactly what we asked".
925-
ensure!(remaining.is_zero(), Error::<T>::SeizeFailed);
926-
ensure!(slashed == seize_hdx, Error::<T>::SeizeFailed);
927-
}
928-
}
929-
930-
Stakes::<T>::try_mutate(recipient, |maybe| -> DispatchResult {
931-
let s = maybe.get_or_insert_with(StakeRecord::default);
932-
s.hdx = s.hdx.checked_add(seize_hdx).ok_or(Error::<T>::Overflow)?;
933-
s.gigahdx = s.gigahdx.checked_add(seize_gigahdx).ok_or(Error::<T>::Overflow)?;
934-
Ok(())
935-
})?;
936-
937-
Self::refresh_lock(recipient)?;
938-
Ok(())
939-
}
940-
941869
/// Total HDX backing all stHDX:
942870
/// `TotalLocked + free_balance(gigapot_account_id)`.
943871
pub fn total_staked_hdx() -> Balance {
@@ -988,21 +916,103 @@ pub mod pallet {
988916
}
989917
}
990918

991-
impl<T: Config> crate::traits::Seize<T::AccountId> for Pallet<T> {
919+
impl<T: Config> hydradx_traits::gigahdx::Seize<T::AccountId> for Pallet<T> {
920+
fn realize_yield(borrower: &T::AccountId) -> DispatchResult {
921+
Self::do_realize_yield(borrower).map(|_| ())
922+
}
923+
992924
fn snapshot_stake(borrower: &T::AccountId) -> Result<(Balance, Balance), DispatchError> {
993-
Self::seize_snapshot(borrower)
925+
let s = Stakes::<T>::get(borrower).ok_or(Error::<T>::NoStake)?;
926+
Ok((s.hdx, s.gigahdx))
994927
}
995-
fn pre_seize(borrower: &T::AccountId) -> Result<Balance, DispatchError> {
996-
Self::seize_unlock_atoken(borrower)
928+
929+
fn on_pre_seize(borrower: &T::AccountId) -> Result<Balance, DispatchError> {
930+
Stakes::<T>::try_mutate(borrower, |maybe| -> Result<Balance, DispatchError> {
931+
let s = maybe.as_mut().ok_or(Error::<T>::NoStake)?;
932+
let prev = s.gigahdx;
933+
s.gigahdx = 0;
934+
Ok(prev)
935+
})
997936
}
998-
fn finalise_seize(
937+
938+
/// `orig_gigahdx` is the borrower's pre-seize aToken balance from
939+
/// `snapshot_stake`; `on_pre_seize` has since zeroed the stored value,
940+
/// so the residual cannot be derived from state here. The `checked_sub`
941+
/// also rejects a `seize_gigahdx` larger than the snapshot.
942+
#[transactional]
943+
fn on_seize(
999944
borrower: &T::AccountId,
1000945
recipient: &T::AccountId,
1001946
seize_hdx: Balance,
1002947
seize_gigahdx: Balance,
1003-
residual_borrower_gigahdx: Balance,
948+
orig_gigahdx: Balance,
1004949
) -> DispatchResult {
1005-
Self::seize_finalise(borrower, recipient, seize_hdx, seize_gigahdx, residual_borrower_gigahdx)
950+
Stakes::<T>::try_mutate(borrower, |maybe| -> DispatchResult {
951+
let s = maybe.as_mut().ok_or(Error::<T>::NoStake)?;
952+
// `seize_gigahdx` must not exceed the borrower's snapshot — a
953+
// larger ask means the caller mis-computed the pro-rata split.
954+
// Treated as unreachable in production; the debug_assert turns
955+
// it into a hard panic under fuzzing instead of a silent
956+
// `SeizeFailed` revert.
957+
debug_assert!(
958+
seize_gigahdx <= orig_gigahdx,
959+
"on_seize: seize_gigahdx ({:?}) exceeds orig_gigahdx snapshot ({:?})",
960+
seize_gigahdx,
961+
orig_gigahdx,
962+
);
963+
let residual_borrower_gigahdx =
964+
orig_gigahdx.checked_sub(seize_gigahdx).ok_or(Error::<T>::SeizeFailed)?;
965+
s.hdx = s.hdx.checked_sub(seize_hdx).ok_or(Error::<T>::Overflow)?;
966+
// Votes stay intact across a seize; clamp `frozen` so the
967+
// `hdx >= frozen` invariant still holds on the residual stake.
968+
s.frozen = s.frozen.min(s.hdx);
969+
s.gigahdx = residual_borrower_gigahdx;
970+
Ok(())
971+
})?;
972+
// Shrink the borrower's lock *before* withdrawing. The stale
973+
// pre-seize ghdxlock (sized to `hdx + unstaking`) would otherwise
974+
// block the transfer with `LiquidityRestrictions` for any
975+
// borrower whose free balance equals their staked amount.
976+
Self::refresh_lock(borrower)?;
977+
978+
if !seize_hdx.is_zero() {
979+
// Prefer a clean transfer. If the borrower's remaining locks
980+
// (e.g. uncleared `pyconvot`, vesting, or any unmanaged lock)
981+
// still block the move, fall back to `slash` + `resolve_creating`
982+
// — liquidation is top priority and must always land.
983+
let new_balance = T::NativeCurrency::free_balance(borrower)
984+
.checked_sub(seize_hdx)
985+
.ok_or(Error::<T>::SeizeFailed)?;
986+
let can_transfer =
987+
T::NativeCurrency::ensure_can_withdraw(borrower, seize_hdx, WithdrawReasons::TRANSFER, new_balance)
988+
.is_ok();
989+
if can_transfer {
990+
T::NativeCurrency::transfer(borrower, recipient, seize_hdx, ExistenceRequirement::AllowDeath)?;
991+
} else {
992+
// `slash` ignores locks (unlike `transfer`), but
993+
// `pallet_balances` refuses to push a non-reapable
994+
// account below ED. Tolerate that ≤ED dust — Aave has
995+
// already moved the collateral aToken by this point, so
996+
// the seize must land. Larger shortfalls keep the
997+
// fail-loud tripwire for a genuinely broken stake/lock
998+
// ledger (the `free >= seize_hdx` staking invariant
999+
// bounds the shortfall to exactly the ED).
1000+
let (imbalance, remaining) = T::NativeCurrency::slash(borrower, seize_hdx);
1001+
let ed = T::NativeCurrency::minimum_balance();
1002+
ensure!(remaining <= ed, Error::<T>::SeizeFailed);
1003+
T::NativeCurrency::resolve_creating(recipient, imbalance);
1004+
}
1005+
}
1006+
1007+
Stakes::<T>::try_mutate(recipient, |maybe| -> DispatchResult {
1008+
let s = maybe.get_or_insert_with(StakeRecord::default);
1009+
s.hdx = s.hdx.checked_add(seize_hdx).ok_or(Error::<T>::Overflow)?;
1010+
s.gigahdx = s.gigahdx.checked_add(seize_gigahdx).ok_or(Error::<T>::Overflow)?;
1011+
Ok(())
1012+
})?;
1013+
1014+
Self::refresh_lock(recipient)?;
1015+
Ok(())
10061016
}
10071017
}
10081018
}

pallets/gigahdx/src/tests/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ mod invariants;
77
mod migrate;
88
mod mock;
99
mod multi_positions;
10-
mod seize;
1110
mod realize_yield;
11+
mod seize;
1212
mod set_pool_contract;
1313
mod stake;
1414
mod unlock;

0 commit comments

Comments
 (0)